diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e1e9b898..9e4f662a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Remove Invisible Characters** in the Query menu. (#2717) - **Show invisible characters** in Settings > Editor. (#2717) - Warnings in the SQL editor for full-width punctuation, curly quotes and non-ASCII spaces. (#2717) +- Highlight rules that color data grid rows or cells by value. (#2723) ### Changed @@ -42,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Blank welcome window list when a search matched nothing and a favorite existed. - Welcome window reading No Connections while a tag filter hid every connection. - Favorited connection inside a group listed twice on the welcome window. +- Missing red wash on a row deleted together with a new, unsaved row. - Welcome window tag filter stuck on a tag no connection carries any more, hiding every connection. - Collapsing every group on the welcome window undone at the next launch. - Linked Folders and Team Library connections ignoring the welcome window search, with no context menu. diff --git a/TablePro/Core/DataGrid/DataGridDisplayState.swift b/TablePro/Core/DataGrid/DataGridDisplayState.swift index ddb718ca36..b4f23d2920 100644 --- a/TablePro/Core/DataGrid/DataGridDisplayState.swift +++ b/TablePro/Core/DataGrid/DataGridDisplayState.swift @@ -50,4 +50,5 @@ final class DataGridDisplayState { /// reports a schema and a format change and clears the text it was just handed. var identitySchema: ColumnIdentitySchema? var displayFormats: [ValueDisplayFormat?]? + var highlightRuleSetKey: HighlightRuleSet.Key? } diff --git a/TablePro/Core/DataGrid/RowDisplayBox.swift b/TablePro/Core/DataGrid/RowDisplayBox.swift index b4f475587c..99f39e1bd2 100644 --- a/TablePro/Core/DataGrid/RowDisplayBox.swift +++ b/TablePro/Core/DataGrid/RowDisplayBox.swift @@ -25,6 +25,7 @@ final class RowDisplayCache { } private var storage: [RowID: Entry] = [:] + private var highlights: [RowID: RowHighlight] = [:] private var insertionOrder: [RowID] = [] private var insertionHead: Int = 0 private var totalCost: Int = 0 @@ -52,8 +53,28 @@ final class RowDisplayCache { evictIfNeeded() } + func highlight(forID id: RowID) -> RowHighlight? { + highlights[id] + } + + func setHighlight(_ highlight: RowHighlight, forID id: RowID) { + if highlights.count >= countLimit { + highlights.removeAll(keepingCapacity: true) + } + highlights[id] = highlight + } + + func clearHighlight(forID id: RowID) { + highlights.removeValue(forKey: id) + } + + func clearHighlights() { + highlights.removeAll(keepingCapacity: true) + } + func removeAll() { storage.removeAll(keepingCapacity: true) + highlights.removeAll(keepingCapacity: true) insertionOrder.removeAll(keepingCapacity: true) insertionHead = 0 totalCost = 0 @@ -64,6 +85,7 @@ final class RowDisplayCache { /// whose content changed in place keeps its id and would otherwise be served /// its pre-edit text. func clearValues(forID id: RowID) { + highlights.removeValue(forKey: id) guard let existing = storage[id] else { return } totalCost -= existing.cost for index in existing.box.values.indices { diff --git a/TablePro/Core/Menu/ViewMenuBuilder.swift b/TablePro/Core/Menu/ViewMenuBuilder.swift index b6545cdf8c..16a2d8d11a 100644 --- a/TablePro/Core/Menu/ViewMenuBuilder.swift +++ b/TablePro/Core/Menu/ViewMenuBuilder.swift @@ -71,6 +71,10 @@ enum ViewMenuBuilder { shortcut: .toggleFilters, keyboard: keyboard ), + MenuItemFactory.item( + String(localized: "Highlight Rules…"), + action: #selector(MainSplitViewController.showHighlightRules(_:)) + ), MenuItemFactory.item( String(localized: "Show Query History"), action: #selector(MainSplitViewController.toggleQueryHistory(_:)), diff --git a/TablePro/Core/Services/Highlight/HighlightCondition.swift b/TablePro/Core/Services/Highlight/HighlightCondition.swift new file mode 100644 index 0000000000..2f16f6e85c --- /dev/null +++ b/TablePro/Core/Services/Highlight/HighlightCondition.swift @@ -0,0 +1,275 @@ +// +// HighlightCondition.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct HighlightCondition { + static let searchLimit = 10_000 + + private enum ValueKind { + case numeric + case boolean + case text + } + + private struct Operand { + let text: String + let number: Decimal? + let boolean: Bool? + let isNullLiteral: Bool + + init(_ raw: String, allowsNullLiteral: Bool) { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + text = raw + number = HighlightCondition.number(from: trimmed) + boolean = HighlightCondition.boolean(from: trimmed) + isNullLiteral = allowsNullLiteral && HighlightCondition.isNullKeyword(trimmed) + } + } + + private let filterOperator: FilterOperator + private let valueKind: ValueKind + private let comparesCaseInsensitively: Bool + private let supportsEmptyString: Bool + private let operand: Operand + private let secondOperand: Operand + private let listOperands: [Operand] + private let regex: NSRegularExpression? + + init(rule: HighlightRule, columnType: ColumnType?) { + filterOperator = rule.filterOperator + valueKind = Self.valueKind(for: columnType) + comparesCaseInsensitively = rule.filterOperator.supportsCaseSensitivity && !rule.isCaseSensitive + supportsEmptyString = ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) + + let allowsNullLiteral = Self.allowsNullLiteral(for: columnType) + operand = Operand(rule.value, allowsNullLiteral: allowsNullLiteral) + secondOperand = Operand(rule.secondValue ?? "", allowsNullLiteral: allowsNullLiteral) + listOperands = rule.filterOperator == .inList || rule.filterOperator == .notInList + ? Self.listItems(rule.value).map { Operand($0, allowsNullLiteral: allowsNullLiteral) } + : [] + regex = rule.filterOperator == .regex + ? Self.regularExpression(rule.value, ignoresCase: comparesCaseInsensitively) + : nil + } + + func matches(_ value: PluginCellValue) -> Bool { + switch value { + case .null: + return matchesNull() + case .bytes: + return matchesBinary() + case .text(let text): + return matches(text: text) + } + } + + private func matchesNull() -> Bool { + switch filterOperator { + case .isNull, .isEmpty: + return true + case .equal: + return operand.isNullLiteral + case .inList: + return listOperands.contains { $0.isNullLiteral } + case .notEqual, .contains, .notContains, .startsWith, .endsWith, .greaterThan, .greaterOrEqual, + .lessThan, .lessOrEqual, .isNotNull, .isNotEmpty, .notInList, .between, .regex: + return false + } + } + + private func matchesBinary() -> Bool { + switch filterOperator { + case .isNotNull, .isNotEmpty: + return true + case .isNull, .isEmpty, .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, + .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .inList, .notInList, .between, .regex: + return false + } + } + + private func matches(text: String) -> Bool { + switch filterOperator { + case .equal: + return !operand.isNullLiteral && order(text, against: operand) == .orderedSame + case .notEqual: + return operand.isNullLiteral || order(text, against: operand) != .orderedSame + case .contains: + return contains(text) + case .notContains: + return !contains(text) + case .startsWith: + return hasAffix(text, anchoredAtEnd: false) + case .endsWith: + return hasAffix(text, anchoredAtEnd: true) + case .greaterThan: + return !operand.isNullLiteral && order(text, against: operand) == .orderedDescending + case .greaterOrEqual: + return !operand.isNullLiteral && order(text, against: operand) != .orderedAscending + case .lessThan: + return !operand.isNullLiteral && order(text, against: operand) == .orderedAscending + case .lessOrEqual: + return !operand.isNullLiteral && order(text, against: operand) != .orderedDescending + case .isNull: + return false + case .isNotNull: + return true + case .isEmpty: + return supportsEmptyString && text.isEmpty + case .isNotEmpty: + return !supportsEmptyString || !text.isEmpty + case .inList: + return listOperands.contains { !$0.isNullLiteral && order(text, against: $0) == .orderedSame } + case .notInList: + let values = listOperands.filter { !$0.isNullLiteral } + return !values.isEmpty && !values.contains { order(text, against: $0) == .orderedSame } + case .between: + return order(text, against: operand) != .orderedAscending + && order(text, against: secondOperand) != .orderedDescending + case .regex: + return matchesRegex(text) + } + } + + private func order(_ text: String, against operand: Operand) -> ComparisonResult { + if prefersNumbers, let lhs = Self.number(from: text), let rhs = operand.number { + return Self.compare(lhs, rhs) + } + if prefersBooleans, let lhs = Self.boolean(from: text), let rhs = operand.boolean { + return Self.compare(lhs ? 1 : 0, rhs ? 1 : 0) + } + return text.compare(operand.text, options: comparesCaseInsensitively ? [.caseInsensitive] : [.literal]) + } + + private var prefersNumbers: Bool { + switch valueKind { + case .numeric: + return true + case .boolean: + return false + case .text: + return isOrderingOperator + } + } + + private var prefersBooleans: Bool { + switch valueKind { + case .numeric, .boolean: + return true + case .text: + return false + } + } + + private var isOrderingOperator: Bool { + switch filterOperator { + case .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual, .between: + return true + case .equal, .notEqual, .contains, .notContains, .startsWith, .endsWith, .isNull, .isNotNull, + .isEmpty, .isNotEmpty, .inList, .notInList, .regex: + return false + } + } + + private var searchOptions: String.CompareOptions { + comparesCaseInsensitively ? [.caseInsensitive] : [.literal] + } + + private func contains(_ text: String) -> Bool { + guard !operand.text.isEmpty else { return true } + return Self.searchable(text).range(of: operand.text, options: searchOptions) != nil + } + + private func hasAffix(_ text: String, anchoredAtEnd: Bool) -> Bool { + guard !operand.text.isEmpty else { return true } + let options = searchOptions.union(anchoredAtEnd ? [.anchored, .backwards] : [.anchored]) + return text.range(of: operand.text, options: options) != nil + } + + private func matchesRegex(_ text: String) -> Bool { + guard let regex else { return false } + let searchable = Self.searchable(text) as NSString + return regex.firstMatch( + in: searchable as String, + options: [], + range: NSRange(location: 0, length: searchable.length) + ) != nil + } + + private static func searchable(_ text: String) -> String { + let source = text as NSString + guard source.length > searchLimit else { return text } + let cut = source.rangeOfComposedCharacterSequence(at: searchLimit).location + return source.substring(to: cut) + } + + private static func regularExpression(_ pattern: String, ignoresCase: Bool) -> NSRegularExpression? { + guard !pattern.isEmpty, (pattern as NSString).length <= searchLimit else { return nil } + return try? NSRegularExpression(pattern: pattern, options: ignoresCase ? [.caseInsensitive] : []) + } + + private static func valueKind(for columnType: ColumnType?) -> ValueKind { + switch columnType { + case .integer, .decimal: + return .numeric + case .boolean: + return .boolean + case .text, .date, .timestamp, .datetime, .blob, .json, .enumType, .set, .spatial, .array, .none: + return .text + } + } + + private static func listItems(_ input: String) -> [String] { + input.split(separator: ",", omittingEmptySubsequences: true).compactMap { + let trimmed = $0.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } + } + + static func readsAsNullLiteral(_ text: String, columnType: ColumnType?) -> Bool { + allowsNullLiteral(for: columnType) && isNullKeyword(text.trimmingCharacters(in: .whitespaces)) + } + + private static func allowsNullLiteral(for columnType: ColumnType?) -> Bool { + !ColumnTypeSQLQuoting.isKnownTextLike(columnType) + } + + private static func isNullKeyword(_ text: String) -> Bool { + text.caseInsensitiveCompare("NULL") == .orderedSame + } + + static func number(from text: String) -> Decimal? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard PluginNumericLiteral.isValid(trimmed) else { return nil } + return Decimal(string: trimmed, locale: Locale(identifier: "en_US_POSIX")) + } + + static func boolean(from text: String) -> Bool? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + switch PluginSQLLiteral.booleanSynonym(for: trimmed) { + case .isTrue: + return true + case .isFalse: + return false + default: + break + } + switch trimmed.lowercased() { + case "t": + return true + case "f": + return false + default: + return nil + } + } + + private static func compare(_ lhs: Value, _ rhs: Value) -> ComparisonResult { + if lhs < rhs { return .orderedAscending } + if lhs > rhs { return .orderedDescending } + return .orderedSame + } +} diff --git a/TablePro/Core/Services/Highlight/HighlightRuleSet.swift b/TablePro/Core/Services/Highlight/HighlightRuleSet.swift new file mode 100644 index 0000000000..15cb302910 --- /dev/null +++ b/TablePro/Core/Services/Highlight/HighlightRuleSet.swift @@ -0,0 +1,93 @@ +// +// HighlightRuleSet.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct HighlightRuleSet { + struct Key: Equatable { + let rules: [HighlightRule] + let columns: [String] + let columnTypes: [ColumnType] + } + + private struct CompiledRule { + let rule: HighlightRule + let column: Int + let condition: HighlightCondition + } + + let key: Key + let unresolvedRuleIDs: Set + private let rowRules: [CompiledRule] + private let cellRules: [CompiledRule] + + static let empty = HighlightRuleSet(rules: [], columns: [], columnTypes: []) + + init(rules: [HighlightRule], columns: [String], columnTypes: [ColumnType]) { + key = Key(rules: rules, columns: columns, columnTypes: columnTypes) + + var rowRules: [CompiledRule] = [] + var cellRules: [CompiledRule] = [] + var unresolved = Set() + for rule in rules where rule.isEnabled && rule.isValid { + guard let column = Self.columnIndex( + named: rule.columnName, + occurrence: rule.columnOccurrence, + in: columns + ) else { + unresolved.insert(rule.id) + continue + } + let columnType = column < columnTypes.count ? columnTypes[column] : nil + let compiled = CompiledRule( + rule: rule, + column: column, + condition: HighlightCondition(rule: rule, columnType: columnType) + ) + switch rule.target { + case .row: + rowRules.append(compiled) + case .cell: + cellRules.append(compiled) + } + } + self.rowRules = rowRules + self.cellRules = cellRules + self.unresolvedRuleIDs = unresolved + } + + var isEmpty: Bool { rowRules.isEmpty && cellRules.isEmpty } + + func highlight(for values: ContiguousArray) -> RowHighlight { + guard !isEmpty else { return .none } + let rowRule = rowRules.first { Self.matches($0, in: values) }?.rule + var matchedCells: [Int: HighlightRule] = [:] + for compiled in cellRules where matchedCells[compiled.column] == nil && Self.matches(compiled, in: values) { + matchedCells[compiled.column] = compiled.rule + } + return RowHighlight(rowRule: rowRule, cellRules: matchedCells) + } + + static func columnIndex(named name: String, occurrence: Int, in columns: [String]) -> Int? { + var seen = 0 + for (index, column) in columns.enumerated() where column == name { + if seen == occurrence { return index } + seen += 1 + } + return nil + } + + static func occurrence(ofColumnAt index: Int, in columns: [String]) -> Int { + guard index >= 0, index < columns.count else { return 0 } + let name = columns[index] + return columns[..) -> Bool { + guard compiled.column < values.count else { return false } + return compiled.condition.matches(values[compiled.column]) + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 2a11ff404c..997bf4727f 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -21,6 +21,7 @@ struct MenuValidationContext: Equatable { var canUseGridFindCommands = false /// Jump to Column reads the mounted data grid, so it needs one on screen with columns to list. var canJumpToColumn = false + var canPresentHighlightRules = false /// Save As writes the selected tab's SQL, so it needs a query tab and not merely a connection. var isQueryTab = false /// Export Results exports the selected tab's rows, so an empty grid has nothing to offer. @@ -261,6 +262,8 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(toggleFilterBar(_:)): return context.isConnected && context.canUseTableResultCommands + case #selector(showHighlightRules(_:)): + return context.isConnected && context.canPresentHighlightRules case #selector(pinResult(_:)): return context.canPinResultTab case #selector(navigateBack(_:)): @@ -298,6 +301,7 @@ extension MainSplitViewController: NSMenuItemValidation { canUseTableResultCommands: actions.canUseTableResultCommands, canUseGridFindCommands: actions.canUseGridFindCommands, canJumpToColumn: actions.canJumpToColumn, + canPresentHighlightRules: actions.canPresentHighlightRules, isQueryTab: actions.isQueryTab, hasResultRows: actions.hasResultRows, isCurrentTabEditable: actions.isCurrentTabEditable, diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift index 39f1496233..882d26cfbb 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+ViewMenuActions.swift @@ -63,6 +63,10 @@ extension MainSplitViewController { commandActions?.toggleFilterPanel() } + @objc func showHighlightRules(_ sender: Any?) { + commandActions?.showHighlightRules() + } + @objc func toggleQueryHistory(_ sender: Any?) { commandActions?.toggleHistoryPanel() } diff --git a/TablePro/Core/Storage/ConnectionLocalState.swift b/TablePro/Core/Storage/ConnectionLocalState.swift index 35a481283c..0891c8ab73 100644 --- a/TablePro/Core/Storage/ConnectionLocalState.swift +++ b/TablePro/Core/Storage/ConnectionLocalState.swift @@ -38,6 +38,7 @@ internal enum ConnectionLocalState { } FilterSettingsStorage.shared.removeFilters(for: connectionIds) + HighlightRuleStorage.shared.removeRules(for: connectionIds) DatabaseTreeFilterStorage.shared.removeFilters(for: connectionIds) RecentlyClosedTabStore.shared.removeEntries(for: connectionIds) WorkspaceRailOrderStore.shared.removeEntries(for: connectionIds) diff --git a/TablePro/Core/Storage/HighlightRuleStorage.swift b/TablePro/Core/Storage/HighlightRuleStorage.swift new file mode 100644 index 0000000000..ae2ba5bad7 --- /dev/null +++ b/TablePro/Core/Storage/HighlightRuleStorage.swift @@ -0,0 +1,179 @@ +// +// HighlightRuleStorage.swift +// TablePro +// + +import Foundation +import Observation +import os + +@MainActor +@Observable +final class HighlightRuleStorage { + static let shared = HighlightRuleStorage() + + nonisolated private static let logger = Logger( + subsystem: "com.TablePro", + category: "HighlightRuleStorage" + ) + + private(set) var revision = 0 + + @ObservationIgnored private let storageDirectory: URL + @ObservationIgnored private var cache: [UUID: [String: [HighlightRule]]] = [:] + @ObservationIgnored private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + @ObservationIgnored private let decoder = JSONDecoder() + + init(storageDirectory: URL? = nil) { + self.storageDirectory = storageDirectory ?? Self.resolvedStorageDirectory() + do { + try FileManager.default.createDirectory(at: self.storageDirectory, withIntermediateDirectories: true) + } catch { + Self.logger.error("Failed to create storage directory: \(error.localizedDescription)") + } + } + + func rules(for scope: TableScope) -> [HighlightRule] { + _ = revision + return loadEntries(for: scope.connectionId)[scope.storageComponent] ?? [] + } + + func setRules(_ rules: [HighlightRule], for scope: TableScope) { + var entries = loadEntries(for: scope.connectionId) + guard entries[scope.storageComponent, default: []] != rules else { return } + if rules.isEmpty { + entries.removeValue(forKey: scope.storageComponent) + } else { + entries[scope.storageComponent] = rules + } + commit(entries, for: scope.connectionId) + } + + func rename(from oldScope: TableScope, to newScope: TableScope) { + guard oldScope.storageComponent != newScope.storageComponent else { return } + var entries = loadEntries(for: oldScope.connectionId) + guard let moving = entries.removeValue(forKey: oldScope.storageComponent) else { return } + entries[newScope.storageComponent] = moving + commit(entries, for: oldScope.connectionId) + } + + func renameScope( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + let oldPrefix = TableScope.storagePrefix(connectionId: connectionId, database: fromDatabase, schema: fromSchema) + let newPrefix = TableScope.storagePrefix(connectionId: connectionId, database: toDatabase, schema: toSchema) + guard oldPrefix != newPrefix else { return } + + var entries = loadEntries(for: connectionId) + let moving = entries.keys.filter { $0.hasPrefix(oldPrefix) } + guard !moving.isEmpty else { return } + for key in moving { + entries[newPrefix + key.dropFirst(oldPrefix.count)] = entries.removeValue(forKey: key) + } + commit(entries, for: connectionId) + } + + func removeRules(for connectionIds: Set) { + guard !connectionIds.isEmpty else { return } + for connectionId in connectionIds { + cache[connectionId] = [:] + removeFile(at: fileURL(for: connectionId)) + } + revision &+= 1 + } + + private func commit(_ entries: [String: [HighlightRule]], for connectionId: UUID) { + cache[connectionId] = entries + if entries.isEmpty { + removeFile(at: fileURL(for: connectionId)) + } else { + write(entries, for: connectionId) + } + revision &+= 1 + } + + private func loadEntries(for connectionId: UUID) -> [String: [HighlightRule]] { + if let cached = cache[connectionId] { return cached } + + let url = fileURL(for: connectionId) + guard FileManager.default.fileExists(atPath: url.path) else { + cache[connectionId] = [:] + return [:] + } + + do { + let data = try Data(contentsOf: url) + let decoded = try decoder.decode([String: [LossyHighlightRule]].self, from: data) + let entries = decoded.compactMapValues { lossy -> [HighlightRule]? in + let rules = lossy.compactMap(\.rule) + return rules.isEmpty ? nil : rules + } + cache[connectionId] = entries + return entries + } catch { + Self.logger.error( + "Unreadable highlight rules for \(connectionId, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + preserveUnreadableFile(at: url) + cache[connectionId] = [:] + return [:] + } + } + + private func write(_ entries: [String: [HighlightRule]], for connectionId: UUID) { + do { + let data = try encoder.encode(entries) + try data.write(to: fileURL(for: connectionId), options: .atomic) + } catch { + Self.logger.error( + "Failed to write highlight rules for \(connectionId, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + } + } + + private func preserveUnreadableFile(at url: URL) { + let preserved = url.deletingPathExtension().appendingPathExtension("unreadable.json") + let fileManager = FileManager.default + try? fileManager.removeItem(at: preserved) + do { + try fileManager.moveItem(at: url, to: preserved) + } catch { + Self.logger.error("Failed to set aside unreadable highlight rules: \(error.localizedDescription, privacy: .public)") + } + } + + private func removeFile(at url: URL) { + guard FileManager.default.fileExists(atPath: url.path) else { return } + do { + try FileManager.default.removeItem(at: url) + } catch { + Self.logger.error("Failed to remove highlight rules file: \(error.localizedDescription, privacy: .public)") + } + } + + private func fileURL(for connectionId: UUID) -> URL { + storageDirectory.appendingPathComponent("\(connectionId.uuidString).json") + } + + private static func resolvedStorageDirectory() -> URL { + AppStorageEnvironment.shared.applicationSupportRoot + .appendingPathComponent("TablePro", isDirectory: true) + .appendingPathComponent("HighlightRules", isDirectory: true) + } +} + +private struct LossyHighlightRule: Decodable { + let rule: HighlightRule? + + init(from decoder: Decoder) throws { + rule = try? HighlightRule(from: decoder) + } +} diff --git a/TablePro/Models/Highlight/HighlightRule.swift b/TablePro/Models/Highlight/HighlightRule.swift new file mode 100644 index 0000000000..9dcec164c7 --- /dev/null +++ b/TablePro/Models/Highlight/HighlightRule.swift @@ -0,0 +1,139 @@ +// +// HighlightRule.swift +// TablePro +// + +import Foundation + +enum HighlightColor: String, CaseIterable, Identifiable, Codable, Sendable { + case red + case orange + case yellow + case green + case blue + case purple + case gray + + var id: String { rawValue } + + var displayName: String { + switch self { + case .red: return String(localized: "Red") + case .orange: return String(localized: "Orange") + case .yellow: return String(localized: "Yellow") + case .green: return String(localized: "Green") + case .blue: return String(localized: "Blue") + case .purple: return String(localized: "Purple") + case .gray: return String(localized: "Gray") + } + } +} + +enum HighlightTarget: String, CaseIterable, Identifiable, Codable, Sendable { + case row + case cell + + var id: String { rawValue } + + var displayName: String { + switch self { + case .row: return String(localized: "Row") + case .cell: return String(localized: "Cell") + } + } +} + +struct HighlightRule: Identifiable, Equatable, Hashable, Codable, Sendable { + let id: UUID + var isEnabled: Bool + var columnName: String + var columnOccurrence: Int + var filterOperator: FilterOperator + var value: String + var secondValue: String? + var isCaseSensitive: Bool + var color: HighlightColor + var target: HighlightTarget + + init( + id: UUID = UUID(), + isEnabled: Bool = true, + columnName: String, + columnOccurrence: Int = 0, + filterOperator: FilterOperator = .equal, + value: String = "", + secondValue: String? = nil, + isCaseSensitive: Bool? = nil, + color: HighlightColor = .yellow, + target: HighlightTarget = .row + ) { + self.id = id + self.isEnabled = isEnabled + self.columnName = columnName + self.columnOccurrence = max(0, columnOccurrence) + self.filterOperator = filterOperator + self.value = value + self.secondValue = secondValue + self.isCaseSensitive = isCaseSensitive ?? filterOperator.defaultIsCaseSensitive + self.color = color + self.target = target + } + + private enum CodingKeys: String, CodingKey { + case id, isEnabled, columnName, columnOccurrence, filterOperator, value, secondValue + case isCaseSensitive, color, target + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedOperator = try container.decode(FilterOperator.self, forKey: .filterOperator) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true + self.columnName = try container.decode(String.self, forKey: .columnName) + self.columnOccurrence = max(0, try container.decodeIfPresent(Int.self, forKey: .columnOccurrence) ?? 0) + self.filterOperator = decodedOperator + self.value = try container.decodeIfPresent(String.self, forKey: .value) ?? "" + self.secondValue = try container.decodeIfPresent(String.self, forKey: .secondValue) + self.isCaseSensitive = try container.decodeIfPresent(Bool.self, forKey: .isCaseSensitive) + ?? decodedOperator.defaultIsCaseSensitive + self.color = try container.decode(HighlightColor.self, forKey: .color) + self.target = try container.decodeIfPresent(HighlightTarget.self, forKey: .target) ?? .row + } + + var isValid: Bool { + guard !columnName.isEmpty else { return false } + guard filterOperator.requiresValue else { return true } + guard !value.isEmpty else { return false } + guard filterOperator.requiresSecondValue else { return true } + return !(secondValue?.isEmpty ?? true) + } + + func hasSameCondition(as other: HighlightRule) -> Bool { + columnName == other.columnName + && columnOccurrence == other.columnOccurrence + && filterOperator == other.filterOperator + && value == other.value + && (filterOperator.requiresSecondValue ? secondValue == other.secondValue : true) + && isCaseSensitive == other.isCaseSensitive + && target == other.target + } +} + +struct RowHighlight: Equatable, Sendable { + let rowRule: HighlightRule? + let cellRules: [Int: HighlightRule] + + static let none = RowHighlight(rowRule: nil, cellRules: [:]) + + var isEmpty: Bool { rowRule == nil && cellRules.isEmpty } + + var rowColor: HighlightColor? { rowRule?.color } + + func cellRule(forColumn column: Int) -> HighlightRule? { + cellRules[column] + } + + func describingRule(forColumn column: Int) -> HighlightRule? { + cellRules[column] ?? rowRule + } +} diff --git a/TablePro/Models/Highlight/HighlightRuleDescription.swift b/TablePro/Models/Highlight/HighlightRuleDescription.swift new file mode 100644 index 0000000000..18c1ab6a20 --- /dev/null +++ b/TablePro/Models/Highlight/HighlightRuleDescription.swift @@ -0,0 +1,67 @@ +// +// HighlightRuleDescription.swift +// TablePro +// + +import Foundation + +enum HighlightRuleDescription { + static let menuValueLimit = 32 + + static func condition(of rule: HighlightRule, valueLimit: Int? = nil) -> String { + condition( + columnName: rule.columnName, + filterOperator: rule.filterOperator, + value: rule.value, + secondValue: rule.secondValue, + valueLimit: valueLimit + ) + } + + static func condition( + columnName: String, + filterOperator: FilterOperator, + value: String, + secondValue: String?, + valueLimit: Int? = nil + ) -> String { + guard filterOperator.requiresValue else { + return String(format: String(localized: "%1$@ %2$@"), columnName, filterOperator.displayName) + } + + let first = truncated(value, to: valueLimit) + if filterOperator.requiresSecondValue { + return String( + format: String(localized: "%1$@ between “%2$@” and “%3$@”"), + columnName, + first, + truncated(secondValue ?? "", to: valueLimit) + ) + } + + return String( + format: String(localized: "%1$@ %2$@ “%3$@”"), + columnName, + operatorText(filterOperator), + first + ) + } + + static func truncated(_ value: String, to limit: Int?) -> String { + guard let limit, limit > 0 else { return value } + let source = value as NSString + guard source.length > limit else { return value } + let cut = source.rangeOfComposedCharacterSequence(at: limit).location + return source.substring(to: cut) + "\u{2026}" + } + + private static func operatorText(_ filterOperator: FilterOperator) -> String { + switch filterOperator { + case .equal, .notEqual, .greaterThan, .greaterOrEqual, .lessThan, .lessOrEqual: + return filterOperator.symbol + case .contains, .notContains, .startsWith, .endsWith, .isNull, .isNotNull, .isEmpty, + .isNotEmpty, .inList, .notInList, .between, .regex: + return filterOperator.displayName + } + } +} diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index ece3ac2985..e94279226c 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -74,6 +74,7 @@ struct QueryTab: Identifiable, Equatable { /// run with no `DataGridView` in the view tree. Living on the grid's SwiftUI coordinator meant /// switching result mode did not hide the order, it deleted it. (#2251) var valueFilter: GridValueFilterState + var sessionHighlightRules: [HighlightRule] = [] var pagination: PaginationState var chartConfiguration: ResultChartConfiguration var hasUserInteraction: Bool @@ -407,6 +408,7 @@ struct QueryTab: Identifiable, Equatable { && lhs.pagination == rhs.pagination && lhs.sortState == rhs.sortState && lhs.valueFilter == rhs.valueFilter + && lhs.sessionHighlightRules == rhs.sessionHighlightRules && lhs.chartConfiguration == rhs.chartConfiguration && lhs.display == rhs.display && lhs.tableContext.isEditable == rhs.tableContext.isEditable diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index e4480deabf..48e920e70f 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -657,6 +657,7 @@ struct TabDisplayState: Equatable { var isResultsCollapsed: Bool = false var resultSets: [ResultSet] = [] var activeResultSetId: UUID? + var highlightRulesPresentationRequest: Int = 0 var activeResultSet: ResultSet? { guard let id = activeResultSetId else { return resultSets.last } @@ -698,5 +699,6 @@ struct TabDisplayState: Equatable { && lhs.isResultsCollapsed == rhs.isResultsCollapsed && lhs.resultSets.map(\.id) == rhs.resultSets.map(\.id) && lhs.activeResultSetId == rhs.activeResultSetId + && lhs.highlightRulesPresentationRequest == rhs.highlightRulesPresentationRequest } } diff --git a/TablePro/Models/Query/ResultStatusModel.swift b/TablePro/Models/Query/ResultStatusModel.swift index f58e2c1ad3..ccb0032825 100644 --- a/TablePro/Models/Query/ResultStatusModel.swift +++ b/TablePro/Models/Query/ResultStatusModel.swift @@ -41,6 +41,7 @@ struct ResultStatusControls: Equatable { var showsCountInProgress = false var showsFetchAll = false var showsColumns = false + var showsHighlightRules = false var showsFilters = false var showsPagination = false /// First, Previous, Next, Last and the page number, which an engine that cannot skip rows has @@ -115,6 +116,7 @@ struct ResultStatusModel: Equatable { && !pagination.isLoadingMore controls.showsColumns = viewMode.showsColumnControls && describesAResult + controls.showsHighlightRules = viewMode == .data && describesAResult controls.showsFilters = viewMode.showsRowFilters && isTable && snapshot.hasTableName controls.showsPagination = viewMode.showsResultScope && isTable && snapshot.hasTableName controls.showsPageNavigation = controls.showsPagination && snapshot.paginationCapability.allowsSeeking diff --git a/TablePro/Views/Components/ClosureMenuTarget.swift b/TablePro/Views/Components/ClosureMenuTarget.swift new file mode 100644 index 0000000000..0e85c2f969 --- /dev/null +++ b/TablePro/Views/Components/ClosureMenuTarget.swift @@ -0,0 +1,31 @@ +// +// ClosureMenuTarget.swift +// TablePro +// + +import AppKit + +/// `NSMenuItem` holds its target weakly, so the closure needs an owner that outlives the menu. +/// `representedObject` is that owner: it is strong, it belongs to the item, and it goes when the +/// item does. +@MainActor +final class ClosureMenuTarget: NSObject { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc func fire() { + action() + } + + static func item(title: String, isEnabled: Bool = true, action: @escaping () -> Void) -> NSMenuItem { + let item = NSMenuItem(title: title, action: #selector(fire), keyEquivalent: "") + let target = ClosureMenuTarget(action: action) + item.target = target + item.representedObject = target + item.isEnabled = isEnabled + return item + } +} diff --git a/TablePro/Views/Highlight/HighlightColor+AppKit.swift b/TablePro/Views/Highlight/HighlightColor+AppKit.swift new file mode 100644 index 0000000000..fb3bc5b7d6 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightColor+AppKit.swift @@ -0,0 +1,42 @@ +// +// HighlightColor+AppKit.swift +// TablePro +// + +import AppKit + +extension HighlightColor { + static let washAlpha: CGFloat = 0.2 + + var systemColor: NSColor { + switch self { + case .red: return .systemRed + case .orange: return .systemOrange + case .yellow: return .systemYellow + case .green: return .systemGreen + case .blue: return .systemBlue + case .purple: return .systemPurple + case .gray: return .systemGray + } + } + + var washColor: NSColor { + systemColor.withAlphaComponent(Self.washAlpha) + } + + func swatchImage(diameter: CGFloat = 12) -> NSImage { + let color = systemColor + let image = NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)).fill() + NSColor.separatorColor.setStroke() + let outline = NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)) + outline.lineWidth = 0.5 + outline.stroke() + return true + } + image.isTemplate = false + image.accessibilityDescription = displayName + return image + } +} diff --git a/TablePro/Views/Highlight/HighlightColumnOption.swift b/TablePro/Views/Highlight/HighlightColumnOption.swift new file mode 100644 index 0000000000..945e8fb854 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightColumnOption.swift @@ -0,0 +1,31 @@ +// +// HighlightColumnOption.swift +// TablePro +// + +import Foundation + +struct HighlightColumnOption: Identifiable, Hashable { + let name: String + let occurrence: Int + let label: String + + var id: String { Self.identifier(name: name, occurrence: occurrence) } + + static func identifier(name: String, occurrence: Int) -> String { + "\(occurrence)#\(name)" + } + + static func options(for columns: [String]) -> [HighlightColumnOption] { + var seen: [String: Int] = [:] + let totals = columns.reduce(into: [String: Int]()) { $0[$1, default: 0] += 1 } + return columns.map { name in + let occurrence = seen[name, default: 0] + seen[name] = occurrence + 1 + let label = totals[name, default: 0] > 1 + ? String(format: String(localized: "%1$@ (%2$d)"), name, occurrence + 1) + : name + return HighlightColumnOption(name: name, occurrence: occurrence, label: label) + } + } +} diff --git a/TablePro/Views/Highlight/HighlightMenuBuilder.swift b/TablePro/Views/Highlight/HighlightMenuBuilder.swift new file mode 100644 index 0000000000..537505cbba --- /dev/null +++ b/TablePro/Views/Highlight/HighlightMenuBuilder.swift @@ -0,0 +1,144 @@ +// +// HighlightMenuBuilder.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +@MainActor +enum HighlightMenuBuilder { + struct CellContext { + let columnName: String + let columnOccurrence: Int + let columnType: ColumnType? + let value: PluginCellValue + let existingRules: [HighlightRule] + } + + struct Actions { + let apply: (HighlightRule) -> Void + let remove: (HighlightRule) -> Void + let showRules: () -> Void + } + + static func quickRule( + columnName: String, + columnOccurrence: Int, + columnType: ColumnType?, + value: PluginCellValue, + target: HighlightTarget, + color: HighlightColor + ) -> HighlightRule? { + switch value { + case .null: + return HighlightRule( + columnName: columnName, + columnOccurrence: columnOccurrence, + filterOperator: .isNull, + color: color, + target: target + ) + case .text(let text) where text.isEmpty: + return HighlightRule( + columnName: columnName, + columnOccurrence: columnOccurrence, + filterOperator: .isEmpty, + color: color, + target: target + ) + case .text(let text) where HighlightCondition.readsAsNullLiteral(text, columnType: columnType): + return nil + case .text(let text): + return HighlightRule( + columnName: columnName, + columnOccurrence: columnOccurrence, + filterOperator: .equal, + value: text, + color: color, + target: target + ) + case .bytes: + return nil + } + } + + static func sectionTitle(for rule: HighlightRule) -> String { + let condition = HighlightRuleDescription.condition( + of: rule, + valueLimit: HighlightRuleDescription.menuValueLimit + ) + switch rule.target { + case .row: + return String(format: String(localized: "Rows Where %@"), condition) + case .cell: + return String(format: String(localized: "Cells Where %@"), condition) + } + } + + static func menuItem(for context: CellContext, actions: Actions) -> NSMenuItem? { + let templates = HighlightTarget.allCases.compactMap { target in + quickRule( + columnName: context.columnName, + columnOccurrence: context.columnOccurrence, + columnType: context.columnType, + value: context.value, + target: target, + color: .yellow + ) + } + guard !templates.isEmpty else { return nil } + + let submenu = NSMenu() + var existingMatches: [HighlightRule] = [] + for template in templates { + let existing = context.existingRules.first { $0.hasSameCondition(as: template) } + if let existing { existingMatches.append(existing) } + submenu.addItem(.sectionHeader(title: sectionTitle(for: template))) + submenu.addItem(paletteItem(for: template, existing: existing, actions: actions)) + } + + submenu.addItem(.separator()) + if !existingMatches.isEmpty { + submenu.addItem(ClosureMenuTarget.item(title: String(localized: "Remove Highlight")) { + existingMatches.forEach(actions.remove) + }) + } + submenu.addItem(ClosureMenuTarget.item(title: String(localized: "Highlight Rules…"), action: actions.showRules)) + + let item = NSMenuItem(title: String(localized: "Highlight"), action: nil, keyEquivalent: "") + item.image = NSImage(systemSymbolName: "highlighter", accessibilityDescription: nil) + item.submenu = submenu + return item + } + + private static func paletteItem( + for template: HighlightRule, + existing: HighlightRule?, + actions: Actions + ) -> NSMenuItem { + let colors = HighlightColor.allCases + let palette = NSMenu.palette( + colors: colors.map(\.systemColor), + titles: colors.map(\.displayName) + ) { menu in + let selected = menu.selectedItems.compactMap { menu.items.firstIndex(of: $0) } + guard let index = selected.first, colors.indices.contains(index) else { + if let existing { actions.remove(existing) } + return + } + var rule = existing ?? template + rule.color = colors[index] + rule.isEnabled = true + actions.apply(rule) + } + palette.selectionMode = .selectOne + if let existing, let index = colors.firstIndex(of: existing.color), index < palette.items.count { + palette.selectedItems = [palette.items[index]] + } + + let item = NSMenuItem(title: sectionTitle(for: template), action: nil, keyEquivalent: "") + item.submenu = palette + return item + } +} diff --git a/TablePro/Views/Highlight/HighlightRuleRow.swift b/TablePro/Views/Highlight/HighlightRuleRow.swift new file mode 100644 index 0000000000..7e07625e35 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightRuleRow.swift @@ -0,0 +1,224 @@ +// +// HighlightRuleRow.swift +// TablePro +// + +import SwiftUI + +struct HighlightRuleRow: View { + @Binding var rule: HighlightRule + let columnOptions: [HighlightColumnOption] + @Binding var focusedRuleID: UUID? + let canMoveUp: Bool + let canMoveDown: Bool + let onMoveUp: () -> Void + let onMoveDown: () -> Void + let onRemove: () -> Void + let onCancel: () -> Void + + private var isColumnMissing: Bool { + !columnOptions.contains { $0.name == rule.columnName && $0.occurrence == rule.columnOccurrence } + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Toggle("", isOn: $rule.isEnabled) + .toggleStyle(.checkbox) + .labelsHidden() + .accessibilityLabel(String(localized: "Enable rule")) + .accessibilityIdentifier("highlight-rule-enabled") + .help(String(localized: "Apply this rule")) + conditionEditor + .opacity(rule.isEnabled ? 1 : 0.5) + } + HStack(spacing: 8) { + colorPicker + targetPicker + if isColumnMissing { + Label(String(localized: "Not in this result"), systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .help(String(format: String(localized: "This result has no column named %@"), rule.columnName)) + } + Spacer(minLength: 0) + removeButton + } + .padding(.leading, 22) + .opacity(rule.isEnabled ? 1 : 0.5) + } + .padding(.vertical, 4) + .accessibilityElement(children: .contain) + .accessibilityLabel(HighlightRuleDescription.condition(of: rule)) + .accessibilityActions { + if canMoveUp { + Button(String(localized: "Move Rule Up"), action: onMoveUp) + } + if canMoveDown { + Button(String(localized: "Move Rule Down"), action: onMoveDown) + } + } + } + + private var conditionEditor: some View { + HStack(spacing: 6) { + columnPicker + operatorMenu + valueFields + } + } + + private var columnSelection: Binding { + Binding( + get: { HighlightColumnOption.identifier(name: rule.columnName, occurrence: rule.columnOccurrence) }, + set: { identifier in + guard let option = columnOptions.first(where: { $0.id == identifier }) else { return } + rule.columnName = option.name + rule.columnOccurrence = option.occurrence + } + ) + } + + private var columnPicker: some View { + Picker("", selection: columnSelection) { + ForEach(columnOptions) { option in + Text(option.label).tag(option.id) + } + if isColumnMissing { + Divider() + Text(rule.columnName) + .tag(HighlightColumnOption.identifier(name: rule.columnName, occurrence: rule.columnOccurrence)) + } + } + .pickerStyle(.menu) + .controlSize(.small) + .fixedSize() + .labelsHidden() + .accessibilityLabel(String(localized: "Rule column")) + .accessibilityValue(rule.columnName) + } + + private var operatorSelection: Binding { + Binding( + get: { rule.filterOperator }, + set: { newOperator in + guard newOperator != rule.filterOperator else { return } + rule.filterOperator = newOperator + rule.isCaseSensitive = newOperator.defaultIsCaseSensitive + } + ) + } + + private var operatorMenu: some View { + Menu { + Picker("", selection: operatorSelection) { + ForEach(FilterOperator.allCases) { filterOperator in + Text(Self.operatorLabel(filterOperator)) + .accessibilityLabel(filterOperator.displayName) + .tag(filterOperator) + } + } + .pickerStyle(.inline) + .labelsHidden() + + if rule.filterOperator.supportsCaseSensitivity { + Divider() + Toggle(String(localized: "Match Case"), isOn: $rule.isCaseSensitive) + } + } label: { + HStack(spacing: 3) { + Text(Self.operatorLabel(rule.filterOperator)) + if rule.filterOperator.supportsCaseSensitivity, + rule.isCaseSensitive != rule.filterOperator.defaultIsCaseSensitive { + Image(systemName: "textformat") + .imageScale(.small) + .foregroundStyle(.secondary) + } + } + } + .menuStyle(.button) + .controlSize(.small) + .fixedSize() + .accessibilityLabel(String(localized: "Rule operator")) + .accessibilityValue(rule.filterOperator.displayName) + } + + @ViewBuilder + private var valueFields: some View { + if rule.filterOperator.requiresValue { + FilterValueTextField( + text: $rule.value, + focusedId: $focusedRuleID, + identity: rule.id, + placeholder: String(localized: "Value"), + onCancel: onCancel + ) + .frame(minWidth: 90) + .accessibilityLabel(String(localized: "Rule value")) + + if rule.filterOperator.requiresSecondValue { + Text("and") + .font(.subheadline) + .foregroundStyle(.secondary) + TextField("Value", text: Binding( + get: { rule.secondValue ?? "" }, + set: { rule.secondValue = $0 } + )) + .textFieldStyle(.roundedBorder) + .controlSize(.small) + .autocorrectionDisabled(true) + .frame(minWidth: 70) + .accessibilityLabel(String(localized: "Second rule value")) + } + } else { + Spacer(minLength: 0) + } + } + + private var colorPicker: some View { + Picker("", selection: $rule.color) { + ForEach(HighlightColor.allCases) { color in + Label { + Text(color.displayName) + } icon: { + Image(nsImage: color.swatchImage()) + } + .tag(color) + } + } + .pickerStyle(.menu) + .controlSize(.small) + .fixedSize() + .labelsHidden() + .accessibilityLabel(String(localized: "Highlight color")) + .accessibilityValue(rule.color.displayName) + } + + private var targetPicker: some View { + Picker("", selection: $rule.target) { + ForEach(HighlightTarget.allCases) { target in + Text(target.displayName).tag(target) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .fixedSize() + .labelsHidden() + .accessibilityLabel(String(localized: "Apply To")) + .help(String(localized: "Color the whole row, or only the matching cell")) + } + + private var removeButton: some View { + Button(String(localized: "Remove Rule"), systemImage: "minus", action: onRemove) + .labelStyle(.iconOnly) + .buttonStyle(.bordered) + .controlSize(.small) + .help(String(localized: "Remove this rule")) + } + + private static func operatorLabel(_ filterOperator: FilterOperator) -> String { + filterOperator.symbol.isEmpty + ? filterOperator.displayName + : "\(filterOperator.symbol) \(filterOperator.displayName)" + } +} diff --git a/TablePro/Views/Highlight/HighlightRulesPopover.swift b/TablePro/Views/Highlight/HighlightRulesPopover.swift new file mode 100644 index 0000000000..642004f9c0 --- /dev/null +++ b/TablePro/Views/Highlight/HighlightRulesPopover.swift @@ -0,0 +1,144 @@ +// +// HighlightRulesPopover.swift +// TablePro +// + +import SwiftUI + +struct HighlightRulesPopover: View { + let columns: [String] + let rules: [HighlightRule] + let isPersisted: Bool + let onChange: ([HighlightRule]) -> Void + + @State private var focusedRuleID: UUID? + @Environment(\.dismiss) private var dismiss + + private static let rowHeight: CGFloat = 64 + private static let maximumListHeight: CGFloat = 420 + + private var columnOptions: [HighlightColumnOption] { + HighlightColumnOption.options(for: columns) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + if rules.isEmpty { + emptyState + } else { + ruleList + } + Divider() + footer + } + .frame(width: 540) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Highlight Rules") + .font(.headline) + Text("Rules are checked in order. The first match sets the color.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var emptyState: some View { + ContentUnavailableView { + Label(String(localized: "No Highlight Rules"), systemImage: "highlighter") + } description: { + Text("Right-click a cell and choose Highlight to color rows by value.") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private var ruleList: some View { + List { + ForEach(rules) { rule in + HighlightRuleRow( + rule: binding(for: rule), + columnOptions: columnOptions, + focusedRuleID: $focusedRuleID, + canMoveUp: rules.first?.id != rule.id, + canMoveDown: rules.last?.id != rule.id, + onMoveUp: { move(rule, by: -1) }, + onMoveDown: { move(rule, by: 1) }, + onRemove: { remove(rule) }, + onCancel: close + ) + } + .onMove(perform: move) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .frame(height: min(CGFloat(rules.count) * Self.rowHeight + 8, Self.maximumListHeight)) + } + + private var footer: some View { + HStack(spacing: 8) { + Button(String(localized: "Add Rule"), systemImage: "plus", action: addRule) + .controlSize(.small) + .disabled(columns.isEmpty) + .accessibilityIdentifier("highlight-rules-add") + + Spacer(minLength: 8) + + if !isPersisted { + Text("Rules for this query result are not saved.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func binding(for rule: HighlightRule) -> Binding { + Binding( + get: { rules.first { $0.id == rule.id } ?? rule }, + set: { updated in + var next = rules + guard let index = next.firstIndex(where: { $0.id == updated.id }) else { return } + next[index] = updated + onChange(next) + } + ) + } + + private func addRule() { + guard let first = columnOptions.first else { return } + let rule = HighlightRule(columnName: first.name, columnOccurrence: first.occurrence) + onChange(rules + [rule]) + focusedRuleID = rule.id + } + + private func close() { + dismiss() + } + + private func remove(_ rule: HighlightRule) { + onChange(rules.filter { $0.id != rule.id }) + } + + private func move(from source: IndexSet, to destination: Int) { + var next = rules + next.move(fromOffsets: source, toOffset: destination) + onChange(next) + } + + private func move(_ rule: HighlightRule, by offset: Int) { + guard let index = rules.firstIndex(where: { $0.id == rule.id }) else { return } + let target = index + offset + guard rules.indices.contains(target) else { return } + var next = rules + next.swapAt(index, target) + onChange(next) + } +} diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index 4de563328c..2de98910ef 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -134,6 +134,48 @@ final class DataTabGridDelegate: DataGridViewDelegate { return menu } + func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { + guard let coordinator, + let grid = tableViewCoordinator, + let tab = coordinator.tabManager.selectedTab, + let row = grid.displayRow(at: displayRow) else { return nil } + let tableRows = grid.tableRowsProvider() + let columns = tableRows.columns + guard columns.indices.contains(dataColumn), dataColumn < row.values.count else { return nil } + + let tabId = tab.id + let context = HighlightMenuBuilder.CellContext( + columnName: columns[dataColumn], + columnOccurrence: HighlightRuleSet.occurrence(ofColumnAt: dataColumn, in: columns), + columnType: dataColumn < tableRows.columnTypes.count ? tableRows.columnTypes[dataColumn] : nil, + value: row.values[dataColumn], + existingRules: coordinator.highlightRules(for: tab) + ) + let actions = HighlightMenuBuilder.Actions( + apply: { [weak coordinator] rule in + coordinator?.applyQuickHighlight(rule, forTab: tabId) + }, + remove: { [weak coordinator] rule in + coordinator?.removeHighlightRules(sharingConditionWith: rule, forTab: tabId) + }, + showRules: { [weak coordinator] in + coordinator?.presentHighlightRules() + } + ) + return HighlightMenuBuilder.menuItem(for: context, actions: actions) + } + + func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? { + guard coordinator != nil, let grid = tableViewCoordinator else { return nil } + let columns = grid.tableRowsProvider().columns + guard columns.indices.contains(dataColumnIndex) else { return nil } + let columnName = columns[dataColumnIndex] + let occurrence = HighlightRuleSet.occurrence(ofColumnAt: dataColumnIndex, in: columns) + return ClosureMenuTarget.item(title: String(localized: "Highlight Values…")) { [weak coordinator] in + coordinator?.presentHighlightRules(addingRuleForColumn: columnName, occurrence: occurrence) + } + } + weak var tableViewCoordinator: TableViewCoordinator? func dataGridAttach(tableViewCoordinator: TableViewCoordinator) { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bfe980b42f..07b6b67c9c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -916,6 +916,7 @@ struct MainEditorContentView: View { editRefusalMessage: refusal?.message ), displayFormats: coordinator.displayFormats(for: tab), + highlightRules: coordinator.highlightRules(for: tab), delegate: dataTabDelegate, selectedRowIndices: Binding( get: { selectionState.indices }, @@ -1019,6 +1020,18 @@ struct MainEditorContentView: View { ? { coordinator.showColumnJump(seededWith: $0) } : nil ), + highlightState: StatusBarHighlightState( + rules: coordinator.highlightRules(for: tab), + columns: resolvedRows.columns, + isPersisted: coordinator.highlightRuleScope(for: tab) != nil, + presentationRequest: tab.display.highlightRulesPresentationRequest, + onChange: { [coordinator, tabId = tab.id] rules in + coordinator.setHighlightRules(rules, forTab: tabId) + }, + onDismiss: { [coordinator, tabId = tab.id] in + coordinator.discardIncompleteHighlightRules(forTab: tabId) + } + ), paginationCallbacks: PaginationCallbacks( onFirst: onFirstPage, onPrevious: onPreviousPage, diff --git a/TablePro/Views/Main/EditorTabContextMenuBuilder.swift b/TablePro/Views/Main/EditorTabContextMenuBuilder.swift index f06ad43bb6..214b893374 100644 --- a/TablePro/Views/Main/EditorTabContextMenuBuilder.swift +++ b/TablePro/Views/Main/EditorTabContextMenuBuilder.swift @@ -58,27 +58,6 @@ internal enum EditorTabContextMenuBuilder { isEnabled: Bool = true, action: @escaping () -> Void ) { - let item = NSMenuItem(title: title, action: #selector(ClosureMenuTarget.fire), keyEquivalent: "") - let target = ClosureMenuTarget(action: action) - item.target = target - item.representedObject = target - item.isEnabled = isEnabled - menu.addItem(item) - } -} - -/// `NSMenuItem` holds its target weakly, so the closure needs an owner that outlives the menu. -/// `representedObject` is that owner: it is strong, it belongs to the item, and it goes when the -/// item does. -@MainActor -private final class ClosureMenuTarget: NSObject { - private let action: () -> Void - - init(action: @escaping () -> Void) { - self.action = action - } - - @objc func fire() { - action() + menu.addItem(ClosureMenuTarget.item(title: title, isEnabled: isEnabled, action: action)) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+HighlightRules.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+HighlightRules.swift new file mode 100644 index 0000000000..ab684bfd84 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+HighlightRules.swift @@ -0,0 +1,66 @@ +// +// MainContentCoordinator+HighlightRules.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + func highlightRuleScope(for tab: QueryTab) -> TableScope? { + tab.tableContext.scope(connectionId: connectionId) + } + + func highlightRules(for tab: QueryTab) -> [HighlightRule] { + guard let scope = highlightRuleScope(for: tab) else { return tab.sessionHighlightRules } + return HighlightRuleStorage.shared.rules(for: scope) + } + + func setHighlightRules(_ rules: [HighlightRule], forTab tabId: UUID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + if let scope = highlightRuleScope(for: tabManager.tabs[index]) { + HighlightRuleStorage.shared.setRules(rules, for: scope) + return + } + guard tabManager.tabs[index].sessionHighlightRules != rules else { return } + tabManager.mutate(at: index) { $0.sessionHighlightRules = rules } + } + + func applyQuickHighlight(_ rule: HighlightRule, forTab tabId: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + var rules = highlightRules(for: tab) + rules.removeAll { $0.hasSameCondition(as: rule) } + rules.insert(rule, at: 0) + setHighlightRules(rules, forTab: tabId) + } + + func removeHighlightRules(sharingConditionWith rule: HighlightRule, forTab tabId: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + let rules = highlightRules(for: tab).filter { !$0.hasSameCondition(as: rule) } + setHighlightRules(rules, forTab: tabId) + } + + func discardIncompleteHighlightRules(forTab tabId: UUID) { + guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return } + let rules = highlightRules(for: tab) + let complete = rules.filter(\.isValid) + guard complete.count != rules.count else { return } + setHighlightRules(complete, forTab: tabId) + } + + func presentHighlightRules(addingRuleForColumn columnName: String? = nil, occurrence: Int = 0) { + guard let index = tabManager.selectedTabIndex else { return } + let tabId = tabManager.tabs[index].id + if let columnName { + let newRule = HighlightRule(columnName: columnName, columnOccurrence: occurrence) + setHighlightRules(highlightRules(for: tabManager.tabs[index]) + [newRule], forTab: tabId) + } + tabManager.mutate(at: index) { $0.display.highlightRulesPresentationRequest &+= 1 } + } + + var canPresentHighlightRules: Bool { + guard hasMountedDataGrid, + let tab = tabManager.selectedTab, + tab.display.resultsViewMode == .data else { return false } + return !(tabSessionRegistry.existingTableRows(for: tab.id)?.columns.isEmpty ?? true) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift index 3bcfabbfb6..dd32e6b913 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift @@ -98,6 +98,11 @@ extension MainContentCoordinator { connectionId: connectionId, databaseName: database, schemaName: schema, tableName: newName ) ) + let scopedDatabase = database.isEmpty ? nil : database + HighlightRuleStorage.shared.rename( + from: TableScope(connectionId: connectionId, database: scopedDatabase, schema: schema, table: oldName), + to: TableScope(connectionId: connectionId, database: scopedDatabase, schema: schema, table: newName) + ) } private func moveFavorite(_ ref: DatabaseTreeTableRef, to newName: String, database: String?) { @@ -139,6 +144,10 @@ extension MainContentCoordinator { connectionId: connectionId, fromDatabase: database, fromSchema: schema, toDatabase: toDatabase, toSchema: toSchema ) + HighlightRuleStorage.shared.renameScope( + connectionId: connectionId, fromDatabase: database, fromSchema: schema, + toDatabase: toDatabase, toSchema: toSchema + ) retargetFavoriteTables( database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema ) diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 8ff2fda18c..b2b3ae6211 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -936,6 +936,15 @@ final class MainContentCommandActions { coordinator.toggleFilterPanel() } + var canPresentHighlightRules: Bool { + coordinator?.canPresentHighlightRules ?? false + } + + func showHighlightRules() { + guard canPresentHighlightRules, let coordinator else { return } + coordinator.presentHighlightRules() + } + func showFindBar() { guard canUseGridFindCommands, let coordinator else { return } coordinator.findCoordinator.show() diff --git a/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift b/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift index 3978dc9a0c..1d5ba6d736 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAccessibilityView.swift @@ -88,11 +88,20 @@ internal final class DataGridCellAccessibilityView: NSView { override internal func accessibilityValue() -> Any? { text } override internal func accessibilityLabel() -> String? { - String( - format: String(localized: "Row %d, column %d: %@"), + guard let highlight = coordinator?.highlightDescription(row: row, columnIndex: dataColumn) else { + return String( + format: String(localized: "Row %d, column %d: %@"), + row + 1, + dataColumn + 1, + text + ) + } + return String( + format: String(localized: "Row %d, column %d: %@, highlighted where %@"), row + 1, dataColumn + 1, - text + text, + highlight ) } diff --git a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift index 9a0d23f2d9..01b03621fe 100644 --- a/TablePro/Views/Results/Cells/DataGridCellAppearance.swift +++ b/TablePro/Views/Results/Cells/DataGridCellAppearance.swift @@ -15,7 +15,7 @@ struct DataGridCellAppearance: Equatable { let text: String let font: NSFont let textColor: NSColor - /// Painted behind the text, for a find match or a modified value. + /// Painted behind the text, for a find match, a modified value or a highlight rule. let backgroundTint: NSColor? let accessory: DataGridCellAccessory /// Which symbol the accessory draws, resolved here because it follows the row's state rather @@ -52,18 +52,19 @@ struct DataGridCellAppearance: Equatable { } let findTint: NSColor? = state.isCurrentFindMatch ? palette.findMatchTint : nil - let modifiedTint: NSColor? + let highlightColor = state.visualState.cellHighlightColor(forColumn: state.columnIndex) + let stateTint: NSColor? if state.visualState.isDeleted || state.visualState.isInserted { - modifiedTint = nil + stateTint = nil } else if state.visualState.isModified(columnIndex: state.columnIndex) { - modifiedTint = palette.modifiedColumnTint + stateTint = palette.modifiedColumnTint } else { - modifiedTint = nil + stateTint = highlightColor?.washColor } // A find match keeps its own highlight whatever else is true, and the text turns black // against it. Otherwise a selected row's text takes the selection's own colour, and the - // modified tint stands down so the selection fill is not painted over. + // modified or highlight tint stands down so the selection fill is not painted over. let backgroundTint: NSColor? let textColor: NSColor if let findTint { @@ -73,7 +74,7 @@ struct DataGridCellAppearance: Equatable { backgroundTint = nil textColor = .alternateSelectedControlTextColor } else { - backgroundTint = modifiedTint + backgroundTint = stateTint textColor = baseColor } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index 88ca3cb1d4..28e2f55309 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -550,6 +550,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData static let rowViewIdentifier = NSUserInterfaceItemIdentifier("TableRowView") let visualIndex = RowVisualIndex() + var highlightRuleSet: HighlightRuleSet = .empty private let largeDatasetThreshold = 5_000 var isLargeDataset: Bool { cachedRowCount > largeDatasetThreshold } @@ -693,6 +694,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData systemTimeZoneCancellable = nil detachAccessibilityActivationObserver() visualIndex.clear() + highlightRuleSet = .empty displayCache.removeAll() columnDisplayFormats = [] cachedRowCount = 0 @@ -747,6 +749,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData visualIndex.rebuild(from: changeManager, displayIDs: displayIDs) updateCache() tableView.insertRows(at: indices, withAnimation: Self.rowAnimation(.slideDown)) + repaintVisibleRowDecorations() } /// Accessibility > Display > Reduce Motion asks for no sliding rows, and the app @@ -764,6 +767,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData visualIndex.rebuild(from: changeManager, displayIDs: displayIDs) updateCache() tableView.removeRows(at: indices, withAnimation: Self.rowAnimation(.slideUp)) + repaintVisibleRowDecorations() } private func bumpDisplayRevision() { @@ -1046,6 +1050,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData private func invalidateDisplayCache(forDisplayRow displayIndex: Int, column: Int) { guard let row = displayRow(at: displayIndex) else { return } + displayCache.clearHighlight(forID: row.id) guard let box = displayCache.box(forID: row.id), column >= 0, column < box.values.count else { return } box.values[column] = nil @@ -1062,6 +1067,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData invalidateDisplayCache(forDisplayRow: row, column: column) visualIndex.updateRow(row, from: changeManager, displayIDs: displayIDs) redrawCells(rows: IndexSet(integer: row), tableColumnIndexes: IndexSet(integer: tableColumn)) + invalidateRowDecoration(displayRow: row) case .cellsChanged(let positions): guard !positions.isEmpty, let tableView else { return } var rowSet = IndexSet() @@ -1080,6 +1086,9 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData visualIndex.updateRow(row, from: changeManager, displayIDs: displayIDs) } redrawCells(rows: rowSet, tableColumnIndexes: colSet) + for row in rowSet { + invalidateRowDecoration(displayRow: row) + } case .rowsInserted(let indices): guard !indices.isEmpty else { return } overlayEditor?.dismiss(commit: false) @@ -1130,21 +1139,6 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData refreshRowVisualState(at: row) } - func refreshVisibleRowVisualStates() { - guard let tableView else { return } - tableView.enumerateAvailableRowViews { [weak self] rowView, row in - guard let self, let dataRowView = rowView as? DataGridRowView else { return } - dataRowView.applyVisualState(self.visualState(for: row)) - } - } - - func refreshRowVisualState(at row: Int) { - guard let tableView, - let dataRowView = tableView.rowView(atRow: row, makeIfNecessary: false) as? DataGridRowView - else { return } - dataRowView.applyVisualState(visualState(for: row)) - } - func commitActiveCellEdit() { overlayEditor?.dismiss(commit: true) overlayViewer?.dismiss() @@ -1419,7 +1413,7 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData if let delegateState = delegate?.dataGridVisualState(forRow: row) { return delegateState } - return visualIndex.visualState(for: row) + return visualIndex.visualState(for: row).highlighted(highlight(forDisplayRow: row)) } // MARK: - NSTableViewDataSource diff --git a/TablePro/Views/Results/DataGridRowView.swift b/TablePro/Views/Results/DataGridRowView.swift index df68344e02..53faf4f8f1 100644 --- a/TablePro/Views/Results/DataGridRowView.swift +++ b/TablePro/Views/Results/DataGridRowView.swift @@ -37,8 +37,9 @@ class DataGridRowView: NSTableRowView { private var seededRowIndex: Int = 0 - private(set) var visualState: RowVisualState = .empty - private var rowTint: NSColor? + var visualState: RowVisualState { + coordinator?.visualState(for: rowIndex) ?? .empty + } /// Draws the row's data cells. /// @@ -201,14 +202,7 @@ class DataGridRowView: NSTableRowView { "hidden": NSNull(), ] - /// The tint derives from the row state and the active theme, so it is recomputed on every call - /// and the colour comparison below decides whether anything needs redrawing. Returning early on - /// an unchanged state would ignore the theme, which is the input a theme change moves. - func applyVisualState(_ state: RowVisualState) { - visualState = state - let nextTint = state.tint - guard !colorsEqual(rowTint, nextTint) else { return } - rowTint = nextTint + func invalidateVisualState() { needsDisplay = true } @@ -236,8 +230,8 @@ class DataGridRowView: NSTableRowView { override func drawBackground(in dirtyRect: NSRect) { super.drawBackground(in: dirtyRect) - if let rowTint, !isSelected { - rowTint.setFill() + if !isSelected, let tint = visualState.tint { + tint.setFill() bounds.fill() } drawCellSelectionFill(in: dirtyRect) @@ -277,14 +271,6 @@ class DataGridRowView: NSTableRowView { private static let emphasizedCellSelectionAlpha: CGFloat = 0.28 - private func colorsEqual(_ lhs: NSColor?, _ rhs: NSColor?) -> Bool { - switch (lhs, rhs) { - case (nil, nil): return true - case let (l?, r?): return l == r - default: return false - } - } - private func addForeignKeyMenuItems(to menu: NSMenu, dataColumnIndex: Int, tableRows: TableRows) { guard let coordinator, dataColumnIndex >= 0, dataColumnIndex < tableRows.columns.count else { return } let columnName = tableRows.columns[dataColumnIndex] @@ -510,6 +496,14 @@ class DataGridRowView: NSTableRowView { jsonViewItem.target = self menu.addItem(jsonViewItem) + if dataColumnIndex >= 0, + let highlightItem = coordinator.delegate?.dataGridHighlightMenuItem( + forRow: rowIndex, + dataColumn: dataColumnIndex + ) { + menu.addItem(highlightItem) + } + let tableRows = coordinator.tableRowsProvider() addForeignKeyMenuItems(to: menu, dataColumnIndex: dataColumnIndex, tableRows: tableRows) diff --git a/TablePro/Views/Results/DataGridUpdateSnapshot.swift b/TablePro/Views/Results/DataGridUpdateSnapshot.swift index 51c3667cfa..c5a1359b4c 100644 --- a/TablePro/Views/Results/DataGridUpdateSnapshot.swift +++ b/TablePro/Views/Results/DataGridUpdateSnapshot.swift @@ -13,6 +13,7 @@ struct DataGridUpdateSnapshot: Equatable { let columns: [String] let valueFilteredIDsCount: Int? let displayFormats: [ValueDisplayFormat?] + let highlightRules: [HighlightRule] let configuration: DataGridConfiguration let isEditable: Bool let rowReorder: DataGridRowReorder diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index a01aebcd71..baabbbbcca 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -19,11 +19,28 @@ struct RowVisualState: Equatable { let isDeleted: Bool let isInserted: Bool let modifiedColumns: Set + let highlight: RowHighlight + + init(isDeleted: Bool, isInserted: Bool, modifiedColumns: Set, highlight: RowHighlight = .none) { + self.isDeleted = isDeleted + self.isInserted = isInserted + self.modifiedColumns = modifiedColumns + self.highlight = highlight + } func isModified(columnIndex: Int) -> Bool { modifiedColumns.contains(columnIndex) } + func highlighted(_ highlight: RowHighlight) -> RowVisualState { + RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: modifiedColumns, + highlight: highlight + ) + } + static let empty = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: []) } @@ -33,7 +50,20 @@ extension RowVisualState { @MainActor var tint: NSColor? { if isDeleted { return ThemeEngine.shared.colors.dataGrid.deleted } if isInserted { return ThemeEngine.shared.colors.dataGrid.inserted } - return nil + return highlight.rowColor?.washColor + } + + func cellHighlightColor(forColumn column: Int) -> HighlightColor? { + guard !isDeleted, !isInserted else { return nil } + return highlight.cellRule(forColumn: column)?.color + } + + func drawnHighlightRule(forColumn column: Int) -> HighlightRule? { + guard !isDeleted, !isInserted else { return nil } + if !isModified(columnIndex: column), let cellRule = highlight.cellRule(forColumn: column) { + return cellRule + } + return highlight.rowRule } } @@ -45,6 +75,7 @@ struct DataGridView: NSViewRepresentable { let isEditable: Bool var configuration: DataGridConfiguration = .init() var displayFormats: [ValueDisplayFormat?] = [] + var highlightRules: [HighlightRule] = [] var delegate: (any DataGridViewDelegate)? var layoutPersister: (any ColumnLayoutPersisting)? /// Whether a row may be dragged to a new position, and why not when it may not. @@ -145,6 +176,7 @@ struct DataGridView: NSViewRepresentable { let initialRows = tableRowsProvider() coordinator.rebuildColumnMetadataCache(from: initialRows) + coordinator.syncHighlightRules(highlightRules, tableRows: initialRows) coordinator.isRebuildingColumns = true let storedInitialLayout = coordinator.layoutDiscardingUnownedWidths( @@ -223,6 +255,7 @@ struct DataGridView: NSViewRepresentable { columns: latestRows.columns, valueFilteredIDsCount: coordinator.valueFilteredIDs?.count, displayFormats: displayFormats, + highlightRules: highlightRules, configuration: configuration, isEditable: isEditable, rowReorder: rowReorder, @@ -320,6 +353,7 @@ struct DataGridView: NSViewRepresentable { let liveColumnWidths = latestRows.columns.isEmpty ? [:] : coordinator.currentColumnWidths() coordinator.apply(configuration: configuration, isEditable: isEditable) let schemaChanged = coordinator.rebuildColumnMetadataCache(from: latestRows) + let highlightsChanged = coordinator.syncHighlightRules(highlightRules, tableRows: latestRows) let presentationChanges = coordinator.updateColumnPresentations(from: latestRows) let needsFullReload = structureChanged || schemaChanged @@ -385,6 +419,8 @@ struct DataGridView: NSViewRepresentable { coordinator.startBackgroundPrewarm() } else if displayFormatsChanged { coordinator.reloadAfterDisplayFormatChange() + } else if highlightsChanged { + coordinator.repaintVisibleRowDecorations() } } diff --git a/TablePro/Views/Results/DataGridViewDelegate.swift b/TablePro/Views/Results/DataGridViewDelegate.swift index f21ea8e748..68b40dc12f 100644 --- a/TablePro/Views/Results/DataGridViewDelegate.swift +++ b/TablePro/Views/Results/DataGridViewDelegate.swift @@ -38,6 +38,8 @@ protocol DataGridViewDelegate: AnyObject { func dataGridShowAllColumns() func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] + func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? + func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? func dataGridVisualState(forRow row: Int) -> RowVisualState? func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? func dataGridEmptySpaceMenu() -> NSMenu? @@ -83,6 +85,8 @@ extension DataGridViewDelegate { func dataGridShowAllColumns() {} func dataGridColumnStructureMenuItems(forColumn dataColumnIndex: Int) -> [NSMenuItem] { [] } func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { [] } + func dataGridHighlightMenuItem(forRow displayRow: Int, dataColumn: Int) -> NSMenuItem? { nil } + func dataGridHighlightValuesMenuItem(forColumn dataColumnIndex: Int) -> NSMenuItem? { nil } func dataGridVisualState(forRow row: Int) -> RowVisualState? { nil } func dataGridRowView(for tableView: NSTableView, row: Int, coordinator: TableViewCoordinator) -> NSTableRowView? { nil } func dataGridEmptySpaceMenu() -> NSMenu? { nil } diff --git a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift index ddb83f651c..285570a131 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift @@ -20,6 +20,7 @@ extension TableViewCoordinator { invalidateDisplayCache() visualIndex.updateRow(row, from: changeManager, displayIDs: displayIDs) + invalidateRowDecoration(displayRow: row) guard let tableColumnIndex = tableColumnIndex(for: columnIndex) else { return } redrawCells(rows: IndexSet(integer: row), tableColumnIndexes: IndexSet(integer: tableColumnIndex)) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift index d454f915cd..a21e7c2109 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Columns.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Columns.swift @@ -142,13 +142,7 @@ extension TableViewCoordinator { func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { if let delegateRowView = delegate?.dataGridRowView(for: tableView, row: row, coordinator: self) { - // Delegate-provided row views (e.g. StructureRowViewWithMenu) must still - // pick up the deleted/inserted/modified tint. Apply the visual state if - // the row view subclasses DataGridRowView; otherwise the delegate is - // responsible for its own visual state. - if let dataGridRow = delegateRowView as? DataGridRowView { - dataGridRow.applyVisualState(visualState(for: row)) - } + (delegateRowView as? DataGridRowView)?.invalidateVisualState() return delegateRowView } let rowView = (tableView.makeView(withIdentifier: Self.rowViewIdentifier, owner: nil) as? DataGridRowView) @@ -156,7 +150,7 @@ extension TableViewCoordinator { rowView.identifier = Self.rowViewIdentifier rowView.coordinator = self rowView.rowIndex = row - rowView.applyVisualState(visualState(for: row)) + rowView.invalidateVisualState() return rowView } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+RowDecoration.swift b/TablePro/Views/Results/Extensions/DataGridView+RowDecoration.swift new file mode 100644 index 0000000000..b3c8e9538d --- /dev/null +++ b/TablePro/Views/Results/Extensions/DataGridView+RowDecoration.swift @@ -0,0 +1,77 @@ +// +// DataGridView+RowDecoration.swift +// TablePro +// + +import AppKit + +extension TableViewCoordinator { + func refreshVisibleRowVisualStates() { + guard let tableView else { return } + tableView.enumerateAvailableRowViews { rowView, _ in + (rowView as? DataGridRowView)?.invalidateVisualState() + } + } + + func refreshRowVisualState(at row: Int) { + guard let tableView, + let dataRowView = tableView.rowView(atRow: row, makeIfNecessary: false) as? DataGridRowView + else { return } + dataRowView.invalidateVisualState() + } + + @discardableResult + func syncHighlightRules(_ rules: [HighlightRule], tableRows: TableRows) -> Bool { + let key = HighlightRuleSet.Key(rules: rules, columns: tableRows.columns, columnTypes: tableRows.columnTypes) + let compiledChanged = highlightRuleSet.key != key + if compiledChanged { + highlightRuleSet = HighlightRuleSet( + rules: rules, + columns: tableRows.columns, + columnTypes: tableRows.columnTypes + ) + } + guard displayState.highlightRuleSetKey != key else { return compiledChanged } + displayState.highlightRuleSetKey = key + displayCache.clearHighlights() + return true + } + + func highlight(forDisplayRow displayIndex: Int) -> RowHighlight { + guard !highlightRuleSet.isEmpty, let row = displayRow(at: displayIndex) else { return .none } + if let cached = displayCache.highlight(forID: row.id) { return cached } + let resolved = highlightRuleSet.highlight(for: row.values) + displayCache.setHighlight(resolved, forID: row.id) + return resolved + } + + func highlightDescription(row: Int, columnIndex: Int) -> String? { + guard let rule = visualState(for: row).drawnHighlightRule(forColumn: columnIndex) else { return nil } + return HighlightRuleDescription.condition(of: rule, valueLimit: HighlightRuleDescription.menuValueLimit) + } + + func invalidateRowDecoration(displayRow row: Int) { + guard let tableView, row >= 0, row < tableView.numberOfRows else { return } + if let rowView = tableView.rowView(atRow: row, makeIfNecessary: false) as? DataGridRowView { + rowView.invalidateVisualState() + rowView.redrawCells() + } + repaintRowGutter(forRow: row) + } + + func repaintVisibleRowDecorations() { + guard let tableView else { return } + tableView.enumerateAvailableRowViews { rowView, _ in + guard let dataRowView = rowView as? DataGridRowView else { return } + dataRowView.invalidateVisualState() + dataRowView.redrawCells() + } + repaintRowGutter() + } + + private func repaintRowGutter(forRow row: Int) { + guard let rowGutter, let tableView else { return } + let band = rowGutter.convert(tableView.rect(ofRow: row), from: tableView) + rowGutter.setNeedsDisplay(NSRect(x: 0, y: band.minY, width: rowGutter.bounds.width, height: band.height)) + } +} diff --git a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift index 2fd249928c..e0b9ea45f2 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Sort.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Sort.swift @@ -170,6 +170,11 @@ extension TableViewCoordinator { menu.addItem(clearAllItem) } + if let dataColumnIndex = dataColumnIndex(from: column.identifier), + let highlightItem = delegate?.dataGridHighlightValuesMenuItem(forColumn: dataColumnIndex) { + menu.addItem(highlightItem) + } + if let dataColumnIndex = dataColumnIndex(from: column.identifier) { addDisplayFormatMenu(to: menu, dataColumnIndex: dataColumnIndex, tableRows: tableRows) } diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index 35d02b1ad1..c2fa3a6478 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -24,6 +24,7 @@ struct ResultStatusBar: View { let snapshot: StatusBarSnapshot let filterState: TabFilterState let columnState: StatusBarColumnState + let highlightState: StatusBarHighlightState let paginationCallbacks: PaginationCallbacks let structureFooter: StructureFooterCapability let execution: ExecutionReadout @@ -37,6 +38,7 @@ struct ResultStatusBar: View { let onStructureRemove: () -> Void @State private var showColumnPopover = false + @State private var showHighlightPopover = false var body: some View { HStack(spacing: StatusBarChrome.clusterSpacing) { @@ -50,6 +52,15 @@ struct ResultStatusBar: View { .statusBarChrome() .onChange(of: snapshot.tabId) { _, _ in showColumnPopover = false + showHighlightPopover = false + } + .onChange(of: showHighlightPopover) { _, isShown in + guard !isShown else { return } + highlightState.onDismiss() + } + .onChange(of: highlightPresentation) { previous, current in + guard previous.tabId == current.tabId, model.controls.showsHighlightRules else { return } + showHighlightPopover = true } } @@ -159,6 +170,9 @@ struct ResultStatusBar: View { if model.controls.showsColumns { columnsButton } + if model.controls.showsHighlightRules { + highlightButton + } if model.controls.showsFilters { filtersToggle } @@ -240,6 +254,43 @@ struct ResultStatusBar: View { } } + private var highlightButton: some View { + Button { + showHighlightPopover.toggle() + } label: { + Label { + Text("Highlight Rules") + } icon: { + Image(systemName: "highlighter") + } + } + .labelStyle(.iconOnly) + .controlSize(.small) + .disabled(highlightState.columns.isEmpty) + .help(String(localized: "Highlight Rules")) + .accessibilityLabel(String(localized: "Highlight Rules")) + .accessibilityValue(highlightAccessibilityValue) + .accessibilityIdentifier("result-status-highlight") + .popover(isPresented: $showHighlightPopover, arrowEdge: .top) { + HighlightRulesPopover( + columns: highlightState.columns, + rules: highlightState.rules, + isPersisted: highlightState.isPersisted, + onChange: highlightState.onChange + ) + } + } + + private var highlightPresentation: HighlightPresentationRequest { + HighlightPresentationRequest(tabId: snapshot.tabId, count: highlightState.presentationRequest) + } + + private var highlightAccessibilityValue: String { + let count = highlightState.activeRuleCount + guard count > 0 else { return String(localized: "No highlight rules") } + return String(format: String(localized: "%d rules"), count) + } + private var filtersToggle: some View { Toggle(isOn: Binding(get: { filterState.isVisible }, set: { _ in onToggleFilters() })) { Label { diff --git a/TablePro/Views/Results/ResultStatusInputs.swift b/TablePro/Views/Results/ResultStatusInputs.swift index 5473a9d500..3c060b58d1 100644 --- a/TablePro/Views/Results/ResultStatusInputs.swift +++ b/TablePro/Views/Results/ResultStatusInputs.swift @@ -16,6 +16,24 @@ struct PaginationCallbacks { let onRequestExactCount: () -> Void } +struct HighlightPresentationRequest: Equatable { + let tabId: UUID? + let count: Int +} + +struct StatusBarHighlightState { + let rules: [HighlightRule] + let columns: [String] + let isPersisted: Bool + let presentationRequest: Int + let onChange: ([HighlightRule]) -> Void + let onDismiss: () -> Void + + var activeRuleCount: Int { + rules.filter { $0.isEnabled && $0.isValid }.count + } +} + struct StatusBarColumnState { let hidden: Set let columns: [GridColumnEntry] diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index abc9f5bd9f..319a9d5f5c 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -474,13 +474,6 @@ final class StructureGridDelegate: DataGridViewDelegate { rowView.isStructureEditable = connection.type.supportsSchemaEditing let src = sourceRow(for: row) - // Don't set `isDeleted` / visual state here. `DataGridView+Columns` - // calls `applyVisualState(visualState(for: row))` on every row view it - // returns from `tableView(_:rowViewForRow:)`. Setting it twice is a - // smell that previously hid the bug: when `applyVisualState` was a - // tint-only setter, this line was the only place the menu's - // `isDeleted` flag was assigned, and it was assigned only on row-view - // creation. Single source of truth now is `DataGridRowView.visualState`. if selectedTab == .foreignKeys, src < structureChangeManager.workingForeignKeys.count { rowView.referencedTableName = structureChangeManager.workingForeignKeys[src].referencedTable diff --git a/TablePro/Views/Structure/StructureRowViewWithMenu.swift b/TablePro/Views/Structure/StructureRowViewWithMenu.swift index 7cabb81da2..6cd092ac5c 100644 --- a/TablePro/Views/Structure/StructureRowViewWithMenu.swift +++ b/TablePro/Views/Structure/StructureRowViewWithMenu.swift @@ -11,8 +11,8 @@ import AppKit /// Row view providing a context menu tailored to the Structure tab. Inherits /// selection/emphasis cell invalidation, deleted/inserted-row tint, and the /// `RowVisualState` source-of-truth from `DataGridRowView`. The context menu -/// reads `visualState.isDeleted` directly, so a single `applyVisualState` call -/// updates both the tint and the menu without a shadow flag to keep in sync. +/// reads the same live `visualState` the tint is drawn from, so the two cannot +/// disagree. final class StructureRowViewWithMenu: DataGridRowView { var structureTab: StructureTab = .columns var isStructureEditable: Bool = true diff --git a/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift index fdf38700ae..ae2b3a0286 100644 --- a/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift +++ b/TableProTests/Core/DataGrid/RowDisplayCacheTests.swift @@ -191,6 +191,27 @@ struct RowDisplayCacheTests { #expect(cache.box(forID: .existing(1)) == nil) } + @Test("A row's highlight lives and dies with its formatted text") + func highlightSharesTheTextLifetime() { + let cache = RowDisplayCache() + let highlight = RowHighlight(rowRule: HighlightRule(columnName: "c", value: "x"), cellRules: [:]) + cache.setBox(makeBox(["x"]), forID: .existing(1)) + cache.setHighlight(highlight, forID: .existing(1)) + cache.setHighlight(highlight, forID: .existing(2)) + + cache.clearValues(forID: .existing(1)) + #expect(cache.highlight(forID: .existing(1)) == nil) + #expect(cache.highlight(forID: .existing(2)) == highlight) + + cache.clearHighlights() + #expect(cache.highlight(forID: .existing(2)) == nil) + #expect(cache.box(forID: .existing(1)) != nil) + + cache.setHighlight(highlight, forID: .existing(3)) + cache.removeAll() + #expect(cache.highlight(forID: .existing(3)) == nil) + } + @Test("Inserted row IDs of both kinds round-trip") func mixedRowIDKinds() { let cache = RowDisplayCache() diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 03d66c10d8..d088d362d4 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -419,6 +419,17 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.toggleFilterBar(_:)), context)) } + @Test("Highlight Rules needs a connected data grid with columns") + func highlightRulesNeedsDataGrid() { + var context = MenuValidationContext() + context.canPresentHighlightRules = true + #expect(!enabled(#selector(MainSplitViewController.showHighlightRules(_:)), context)) + context.isConnected = true + #expect(enabled(#selector(MainSplitViewController.showHighlightRules(_:)), context)) + context.canPresentHighlightRules = false + #expect(!enabled(#selector(MainSplitViewController.showHighlightRules(_:)), context)) + } + @Test("Capability flags gate driver-specific commands") func capabilitiesGateCommands() { var context = MenuValidationContext() @@ -445,6 +456,8 @@ struct MainMenuValidationTests { private func capableContext() -> MenuValidationContext { var context = MenuValidationContext() context.canUseTableResultCommands = true + context.canPresentHighlightRules = true + context.canNavigatePages = true context.isQueryTab = true context.hasResultRows = true context.hasQueryText = true @@ -489,7 +502,8 @@ struct MainMenuValidationTests { #selector(MainSplitViewController.openContainerSwitcher(_:)), #selector(MainSplitViewController.showServerDashboard(_:)), #selector(MainSplitViewController.showUsersAndRoles(_:)), - #selector(MainSplitViewController.toggleFilterBar(_:)) + #selector(MainSplitViewController.toggleFilterBar(_:)), + #selector(MainSplitViewController.showHighlightRules(_:)) ] } diff --git a/TableProTests/Core/Services/Highlight/HighlightConditionTests.swift b/TableProTests/Core/Services/Highlight/HighlightConditionTests.swift new file mode 100644 index 0000000000..c18f9d2f9f --- /dev/null +++ b/TableProTests/Core/Services/Highlight/HighlightConditionTests.swift @@ -0,0 +1,147 @@ +// +// HighlightConditionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Highlight condition matching") +struct HighlightConditionTests { + private func matches( + _ value: PluginCellValue, + _ filterOperator: FilterOperator, + _ operand: String = "", + second: String? = nil, + caseSensitive: Bool? = nil, + type: ColumnType? = .text(rawType: "VARCHAR") + ) -> Bool { + let rule = HighlightRule( + columnName: "c", + filterOperator: filterOperator, + value: operand, + secondValue: second, + isCaseSensitive: caseSensitive + ) + return HighlightCondition(rule: rule, columnType: type).matches(value) + } + + @Test("Equality on text is exact and case-sensitive by default") + func textEquality() { + #expect(matches("paid", .equal, "paid")) + #expect(!matches("Paid", .equal, "paid")) + #expect(matches("Paid", .equal, "paid", caseSensitive: false)) + #expect(matches("pending", .notEqual, "paid")) + #expect(!matches("007", .equal, "7")) + } + + @Test("A padded value matches exactly as stored, so a quick rule matches its own cell") + func paddedValuesMatchAsStored() { + #expect(matches("abc ", .equal, "abc ", type: .text(rawType: "CHAR(10)"))) + #expect(!matches("abc", .equal, "abc ", type: .text(rawType: "CHAR(10)"))) + #expect(matches(" ", .equal, " ")) + #expect(matches("42", .equal, " 42 ", type: .integer(rawType: "INT"))) + } + + @Test("NULL fails every comparison and matches only IS NULL and IS EMPTY") + func nullSemantics() { + #expect(matches(.null, .isNull)) + #expect(matches(.null, .isEmpty)) + #expect(!matches(.null, .isNotNull)) + #expect(!matches(.null, .notEqual, "paid")) + #expect(!matches(.null, .greaterThan, "1", type: .integer(rawType: "INT"))) + #expect(!matches(.null, .notContains, "x")) + #expect(!matches(.null, .notInList, "a, b")) + } + + @Test("The literal NULL means IS NULL on a column that is not text") + func nullLiteral() { + #expect(matches(.null, .equal, "NULL", type: .integer(rawType: "INT"))) + #expect(!matches("5", .equal, "NULL", type: .integer(rawType: "INT"))) + #expect(matches("5", .notEqual, "null", type: .integer(rawType: "INT"))) + #expect(!matches(.null, .equal, "NULL")) + #expect(matches("NULL", .equal, "NULL")) + } + + @Test("Numbers compare numerically on a numeric column") + func numericColumns() { + let integer = ColumnType.integer(rawType: "INT") + #expect(matches("1000", .greaterThan, "999", type: integer)) + #expect(matches("1.0", .equal, "1", type: .decimal(rawType: "DECIMAL"))) + #expect(matches("5", .between, "1", second: "10", type: integer)) + #expect(!matches("11", .between, "1", second: "10", type: integer)) + #expect(matches("10", .lessOrEqual, "10", type: integer)) + } + + @Test("Ordering on text compares numerically only when both sides are numbers") + func orderingOnText() { + #expect(matches("1000", .greaterThan, "999")) + #expect(matches("banana", .greaterThan, "apple")) + #expect(!matches("apple", .greaterThan, "banana")) + } + + @Test("Boolean columns accept every spelling of true and false") + func booleans() { + let boolean = ColumnType.boolean(rawType: "BOOLEAN") + #expect(matches("t", .equal, "true", type: boolean)) + #expect(matches("1", .equal, "yes", type: boolean)) + #expect(matches("false", .equal, "0", type: boolean)) + #expect(!matches("f", .equal, "true", type: boolean)) + #expect(matches("1", .equal, "true", type: .integer(rawType: "TINYINT(1)"))) + } + + @Test("Pattern operators ignore case by default and honour Match Case") + func patterns() { + #expect(matches("Hello World", .contains, "world")) + #expect(!matches("Hello World", .contains, "world", caseSensitive: true)) + #expect(matches("Hello", .startsWith, "he")) + #expect(matches("Hello", .endsWith, "LLO")) + #expect(!matches("Hello", .endsWith, "hel")) + #expect(matches("Hello", .notContains, "xyz")) + } + + @Test("Empty means NULL or an empty string on text, and only NULL elsewhere") + func emptiness() { + #expect(matches("", .isEmpty)) + #expect(!matches("x", .isEmpty)) + #expect(matches("x", .isNotEmpty)) + #expect(!matches("", .isNotEmpty)) + #expect(!matches("", .isEmpty, type: .integer(rawType: "INT"))) + #expect(matches("", .isNotEmpty, type: .integer(rawType: "INT"))) + } + + @Test("IN and NOT IN split on commas and trim each item") + func lists() { + #expect(matches("b", .inList, "a, b ,c")) + #expect(!matches("d", .inList, "a, b, c")) + #expect(matches("d", .notInList, "a, b, c")) + #expect(!matches("a", .notInList, "a, b")) + #expect(matches(.null, .inList, "a, NULL", type: .integer(rawType: "INT"))) + } + + @Test("A regular expression searches the value, and an invalid one matches nothing") + func regex() { + #expect(matches("order-42", .regex, "\\d+$")) + #expect(!matches("order", .regex, "\\d+$")) + #expect(matches("ABC", .regex, "abc", caseSensitive: false)) + #expect(!matches("anything", .regex, "(unclosed")) + } + + @Test("A binary value matches only IS NULL and IS NOT NULL") + func binary() { + let bytes = PluginCellValue.bytes(Data([0x01, 0x02])) + #expect(matches(bytes, .isNotNull)) + #expect(!matches(bytes, .isNull)) + #expect(!matches(bytes, .equal, "0x0102")) + #expect(!matches(bytes, .contains, "01")) + } + + @Test("A search past the cap only looks at the leading part of a very long value") + func searchIsCapped() { + let long = String(repeating: "a", count: HighlightCondition.searchLimit + 50) + "needle" + #expect(!matches(.text(long), .contains, "needle")) + #expect(matches(.text("needle" + long), .contains, "needle")) + } +} diff --git a/TableProTests/Core/Services/Highlight/HighlightRuleSetTests.swift b/TableProTests/Core/Services/Highlight/HighlightRuleSetTests.swift new file mode 100644 index 0000000000..b8f5d9713e --- /dev/null +++ b/TableProTests/Core/Services/Highlight/HighlightRuleSetTests.swift @@ -0,0 +1,176 @@ +// +// HighlightRuleSetTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Highlight rule set") +struct HighlightRuleSetTests { + private let columns = ["id", "status", "total"] + private let types: [ColumnType] = [.integer(rawType: "INT"), .text(rawType: "VARCHAR"), .decimal(rawType: "DECIMAL")] + + private func row(_ values: PluginCellValue...) -> ContiguousArray { + ContiguousArray(values) + } + + @Test("The first matching row rule sets the row's color, and reordering flips it") + func firstRowRuleWins() { + let paid = HighlightRule(columnName: "status", value: "paid", color: .green) + let big = HighlightRule(columnName: "total", filterOperator: .greaterThan, value: "100", color: .red) + let values = row("1", "paid", "500") + + let paidFirst = HighlightRuleSet(rules: [paid, big], columns: columns, columnTypes: types) + let bigFirst = HighlightRuleSet(rules: [big, paid], columns: columns, columnTypes: types) + + #expect(paidFirst.highlight(for: values).rowColor == .green) + #expect(bigFirst.highlight(for: values).rowColor == .red) + } + + @Test("A cell rule colours its own column and leaves the row rule in place") + func cellRulesColourTheirColumn() { + let rowRule = HighlightRule(columnName: "status", value: "paid", color: .green) + let cellRule = HighlightRule( + columnName: "total", filterOperator: .greaterThan, value: "100", color: .red, target: .cell + ) + let highlight = HighlightRuleSet(rules: [rowRule, cellRule], columns: columns, columnTypes: types) + .highlight(for: row("1", "paid", "500")) + + #expect(highlight.rowColor == .green) + #expect(highlight.cellRule(forColumn: 2)?.color == .red) + #expect(highlight.cellRule(forColumn: 1) == nil) + #expect(highlight.describingRule(forColumn: 2) == cellRule) + #expect(highlight.describingRule(forColumn: 0) == rowRule) + } + + @Test("Disabled and incomplete rules never match") + func disabledAndIncompleteRules() { + let disabled = HighlightRule(isEnabled: false, columnName: "status", value: "paid", color: .green) + let incomplete = HighlightRule(columnName: "status", value: "", color: .red) + let set = HighlightRuleSet(rules: [disabled, incomplete], columns: columns, columnTypes: types) + + #expect(set.isEmpty) + #expect(set.highlight(for: row("1", "paid", "5")) == .none) + } + + @Test("A rule whose column is not in the result is reported, not dropped") + func missingColumnIsUnresolved() { + let rule = HighlightRule(columnName: "archived", filterOperator: .isNotNull, color: .gray) + let set = HighlightRuleSet(rules: [rule], columns: columns, columnTypes: types) + + #expect(set.unresolvedRuleIDs == [rule.id]) + #expect(set.highlight(for: row("1", "paid", "5")) == .none) + } + + @Test("A duplicated column name resolves by occurrence") + func duplicateColumnsResolveByOccurrence() { + let duplicated = ["status", "status"] + let textTypes: [ColumnType] = [.text(rawType: nil), .text(rawType: nil)] + let second = HighlightRule( + columnName: "status", columnOccurrence: 1, value: "paid", color: .blue, target: .cell + ) + let highlight = HighlightRuleSet(rules: [second], columns: duplicated, columnTypes: textTypes) + .highlight(for: row("paid", "paid")) + + #expect(highlight.cellRule(forColumn: 0) == nil) + #expect(highlight.cellRule(forColumn: 1)?.color == .blue) + #expect(HighlightRuleSet.occurrence(ofColumnAt: 1, in: duplicated) == 1) + #expect(HighlightRuleSet.columnIndex(named: "status", occurrence: 2, in: duplicated) == nil) + } +} + +@Suite("Highlight rule descriptions and quick rules") +@MainActor +struct HighlightRuleDescriptionTests { + @Test("A comparison reads as column, symbol and quoted value") + func comparisonTitle() { + let rule = HighlightRule(columnName: "status", value: "paid") + #expect(HighlightRuleDescription.condition(of: rule) == "status = “paid”") + } + + @Test("An operator without a value reads as its name") + func valuelessTitle() { + let rule = HighlightRule(columnName: "notes", filterOperator: .isNull) + #expect(HighlightRuleDescription.condition(of: rule) == "notes is NULL") + } + + @Test("A long value is truncated in menu titles only") + func longValuesTruncate() { + let value = String(repeating: "x", count: 50) + let rule = HighlightRule(columnName: "notes", value: value) + let title = HighlightRuleDescription.condition(of: rule, valueLimit: HighlightRuleDescription.menuValueLimit) + + #expect(title == "notes = “\(String(repeating: "x", count: 32))…”") + #expect(HighlightRuleDescription.condition(of: rule).contains(value)) + } + + @Test("The quick rule follows the clicked cell's raw value") + func quickRuleFromCell() { + let text = HighlightMenuBuilder.quickRule( + columnName: "status", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: "paid", target: .row, color: .green + ) + let null = HighlightMenuBuilder.quickRule( + columnName: "status", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: .null, target: .cell, color: .red + ) + let empty = HighlightMenuBuilder.quickRule( + columnName: "status", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: "", target: .row, color: .red + ) + let binary = HighlightMenuBuilder.quickRule( + columnName: "blob", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), value: .bytes(Data([1])), target: .row, color: .red + ) + + #expect(text?.filterOperator == .equal) + #expect(text?.value == "paid") + #expect(null?.filterOperator == .isNull) + #expect(null?.target == .cell) + #expect(empty?.filterOperator == .isEmpty) + #expect(binary == nil) + } + + @Test("A quick rule cannot be built from a value the rule would read as NULL") + func quickRuleRefusesTheNullKeyword() { + let json = HighlightMenuBuilder.quickRule( + columnName: "payload", columnOccurrence: 0, columnType: .json(rawType: "JSONB"), + value: "null", target: .row, color: .red + ) + let text = HighlightMenuBuilder.quickRule( + columnName: "note", columnOccurrence: 0, columnType: .text(rawType: "VARCHAR"), + value: "null", target: .row, color: .red + ) + + #expect(json == nil) + #expect(text?.value == "null") + } + + @Test("Menu sections name the target and the condition") + func sectionTitles() { + let row = HighlightRule(columnName: "status", value: "paid", target: .row) + let cell = HighlightRule(columnName: "status", value: "paid", target: .cell) + + #expect(HighlightMenuBuilder.sectionTitle(for: row) == "Rows Where status = “paid”") + #expect(HighlightMenuBuilder.sectionTitle(for: cell) == "Cells Where status = “paid”") + } + + @Test("Two rules share a condition regardless of their color") + func sameCondition() { + let green = HighlightRule(columnName: "status", value: "paid", color: .green) + var red = green + red.color = .red + let cell = HighlightRule(columnName: "status", value: "paid", color: .green, target: .cell) + + #expect(HighlightRule(columnName: "status", value: "paid", color: .red).hasSameCondition(as: green)) + #expect(red.hasSameCondition(as: green)) + #expect(!cell.hasSameCondition(as: green)) + } + + @Test("Duplicated columns get a numbered label") + func columnOptions() { + let options = HighlightColumnOption.options(for: ["id", "status", "status"]) + + #expect(options.map(\.label) == ["id", "status (1)", "status (2)"]) + #expect(options.map(\.occurrence) == [0, 0, 1]) + } +} diff --git a/TableProTests/Core/Storage/HighlightRuleStorageTests.swift b/TableProTests/Core/Storage/HighlightRuleStorageTests.swift new file mode 100644 index 0000000000..f970093cb6 --- /dev/null +++ b/TableProTests/Core/Storage/HighlightRuleStorageTests.swift @@ -0,0 +1,132 @@ +// +// HighlightRuleStorageTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Highlight rule storage") +@MainActor +struct HighlightRuleStorageTests { + private let directory: URL + private let connectionId = UUID() + + init() { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("HighlightRuleStorageTests-\(UUID().uuidString)", isDirectory: true) + } + + private func scope(table: String, database: String? = "shop", schema: String? = "public") -> TableScope { + TableScope(connectionId: connectionId, database: database, schema: schema, table: table) + } + + private var fileURL: URL { + directory.appendingPathComponent("\(connectionId.uuidString).json") + } + + @Test("Rules round-trip through a fresh store") + func roundTrip() { + let rules = [ + HighlightRule(columnName: "status", value: "paid", color: .green), + HighlightRule(columnName: "total", filterOperator: .greaterThan, value: "10", color: .red, target: .cell) + ] + HighlightRuleStorage(storageDirectory: directory).setRules(rules, for: scope(table: "orders")) + + let reloaded = HighlightRuleStorage(storageDirectory: directory) + #expect(reloaded.rules(for: scope(table: "orders")) == rules) + #expect(reloaded.rules(for: scope(table: "customers")).isEmpty) + } + + @Test("Clearing the last rule removes the connection's file") + func clearingRemovesFile() { + let storage = HighlightRuleStorage(storageDirectory: directory) + storage.setRules([HighlightRule(columnName: "status", value: "paid")], for: scope(table: "orders")) + #expect(FileManager.default.fileExists(atPath: fileURL.path)) + + storage.setRules([], for: scope(table: "orders")) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("A table rename moves its rules to the new name") + func renameMovesRules() { + let storage = HighlightRuleStorage(storageDirectory: directory) + let rules = [HighlightRule(columnName: "status", value: "paid")] + storage.setRules(rules, for: scope(table: "orders")) + + storage.rename(from: scope(table: "orders"), to: scope(table: "purchases")) + + let reloaded = HighlightRuleStorage(storageDirectory: directory) + #expect(reloaded.rules(for: scope(table: "orders")).isEmpty) + #expect(reloaded.rules(for: scope(table: "purchases")) == rules) + } + + @Test("A schema rename moves every table's rules in it") + func renameScopeMovesEveryTable() { + let storage = HighlightRuleStorage(storageDirectory: directory) + let rules = [HighlightRule(columnName: "status", value: "paid")] + storage.setRules(rules, for: scope(table: "orders")) + storage.setRules(rules, for: scope(table: "items")) + + storage.renameScope( + connectionId: connectionId, fromDatabase: "shop", fromSchema: "public", + toDatabase: "shop", toSchema: "sales" + ) + + #expect(storage.rules(for: scope(table: "orders", schema: "sales")) == rules) + #expect(storage.rules(for: scope(table: "items", schema: "sales")) == rules) + #expect(storage.rules(for: scope(table: "orders")).isEmpty) + } + + @Test("Deleting a connection removes its rules") + func removingConnectionPurges() { + let storage = HighlightRuleStorage(storageDirectory: directory) + storage.setRules([HighlightRule(columnName: "status", value: "paid")], for: scope(table: "orders")) + + storage.removeRules(for: [connectionId]) + + #expect(storage.rules(for: scope(table: "orders")).isEmpty) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("Every change moves the observed revision") + func revisionMoves() { + let storage = HighlightRuleStorage(storageDirectory: directory) + let before = storage.revision + storage.setRules([HighlightRule(columnName: "status", value: "paid")], for: scope(table: "orders")) + #expect(storage.revision != before) + } + + @Test("An unreadable file is set aside rather than overwritten") + func unreadableFileIsPreserved() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("{ not json".utf8).write(to: fileURL) + + let storage = HighlightRuleStorage(storageDirectory: directory) + #expect(storage.rules(for: scope(table: "orders")).isEmpty) + + let preserved = directory.appendingPathComponent("\(connectionId.uuidString).unreadable.json") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + #expect(try String(contentsOf: preserved, encoding: .utf8) == "{ not json") + } + + @Test("A rule the app cannot decode is skipped and the rest survive") + func undecodableRuleIsSkipped() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let key = scope(table: "orders").storageComponent + let json = """ + {"\(key)": [ + {"columnName": "status", "filterOperator": "=", "value": "paid", "color": "green"}, + {"columnName": "status", "filterOperator": "SOUNDS LIKE", "value": "x", "color": "green"}, + {"columnName": "status", "filterOperator": "=", "value": "late", "color": "chartreuse"} + ]} + """ + try Data(json.utf8).write(to: fileURL) + + let rules = HighlightRuleStorage(storageDirectory: directory).rules(for: scope(table: "orders")) + #expect(rules.count == 1) + #expect(rules.first?.value == "paid") + #expect(rules.first?.color == .green) + } +} diff --git a/TableProTests/Models/ResultStatusModelTests.swift b/TableProTests/Models/ResultStatusModelTests.swift index b5d378e0d9..00d7005949 100644 --- a/TableProTests/Models/ResultStatusModelTests.swift +++ b/TableProTests/Models/ResultStatusModelTests.swift @@ -199,6 +199,21 @@ struct ResultStatusModelTests { #expect(!structure.controls.showsPagination) } + @Test("Highlight rules are offered only where the data grid draws the result") + func highlightRulesFollowTheDataGrid() { + let table = makeSnapshot(rowCount: 10) + #expect(model(table, viewMode: .data).controls.showsHighlightRules) + #expect(!model(table, viewMode: .json).controls.showsHighlightRules) + #expect(!model(table, viewMode: .chart).controls.showsHighlightRules) + #expect(!model(table, viewMode: .structure).controls.showsHighlightRules) + + let query = makeSnapshot(tabType: .query, rowCount: 3, hasTableName: false) + #expect(model(query, viewMode: .data).controls.showsHighlightRules) + + let noResult = makeSnapshot(tabType: .query, rowCount: 0, hasColumns: false, hasTableName: false) + #expect(!model(noResult, viewMode: .data).controls.showsHighlightRules) + } + @Test("A query tab never offers table-only controls") func queryTabHasNoTableControls() { var pagination = PaginationState(pageSize: 1_000) diff --git a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift index 577126b481..eb163d7508 100644 --- a/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift +++ b/TableProTests/Views/Main/ResultStatusBarLayoutTests.swift @@ -51,6 +51,14 @@ struct ResultStatusBarLayoutTests { onReset: {}, onJumpToColumn: nil ), + highlightState: StatusBarHighlightState( + rules: [], + columns: hasColumns ? ["id", "name"] : [], + isPersisted: tabType == .table, + presentationRequest: 0, + onChange: { _ in }, + onDismiss: {} + ), paginationCallbacks: PaginationCallbacks( onFirst: {}, onPrevious: {}, diff --git a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift index 731f8fbc37..56a47edb1b 100644 --- a/TableProTests/Views/Results/DataGridCellAppearanceTests.swift +++ b/TableProTests/Views/Results/DataGridCellAppearanceTests.swift @@ -179,4 +179,70 @@ struct DataGridCellAppearanceTests { func noAccessoryNoRole() { #expect(resolve().accessoryRole == nil) } + + // MARK: - Highlight rules + + private func highlighted( + column: Int, + color: HighlightColor = .green, + isDeleted: Bool = false, + isInserted: Bool = false, + modifiedColumns: Set = [] + ) -> RowVisualState { + let rule = HighlightRule(columnName: "status", value: "paid", color: color, target: .cell) + return RowVisualState( + isDeleted: isDeleted, + isInserted: isInserted, + modifiedColumns: modifiedColumns, + highlight: RowHighlight(rowRule: nil, cellRules: [column: rule]) + ) + } + + @Test("A cell a highlight rule matches takes the rule's wash") + func highlightedCellTakesTheWash() { + let appearance = resolve(visualState: highlighted(column: 1), columnIndex: 1) + + #expect(appearance.backgroundTint == HighlightColor.green.washColor) + #expect(resolve(visualState: highlighted(column: 1), columnIndex: 2).backgroundTint == nil) + } + + @Test("A modified cell keeps the modified tint over a highlight") + func modifiedTintOutranksHighlight() { + let appearance = resolve(visualState: highlighted(column: 0, modifiedColumns: [0]), columnIndex: 0) + + #expect(appearance.backgroundTint == palette.modifiedColumnTint) + } + + @Test("A find match and a selection both outrank a highlight") + func findAndSelectionOutrankHighlight() { + let found = resolve(visualState: highlighted(column: 0), isCurrentFindMatch: true, columnIndex: 0) + let selected = resolve(visualState: highlighted(column: 0), columnIndex: 0, onEmphasizedSelection: true) + + #expect(found.backgroundTint == palette.findMatchTint) + #expect(selected.backgroundTint == nil) + } + + @Test("A pending insert or delete shows no cell highlight, so it cannot pass for one") + func pendingRowsShowNoCellHighlight() { + let inserted = resolve(visualState: highlighted(column: 0, isInserted: true), columnIndex: 0) + let deleted = resolve(visualState: highlighted(column: 0, isDeleted: true), columnIndex: 0) + + #expect(inserted.backgroundTint == nil) + #expect(deleted.backgroundTint == nil) + } + + @Test("Only a highlight that is drawn is named to VoiceOver") + func drawnHighlightRule() { + let rowRule = HighlightRule(columnName: "status", value: "paid", color: .green) + let cellRule = HighlightRule(columnName: "total", value: "9", color: .red, target: .cell) + let highlight = RowHighlight(rowRule: rowRule, cellRules: [1: cellRule]) + let plain = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [], highlight: highlight) + let modified = RowVisualState(isDeleted: false, isInserted: false, modifiedColumns: [1], highlight: highlight) + let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: [], highlight: highlight) + + #expect(plain.drawnHighlightRule(forColumn: 1) == cellRule) + #expect(plain.drawnHighlightRule(forColumn: 0) == rowRule) + #expect(modified.drawnHighlightRule(forColumn: 1) == rowRule) + #expect(deleted.drawnHighlightRule(forColumn: 1) == nil) + } } diff --git a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift index 059bc25999..9997f2c42d 100644 --- a/TableProTests/Views/Results/DataGridRowTintThemeTests.swift +++ b/TableProTests/Views/Results/DataGridRowTintThemeTests.swift @@ -4,9 +4,28 @@ // import AppKit +import SwiftUI @testable import TablePro import Testing +@MainActor +private final class FixedVisualStateDelegate: DataGridViewDelegate { + var state: RowVisualState + + init(state: RowVisualState) { + self.state = state + } + + func dataGridVisualState(forRow row: Int) -> RowVisualState? { state } +} + +@MainActor +private final class NoopColumnLayoutPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + /// These tests activate a theme on the shared `ThemeEngine`. What keeps that from reaching a suite /// running in parallel is that both bodies are synchronous and `@MainActor`, so nothing else on the /// main actor can interleave between activating the test theme and restoring the original one. @@ -17,8 +36,29 @@ import Testing struct DataGridRowTintThemeTests { private static let deleted = RowVisualState(isDeleted: true, isInserted: false, modifiedColumns: []) - private func makeRowView() -> DataGridRowView { - DataGridRowView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + private final class Harness { + let delegate: FixedVisualStateDelegate + let coordinator: TableViewCoordinator + let rowView: DataGridRowView + + @MainActor + init(state: RowVisualState) { + delegate = FixedVisualStateDelegate(state: state) + coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: false, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: NoopColumnLayoutPersister() + ) + rowView = DataGridRowView(frame: NSRect(x: 0, y: 0, width: 120, height: 24)) + rowView.coordinator = coordinator + rowView.rowIndex = 0 + } + } + + private func makeRowView(state: RowVisualState) -> Harness { + Harness(state: state) } private func renderedTint(of rowView: DataGridRowView) throws -> NSColor { @@ -42,18 +82,53 @@ struct DataGridRowTintThemeTests { defer { engine.activateTheme(original) } engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) - let rowView = makeRowView() - rowView.applyVisualState(Self.deleted) - let firstTint = try renderedTint(of: rowView) + let harness = makeRowView(state: Self.deleted) + let firstTint = try renderedTint(of: harness.rowView) engine.activateTheme(theme(original, id: "test.tint.blue", deletedHex: "#0000FF")) - rowView.applyVisualState(Self.deleted) - let secondTint = try renderedTint(of: rowView) + harness.rowView.invalidateVisualState() + let secondTint = try renderedTint(of: harness.rowView) #expect(firstTint.redComponent > secondTint.redComponent) #expect(secondTint.blueComponent > firstTint.blueComponent) } + @Test("A row paints the state its coordinator reports now, not one pushed into it earlier") + func rowPaintsTheLiveState() throws { + let engine = ThemeEngine.shared + let original = engine.activeTheme + defer { engine.activateTheme(original) } + engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) + + let harness = makeRowView(state: .empty) + let before = try renderedTint(of: harness.rowView) + harness.delegate.state = Self.deleted + let after = try renderedTint(of: harness.rowView) + + #expect(before.alphaComponent == 0) + #expect(after.redComponent > 0.5) + #expect(harness.rowView.visualState == Self.deleted) + } + + @Test("A pending delete keeps its wash over a matching highlight rule") + func pendingDeleteOutranksHighlightWash() throws { + let engine = ThemeEngine.shared + let original = engine.activeTheme + defer { engine.activateTheme(original) } + engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) + + let highlight = RowHighlight( + rowRule: HighlightRule(columnName: "status", value: "paid", color: .blue), + cellRules: [:] + ) + let harness = makeRowView(state: Self.deleted.highlighted(highlight)) + let tint = try renderedTint(of: harness.rowView) + + #expect(tint.redComponent > tint.blueComponent) + #expect(Self.deleted.highlighted(highlight).tint == engine.colors.dataGrid.deleted) + #expect(RowVisualState.empty.highlighted(highlight).tint == HighlightColor.blue.washColor) + } + @Test("A row with no deleted or inserted state stays untinted across a theme change") func plainRowStaysUntinted() throws { let engine = ThemeEngine.shared @@ -61,13 +136,12 @@ struct DataGridRowTintThemeTests { defer { engine.activateTheme(original) } engine.activateTheme(theme(original, id: "test.tint.red", deletedHex: "#FF0000")) - let rowView = makeRowView() - rowView.applyVisualState(.empty) - let firstTint = try renderedTint(of: rowView) + let harness = makeRowView(state: .empty) + let firstTint = try renderedTint(of: harness.rowView) engine.activateTheme(theme(original, id: "test.tint.blue", deletedHex: "#0000FF")) - rowView.applyVisualState(.empty) - let secondTint = try renderedTint(of: rowView) + harness.rowView.invalidateVisualState() + let secondTint = try renderedTint(of: harness.rowView) #expect(firstTint.redComponent == secondTint.redComponent) #expect(firstTint.blueComponent == secondTint.blueComponent) diff --git a/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift b/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift index e37360eff5..d8a139b418 100644 --- a/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift +++ b/TableProTests/Views/Results/DataGridUpdateSnapshotTests.swift @@ -16,6 +16,7 @@ struct DataGridUpdateSnapshotTests { reloadVersion: Int = 0, contentRevision: Int = 0, displayFormats: [ValueDisplayFormat?] = [], + highlightRules: [HighlightRule] = [], columnComments: [String: String] = [:] ) -> DataGridUpdateSnapshot { DataGridUpdateSnapshot( @@ -24,6 +25,7 @@ struct DataGridUpdateSnapshotTests { columns: columns, valueFilteredIDsCount: nil, displayFormats: displayFormats, + highlightRules: highlightRules, configuration: DataGridConfiguration(), isEditable: true, rowReorder: .disabled, @@ -79,6 +81,20 @@ struct DataGridUpdateSnapshotTests { #expect(raw != uuid) } + @Test("A highlight rule change invalidates the update snapshot") + func highlightRuleChangesSnapshot() { + let rule = HighlightRule(columnName: "type", value: "admin", color: .green) + var recolored = rule + recolored.color = .red + + let highlighted = makeSnapshot(highlightRules: [rule]) + let rebuilt = makeSnapshot(highlightRules: [rule]) + + #expect(makeSnapshot() != highlighted) + #expect(highlighted != makeSnapshot(highlightRules: [recolored])) + #expect(highlighted == rebuilt) + } + @Test("Display format cache entries are scoped to a pinned result set") func displayFormatCacheUsesResultSetIdentity() { let firstResult = UUID() diff --git a/TableProTests/Views/Results/TableViewCoordinatorHighlightTests.swift b/TableProTests/Views/Results/TableViewCoordinatorHighlightTests.swift new file mode 100644 index 0000000000..0d644c8514 --- /dev/null +++ b/TableProTests/Views/Results/TableViewCoordinatorHighlightTests.swift @@ -0,0 +1,145 @@ +// +// TableViewCoordinatorHighlightTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class HighlightTestPersister: ColumnLayoutPersisting { + func load(for key: ColumnLayoutTableKey) -> ColumnLayoutState? { nil } + func save(_ layout: ColumnLayoutState, for key: ColumnLayoutTableKey) {} + func clear(for key: ColumnLayoutTableKey) {} +} + +@MainActor +private final class StructureStateDelegate: DataGridViewDelegate { + func dataGridVisualState(forRow row: Int) -> RowVisualState? { .empty } +} + +@MainActor +private final class HighlightGrid { + var tableRows: TableRows + let coordinator: TableViewCoordinator + + init(statuses: [String], delegate: (any DataGridViewDelegate)? = nil) { + let rows = ContiguousArray(statuses.enumerated().map { index, status in + Row(id: .existing(index), values: [.text("\(index)"), .text(status)]) + }) + tableRows = TableRows( + rows: rows, + columns: ["id", "status"], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "VARCHAR")] + ) + coordinator = TableViewCoordinator( + changeManager: AnyChangeManager(DataChangeManager()), + isEditable: true, + selectedRowIndices: .constant([]), + delegate: delegate, + layoutPersister: HighlightTestPersister() + ) + coordinator.tableRowsProvider = { [weak self] in self?.tableRows ?? TableRows() } + coordinator.tableRowsMutator = { [weak self] mutation in + guard let self else { return } + mutation(&self.tableRows) + } + coordinator.rebuildColumnMetadataCache(from: tableRows) + coordinator.updateCache() + } + + @discardableResult + func apply(_ rules: [HighlightRule]) -> Bool { + coordinator.syncHighlightRules(rules, tableRows: tableRows) + } + + func rowColor(_ row: Int) -> HighlightColor? { + coordinator.visualState(for: row).highlight.rowColor + } +} + +@Suite("Grid coordinator highlight rules") +@MainActor +struct TableViewCoordinatorHighlightTests { + private let paid = HighlightRule(columnName: "status", value: "paid", color: .green) + + @Test("Only the rows a rule matches carry its highlight") + func matchingRowsCarryTheHighlight() { + let grid = HighlightGrid(statuses: ["paid", "pending", "paid"]) + grid.apply([paid]) + + #expect(grid.rowColor(0) == .green) + #expect(grid.rowColor(1) == nil) + #expect(grid.rowColor(2) == .green) + } + + @Test("Changing the rules reports a change and recolours rows already evaluated") + func changingRulesRecolours() { + let grid = HighlightGrid(statuses: ["paid"]) + #expect(grid.apply([paid])) + #expect(grid.rowColor(0) == .green) + + var recolored = paid + recolored.color = .red + #expect(grid.apply([recolored])) + #expect(grid.rowColor(0) == .red) + #expect(!grid.apply([recolored])) + } + + @Test("An edit that flips a rule is reflected once the edit commits") + func editFlipsTheHighlight() { + let grid = HighlightGrid(statuses: ["pending"]) + grid.apply([paid]) + #expect(grid.rowColor(0) == nil) + + grid.coordinator.commitTypedCellEdit(row: 0, columnIndex: 1, newValue: .text("paid")) + + #expect(grid.tableRows.rows[0].values[1] == .text("paid")) + #expect(grid.rowColor(0) == .green) + } + + @Test("New rows under the same positional ids are evaluated afresh once the cache is dropped") + func positionalIdsAreNotServedStaleHighlights() { + let grid = HighlightGrid(statuses: ["paid"]) + grid.apply([paid]) + #expect(grid.rowColor(0) == .green) + + grid.tableRows.rows[0].values[1] = .text("pending") + grid.coordinator.invalidateDisplayCache() + + #expect(grid.rowColor(0) == nil) + } + + @Test("A fresh display state for a new page carries no highlights from the old one") + func freshDisplayStateStartsClean() { + let grid = HighlightGrid(statuses: ["paid"]) + grid.apply([paid]) + #expect(grid.rowColor(0) == .green) + + grid.tableRows.rows[0].values[1] = .text("pending") + grid.coordinator.adoptDisplayState(DataGridDisplayState()) + grid.apply([paid]) + + #expect(grid.rowColor(0) == nil) + } + + @Test("A grid whose owner supplies its own row state is never highlighted") + func delegateStateSuppressesHighlights() { + let delegate = StructureStateDelegate() + let grid = HighlightGrid(statuses: ["paid"], delegate: delegate) + grid.apply([paid]) + + #expect(grid.rowColor(0) == nil) + } + + @Test("The accessibility description names the rule that coloured the cell") + func accessibilityDescription() { + let grid = HighlightGrid(statuses: ["paid"]) + grid.apply([paid]) + + #expect(grid.coordinator.highlightDescription(row: 0, columnIndex: 0) == "status = “paid”") + } +} diff --git a/TableProUITests/HighlightRulesUITests.swift b/TableProUITests/HighlightRulesUITests.swift new file mode 100644 index 0000000000..5cb6e155c4 --- /dev/null +++ b/TableProUITests/HighlightRulesUITests.swift @@ -0,0 +1,106 @@ +// +// HighlightRulesUITests.swift +// TableProUITests +// + +import AppKit +import XCTest + +final class HighlightRulesUITests: UITestCase { + func testAddingARuleFromTheStatusBarKeepsItForTheTable() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + _ = try albumGrid(in: window) + + openRulesFromStatusBar(in: window) + let add = window.buttons["highlight-rules-add"].firstMatch + XCTAssertTrue(waitUntilHittable(add, timeout: 10), "The popover must offer Add Rule") + XCTAssertFalse(ruleCheckbox(in: window).exists, "A table nobody highlighted starts with no rules") + add.click() + XCTAssertTrue(ruleCheckbox(in: window).waitToExist(timeout: 10), "Add Rule must list a new rule") + app.typeText("1") + + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(add.waitForNonExistence(timeout: 5), "Escape in the value field must close the popover") + + openRulesFromStatusBar(in: window) + XCTAssertTrue( + ruleCheckbox(in: window).waitToExist(timeout: 10), + "A rule belongs to the table, so reopening the popover lists it again" + ) + + let reopenedAdd = window.buttons["highlight-rules-add"].firstMatch + XCTAssertTrue(waitUntilHittable(reopenedAdd, timeout: 10)) + reopenedAdd.click() + XCTAssertTrue(waitForPredicate(timeout: 10) { self.ruleCheckboxes(in: window).count == 2 }) + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue(reopenedAdd.waitForNonExistence(timeout: 5)) + + openRulesFromStatusBar(in: window) + XCTAssertTrue(ruleCheckbox(in: window).waitToExist(timeout: 10)) + XCTAssertEqual(ruleCheckboxes(in: window).count, 1, "A rule closed without a value is not kept") + } + + func testTheCellMenuOffersHighlightAndOpensTheRules() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + let grid = try albumGrid(in: window) + + let cell = gridPoint(in: grid, of: window, dy: 70) + cell.click() + Thread.sleep(forTimeInterval: NSEvent.doubleClickInterval) + cell.rightClick() + + let highlight = window.menus.menuItems["Highlight"].firstMatch + XCTAssertTrue(highlight.waitToExist(timeout: 15), "A cell's context menu must offer Highlight") + highlight.hover() + + let showRules = contextMenuItem("Highlight Rules…", in: app) + XCTAssertTrue(waitUntilHittable(showRules, timeout: 10), "The Highlight submenu must offer Highlight Rules…") + showRules.click() + + XCTAssertTrue( + window.buttons["highlight-rules-add"].firstMatch.waitToExist(timeout: 10), + "Highlight Rules… must open the rules popover" + ) + } + + // MARK: - Helpers + + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.matching(NSPredicate(format: "identifier != %@", "welcome")).firstMatch + XCTAssertTrue(window.waitToExist(timeout: 60), "The sample database produced no window") + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + return window + } + + private func albumGrid(in window: XCUIElement) throws -> XCUIElement { + let row = window.outlines.firstMatch.staticTexts + .matching(NSPredicate(format: "value == %@", "Table: Album")) + .firstMatch + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") + clickAtCenter(row) + + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "Album produced no data grid") + XCTAssertTrue(waitForClickableRows(in: grid), "Album must load rows before a cell can be highlighted") + return grid + } + + private func openRulesFromStatusBar(in window: XCUIElement) { + let button = window.buttons["result-status-highlight"] + XCTAssertTrue(waitUntilHittable(button, timeout: 15), "The status bar must offer Highlight") + button.click() + } + + private func ruleCheckbox(in window: XCUIElement) -> XCUIElement { + ruleCheckboxes(in: window).firstMatch + } + + private func ruleCheckboxes(in window: XCUIElement) -> XCUIElementQuery { + window.checkBoxes.matching(identifier: "highlight-rule-enabled") + } +} diff --git a/docs/features/data-grid.mdx b/docs/features/data-grid.mdx index dd8110ceae..9fc8d6c10c 100644 --- a/docs/features/data-grid.mdx +++ b/docs/features/data-grid.mdx @@ -1,9 +1,9 @@ --- title: Data Grid -description: Sort, size, hide, chart and copy the rows a table or a query puts in the grid +description: Sort, size, hide, highlight, chart and copy the rows a table or a query puts in the grid --- -Most of the grid's controls sit in the status bar beneath it. The view switcher is at the leading edge, the row count in the middle, then the columns, filter and page buttons at the trailing edge. +Most of the grid's controls sit in the status bar beneath it. The view switcher is at the leading edge, the row count in the middle, then the columns, highlight, filter and page buttons at the trailing edge. Data grid @@ -40,6 +40,23 @@ The header menu carries two filters that answer different questions. A value filter lives as long as the result does: switching view mode or tab keeps it, replacing the result clears it. **Fetch All** keeps it and applies it to the rows it loads. +## Highlighting + +Right-click a cell and open **Highlight**. A color under **Rows Where status = “paid”** tints every row holding that value; a color under **Cells Where status = “paid”** tints only that cell. The palette marks the color a matching rule already uses, and **Remove Highlight** takes the rule away. + +For anything other than an exact match, click the highlighter button in the status bar or choose **View > Highlight Rules**. A rule is a column, an operator from the [filter bar](/features/filtering), a value, a color, and **Row** or **Cell**. **Highlight Values…** in the header menu starts a rule on that column. A rule left without a value is dropped when the popover closes. + + + Highlight Rules popover listing three rules over an Invoice grid with tinted rows and cells + Highlight Rules popover listing three rules over an Invoice grid with tinted rows and cells + + +Rules run top to bottom and the first match colors the row, so drag the rule that should win to the top. A rule picked from the cell menu goes in first. A cell rule tints its own cell over the row's color. Rules read the stored value rather than the text a **Display As** format shows: a numeric column compares as numbers, a boolean column accepts `true`, `1`, `t` and `yes` alike, and `NULL` never satisfies a comparison: match it with **is NULL** or **is empty**. + +A row waiting to be inserted or deleted keeps its [change tracking](/features/change-tracking) tint over any rule, and a selected row shows the selection. On a table you edit, give row rules a color other than the green and red those tints use, so a highlight never reads as a pending change. + +Rules belong to the table, scoped to the connection, database, and schema, and follow the table through a rename. A query result that comes from one table uses that table's rules. Rules on any other query result are not saved, and go when the tab closes or the app quits. Saved rules stay on this Mac and do not sync. + ## Columns Drag a border to resize a column, or double-click it to fit the content; **Size to Fit** and **Size All Columns to Fit** on the header menu do the same. A fitted column stops at half the visible grid width. diff --git a/docs/images/highlight-rules-dark.png b/docs/images/highlight-rules-dark.png new file mode 100644 index 0000000000..7f5c21fc9b Binary files /dev/null and b/docs/images/highlight-rules-dark.png differ diff --git a/docs/images/highlight-rules.png b/docs/images/highlight-rules.png new file mode 100644 index 0000000000..21febb50ad Binary files /dev/null and b/docs/images/highlight-rules.png differ