Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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] })
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>] {
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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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..<end
if let attrRange = Range(lower, in: result) {
result[attrRange].font = .rubik(14, weight: .semibold)
result[attrRange].foregroundColor = theme.accentColor
}
}
return result
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CompassData
import CompassKit
import SwiftUI

public struct CommandPaletteOverlay: View {
Expand All @@ -19,6 +20,7 @@ public struct CommandPaletteOverlay: View {
if paletteStore.isOpen {
let sections = model.filteredPaletteSections()
let selectedId = model.selectedPaletteItemId
let query = paletteStore.query
ZStack {
theme.overlayBackdropColor
.ignoresSafeArea()
Expand All @@ -39,7 +41,7 @@ public struct CommandPaletteOverlay: View {
ScrollView {
VStack(alignment: .leading, spacing: 8) {
if sections.isEmpty {
Text("No results for “\(paletteStore.query)”")
Text("No results for “\(query)”")
.foregroundStyle(theme.textMutedColor)
.padding(12)
}
Expand All @@ -50,7 +52,7 @@ public struct CommandPaletteOverlay: View {
.foregroundStyle(theme.textMutedColor)
}
ForEach(section.items) { item in
paletteRow(item, selected: item.id == selectedId)
paletteRow(item, query: query, selected: item.id == selectedId)
.id(item.id)
}
}
Expand Down Expand Up @@ -86,6 +88,7 @@ public struct CommandPaletteOverlay: View {
RoundedRectangle(cornerRadius: 12)
.stroke(theme.borderColor, lineWidth: 1)
)
.shadow(color: theme.shadowDefaultColor, radius: 16, x: 0, y: 8)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("compass-native-command-palette")
}
Expand All @@ -94,15 +97,13 @@ public struct CommandPaletteOverlay: View {
}

@ViewBuilder
private func paletteRow(_ item: CommandPaletteItem, selected: Bool) -> 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))
Expand All @@ -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)
}
}
Loading
Loading