From 21e0127fc5567fecbe4221634b72b1109b197f29 Mon Sep 17 00:00:00 2001 From: Oddur Sigurdsson Date: Fri, 16 Oct 2026 21:46:44 -0400 Subject: [PATCH] feat(panel): macOS 26 layout with a capsule field and sectioned results The panel was one box with a flat list, which read as a generic launcher rather than part of macOS 26. The field is now a glass capsule and results sit on their own glass card below it, grouped the way Spotlight groups them, with quieter rows. Settings topics match by word start and keywords from the fourth letter, because once results were grouped the System Settings section was visibly full of noise for three-letter queries. --- CHANGELOG.md | 7 + CLAUDE.md | 2 +- README.md | 4 +- Sources/CommandKit/Catalogs.swift | 9 +- Sources/CommandKit/Fuzzy.swift | 39 ++++++ Sources/CommandKit/Item.swift | 9 +- Sources/CommandKit/Layout.swift | 40 ++++-- Sources/CommandKit/Ranker.swift | 5 +- Sources/CommandKit/Results.swift | 99 ++++++++++++--- Sources/CommonCommand/AppController.swift | 4 +- Sources/CommonCommand/PanelContent.swift | 79 ++++++++++++ Sources/CommonCommand/PanelController.swift | 105 +++++++-------- Sources/CommonCommand/PanelView.swift | 134 ++++++++++++-------- Sources/CommonCommand/SearchModel.swift | 10 +- Sources/CommonCommand/Snapshot.swift | 44 ++++--- Tests/CommandKitTests/CatalogTests.swift | 31 +++-- Tests/CommandKitTests/ConverterTests.swift | 13 +- Tests/CommandKitTests/SearchTests.swift | 126 ++++++++++++++---- 18 files changed, 557 insertions(+), 203 deletions(-) create mode 100644 Sources/CommonCommand/PanelContent.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d7988b..9c23a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ All notable changes to this project are documented here. The format follows [Kee - A result chosen for a query also leans shorter and longer starts of that query its way, so you can type less next time. - Unit conversions such as "10 km to mi", "70 f in c", or "5 GB in MB", offline, with Return to copy. - Moving the pointer over a result selects it, and apps, files, and folders can be dragged out of the panel. +- A macOS 26 look: a glass capsule for the field and a glass card for results, grouped into Top Hit, Applications, System Settings, Files, and Search the Web. A short fade on opening, none with Reduce Motion. +- "No Results" when nothing matches and web search is off. + +### Changed + +- Rows say less: no "Application" label, a pane's name instead of "System Settings ›", and file paths keep their start and end when shortened. +- System Settings topics match only where each typed word starts a word of the topic, and keywords only from the fourth letter, so a short query finds apps instead of a dozen loosely related settings. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 334f635..7ad84ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,4 +35,4 @@ Conventional Commits: `type(scope): subject`. Write the subject in the imperativ - The panel is a non-activating `NSPanel`. Don't call `NSApp.activate()` when showing it; the user's app must stay frontmost. - A bug fix comes with the test that would have caught it. Ranking changes need a test showing the order they fix. - `CommonTools.swift` is shared word for word across the Common Tools apps. Change it in all of them together. -- For visual review, use `swift build && .build/debug/CommonCommand --snapshot out.png "query" [--theme light|dark]` and inspect `out.png`. +- For visual review, use `swift build && .build/debug/CommonCommand --snapshot out.png "query" [--theme light|dark]` and inspect `out.png`. It renders offscreen; never drive the live screen with synthesized keys or the global shortcut to check a design. diff --git a/README.md b/README.md index daa3b9e..af0db46 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A small, fast launcher for macOS. Press ⌥Space, type a few letters, press Retu Common Command is built to feel like part of macOS: -- **It matches your Mac.** The panel follows light and dark mode, uses Liquid Glass on macOS 26 (the popover material before that), and highlights with your accent colour. Choose a fixed theme, a solid background, or a size in Settings. +- **It looks like macOS 26.** A Liquid Glass capsule for the search field and a glass card for the results under it, with Spotlight's sections: the Top Hit, then Applications, System Settings, Files, and the web. It follows light and dark mode and highlights with your accent colour; earlier macOS versions get the popover material. Choose a fixed theme, a solid background, or a size in Settings. - **It stays out of the way.** The panel never activates the app, so the app you were in stays frontmost and keeps its menu bar. Escape, or a click anywhere else, returns you to it with nothing to restore. - **It learns.** Whatever you choose for a query comes first the next time you type it, and leans the same way for a shorter start: choose Safari for "safari" once, and "sa" prefers it too. Things you open often rise for every query. Usage decays with a one-week half-life, so last year's habits fade. - **Typing is forgiving.** "vsc" finds Visual Studio Code, "sys set" finds System Settings, "cafe" finds Café. Matched letters are drawn in bold. @@ -102,7 +102,7 @@ scripts/agent doctor # check tools and repository state See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow. -`swift build && .build/debug/CommonCommand --snapshot out.png "query" --theme dark` opens the real panel with the query typed, captures it, and quits. Use it to review design changes. It uses its own settings, so it never changes yours. +`swift build && .build/debug/CommonCommand --snapshot out.png "query" --theme dark` draws the panel with the query typed into `out.png` and quits. It renders offscreen, so nothing appears on screen and nothing takes focus; glass shows as a solid fill. It uses its own settings, so it never changes yours. Layout: diff --git a/Sources/CommandKit/Catalogs.swift b/Sources/CommandKit/Catalogs.swift index 1733b83..7c53e1b 100644 --- a/Sources/CommandKit/Catalogs.swift +++ b/Sources/CommandKit/Catalogs.swift @@ -32,7 +32,7 @@ public enum AppCatalog { let fileName = url.deletingPathExtension().lastPathComponent let name = displayName(url) let item = Item( - id: "app:" + resolved, title: name, subtitle: "Application", kind: .application, + id: "app:" + resolved, title: name, subtitle: "", kind: .application, action: .open(url)) candidates.append(Candidate(item, alternatives: [fileName], weight: weight)) } @@ -137,13 +137,14 @@ public enum SettingsPanes { guard seenTitles.insert(key).inserted else { continue } let item = Item( id: "pane:\(identifier)#\(topic.title)", title: topic.title, - subtitle: name.map { "System Settings › \($0)" } ?? "System Settings", kind: .settings, + subtitle: name ?? "", kind: .settings, action: .open(target), iconFile: icon) - result.append(Candidate(item, keywords: topic.keywords, weight: topicWeight)) + // Hundreds of long topic names would match almost any three letters as initials. + result.append(Candidate(item, keywords: topic.keywords, weight: topicWeight, matchesWordStarts: true)) } if let name { let item = Item( - id: "pane:" + identifier, title: name, subtitle: "System Settings", kind: .settings, + id: "pane:" + identifier, title: name, subtitle: "", kind: .settings, action: .open(target), iconFile: icon) result.insert(Candidate(item, keywords: paneKeywords), at: 0) } diff --git a/Sources/CommandKit/Fuzzy.swift b/Sources/CommandKit/Fuzzy.swift index a44b466..0591e29 100644 --- a/Sources/CommandKit/Fuzzy.swift +++ b/Sources/CommandKit/Fuzzy.swift @@ -137,8 +137,47 @@ public enum Fuzzy { return Match(score: score, positions: positions) } + /// A stricter match for long, numerous names such as System Settings topics: each typed word must start + /// a word of the name, in order. "low power" finds "Low Power Mode"; "saf" doesn't find "Screen area + /// for display", which as initials would. Scored like a run of word-start matches. + public static func wordPrefixMatch(_ words: [[UInt32]], in text: SearchText) -> Match? { + guard !words.isEmpty else { return nil } + let t = text.keys + var cursor = 0 + var positions: [Int] = [] + for word in words { + var found: Int? + var start = cursor + while start + word.count <= t.count { + if text.bonus[start] > 0, t[start..<(start + word.count)].elementsEqual(word) { + found = start + break + } + start += 1 + } + guard let found else { return nil } + positions += Array(found..<(found + word.count)) + cursor = found + word.count + } + let m = positions.count + let starts = words.count + var score = matchScore * m + consecutiveBonus * (m - starts) + 9 * starts - (t.count - m) / 4 + if positions.first == 0 { score += 1 } + if m == t.count { score += exactBonus } + return Match(score: score, positions: positions) + } + + /// The query's words, folded, for `wordPrefixMatch`. + public static func prepareWords(_ query: String) -> [[UInt32]] { + query.split(whereSeparator: \.isWhitespace).map { $0.map(fold) } + } + + static let minimumKeywordLength = 4 + /// A keyword the query starts: below a name that starts the same way, above a scattered name match. static func keywordScore(_ q: [UInt32], _ keywords: [[UInt32]]) -> Int? { + // Three letters are nearly always the start of an app's name, and hundreds of keywords would match. + guard q.count >= minimumKeywordLength else { return nil } var best: Int? for keyword in keywords where keyword.count >= q.count && keyword.starts(with: q) { let score = 12 * q.count + 8 + (keyword.count == q.count ? 10 : 0) diff --git a/Sources/CommandKit/Item.swift b/Sources/CommandKit/Item.swift index e1734fa..1a78cb6 100644 --- a/Sources/CommandKit/Item.swift +++ b/Sources/CommandKit/Item.swift @@ -52,8 +52,15 @@ public struct Candidate: Sendable { /// clearly closer name still wins: "Bluetooth" the pane over "Bluetooth File Exchange" the app. public let weight: Int - public init(_ item: Item, alternatives: [String] = [], keywords: [String] = [], weight: Int = 0) { + /// Matches only where each typed word starts a word of a name, never as scattered letters or initials. + let matchesWordStarts: Bool + + public init( + _ item: Item, alternatives: [String] = [], keywords: [String] = [], weight: Int = 0, + matchesWordStarts: Bool = false + ) { self.item = item + self.matchesWordStarts = matchesWordStarts var seen: Set = [item.title] names = [SearchText(item.title)] + alternatives.filter { seen.insert($0).inserted }.map(SearchText.init) // Each keyword whole, spaces dropped, and each of its words, so "darkm" and "mode" both find "Dark Mode". diff --git a/Sources/CommandKit/Layout.swift b/Sources/CommandKit/Layout.swift index 89503fa..c87bd44 100644 --- a/Sources/CommandKit/Layout.swift +++ b/Sources/CommandKit/Layout.swift @@ -1,7 +1,7 @@ import CoreGraphics -/// The panel's dimensions. Every size is a little smaller than Spotlight's: one line of type and a short -/// list, not a window. +/// The panel's dimensions: a capsule search field and, under it, a card of results. Every size is a little +/// smaller than Spotlight's: one line of type and a short list, not a window. public enum PanelSize: String, CaseIterable, Sendable, Identifiable { case small, medium, large @@ -17,9 +17,9 @@ public enum PanelSize: String, CaseIterable, Sendable, Identifiable { public var fieldHeight: CGFloat { switch self { - case .small: 42 - case .medium: 48 - case .large: 54 + case .small: 44 + case .medium: 50 + case .large: 56 } } @@ -47,14 +47,30 @@ public enum PanelSize: String, CaseIterable, Sendable, Identifiable { } } - public var iconSize: CGFloat { rowHeight - 12 } - public var cornerRadius: CGFloat { fieldHeight / 2.6 } - /// Space around the list, so the selection's rounded corners don't touch the panel's edge. - public var listPadding: CGFloat { 6 } + public var headerHeight: CGFloat { rowHeight * 0.75 } + public var headerFontSize: CGFloat { rowFontSize - 3 } + public var iconSize: CGFloat { rowHeight - 10 } + /// The field is a capsule, as on macOS 26. + public var fieldRadius: CGFloat { fieldHeight / 2 } + public var cardRadius: CGFloat { rowHeight * 0.6 } + public var rowRadius: CGFloat { cardRadius - cardPadding } + /// Space inside the card, so the selection's rounded corners sit inside the card's. + public var cardPadding: CGFloat { 7 } + /// Between the field and the card: two pieces of glass, not one box. + public var gap: CGFloat { 8 } - /// The panel's height with `rows` results, of which at most `visible` show before the list scrolls. - public func height(rows: Int, visible: Int) -> CGFloat { + /// The card's height for `rows` results under `headers` section headers, scrolling past `visible` rows. + public func cardHeight(rows: Int, headers: Int, visible: Int) -> CGFloat { + guard rows > 0 else { return 0 } + let content = CGFloat(rows) * rowHeight + CGFloat(headers) * headerHeight + // Room for the visible rows and a couple of headers; past that the list scrolls. + let cap = CGFloat(max(visible, 1)) * rowHeight + 2 * headerHeight + return min(content, cap) + 2 * cardPadding + } + + /// The whole panel: the field alone, or the field, the gap, and the card. + public func height(rows: Int, headers: Int, visible: Int) -> CGFloat { guard rows > 0 else { return fieldHeight } - return fieldHeight + 1 + CGFloat(min(rows, max(visible, 1))) * rowHeight + 2 * listPadding + return fieldHeight + gap + cardHeight(rows: rows, headers: headers, visible: visible) } } diff --git a/Sources/CommandKit/Ranker.swift b/Sources/CommandKit/Ranker.swift index 77f2be3..5bd1ddd 100644 --- a/Sources/CommandKit/Ranker.swift +++ b/Sources/CommandKit/Ranker.swift @@ -22,6 +22,7 @@ public enum Ranker { ) -> [Ranked] { let q = Fuzzy.prepare(query) guard !q.isEmpty, limit > 0 else { return [] } + let words = Fuzzy.prepareWords(query) let booster = usage.booster(for: query, at: now) // Only the best few are ever shown, so keep those in order instead of sorting every match. var top: [(ranked: Ranked, name: [UInt32])] = [] @@ -30,7 +31,9 @@ public enum Ranker { var best: Match? var bestIsTitle = false for (index, name) in candidate.names.enumerated() { - guard let match = Fuzzy.match(q, in: name), match.score > (best?.score ?? .min) else { continue } + let found = + candidate.matchesWordStarts ? Fuzzy.wordPrefixMatch(words, in: name) : Fuzzy.match(q, in: name) + guard let match = found, match.score > (best?.score ?? .min) else { continue } best = match bestIsTitle = index == 0 } diff --git a/Sources/CommandKit/Results.swift b/Sources/CommandKit/Results.swift index 2bef58c..3e0c945 100644 --- a/Sources/CommandKit/Results.swift +++ b/Sources/CommandKit/Results.swift @@ -12,46 +12,111 @@ public struct SearchOptions: Sendable, Equatable { public init() {} } -/// Puts one query's results in order: a web address or calculation first, since typing one is -/// unambiguous, then apps and settings, then files, then the web search that's always there. +/// A run of results under one header, as Spotlight groups them. +public struct ResultSection: Identifiable, Sendable { + public let title: String + public let rows: [Ranked] + + public var id: String { title } + + public init(title: String, rows: [Ranked]) { + self.title = title + self.rows = rows + } +} + +/// Puts one query's results in order and into sections. A web address or a calculation comes first, since +/// typing one is unambiguous. Then the best match on its own as the Top Hit, then the rest grouped by +/// kind, each group placed by its best match, and last the web search that's always there. public enum Results { public static func compose( query: String, candidates: [Candidate], files: [Candidate], usage: Usage, now: Date, options: SearchOptions - ) -> [Ranked] { + ) -> [ResultSection] { let text = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return [] } - var results: [Ranked] = [] + var sections: [ResultSection] = [] if let url = Web.address(text) { let item = Item( - id: "link:" + url.absoluteString, title: url.absoluteString, subtitle: "Open Link", kind: .link, + id: "link:" + url.absoluteString, title: url.absoluteString, subtitle: "Open", kind: .link, action: .open(url)) - results.append(Ranked(item: item, score: .max)) + sections.append(ResultSection(title: "Website", rows: [Ranked(item: item, score: .max)])) } if options.calculator, let conversion = Converter.convert(text, locale: options.locale) { let digits = Converter.significantDigits let shown = Calculator.format(conversion.value, locale: options.locale, digits: digits) let copied = Calculator.format(conversion.value, locale: options.locale, grouping: false, digits: digits) let item = Item( - id: "convert", title: "= \(shown) \(conversion.unit)", subtitle: "Copy", kind: .calculation, + id: "convert", title: "\(shown) \(conversion.unit)", subtitle: "Copy", kind: .calculation, action: .copy(copied)) - results.append(Ranked(item: item, score: .max)) + sections.append(ResultSection(title: "Conversion", rows: [Ranked(item: item, score: .max)])) } else if options.calculator, let value = Calculator.evaluate(text, locale: options.locale) { let shown = Calculator.format(value, locale: options.locale) let copied = Calculator.format(value, locale: options.locale, grouping: false) - let item = Item( - id: "calc", title: "= " + shown, subtitle: "Copy", kind: .calculation, action: .copy(copied)) - results.append(Ranked(item: item, score: .max)) + let item = Item(id: "calc", title: shown, subtitle: "Copy", kind: .calculation, action: .copy(copied)) + sections.append(ResultSection(title: "Calculator", rows: [Ranked(item: item, score: .max)])) } - results += Ranker.rank(candidates, query: text, usage: usage, now: now, limit: options.limit) - results += Ranker.rank(files, query: text, usage: usage, now: now, limit: options.fileLimit) + + let ranked = merge( + Ranker.rank(candidates, query: text, usage: usage, now: now, limit: options.limit), + Ranker.rank(files, query: text, usage: usage, now: now, limit: options.fileLimit)) + sections += group(ranked) + if options.webSearch, let url = options.engine.url(searching: text) { let item = Item( - id: "web", title: text, subtitle: "Search \(options.engine.name)", kind: .webSearch, - action: .open(url)) - results.append(Ranked(item: item, score: .min)) + id: "web", title: text, subtitle: options.engine.name, kind: .webSearch, action: .open(url)) + sections.append(ResultSection(title: "Search the Web", rows: [Ranked(item: item, score: .min)])) + } + return sections + } + + /// Two best-first lists as one, best first; on a tie the first list's entry leads. + static func merge(_ a: [Ranked], _ b: [Ranked]) -> [Ranked] { + var merged: [Ranked] = [] + merged.reserveCapacity(a.count + b.count) + var i = 0 + var j = 0 + while i < a.count || j < b.count { + if j == b.count || (i < a.count && a[i].score >= b[j].score) { + merged.append(a[i]) + i += 1 + } else { + merged.append(b[j]) + j += 1 + } + } + return merged + } + + /// The best result as the Top Hit, then the others under their kind's header, groups in order of + /// their best member. + static func group(_ ranked: [Ranked]) -> [ResultSection] { + guard let top = ranked.first else { return [] } + var order: [String] = [] + var rows: [String: [Ranked]] = [:] + for result in ranked.dropFirst() { + let title = header(for: result.item.kind) + if rows[title] == nil { order.append(title) } + rows[title, default: []].append(result) } - return results + return [ResultSection(title: "Top Hit", rows: [top])] + order.map { ResultSection(title: $0, rows: rows[$0]!) } } + + static func header(for kind: Kind) -> String { + switch kind { + case .application: "Applications" + case .settings: "System Settings" + case .command: "Common Command" + case .file, .folder: "Files" + case .calculation: "Calculator" + case .link: "Website" + case .webSearch: "Search the Web" + } + } +} + +extension [ResultSection] { + /// Every row in display order: what the selection steps through. + public var rows: [Ranked] { flatMap(\.rows) } } diff --git a/Sources/CommonCommand/AppController.swift b/Sources/CommonCommand/AppController.swift index 3e7769c..f8d03b7 100644 --- a/Sources/CommonCommand/AppController.swift +++ b/Sources/CommonCommand/AppController.swift @@ -22,11 +22,11 @@ final class AppController: NSObject { library.commands = [ Candidate( Item( - id: "command:settings", title: "Common Command Settings", subtitle: "Command", kind: .command, + id: "command:settings", title: "Common Command Settings", subtitle: "", kind: .command, action: .command("settings")), alternatives: ["Preferences"]), Candidate( Item( - id: "command:quit", title: "Quit Common Command", subtitle: "Command", kind: .command, + id: "command:quit", title: "Quit Common Command", subtitle: "", kind: .command, action: .command("quit"))), ] library.refreshIfStale() diff --git a/Sources/CommonCommand/PanelContent.swift b/Sources/CommonCommand/PanelContent.swift new file mode 100644 index 0000000..7b30c52 --- /dev/null +++ b/Sources/CommonCommand/PanelContent.swift @@ -0,0 +1,79 @@ +import AppKit +import CommandKit +import SwiftUI + +/// What the panel holds: the field's capsule and, under it, the results card, each its own piece of glass, +/// as the search field and its results are on macOS 26. +@MainActor +final class PanelContent: NSView { + private let size: PanelSize + private let field: NSView + private let card: NSView + + init(size: PanelSize, translucent: Bool, field: FieldView, card: CardView) { + self.size = size + self.field = Self.surface(NSHostingView(rootView: field), radius: size.fieldRadius, translucent: translucent) + self.card = Self.surface(NSHostingView(rootView: card), radius: size.cardRadius, translucent: translucent) + super.init(frame: .zero) + addSubview(self.field) + addSubview(self.card) + } + + required init?(coder: NSCoder) { fatalError("not used from a nib") } + + override var isFlipped: Bool { true } + + override func layout() { + super.layout() + field.frame = NSRect(x: 0, y: 0, width: bounds.width, height: size.fieldHeight) + let cardTop = size.fieldHeight + size.gap + card.frame = NSRect(x: 0, y: cardTop, width: bounds.width, height: max(0, bounds.height - cardTop)) + card.isHidden = card.frame.height <= 0 + } + + /// Liquid Glass on macOS 26, the popover material before it, or nothing when the SwiftUI view draws a + /// solid fill. Clipped to the shape, so the window's shadow follows the rounded outline rather than + /// the rectangle the glass is drawn in. + private static func surface(_ content: NSHostingView, radius: CGFloat, translucent: Bool) -> NSView { + content.sizingOptions = [] + let background: NSView + if !translucent { + background = content + } else if #available(macOS 26.0, *) { + let glass = NSGlassEffectView() + glass.cornerRadius = radius + glass.contentView = content + background = glass + } else { + let effect = NSVisualEffectView() + effect.material = .popover + effect.blendingMode = .behindWindow + effect.state = .active + // The window server draws this blur, so only a mask image rounds it; the layer clip doesn't. + effect.maskImage = roundedMask(radius: radius) + content.autoresizingMask = [.width, .height] + effect.addSubview(content) + background = effect + } + let clip = NSView() + clip.wantsLayer = true + clip.layer?.cornerRadius = radius + clip.layer?.cornerCurve = .continuous + clip.layer?.masksToBounds = true + background.autoresizingMask = [.width, .height] + clip.addSubview(background) + return clip + } + + private static func roundedMask(radius: CGFloat) -> NSImage { + let side = radius * 2 + 1 + let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in + NSColor.black.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + return true + } + image.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + image.resizingMode = .stretch + return image + } +} diff --git a/Sources/CommonCommand/PanelController.swift b/Sources/CommonCommand/PanelController.swift index 1206a15..032a348 100644 --- a/Sources/CommonCommand/PanelController.swift +++ b/Sources/CommonCommand/PanelController.swift @@ -52,11 +52,14 @@ final class PanelController: NSObject, NSWindowDelegate { private let library: Library private let panel = CommandPanel() private weak var field: NSTextField? + private var content: PanelContent? private var builtFor: (size: PanelSize, translucent: Bool)? var perform: ((Item, _ reveal: Bool) -> Void)? /// How far down the screen the panel's top edge sits: high, like Spotlight, but clear of the menu bar. private static let topFraction: CGFloat = 0.2 + /// Short enough that typing straight away never waits on it. + private static let fadeDuration: TimeInterval = 0.12 init(preferences: Preferences, library: Library) { self.preferences = preferences @@ -77,7 +80,6 @@ final class PanelController: NSObject, NSWindowDelegate { } var isVisible: Bool { panel.isVisible } - var frame: NSRect { panel.frame } func toggle() { isVisible ? hide() : show() @@ -91,7 +93,16 @@ final class PanelController: NSObject, NSWindowDelegate { model.pointerMoved = false model.update() position() + // A quick fade, as macOS shows its own panels; none at all with Reduce Motion. + let fades = !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + panel.alphaValue = fades ? 0 : 1 panel.makeKeyAndOrderFront(nil) + if fades { + NSAnimationContext.runAnimationGroup { context in + context.duration = Self.fadeDuration + panel.animator().alphaValue = 1 + } + } refreshShadow() // The last query comes back selected, so typing replaces it and ↩ repeats it. if let field { @@ -155,54 +166,46 @@ final class PanelController: NSObject, NSWindowDelegate { }, cancel: { [weak self] in self?.hide() }, made: { [weak self] in self?.field = $0 }) - let hosting = NSHostingView( - rootView: PanelView(model: model, preferences: preferences, commands: commands) { [weak self] in - self?.choose($0) - }) - hosting.sizingOptions = [] - let background = Self.background(around: hosting, size: size, translucent: translucent) - // Clipped to the panel's shape, so the window's shadow is rounded too and not a rectangle. - let container = NSView() - container.wantsLayer = true - container.layer?.cornerRadius = size.cornerRadius - container.layer?.cornerCurve = .continuous - container.layer?.masksToBounds = true - background.autoresizingMask = [.width, .height] - container.addSubview(background) - panel.contentView = container - background.frame = container.bounds - } - - /// Liquid Glass on macOS 26, the popover material before it, or nothing when the view draws its own - /// solid background. - private static func background(around content: NSView, size: PanelSize, translucent: Bool) -> NSView { - guard translucent else { return content } - if #available(macOS 26.0, *) { - let glass = NSGlassEffectView() - glass.cornerRadius = size.cornerRadius - glass.contentView = content - return glass - } - let effect = NSVisualEffectView() - effect.material = .popover - effect.blendingMode = .behindWindow - effect.state = .active - effect.maskImage = roundedMask(radius: size.cornerRadius) - content.autoresizingMask = [.width, .height] - effect.addSubview(content) - return effect - } - - private static func roundedMask(radius: CGFloat) -> NSImage { - let side = radius * 2 + 1 - let image = NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in - NSColor.black.setFill() - NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() - return true - } - image.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) - image.resizingMode = .stretch - return image + let content = PanelContent( + size: size, translucent: translucent, + field: FieldView(model: model, preferences: preferences, commands: commands), + card: CardView(model: model, preferences: preferences) { [weak self] in self?.choose($0) }) + self.content = content + panel.contentView = content + } + + /// The panel as it would look now, drawn offscreen with solid surfaces, since glass only renders on + /// screen. For design review: nothing appears and nothing takes focus. + func renderOffscreen(appearance: NSAppearance?) -> NSBitmapImageRep? { + let size = preferences.size + let noop = SearchField.Commands(move: { _ in }, submit: {}, cancel: {}, made: { _ in }) + let content = PanelContent( + size: size, translucent: false, + field: FieldView(model: model, preferences: preferences, commands: noop), + card: CardView(model: model, preferences: preferences) { _ in }) + let margin: CGFloat = 24 + let frame = NSRect(x: 0, y: 0, width: size.width + 2 * margin, height: height + 2 * margin) + let window = NSWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false) + let backdrop = NSView(frame: frame) + backdrop.wantsLayer = true + backdrop.layer?.backgroundColor = NSColor.systemGray.withAlphaComponent(0.35).cgColor + content.frame = NSRect(x: margin, y: margin, width: size.width, height: height) + backdrop.addSubview(content) + window.contentView = backdrop + window.appearance = appearance + backdrop.layoutSubtreeIfNeeded() + backdrop.displayIfNeeded() + guard let rep = backdrop.bitmapImageRepForCachingDisplay(in: backdrop.bounds) else { return nil } + backdrop.cacheDisplay(in: backdrop.bounds, to: rep) + return rep + } + + /// The panel's height for what the model shows now. + private var height: CGFloat { + let size = preferences.size + if model.showsNoResults { return size.height(rows: 1, headers: 0, visible: preferences.visibleRows) } + return size.height( + rows: model.results.count, headers: model.sections.count, visible: preferences.visibleRows) } /// Centred on the screen with the pointer, which is the screen you're looking at. @@ -212,7 +215,7 @@ final class PanelController: NSObject, NSWindowDelegate { let visible = screen.visibleFrame let top = visible.maxY - visible.height * Self.topFraction let size = preferences.size - let height = size.height(rows: model.results.count, visible: preferences.visibleRows) + let height = height panel.setFrame( NSRect(x: (visible.midX - size.width / 2).rounded(), y: top - height, width: size.width, height: height), display: false) @@ -222,7 +225,7 @@ final class PanelController: NSObject, NSWindowDelegate { private func resize() { guard panel.isVisible || panel.frame.width > 0 else { return } let size = preferences.size - let height = size.height(rows: model.results.count, visible: preferences.visibleRows) + let height = height guard panel.frame.height != height || panel.frame.width != size.width else { return } let top = panel.frame.maxY panel.setFrame( diff --git a/Sources/CommonCommand/PanelView.swift b/Sources/CommonCommand/PanelView.swift index a1d9545..7b087c4 100644 --- a/Sources/CommonCommand/PanelView.swift +++ b/Sources/CommonCommand/PanelView.swift @@ -2,77 +2,110 @@ import AppKit import CommandKit import SwiftUI -/// The search field and the results under it. -struct PanelView: View { +/// The search field, in its capsule. +struct FieldView: View { @Bindable var model: SearchModel let preferences: Preferences let commands: SearchField.Commands - let choose: (Int) -> Void private var size: PanelSize { preferences.size } var body: some View { - VStack(spacing: 0) { - HStack(spacing: 10) { - Image(systemName: "magnifyingglass") - .font(.system(size: size.fieldFontSize * 0.8, weight: .medium)) - .foregroundStyle(.secondary) - SearchField(model: model, fontSize: size.fieldFontSize, commands: commands) - } - .padding(.horizontal, 16) - .frame(height: size.fieldHeight) - - if !model.results.isEmpty { - Divider().opacity(0.6) - ResultList(model: model, size: size, visibleRows: preferences.visibleRows, choose: choose) - } - } - .frame(width: size.width, alignment: .top) - .frame(maxHeight: .infinity, alignment: .top) - .background { - if !preferences.translucent { - RoundedRectangle(cornerRadius: size.cornerRadius, style: .continuous) - .fill(Color(nsColor: .windowBackgroundColor)) - } - } - .overlay { - // A hairline edge, as on menus and popovers, so the panel holds its shape over any background. - RoundedRectangle(cornerRadius: size.cornerRadius, style: .continuous) - .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: size.fieldFontSize * 0.82, weight: .medium)) + .foregroundStyle(.secondary) + SearchField(model: model, fontSize: size.fieldFontSize, commands: commands) } + .padding(.leading, size.fieldHeight * 0.36) + .padding(.trailing, size.fieldHeight * 0.3) + .frame(width: size.width, height: size.fieldHeight) + .surface(radius: size.fieldRadius, solid: !preferences.translucent) } } -private struct ResultList: View { +/// The results, in sections, on their own card under the field. +struct CardView: View { let model: SearchModel - let size: PanelSize - let visibleRows: Int + let preferences: Preferences let choose: (Int) -> Void + private var size: PanelSize { preferences.size } + var body: some View { ScrollViewReader { proxy in ScrollView(.vertical) { - VStack(spacing: 0) { - ForEach(Array(model.results.enumerated()), id: \.element.id) { index, ranked in - ResultRow( - ranked: ranked, size: size, isSelected: index == model.selection, - shortcut: model.showsShortcuts && index < 9 ? "⌘\(index + 1)" : nil - ) - .id(index) - .onTapGesture { choose(index) } - .onContinuousHover { phase in - if case .active = phase { model.hover(index) } + VStack(alignment: .leading, spacing: 0) { + if model.showsNoResults { + Text("No Results") + .font(.system(size: size.rowFontSize)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: size.rowHeight) + } + ForEach(Array(sectionStarts.enumerated()), id: \.element.section.id) { _, entry in + SectionHeader(title: entry.section.title, size: size) + ForEach(Array(entry.section.rows.enumerated()), id: \.element.id) { offset, ranked in + let index = entry.start + offset + ResultRow( + ranked: ranked, size: size, isSelected: index == model.selection, + shortcut: model.showsShortcuts && index < 9 ? "⌘\(index + 1)" : nil + ) + .id(index) + .onTapGesture { choose(index) } + .onContinuousHover { phase in + if case .active = phase { model.hover(index) } + } + .dragsFile(ranked.item.fileURL) } - .dragsFile(ranked.item.fileURL) } } - .padding(size.listPadding) + .padding(size.cardPadding) } .scrollIndicators(.automatic) - .scrollDisabled(model.results.count <= visibleRows) - .frame(height: CGFloat(min(model.results.count, visibleRows)) * size.rowHeight + 2 * size.listPadding) .onChange(of: model.selection) { _, selection in proxy.scrollTo(selection) } } + .frame(width: size.width) + .frame(maxHeight: .infinity) + .surface(radius: size.cardRadius, solid: !preferences.translucent) + } + + /// Each section with the overall index of its first row, so rows know their place in the selection. + private var sectionStarts: [(section: ResultSection, start: Int)] { + var start = 0 + return model.sections.map { section in + defer { start += section.rows.count } + return (section, start) + } + } +} + +private struct SectionHeader: View { + let title: String + let size: PanelSize + + var body: some View { + Text(title) + .font(.system(size: size.headerFontSize, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, minHeight: size.headerHeight, alignment: .bottomLeading) + .padding(.bottom, 2) + .frame(height: size.headerHeight) + .accessibilityAddTraits(.isHeader) + } +} + +extension View { + /// The drawn part of a glass shape: a solid fill when translucency is off (the glass sits behind in + /// AppKit otherwise), and a hairline edge either way, as on menus and popovers. + func surface(radius: CGFloat, solid: Bool) -> some View { + let shape = RoundedRectangle(cornerRadius: radius, style: .continuous) + return + self + .background { + if solid { shape.fill(Color(nsColor: .windowBackgroundColor)) } + } + .overlay { shape.strokeBorder(Color.primary.opacity(0.1), lineWidth: 0.5) } } } @@ -101,10 +134,11 @@ private struct ResultRow: View { .truncationMode(.middle) Spacer(minLength: 16) Text(shortcut ?? ranked.item.subtitle) - .font(.system(size: size.rowFontSize - 1)) + .font(.system(size: size.rowFontSize - 2)) .foregroundStyle(isSelected ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary)) .lineLimit(1) - .truncationMode(.head) + // A path keeps its start and its end: "~/Code/…/src" says more than "…ode/app/src". + .truncationMode(.middle) .layoutPriority(-1) } .font(.system(size: size.rowFontSize)) @@ -113,7 +147,7 @@ private struct ResultRow: View { .frame(height: size.rowHeight) .background { if isSelected { - RoundedRectangle(cornerRadius: size.cornerRadius - size.listPadding, style: .continuous) + RoundedRectangle(cornerRadius: size.rowRadius, style: .continuous) .fill(Color.accentColor) } } diff --git a/Sources/CommonCommand/SearchModel.swift b/Sources/CommonCommand/SearchModel.swift index b0863f2..8a3ce96 100644 --- a/Sources/CommonCommand/SearchModel.swift +++ b/Sources/CommonCommand/SearchModel.swift @@ -11,6 +11,8 @@ final class SearchModel { update() } } + private(set) var sections: [ResultSection] = [] + /// Every row in display order, which is the order the selection moves through. private(set) var results: [Ranked] = [] var selection = 0 /// True while ⌘ is held, so each row shows its ⌘1–⌘9 shortcut. @@ -35,6 +37,11 @@ final class SearchModel { var selected: Item? { results.indices.contains(selection) ? results[selection].item : nil } + /// Typed something, but nothing matched and the web search is off. + var showsNoResults: Bool { + sections.isEmpty && !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + func move(_ delta: Int) { pointerMoved = false guard !results.isEmpty else { return } @@ -72,10 +79,11 @@ final class SearchModel { let selectedID = keepingSelection && selection != 0 ? selected?.id : nil // Files from the previous keystroke stay until the new ones arrive; ranking drops those that no // longer match, so the list doesn't flicker. - results = Results.compose( + sections = Results.compose( query: query, candidates: library.candidates(includingPanes: preferences.searchesSettings), files: preferences.searchesFiles ? files : [], usage: library.usage, now: Date(), options: preferences.searchOptions) + results = sections.rows if let selectedID, let index = results.firstIndex(where: { $0.id == selectedID }) { selection = index } else { diff --git a/Sources/CommonCommand/Snapshot.swift b/Sources/CommonCommand/Snapshot.swift index e298243..6895657 100644 --- a/Sources/CommonCommand/Snapshot.swift +++ b/Sources/CommonCommand/Snapshot.swift @@ -1,8 +1,8 @@ import AppKit -/// `CommonCommand --snapshot out.png [query] [--theme light|dark]` opens the real panel with `query` typed, -/// captures it over whatever is behind it, and quits. For reviewing design changes. Capturing the -/// desktop behind the panel needs Screen Recording access for the terminal running it. +/// `CommonCommand --snapshot out.png [query] [--theme light|dark]` draws the panel with `query` typed into +/// an image and quits. It renders offscreen, so nothing appears on screen and nothing takes focus; glass +/// shows as a solid fill, since only the window server can draw it. @MainActor enum Snapshot { struct Request { @@ -13,34 +13,38 @@ enum Snapshot { static func request(from arguments: [String]) -> Request? { guard let index = arguments.firstIndex(of: "--snapshot"), index + 1 < arguments.count else { return nil } - let rest = Array(arguments[(index + 2)...]) + var rest = Array(arguments[(index + 2)...]) var theme: Theme? - if let flag = rest.firstIndex(of: "--theme"), flag + 1 < rest.count { theme = Theme(rawValue: rest[flag + 1]) } - let query = rest.first { !$0.hasPrefix("--") && Theme(rawValue: $0) == nil } ?? "" + if let flag = rest.firstIndex(of: "--theme"), flag + 1 < rest.count { + theme = Theme(rawValue: rest[flag + 1]) + rest.removeSubrange(flag...(flag + 1)) + } + let query = rest.first { !$0.hasPrefix("--") } ?? "" return Request(output: arguments[index + 1], query: query, theme: theme) } static func run(_ request: Request, panel: PanelController, preferences: Preferences) { - if let theme = request.theme { preferences.theme = theme } - panel.show() + // Offscreen there's no glass, so draw the solid surfaces instead of nothing. + preferences.translucent = false panel.model.query = request.query - // Long enough for the Spotlight file query and the first icon loads to land. + // Long enough for the app scan and the Spotlight file query to land. DispatchQueue.main.asyncAfter(deadline: .now() + 1) { MainActor.assumeIsolated { - let capture = Process() - capture.executableURL = URL(filePath: "/usr/sbin/screencapture") - // A margin around the frame, so the shadow and the glass's edge show. - let frame = panel.frame.insetBy(dx: -24, dy: -24) - let top = (NSScreen.screens.first?.frame.maxY ?? 0) - frame.maxY - let region = "\(Int(frame.minX)),\(Int(top)),\(Int(frame.width)),\(Int(frame.height))" - capture.arguments = ["-x", "-R\(region)", request.output] + panel.model.update(keepingSelection: true) + let appearance = (request.theme ?? .system).appearance + guard + let png = panel.renderOffscreen(appearance: appearance)?.representation( + using: .png, properties: [:]) + else { + print("snapshot: couldn't render") + exit(1) + } do { - try capture.run() - capture.waitUntilExit() + try png.write(to: URL(filePath: request.output)) print("wrote \(request.output)") - exit(capture.terminationStatus) + exit(0) } catch { - print("screencapture failed: \(error.localizedDescription)") + print("snapshot: \(error.localizedDescription)") exit(1) } } diff --git a/Tests/CommandKitTests/CatalogTests.swift b/Tests/CommandKitTests/CatalogTests.swift index 51d78da..4d87493 100644 --- a/Tests/CommandKitTests/CatalogTests.swift +++ b/Tests/CommandKitTests/CatalogTests.swift @@ -66,7 +66,7 @@ import Testing @Test func findsPanesAndTopicsOnThisMac() { let found = SettingsPanes.scan(isPortable: true) - let panes = found.filter { $0.item.subtitle == "System Settings" && !$0.item.id.contains("#") } + let panes = found.filter { !$0.item.id.contains("#") } #expect(panes.count > 10) #expect(found.count > panes.count) #expect(found.allSatisfy { $0.item.kind == .settings }) @@ -102,8 +102,8 @@ import Testing identifier: "appearance", name: "Appearance", topics: SettingsPanes.topics(in: terms, isPortable: true), target: target, icon: nil) #expect(candidates.map(\.item.title) == ["Appearance", "Accent color", "Low Power Mode"]) - #expect(candidates[0].item.subtitle == "System Settings") - #expect(candidates[1].item.subtitle == "System Settings › Appearance") + #expect(candidates[0].item.subtitle == "") + #expect(candidates[1].item.subtitle == "Appearance") #expect(candidates[1].weight < candidates[0].weight) #expect(Fuzzy.keywordScore(Fuzzy.prepare("dark"), candidates[0].keywords) != nil) } @@ -114,7 +114,8 @@ import Testing topics: [.init(title: "Battery", keywords: ["charge"]), .init(title: "Low Power Mode", keywords: [])], target: target, icon: nil) #expect(candidates.map(\.item.title) == ["Battery", "Low Power Mode"]) - #expect(candidates.allSatisfy { $0.item.subtitle == "System Settings" }) + #expect(candidates.allSatisfy { $0.item.subtitle == "" }) + #expect(candidates.allSatisfy { $0.matchesWordStarts }) } } @@ -165,14 +166,26 @@ import Testing } @Suite struct LayoutTests { - @Test func theFieldAloneIsOneLine() { - #expect(PanelSize.medium.height(rows: 0, visible: 8) == PanelSize.medium.fieldHeight) + let size = PanelSize.medium + + @Test func theFieldAloneIsOneCapsule() { + #expect(size.height(rows: 0, headers: 0, visible: 8) == size.fieldHeight) + #expect(size.fieldRadius * 2 == size.fieldHeight) + } + + @Test func theCardSitsBelowTheFieldWithAGap() { + let card = size.cardHeight(rows: 2, headers: 1, visible: 8) + #expect(size.height(rows: 2, headers: 1, visible: 8) == size.fieldHeight + size.gap + card) + #expect(card == 2 * size.rowHeight + size.headerHeight + 2 * size.cardPadding) } @Test func growsWithResultsUntilItScrolls() { - let size = PanelSize.medium - #expect(size.height(rows: 3, visible: 8) < size.height(rows: 8, visible: 8)) - #expect(size.height(rows: 8, visible: 8) == size.height(rows: 20, visible: 8)) + #expect(size.height(rows: 3, headers: 2, visible: 8) < size.height(rows: 8, headers: 2, visible: 8)) + #expect(size.height(rows: 9, headers: 3, visible: 8) == size.height(rows: 30, headers: 5, visible: 8)) + } + + @Test func theSelectionsCornersNestInTheCards() { + #expect(PanelSize.allCases.allSatisfy { $0.rowRadius + $0.cardPadding == $0.cardRadius }) } @Test func everySizeIsSmallerThanSpotlight() { diff --git a/Tests/CommandKitTests/ConverterTests.swift b/Tests/CommandKitTests/ConverterTests.swift index 22a09e8..0e69f50 100644 --- a/Tests/CommandKitTests/ConverterTests.swift +++ b/Tests/CommandKitTests/ConverterTests.swift @@ -43,9 +43,10 @@ import Testing options.locale = us let results = Results.compose( query: "1 mi to km", candidates: [app("Miro")], files: [], usage: Usage(), now: Date(), options: options) - #expect(results.first?.item.title == "= 1.60934 km") - #expect(results.first?.item.action == .copy("1.60934")) - #expect(results.filter { $0.item.kind == .calculation }.count == 1) + #expect(results.first?.title == "Conversion") + #expect(results.rows.first?.item.title == "1.60934 km") + #expect(results.rows.first?.item.action == .copy("1.60934")) + #expect(results.rows.filter { $0.item.kind == .calculation }.count == 1) } @Test func showsSixFiguresAndCopiesWhatItShows() { @@ -53,8 +54,8 @@ import Testing options.locale = us let results = Results.compose( query: "70 f in c", candidates: [], files: [], usage: Usage(), now: Date(), options: options) - #expect(results.first?.item.title == "= 21.1111 °C") - #expect(results.first?.item.action == .copy("21.1111")) + #expect(results.rows.first?.item.title == "21.1111 °C") + #expect(results.rows.first?.item.action == .copy("21.1111")) } @Test func turnsOffWithTheCalculator() { @@ -62,6 +63,6 @@ import Testing options.calculator = false let results = Results.compose( query: "1 mi to km", candidates: [], files: [], usage: Usage(), now: Date(), options: options) - #expect(!results.contains { $0.item.kind == .calculation }) + #expect(!results.rows.contains { $0.item.kind == .calculation }) } } diff --git a/Tests/CommandKitTests/SearchTests.swift b/Tests/CommandKitTests/SearchTests.swift index b8abc91..d86c43e 100644 --- a/Tests/CommandKitTests/SearchTests.swift +++ b/Tests/CommandKitTests/SearchTests.swift @@ -128,6 +128,42 @@ func titles(_ ranked: [Ranked]) -> [String] { ranked.map(\.item.title) } } } +@Suite struct WordStartTests { + func topic(_ title: String) -> Candidate { + Candidate( + Item( + id: "pane:x#" + title, title: title, subtitle: "", kind: .settings, + action: .open(URL(string: "x-apple.systempreferences:x")!)), + weight: SettingsPanes.topicWeight, matchesWordStarts: true) + } + + func ranks(_ query: String, _ title: String) -> Bool { + !Ranker.rank([topic(title)], query: query, usage: Usage(), now: Date(), limit: 1).isEmpty + } + + @Test func eachWordMustStartAWordInOrder() { + #expect(ranks("low power", "Low Power Mode")) + #expect(ranks("power", "Low Power Mode")) + #expect(ranks("low pow", "Low Power Mode")) + #expect(!ranks("power low", "Low Power Mode")) + } + + @Test func initialsAndScatteredLettersDontMatch() { + #expect(!ranks("saf", "Screen area for display")) + #expect(!ranks("lpm", "Low Power Mode")) + #expect(!ranks("ower", "Low Power Mode")) + } + + @Test func highlightsTheMatchedWordStarts() { + let ranked = Ranker.rank([topic("Low Power Mode")], query: "low pow", usage: Usage(), now: Date(), limit: 1) + #expect(ranked.first?.highlights == [0, 1, 2, 4, 5, 6]) + } + + @Test func appsStillMatchByInitials() { + #expect(!Ranker.rank([app("Visual Studio Code")], query: "vsc", usage: Usage(), now: Date(), limit: 1).isEmpty) + } +} + @Suite struct KeywordTests { let now = Date(timeIntervalSince1970: 1_000_000) @@ -153,6 +189,17 @@ func titles(_ ranked: [Ranked]) -> [String] { ranked.map(\.item.title) } #expect(ranked.count == 1) } + @Test func shortQueriesDontReachKeywords() { + // Three letters are an app's name far more often than a settings keyword. + #expect( + Ranker.rank([pane("Lockdown Mode", keywords: ["safety"])], query: "saf", usage: Usage(), now: now, limit: 1) + .isEmpty) + #expect( + !Ranker.rank( + [pane("Lockdown Mode", keywords: ["safety"])], query: "safe", usage: Usage(), now: now, limit: 1 + ).isEmpty) + } + @Test func keywordsMatchFromTheirStartOnly() { #expect( Ranker.rank([pane("Appearance", keywords: ["theme"])], query: "eme", usage: Usage(), now: now, limit: 1) @@ -245,50 +292,77 @@ func titles(_ ranked: [Ranked]) -> [String] { ranked.map(\.item.title) } return options } + func compose( + _ query: String, _ candidates: [Candidate] = [], files: [Candidate] = [], options: SearchOptions? = nil + ) + -> [ResultSection] + { + Results.compose( + query: query, candidates: candidates, files: files, usage: Usage(), now: now, + options: options ?? self.options) + } + + func file(_ title: String, weight: Int = 0) -> Candidate { + Candidate( + Item( + id: "file:/x/" + title, title: title, subtitle: "~/x", kind: .file, + action: .open(URL(filePath: "/x/" + title))), + weight: weight) + } + @Test func emptyQueryShowsNothing() { - #expect( - Results.compose( - query: " ", candidates: [app("Safari")], files: [], usage: Usage(), now: now, options: options - ).isEmpty) + #expect(compose(" ", [app("Safari")]).isEmpty) } @Test func calculationComesFirstAndWebSearchLast() { - let results = Results.compose( - query: "2+2", candidates: [app("2+2 Game")], files: [], usage: Usage(), now: now, options: options) - #expect(results.map(\.item.kind) == [.calculation, .application, .webSearch]) - #expect(results.first?.item.title == "= 4") - #expect(results.first?.item.action == .copy("4")) + let sections = compose("2+2", [app("2+2 Game")]) + #expect(sections.map(\.title) == ["Calculator", "Top Hit", "Search the Web"]) + #expect(sections.rows.map(\.item.kind) == [.calculation, .application, .webSearch]) + #expect(sections.rows.first?.item.title == "4") + #expect(sections.rows.first?.item.action == .copy("4")) } @Test func copiedResultHasNoGroupingSeparator() { - let results = Results.compose( - query: "1000*1000", candidates: [], files: [], usage: Usage(), now: now, options: options) - #expect(results.first?.item.title == "= 1,000,000") - #expect(results.first?.item.action == .copy("1000000")) + let rows = compose("1000*1000").rows + #expect(rows.first?.item.title == "1,000,000") + #expect(rows.first?.item.action == .copy("1000000")) } - @Test func filesFollowApps() { - let file = Candidate( + @Test func theBestMatchIsTheTopHitAndTheRestGroupByKind() { + let pane = Candidate( Item( - id: "file:/x/Safari Notes.txt", title: "Safari Notes.txt", subtitle: "~/x", kind: .file, - action: .open(URL(filePath: "/x/Safari Notes.txt")))) - let results = Results.compose( - query: "safari", candidates: [app("Safari")], files: [file], usage: Usage(), now: now, options: options) - #expect(results.map(\.item.kind) == [.application, .file, .webSearch]) + id: "pane:notes", title: "Notes settings", subtitle: "", kind: .settings, + action: .open(URL(string: "x-apple.systempreferences:x")!))) + let sections = compose("notes", [app("Notes"), app("Notesnook"), pane], files: [file("notes.txt", weight: 8)]) + #expect(sections.map(\.title) == ["Top Hit", "Files", "Applications", "System Settings", "Search the Web"]) + #expect(sections[0].rows.map(\.item.title) == ["Notes"]) + #expect(sections.rows.count == 5) + } + + @Test func groupsFollowTheirBestMember() { + // The file outscores the second app, so Files comes before Applications. + let merged = Results.merge( + [Ranked(item: app("A").item, score: 90), Ranked(item: app("B").item, score: 40)], + [Ranked(item: file("c").item, score: 60)]) + #expect(merged.map(\.score) == [90, 60, 40]) + #expect(Results.group(merged).map(\.title) == ["Top Hit", "Files", "Applications"]) + } + + @Test func aTieKeepsTheAppFirst() { + let merged = Results.merge([Ranked(item: app("A").item, score: 50)], [Ranked(item: file("b").item, score: 50)]) + #expect(merged.map(\.item.kind) == [.application, .file]) } @Test func turningSourcesOffRemovesThem() { var options = options options.calculator = false options.webSearch = false - #expect( - Results.compose(query: "2+2", candidates: [], files: [], usage: Usage(), now: now, options: options).isEmpty - ) + #expect(compose("2+2", options: options).isEmpty) } @Test func anAddressOpensAsALink() { - let results = Results.compose( - query: "github.com", candidates: [], files: [], usage: Usage(), now: now, options: options) - #expect(results.first?.item.action == .open(URL(string: "https://github.com")!)) + let sections = compose("github.com") + #expect(sections.first?.title == "Website") + #expect(sections.rows.first?.item.action == .open(URL(string: "https://github.com")!)) } }