feat: separate favorite application suggestions

This commit is contained in:
Valdemar Lorenzen
2026-10-14 12:03:36 +02:00
parent fd010ac864
commit 3fd9519169
5 changed files with 47 additions and 8 deletions
+1 -1
View File
@@ -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"]),
]
)
+1 -1
View File
@@ -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.
+27 -1
View File
@@ -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
-5
View File
@@ -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
@@ -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))
}