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")!)) } }