Skip to content

Commit 8a9ed73

Browse files
authored
feat(datagrid): highlight rules that color rows or cells by value (#2738)
Claude-Session: https://claude.ai/code/session_01WA4qjtY9TiRD6QuqhhW5Yr
1 parent 28603bb commit 8a9ed73

57 files changed

Lines changed: 2747 additions & 101 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- **Remove Invisible Characters** in the Query menu. (#2717)
2121
- **Show invisible characters** in Settings > Editor. (#2717)
2222
- Warnings in the SQL editor for full-width punctuation, curly quotes and non-ASCII spaces. (#2717)
23+
- Highlight rules that color data grid rows or cells by value. (#2723)
2324

2425
### Changed
2526

@@ -42,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4243
- Blank welcome window list when a search matched nothing and a favorite existed.
4344
- Welcome window reading No Connections while a tag filter hid every connection.
4445
- Favorited connection inside a group listed twice on the welcome window.
46+
- Missing red wash on a row deleted together with a new, unsaved row.
4547
- Welcome window tag filter stuck on a tag no connection carries any more, hiding every connection.
4648
- Collapsing every group on the welcome window undone at the next launch.
4749
- Linked Folders and Team Library connections ignoring the welcome window search, with no context menu.

‎TablePro/Core/DataGrid/DataGridDisplayState.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ final class DataGridDisplayState {
5050
/// reports a schema and a format change and clears the text it was just handed.
5151
var identitySchema: ColumnIdentitySchema?
5252
var displayFormats: [ValueDisplayFormat?]?
53+
var highlightRuleSetKey: HighlightRuleSet.Key?
5354
}

‎TablePro/Core/DataGrid/RowDisplayBox.swift‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ final class RowDisplayCache {
2525
}
2626

2727
private var storage: [RowID: Entry] = [:]
28+
private var highlights: [RowID: RowHighlight] = [:]
2829
private var insertionOrder: [RowID] = []
2930
private var insertionHead: Int = 0
3031
private var totalCost: Int = 0
@@ -52,8 +53,28 @@ final class RowDisplayCache {
5253
evictIfNeeded()
5354
}
5455

56+
func highlight(forID id: RowID) -> RowHighlight? {
57+
highlights[id]
58+
}
59+
60+
func setHighlight(_ highlight: RowHighlight, forID id: RowID) {
61+
if highlights.count >= countLimit {
62+
highlights.removeAll(keepingCapacity: true)
63+
}
64+
highlights[id] = highlight
65+
}
66+
67+
func clearHighlight(forID id: RowID) {
68+
highlights.removeValue(forKey: id)
69+
}
70+
71+
func clearHighlights() {
72+
highlights.removeAll(keepingCapacity: true)
73+
}
74+
5575
func removeAll() {
5676
storage.removeAll(keepingCapacity: true)
77+
highlights.removeAll(keepingCapacity: true)
5778
insertionOrder.removeAll(keepingCapacity: true)
5879
insertionHead = 0
5980
totalCost = 0
@@ -64,6 +85,7 @@ final class RowDisplayCache {
6485
/// whose content changed in place keeps its id and would otherwise be served
6586
/// its pre-edit text.
6687
func clearValues(forID id: RowID) {
88+
highlights.removeValue(forKey: id)
6789
guard let existing = storage[id] else { return }
6890
totalCost -= existing.cost
6991
for index in existing.box.values.indices {

‎TablePro/Core/Menu/ViewMenuBuilder.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ enum ViewMenuBuilder {
7171
shortcut: .toggleFilters,
7272
keyboard: keyboard
7373
),
74+
MenuItemFactory.item(
75+
String(localized: "Highlight Rules…"),
76+
action: #selector(MainSplitViewController.showHighlightRules(_:))
77+
),
7478
MenuItemFactory.item(
7579
String(localized: "Show Query History"),
7680
action: #selector(MainSplitViewController.toggleQueryHistory(_:)),
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
//
2+
// HighlightCondition.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
struct HighlightCondition {
10+
static let searchLimit = 10_000
11+
12+
private enum ValueKind {
13+
case numeric
14+
case boolean
15+
case text
16+
}
17+
18+
private struct Operand {
19+
let text: String
20+
let number: Decimal?
21+
let boolean: Bool?
22+
let isNullLiteral: Bool
23+
24+
init(_ raw: String, allowsNullLiteral: Bool) {
25+
let trimmed = raw.trimmingCharacters(in: .whitespaces)
26+
text = raw
27+
number = HighlightCondition.number(from: trimmed)
28+
boolean = HighlightCondition.boolean(from: trimmed)
29+
isNullLiteral = allowsNullLiteral && HighlightCondition.isNullKeyword(trimmed)
30+
}
31+
}
32+
33+
private let filterOperator: FilterOperator
34+
private let valueKind: ValueKind
35+
private let comparesCaseInsensitively: Bool
36+
private let supportsEmptyString: Bool
37+
private let operand: Operand
38+
private let secondOperand: Operand
39+
private let listOperands: [Operand]
40+
private let regex: NSRegularExpression?
41+
42+
init(rule: HighlightRule, columnType: ColumnType?) {
43+
filterOperator = rule.filterOperator
44+
valueKind = Self.valueKind(for: columnType)
45+
comparesCaseInsensitively = rule.filterOperator.supportsCaseSensitivity && !rule.isCaseSensitive
46+
supportsEmptyString = ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType)
47+
48+
let allowsNullLiteral = Self.allowsNullLiteral(for: columnType)
49+
operand = Operand(rule.value, allowsNullLiteral: allowsNullLiteral)
50+
secondOperand = Operand(rule.secondValue ?? "", allowsNullLiteral: allowsNullLiteral)
51+
listOperands = rule.filterOperator == .inList || rule.filterOperator == .notInList
52+
? Self.listItems(rule.value).map { Operand($0, allowsNullLiteral: allowsNullLiteral) }
53+
: []
54+
regex = rule.filterOperator == .regex
55+
? Self.regularExpression(rule.value, ignoresCase: comparesCaseInsensitively)
56+
: nil
57+
}
58+
59+
func matches(_ value: PluginCellValue) -> Bool {
60+
switch value {
61+
case .null:
62+
return matchesNull()
63+
case .bytes:
64+
return matchesBinary()
65+
case .text(let text):
66+
return matches(text: text)
67+
}
68+
}
69+
70+
private func matchesNull() -> Bool {
71+
switch filterOperator {
72+
case .isNull, .isEmpty:
73+
return true
74+
case .equal:
75+
return operand.isNullLiteral
76+
case .inList:
77+
return listOperands.contains { $0.isNullLiteral }
78+
case .notEqual, .contains, .notContains, .startsWith, .endsWith, .greaterThan, .greaterOrEqual,
79+
.lessThan, .lessOrEqual, .isNotNull, .isNotEmpty, .notInList, .between, .regex:
80+
return false
81+
}
82+
}
83+
84+
private func matchesBinary() -> Bool {
85+
switch filterOperator {
86+
case .isNotNull, .isNotEmpty:
87+
return true
88+
case .isNull, .isEmpty, .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith,
89+
.greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .inList, .notInList, .between, .regex:
90+
return false
91+
}
92+
}
93+
94+
private func matches(text: String) -> Bool {
95+
switch filterOperator {
96+
case .equal:
97+
return !operand.isNullLiteral && order(text, against: operand) == .orderedSame
98+
case .notEqual:
99+
return operand.isNullLiteral || order(text, against: operand) != .orderedSame
100+
case .contains:
101+
return contains(text)
102+
case .notContains:
103+
return !contains(text)
104+
case .startsWith:
105+
return hasAffix(text, anchoredAtEnd: false)
106+
case .endsWith:
107+
return hasAffix(text, anchoredAtEnd: true)
108+
case .greaterThan:
109+
return !operand.isNullLiteral && order(text, against: operand) == .orderedDescending
110+
case .greaterOrEqual:
111+
return !operand.isNullLiteral && order(text, against: operand) != .orderedAscending
112+
case .lessThan:
113+
return !operand.isNullLiteral && order(text, against: operand) == .orderedAscending
114+
case .lessOrEqual:
115+
return !operand.isNullLiteral && order(text, against: operand) != .orderedDescending
116+
case .isNull:
117+
return false
118+
case .isNotNull:
119+
return true
120+
case .isEmpty:
121+
return supportsEmptyString && text.isEmpty
122+
case .isNotEmpty:
123+
return !supportsEmptyString || !text.isEmpty
124+
case .inList:
125+
return listOperands.contains { !$0.isNullLiteral && order(text, against: $0) == .orderedSame }
126+
case .notInList:
127+
let values = listOperands.filter { !$0.isNullLiteral }
128+
return !values.isEmpty && !values.contains { order(text, against: $0) == .orderedSame }
129+
case .between:
130+
return order(text, against: operand) != .orderedAscending
131+
&& order(text, against: secondOperand) != .orderedDescending
132+
case .regex:
133+
return matchesRegex(text)
134+
}
135+
}
136+
137+
private func order(_ text: String, against operand: Operand) -> ComparisonResult {
138+
if prefersNumbers, let lhs = Self.number(from: text), let rhs = operand.number {
139+
return Self.compare(lhs, rhs)
140+
}
141+
if prefersBooleans, let lhs = Self.boolean(from: text), let rhs = operand.boolean {
142+
return Self.compare(lhs ? 1 : 0, rhs ? 1 : 0)
143+
}
144+
return text.compare(operand.text, options: comparesCaseInsensitively ? [.caseInsensitive] : [.literal])
145+
}
146+
147+
private var prefersNumbers: Bool {
148+
switch valueKind {
149+
case .numeric:
150+
return true
151+
case .boolean:
152+
return false
153+
case .text:
154+
return isOrderingOperator
155+
}
156+
}
157+
158+
private var prefersBooleans: Bool {
159+
switch valueKind {
160+
case .numeric, .boolean:
161+
return true
162+
case .text:
163+
return false
164+
}
165+
}
166+
167+
private var isOrderingOperator: Bool {
168+
switch filterOperator {
169+
case .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .between:
170+
return true
171+
case .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, .isNull, .isNotNull,
172+
.isEmpty, .isNotEmpty, .inList, .notInList, .regex:
173+
return false
174+
}
175+
}
176+
177+
private var searchOptions: String.CompareOptions {
178+
comparesCaseInsensitively ? [.caseInsensitive] : [.literal]
179+
}
180+
181+
private func contains(_ text: String) -> Bool {
182+
guard !operand.text.isEmpty else { return true }
183+
return Self.searchable(text).range(of: operand.text, options: searchOptions) != nil
184+
}
185+
186+
private func hasAffix(_ text: String, anchoredAtEnd: Bool) -> Bool {
187+
guard !operand.text.isEmpty else { return true }
188+
let options = searchOptions.union(anchoredAtEnd ? [.anchored, .backwards] : [.anchored])
189+
return text.range(of: operand.text, options: options) != nil
190+
}
191+
192+
private func matchesRegex(_ text: String) -> Bool {
193+
guard let regex else { return false }
194+
let searchable = Self.searchable(text) as NSString
195+
return regex.firstMatch(
196+
in: searchable as String,
197+
options: [],
198+
range: NSRange(location: 0, length: searchable.length)
199+
) != nil
200+
}
201+
202+
private static func searchable(_ text: String) -> String {
203+
let source = text as NSString
204+
guard source.length > searchLimit else { return text }
205+
let cut = source.rangeOfComposedCharacterSequence(at: searchLimit).location
206+
return source.substring(to: cut)
207+
}
208+
209+
private static func regularExpression(_ pattern: String, ignoresCase: Bool) -> NSRegularExpression? {
210+
guard !pattern.isEmpty, (pattern as NSString).length <= searchLimit else { return nil }
211+
return try? NSRegularExpression(pattern: pattern, options: ignoresCase ? [.caseInsensitive] : [])
212+
}
213+
214+
private static func valueKind(for columnType: ColumnType?) -> ValueKind {
215+
switch columnType {
216+
case .integer, .decimal:
217+
return .numeric
218+
case .boolean:
219+
return .boolean
220+
case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .array, .none:
221+
return .text
222+
}
223+
}
224+
225+
private static func listItems(_ input: String) -> [String] {
226+
input.split(separator: ",", omittingEmptySubsequences: true).compactMap {
227+
let trimmed = $0.trimmingCharacters(in: .whitespaces)
228+
return trimmed.isEmpty ? nil : trimmed
229+
}
230+
}
231+
232+
static func readsAsNullLiteral(_ text: String, columnType: ColumnType?) -> Bool {
233+
allowsNullLiteral(for: columnType) && isNullKeyword(text.trimmingCharacters(in: .whitespaces))
234+
}
235+
236+
private static func allowsNullLiteral(for columnType: ColumnType?) -> Bool {
237+
!ColumnTypeSQLQuoting.isKnownTextLike(columnType)
238+
}
239+
240+
private static func isNullKeyword(_ text: String) -> Bool {
241+
text.caseInsensitiveCompare("NULL") == .orderedSame
242+
}
243+
244+
static func number(from text: String) -> Decimal? {
245+
let trimmed = text.trimmingCharacters(in: .whitespaces)
246+
guard PluginNumericLiteral.isValid(trimmed) else { return nil }
247+
return Decimal(string: trimmed, locale: Locale(identifier: "en_US_POSIX"))
248+
}
249+
250+
static func boolean(from text: String) -> Bool? {
251+
let trimmed = text.trimmingCharacters(in: .whitespaces)
252+
switch PluginSQLLiteral.booleanSynonym(for: trimmed) {
253+
case .isTrue:
254+
return true
255+
case .isFalse:
256+
return false
257+
default:
258+
break
259+
}
260+
switch trimmed.lowercased() {
261+
case "t":
262+
return true
263+
case "f":
264+
return false
265+
default:
266+
return nil
267+
}
268+
}
269+
270+
private static func compare<Value: Comparable>(_ lhs: Value, _ rhs: Value) -> ComparisonResult {
271+
if lhs < rhs { return .orderedAscending }
272+
if lhs > rhs { return .orderedDescending }
273+
return .orderedSame
274+
}
275+
}

0 commit comments

Comments
 (0)