From 3fd9519169c8e4b3d6f077ac6fa8a3f6902bd124 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Wed, 14 Oct 2026 12:03:36 +0200 Subject: [PATCH] feat: separate favorite application suggestions --- Package.swift | 2 +- README.md | 2 +- .../Conjure/LauncherWindowController.swift | 28 ++++++++++++++++++- TODO.md | 5 ---- .../FavoriteSpacingTests.swift | 18 ++++++++++++ 5 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 Tests/ConjureKitTests/FavoriteSpacingTests.swift diff --git a/Package.swift b/Package.swift index b20eff8..0243f84 100644 --- a/Package.swift +++ b/Package.swift @@ -8,6 +8,6 @@ let package = Package( targets: [ .target(name: "ConjureKit"), .executableTarget(name: "Conjure", dependencies: ["ConjureKit"]), - .testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit"]), + .testTarget(name: "ConjureKitTests", dependencies: ["ConjureKit", "Conjure"]), ] ) diff --git a/README.md b/README.md index 0457f58..9495f76 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Search is case- and accent-insensitive. Ranking is exact name, full-name prefix, Words are runs of letters or numbers separated by spaces, punctuation, or symbols. For example, `settings` matches the second word of System Settings, `gc` matches Google Chrome, and `ss` matches System Settings. Initials must occur in order but can skip words: both `vsc` and `vc` match Visual Studio Code. -With an empty search, favorites appear first. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group. +With an empty search, favorites appear first, separated from the remaining applications by a 12-point gap when both groups are present. Typed searches have no group spacing. Within both favorite and non-favorite groups, applications are ordered by successful launch count, then most recent launch, then alphabetically. Favorites and usage are stored locally by application bundle identity and survive restarts; copies with the same name remain independent. Only launches through Conjure count; failed launches do not. Applications without history remain alphabetical within their group. Background discovery preserves the selected application even if its row changes. If that application disappears, the first remaining result is selected. Changing the query or reopening the launcher selects the first result. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 65c73d4..ebfc906 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -527,6 +527,18 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe resultCount } + private func spacingBeforeRow(_ row: Int) -> CGFloat { + guard calculation == nil, + searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + row > 0, results.indices.contains(row), + history.isFavorite(results[row - 1]), !history.isFavorite(results[row]) else { return 0 } + return 12 + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + LauncherRowView.rowHeight + spacingBeforeRow(row) + } + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let identifier = NSUserInterfaceItemIdentifier("applicationCell") let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell) @@ -564,6 +576,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe let view = (tableView.makeView(withIdentifier: identifier, owner: self) as? LauncherRowView) ?? LauncherRowView() view.identifier = identifier + view.topSpacing = spacingBeforeRow(row) return view } @@ -1058,9 +1071,22 @@ final class LauncherRowView: NSTableRowView { static let textSize: CGFloat = 14 static let iconSize: CGFloat = 22 static let iconToTextSpacing: CGFloat = 12 + var topSpacing: CGFloat = 0 { + didSet { needsLayout = true; needsDisplay = true } + } + + override func layout() { + super.layout() + for case let cell as ApplicationCell in subviews { + cell.frame = NSRect(x: 0, y: topSpacing, width: bounds.width, height: bounds.height - topSpacing) + } + } override func drawSelection(in dirtyRect: NSRect) { - let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10) + var selectionBounds = bounds + selectionBounds.origin.y += topSpacing + selectionBounds.size.height -= topSpacing + let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10) SunsetPalette.selectionFill.setFill() selection.fill() let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast diff --git a/TODO.md b/TODO.md index 33ee8ab..e69de29 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +0,0 @@ -- [x] Add Favorite / Remove Favorite actions and persist favorites by application bundle. - - Empty search: favorites first, then usage ranking within each group. - - Typed search: match quality first, then favorites, launch count, recency, and alphabetical order. -- [x] add a star icon to the left of the "Application" type text in the selection entries for favorited items -- [ ] add some spacing between the favorites grouping and the rest of the applications \ No newline at end of file diff --git a/Tests/ConjureKitTests/FavoriteSpacingTests.swift b/Tests/ConjureKitTests/FavoriteSpacingTests.swift new file mode 100644 index 0000000..63b285f --- /dev/null +++ b/Tests/ConjureKitTests/FavoriteSpacingTests.swift @@ -0,0 +1,18 @@ +import AppKit +import Testing +@testable import Conjure + +@Test @MainActor +func favoriteSpacingLeavesApplicationContentAtNormalHeightAndResetsOnReuse() { + _ = NSApplication.shared + let row = LauncherRowView(frame: NSRect(x: 0, y: 0, width: 650, height: 50)) + let cell = ApplicationCell(frame: row.bounds) + row.addSubview(cell) + row.topSpacing = 12 + row.layoutSubtreeIfNeeded() + #expect(cell.frame == NSRect(x: 0, y: 12, width: 650, height: 38)) + row.frame.size.height = 38 + row.topSpacing = 0 + row.layoutSubtreeIfNeeded() + #expect(cell.frame == NSRect(x: 0, y: 0, width: 650, height: 38)) +}