diff --git a/README.md b/README.md index 5d2342c..dcbbb6d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ With an empty search, applications are ordered by successful launch count, then 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. +Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes. + ## Native UI The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded panel, large borderless search field, compact icon rows, right-aligned application labels, and bottom-right launch pill. Its Sunset palette uses a dark brown background, soft amber and rust glows, warm cream text, and orange accents on the caret, selection, and action outline. diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index c100f45..95c96bb 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -11,6 +11,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private let history: LaunchHistory private var applications: [Application] = [] private var results: [Application] = [] + private var locationLabels: [URL: String] = [:] private var discoveryTask: Task? private var isLaunching = false @@ -228,11 +229,34 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe }.value guard let self else { return } self.applications = applications + self.updateLocationLabels() self.discoveryTask = nil self.updateResults(preservingSelection: true) } } + private func updateLocationLabels() { + var nameCounts: [String: Int] = [:] + nameCounts.reserveCapacity(applications.count) + for application in applications { nameCounts[application.name, default: 0] += 1 } + var groups: [String: [(url: URL, folder: URL)]] = [:] + for application in applications where (nameCounts[application.name] ?? 0) > 1 { + groups[application.name, default: []].append( + (application.url, application.url.deletingLastPathComponent()) + ) + } + locationLabels.removeAll(keepingCapacity: true) + for group in groups.values { + var folderCounts: [URL: Int] = [:] + folderCounts.reserveCapacity(group.count) + for entry in group { folderCounts[entry.folder, default: 0] += 1 } + for entry in group { + let location = (folderCounts[entry.folder] ?? 0) > 1 ? entry.url : entry.folder + locationLabels[entry.url] = (location.path as NSString).abbreviatingWithTildeInPath + } + } + } + private func updateResults(preservingSelection: Bool = false) { let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow) ? results[tableView.selectedRow].id : nil @@ -299,10 +323,13 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { let identifier = NSUserInterfaceItemIdentifier("applicationCell") - let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView) + let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell) ?? makeCell(identifier: identifier) let application = results[row] cell.textField?.stringValue = application.name + let location = locationLabels[application.id] + cell.locationLabel.stringValue = location ?? "" + cell.locationLabel.isHidden = location == nil cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path) cell.toolTip = application.url.path return cell @@ -316,8 +343,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe return view } - private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView { - let cell = NSTableCellView() + private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell { + let cell = ApplicationCell() cell.identifier = identifier let icon = NSImageView() icon.imageScaling = .scaleProportionallyDown @@ -326,6 +353,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe label.font = .systemFont(ofSize: 14, weight: .medium) label.textColor = SunsetPalette.text label.lineBreakMode = .byTruncatingTail + let location = cell.locationLabel + location.font = .systemFont(ofSize: 12) + location.textColor = SunsetPalette.secondaryText + location.lineBreakMode = .byTruncatingMiddle + location.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + location.isHidden = true + let title = NSStackView(views: [label, location]) + title.orientation = .horizontal + title.alignment = .centerY + title.spacing = 8 let kind = NSTextField(labelWithString: "Application") kind.font = .systemFont(ofSize: 14) kind.textColor = SunsetPalette.secondaryText @@ -333,7 +370,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe kind.setAccessibilityElement(false) cell.imageView = icon cell.textField = label - for view in [icon, label, kind] { + for view in [icon, title, kind] { view.translatesAutoresizingMaskIntoConstraints = false cell.addSubview(view) } @@ -342,9 +379,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor), icon.widthAnchor.constraint(equalToConstant: 22), icon.heightAnchor.constraint(equalToConstant: 22), - label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12), - label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), - label.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12), + title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12), + title.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + title.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12), kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor), kind.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8), ]) @@ -374,6 +411,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } } +@MainActor +private final class ApplicationCell: NSTableCellView { + let locationLabel = NSTextField(labelWithString: "") +} + @MainActor private final class ApplicationTableView: NSTableView { var openSelection: (() -> Void)?