diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/CommandPalette/CommandPaletteCatalog.swift b/apps/calendar-macos/CompassData/Sources/CompassData/CommandPalette/CommandPaletteCatalog.swift index 48cbf72aa..e609ca474 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/CommandPalette/CommandPaletteCatalog.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/CommandPalette/CommandPaletteCatalog.swift @@ -170,6 +170,16 @@ public enum CommandPaletteCatalog { isTrialing: Bool ) -> [CommandPaletteSection] { var items: [CommandPaletteItem] = [ + CommandPaletteItem( + id: "undo", + label: "Undo", + shortcut: "mod+z", + keywords: ["revert", "back"]), + CommandPaletteItem( + id: "redo", + label: "Redo", + shortcut: "mod+shift+z", + keywords: ["repeat", "forward"]), CommandPaletteItem( id: "show-welcome-guide", label: "Show welcome guide", diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift index 6bcafd920..5972a530e 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarOverlays.swift @@ -130,6 +130,10 @@ extension NativeCalendarRootModel { requestLogout() case "subscribe": handleShortcut(.otherSubscribe) + case "undo": + undoLastChange() + case "redo": + redoLastChange() default: if id.hasPrefix("event-") { let eventId = String(id.dropFirst("event-".count)) @@ -346,17 +350,26 @@ public final class NoOpShortcutsCatalogPresenter: ShortcutsCatalogPresenting { enum CommandPaletteSearch { static func filter(sections: [CommandPaletteSection], query: String) -> [CommandPaletteSection] { - let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !needle.isEmpty else { return sections } - return sections.compactMap { section in - if section.id == "go-to-date" { return section } - let items = section.items.filter { item in - item.label.lowercased().contains(needle) - || item.keywords.contains { $0.lowercased().contains(needle) } - || (item.shortcut?.lowercased().contains(needle) ?? false) - } - guard !items.isEmpty else { return nil } - return CommandPaletteSection(id: section.id, heading: section.heading, items: items) + let mapped = sections.map { section in + CommandPaletteSearchLogic.Section( + id: section.id, + heading: section.heading, + items: section.items.map { + CommandPaletteSearchLogic.CommandPaletteSearchItem( + id: $0.id, + label: $0.label, + keywords: $0.keywords + ($0.shortcut.map { [$0] } ?? [])) + }) + } + let filtered = CommandPaletteSearchLogic.filter(sections: mapped, query: query) + let byId = Dictionary(uniqueKeysWithValues: sections.flatMap { section in + section.items.map { ($0.id, $0) } + }) + return filtered.map { section in + CommandPaletteSection( + id: section.id, + heading: section.heading, + items: section.items.compactMap { byId[$0.id] }) } } } diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/CommandPaletteActionsTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/CommandPaletteActionsTests.swift index 0570c1673..a92377fc3 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/CommandPaletteActionsTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/CommandPaletteActionsTests.swift @@ -101,6 +101,14 @@ final class CommandPaletteActionsTests: XCTestCase { XCTAssertFalse(model.commandPaletteStore.recentCommandIds.contains("recent-today")) } + func testMoreSectionIncludesUndoAndRedoRows() async throws { + let model = try await makeModel() + model.commandPaletteStore.open() + let ids = Set(model.filteredPaletteSections().flatMap(\.items).map(\.id)) + XCTAssertTrue(ids.contains("undo")) + XCTAssertTrue(ids.contains("redo")) + } + func testAboutInvokesNativePresentation() async throws { let model = try await makeModel() var presented = false diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/CommandPalette/CommandPaletteSearchLogic.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/CommandPalette/CommandPaletteSearchLogic.swift new file mode 100644 index 000000000..6f135787e --- /dev/null +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/CommandPalette/CommandPaletteSearchLogic.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Fuzzy command palette filter and label highlight ranges. Mirrors web `command-palette.search.ts`. +public enum CommandPaletteSearchLogic { + private static let minSubsequenceTokenLength = 3 + + public struct ScoredItem: Hashable, Sendable { + public let item: CommandPaletteSearchItem + public let score: Int + } + + public struct CommandPaletteSearchItem: Hashable, Sendable { + public let id: String + public let label: String + public let keywords: [String] + + public init(id: String, label: String, keywords: [String] = []) { + self.id = id + self.label = label + self.keywords = keywords + } + } + + public struct Section: Hashable, Sendable { + public let id: String + public let heading: String + public let items: [CommandPaletteSearchItem] + + public init(id: String, heading: String, items: [CommandPaletteSearchItem]) { + self.id = id + self.heading = heading + self.items = items + } + } + + public static func filter(sections: [Section], query: String) -> [Section] { + let tokens = tokenizeQuery(query) + if tokens.isEmpty { return sections } + + return sections.compactMap { section in + if section.id == "go-to-date" { return section } + let scored = section.items.compactMap { item -> ScoredItem? in + let score = scoreAgainstTokens(item: item, tokens: tokens) + guard score > 0 else { return nil } + return ScoredItem(item: item, score: score) + } + .sorted { $0.score > $1.score } + guard !scored.isEmpty else { return nil } + return Section( + id: section.id, + heading: section.heading, + items: scored.map(\.item)) + } + } + + public static func labelMatchRanges(label: String, query: String) -> [Range] { + let tokens = tokenizeQuery(query) + if tokens.isEmpty { return [] } + + let lowerLabel = label.lowercased() + var ranges: [(Int, Int)] = [] + for token in tokens { + if let index = lowerLabel.range(of: token)?.lowerBound { + let start = lowerLabel.distance(from: lowerLabel.startIndex, to: index) + ranges.append((start, start + token.count)) + } + } + if ranges.isEmpty { return [] } + + ranges.sort { $0.0 < $1.0 } + var merged: [(Int, Int)] = [ranges[0]] + for (start, end) in ranges.dropFirst() { + var last = merged[merged.count - 1] + if start <= last.1 { + last.1 = max(last.1, end) + merged[merged.count - 1] = last + } else { + merged.append((start, end)) + } + } + return merged.map { $0.0..<$0.1 } + } + + public static func scoreCommandItem(item: CommandPaletteSearchItem, query: String) -> Int { + let tokens = tokenizeQuery(query) + if tokens.isEmpty { return 0 } + return scoreAgainstTokens(item: item, tokens: tokens) + } + + private static func tokenizeQuery(_ query: String) -> [String] { + query + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .split(whereSeparator: { $0.isWhitespace }) + .map { normalizeSearchToken(String($0)) } + .filter { !$0.isEmpty } + } + + private static func normalizeSearchToken(_ token: String) -> String { + var t = token.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) + while t.hasPrefix("'") || t.hasPrefix("\"") { t.removeFirst() } + while t.hasSuffix("'") || t.hasSuffix("\"") { t.removeLast() } + return t.replacingOccurrences(of: #"[\.\!?]+$"#, with: "", options: .regularExpression) + } + + private static func scoreAgainstTokens(item: CommandPaletteSearchItem, tokens: [String]) -> Int { + let label = item.label.lowercased() + let keywords = item.keywords.map { $0.lowercased() } + var total = 0 + for token in tokens { + let labelTier = scoreToken(haystack: label, token: token) + let keywordTier = keywords.map { scoreToken(haystack: $0, token: token) }.max() ?? 0 + if labelTier == 0, keywordTier == 0 { return 0 } + let bestTier = max(labelTier, keywordTier) + total += bestTier * 2 + (labelTier == bestTier ? 1 : 0) + } + return total + } + + private static func scoreToken(haystack: String, token: String) -> Int { + if haystack == token { return 5 } + if haystack.hasPrefix(token) { return 4 } + if hasWordStartingWith(haystack: haystack, token: token) { return 3 } + if haystack.contains(token) { return 2 } + if token.count >= minSubsequenceTokenLength, isSubsequence(haystack: haystack, token: token) { + return 1 + } + return 0 + } + + private static func hasWordStartingWith(haystack: String, token: String) -> Bool { + haystack.split(whereSeparator: { $0.isWhitespace || $0 == "-" }) + .contains { $0.lowercased().hasPrefix(token) } + } + + private static func isSubsequence(haystack: String, token: String) -> Bool { + var position = haystack.startIndex + for char in token { + guard let idx = haystack[position...].firstIndex(of: char) else { return false } + position = haystack.index(after: idx) + } + return true + } +} diff --git a/apps/calendar-macos/CompassKitTests/CommandPaletteSearchLogicTests.swift b/apps/calendar-macos/CompassKitTests/CommandPaletteSearchLogicTests.swift new file mode 100644 index 000000000..f8d7cdbb1 --- /dev/null +++ b/apps/calendar-macos/CompassKitTests/CommandPaletteSearchLogicTests.swift @@ -0,0 +1,25 @@ +import CompassKit +import XCTest + +final class CommandPaletteSearchLogicTests: XCTestCase { + func testScoreRequiresEveryToken() { + let item = CommandPaletteSearchLogic.CommandPaletteSearchItem( + id: "today", + label: "Go to Today", + keywords: ["now"]) + XCTAssertGreaterThan(CommandPaletteSearchLogic.scoreCommandItem(item: item, query: "go today"), 0) + XCTAssertEqual(CommandPaletteSearchLogic.scoreCommandItem(item: item, query: "go mars"), 0) + } + + func testLabelMatchRangesMergeAdjacent() { + let ranges = CommandPaletteSearchLogic.labelMatchRanges(label: "Go to Day", query: "go day") + XCTAssertEqual(ranges, [0..<2, 6..<9]) + } + + func testKeywordOnlyMatchDoesNotHighlightLabel() { + let ranges = CommandPaletteSearchLogic.labelMatchRanges( + label: "Play Block Party", + query: "game") + XCTAssertTrue(ranges.isEmpty) + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteHighlightedLabel.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteHighlightedLabel.swift new file mode 100644 index 000000000..bfee54ad1 --- /dev/null +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteHighlightedLabel.swift @@ -0,0 +1,33 @@ +import CompassKit +import SwiftUI + +struct CommandPaletteHighlightedLabel: View { + @Environment(\.nativeWebTheme) private var theme + let label: String + let query: String + + var body: some View { + Text(attributedLabel) + .font(.rubik(14)) + } + + private var attributedLabel: AttributedString { + var result = AttributedString(label) + result.foregroundColor = theme.textColor + let ranges = CommandPaletteSearchLogic.labelMatchRanges(label: label, query: query) + for range in ranges { + guard range.lowerBound >= 0, + range.upperBound <= label.count, + range.lowerBound < range.upperBound + else { continue } + let start = label.index(label.startIndex, offsetBy: range.lowerBound) + let end = label.index(label.startIndex, offsetBy: range.upperBound) + let lower = start.. some View { + private func paletteRow(_ item: CommandPaletteItem, query: String, selected: Bool) -> some View { Button { model.runPaletteCommand(id: item.id) } label: { HStack { VStack(alignment: .leading, spacing: 2) { - Text(item.label) - .font(.rubik(14)) - .foregroundStyle(theme.textColor) + CommandPaletteHighlightedLabel(label: item.label, query: query) if let subtitle = item.subtitle { Text(subtitle) .font(.rubik(11)) @@ -111,19 +112,42 @@ public struct CommandPaletteOverlay: View { } Spacer() if let shortcut = item.shortcut { - Text(shortcut) - .font(.rubik(12)) + Text(paletteShortcutLabel(shortcut)) + .font(.rubik(11)) .foregroundStyle(theme.textMutedColor) } } .padding(.vertical, 6) .padding(.horizontal, 8) - .background(selected ? theme.accentColor.opacity(0.18) : theme.surfaceColor) + .background(selected ? theme.accentColor.opacity(0.12) : theme.surfaceColor) .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + if selected { + RoundedRectangle(cornerRadius: 8) + .stroke(theme.accentColor, lineWidth: 1) + } + } } .buttonStyle(.plain) .accessibilityValue(selected ? "Selected" : "Not selected") .accessibilityAddTraits(selected ? .isSelected : []) .accessibilityIdentifier("compass-native-palette-item-\(item.id)") } + + private func paletteShortcutLabel(_ shortcut: String) -> String { + let normalized = shortcut + .replacingOccurrences(of: "mod+", with: "Mod+") + .replacingOccurrences(of: "shift+", with: "Shift+") + .replacingOccurrences(of: "alt+", with: "Alt+") + let parts = normalized.split(separator: "+").map { part -> String in + let token = String(part) + switch token.lowercased() { + case "mod": return "Mod" + case "shift": return "Shift" + case "alt": return "Alt" + default: return token.count == 1 ? token.uppercased() : token + } + } + return KeycapGlyphs.display(parts) + } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/ThemeTokenColor+SwiftUI.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/ThemeTokenColor+SwiftUI.swift index c74fa3d6d..ed7268324 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/ThemeTokenColor+SwiftUI.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/ThemeTokenColor+SwiftUI.swift @@ -21,6 +21,7 @@ private protocol NativeThemePalette { var success: ThemeTokenColor { get } var warning: ThemeTokenColor { get } var overlayBackdrop: ThemeTokenColor { get } + var shadowDefault: ThemeTokenColor { get } } extension LightBeachPalette: NativeThemePalette {} @@ -70,6 +71,7 @@ public enum NativeWebTheme: String, Sendable, CaseIterable { public var successColor: Color { palette.success.swiftUIColor } public var warningColor: Color { palette.warning.swiftUIColor } public var overlayBackdropColor: Color { palette.overlayBackdrop.swiftUIColor } + public var shadowDefaultColor: Color { palette.shadowDefault.swiftUIColor } /// Web `OverlayPanel` backdrop (`bg-background/85`), used by modal panels. public var panelBackdropColor: Color { backgroundColor.opacity(0.85) } }