style: match Raycast launcher layout and appearance
This commit is contained in:
@@ -24,9 +24,10 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
|
|||||||
- Type to filter applications; the first match is selected automatically.
|
- Type to filter applications; the first match is selected automatically.
|
||||||
- Up/Down changes the selection without leaving search.
|
- Up/Down changes the selection without leaving search.
|
||||||
- Return, keypad Enter, the Open Application button, or a double-click opens the selection.
|
- Return, keypad Enter, the Open Application button, or a double-click opens the selection.
|
||||||
- Escape, closing the window, or switching focus away dismisses the launcher.
|
- Escape, Command-W, or switching focus away dismisses the launcher.
|
||||||
- Reopen from Finder or the Dock to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
|
- Reopen from Finder or the Dock to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it.
|
||||||
- Standard macOS text-editing shortcuts work in the search field.
|
- Standard macOS text-editing shortcuts work in the search field.
|
||||||
|
- Drag the panel background to move it.
|
||||||
|
|
||||||
## Discovery and search
|
## Discovery and search
|
||||||
|
|
||||||
@@ -36,10 +37,12 @@ Search is case- and accent-insensitive. Ranking is exact name, prefix, substring
|
|||||||
|
|
||||||
## Native UI
|
## Native UI
|
||||||
|
|
||||||
AppKit owns the window chrome, search field, inset table selection, application icons, buttons, menus, fonts, and semantic colors. Appearance follows macOS light/dark settings; no custom-painted Raycast skin or third-party UI dependencies.
|
The launcher now follows the Raycast reference: a 750 × 474-point, titlebar-free dark panel with rounded corners, native translucency and shadow, a large borderless search field, compact icon rows, neutral rounded selection, right-aligned application labels, and a bottom-right launch pill with a Return key hint. It deliberately uses dark appearance for this initial visual direction.
|
||||||
|
|
||||||
|
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, semantic colors, and visual-effect materials. Custom styling is limited to layout, rounded surfaces, and selection drawing; increased-contrast settings add a selection outline. No third-party UI dependencies, copied Raycast branding, or nonfunctional Actions menu.
|
||||||
|
|
||||||
- `Sources/Conjure`: application lifecycle, native window, and application launching.
|
- `Sources/Conjure`: application lifecycle, native window, and application launching.
|
||||||
- `Sources/ConjureKit`: filesystem discovery and search ranking.
|
- `Sources/ConjureKit`: filesystem discovery and search ranking.
|
||||||
- `Tests/ConjureKitTests`: deterministic discovery and ranking checks.
|
- `Tests/ConjureKitTests`: deterministic discovery and ranking checks.
|
||||||
|
|
||||||
The launcher uses Apple's [inset table style](https://developer.apple.com/documentation/appkit/nstableview/style-swift.enum/inset), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
|
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
|
||||||
|
|||||||
@@ -14,14 +14,19 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
private var isLaunching = false
|
private var isLaunching = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let window = NSWindow(
|
let window = LauncherPanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
|
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
styleMask: [.borderless],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
window.title = "Conjure"
|
window.title = "Conjure"
|
||||||
window.contentMinSize = NSSize(width: 420, height: 300)
|
window.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
window.backgroundColor = .clear
|
||||||
|
window.isOpaque = false
|
||||||
|
window.hasShadow = true
|
||||||
|
window.isMovableByWindowBackground = true
|
||||||
|
window.hidesOnDeactivate = false
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.center()
|
window.center()
|
||||||
super.init(window: window)
|
super.init(window: window)
|
||||||
@@ -41,6 +46,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
NSApp.activate()
|
NSApp.activate()
|
||||||
window?.makeKeyAndOrderFront(nil)
|
window?.makeKeyAndOrderFront(nil)
|
||||||
window?.makeFirstResponder(searchField)
|
window?.makeFirstResponder(searchField)
|
||||||
|
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = .labelColor
|
||||||
refreshApplications()
|
refreshApplications()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,21 +56,56 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureContent(in window: NSWindow) {
|
private func configureContent(in window: NSWindow) {
|
||||||
guard let content = window.contentView else { return }
|
let content = NSVisualEffectView()
|
||||||
searchField.placeholderString = "Search applications"
|
content.material = .hudWindow
|
||||||
searchField.controlSize = .large
|
content.blendingMode = .behindWindow
|
||||||
searchField.font = .systemFont(ofSize: NSFont.systemFontSize(for: .large))
|
content.state = .active
|
||||||
|
content.wantsLayer = true
|
||||||
|
content.layer?.cornerRadius = 24
|
||||||
|
content.layer?.masksToBounds = true
|
||||||
|
content.layer?.borderWidth = 0.5
|
||||||
|
window.contentView = content
|
||||||
|
let tint = NSView(frame: content.bounds)
|
||||||
|
tint.autoresizingMask = [.width, .height]
|
||||||
|
tint.wantsLayer = true
|
||||||
|
content.addSubview(tint)
|
||||||
|
|
||||||
|
searchField.placeholderString = "Search for apps…"
|
||||||
|
searchField.font = .systemFont(ofSize: 20)
|
||||||
|
searchField.textColor = .labelColor
|
||||||
|
searchField.isBordered = false
|
||||||
|
searchField.drawsBackground = false
|
||||||
|
searchField.focusRingType = .none
|
||||||
|
if let cell = searchField.cell as? NSSearchFieldCell {
|
||||||
|
cell.searchButtonCell = nil
|
||||||
|
cell.cancelButtonCell = nil
|
||||||
|
}
|
||||||
searchField.sendsSearchStringImmediately = true
|
searchField.sendsSearchStringImmediately = true
|
||||||
searchField.sendsWholeSearchString = false
|
searchField.sendsWholeSearchString = false
|
||||||
searchField.delegate = self
|
searchField.delegate = self
|
||||||
searchField.setAccessibilityLabel("Search applications")
|
searchField.setAccessibilityLabel("Search applications")
|
||||||
|
|
||||||
|
let symbol = NSImageView()
|
||||||
|
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
||||||
|
symbol.contentTintColor = .tertiaryLabelColor
|
||||||
|
symbol.setAccessibilityElement(false)
|
||||||
|
let header = NSStackView(views: [symbol, searchField])
|
||||||
|
header.orientation = .horizontal
|
||||||
|
header.alignment = .centerY
|
||||||
|
header.spacing = 12
|
||||||
|
|
||||||
|
let sectionLabel = NSTextField(labelWithString: "Applications")
|
||||||
|
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||||||
|
sectionLabel.textColor = .secondaryLabelColor
|
||||||
|
|
||||||
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
|
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
|
||||||
column.resizingMask = .autoresizingMask
|
column.resizingMask = .autoresizingMask
|
||||||
tableView.addTableColumn(column)
|
tableView.addTableColumn(column)
|
||||||
tableView.headerView = nil
|
tableView.headerView = nil
|
||||||
tableView.style = .inset
|
tableView.style = .plain
|
||||||
tableView.rowHeight = 44
|
tableView.backgroundColor = .clear
|
||||||
|
tableView.rowHeight = 38
|
||||||
|
tableView.intercellSpacing = .zero
|
||||||
tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
|
tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
|
||||||
tableView.allowsMultipleSelection = false
|
tableView.allowsMultipleSelection = false
|
||||||
tableView.allowsEmptySelection = false
|
tableView.allowsEmptySelection = false
|
||||||
@@ -85,43 +126,93 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
scrollView.documentView = tableView
|
scrollView.documentView = tableView
|
||||||
scrollView.hasVerticalScroller = true
|
scrollView.hasVerticalScroller = true
|
||||||
scrollView.autohidesScrollers = true
|
scrollView.autohidesScrollers = true
|
||||||
|
scrollView.scrollerStyle = .overlay
|
||||||
scrollView.borderType = .noBorder
|
scrollView.borderType = .noBorder
|
||||||
|
scrollView.drawsBackground = false
|
||||||
|
scrollView.contentView.drawsBackground = false
|
||||||
|
scrollView.horizontalScrollElasticity = .none
|
||||||
|
|
||||||
statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
statusLabel.font = .systemFont(ofSize: 12)
|
||||||
statusLabel.textColor = .secondaryLabelColor
|
statusLabel.textColor = .secondaryLabelColor
|
||||||
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
emptyLabel.textColor = .secondaryLabelColor
|
emptyLabel.textColor = .secondaryLabelColor
|
||||||
emptyLabel.alignment = .center
|
emptyLabel.alignment = .center
|
||||||
openButton.bezelStyle = .rounded
|
|
||||||
|
openButton.isBordered = false
|
||||||
|
openButton.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
openButton.contentTintColor = .labelColor
|
||||||
openButton.keyEquivalent = "\r"
|
openButton.keyEquivalent = "\r"
|
||||||
openButton.target = self
|
openButton.target = self
|
||||||
openButton.action = #selector(openSelection)
|
openButton.action = #selector(openSelection)
|
||||||
openButton.isEnabled = false
|
openButton.isEnabled = false
|
||||||
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
|
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
|
||||||
|
let returnKey = NSTextField(labelWithString: "↵")
|
||||||
|
returnKey.font = .systemFont(ofSize: 14)
|
||||||
|
returnKey.textColor = .secondaryLabelColor
|
||||||
|
returnKey.alignment = .center
|
||||||
|
returnKey.wantsLayer = true
|
||||||
|
returnKey.layer?.cornerRadius = 5
|
||||||
|
returnKey.layer?.borderWidth = 1
|
||||||
|
returnKey.setAccessibilityElement(false)
|
||||||
|
|
||||||
let footer = NSStackView(views: [statusLabel, NSView(), openButton])
|
let actionPill = NSVisualEffectView()
|
||||||
|
actionPill.material = .popover
|
||||||
|
actionPill.blendingMode = .withinWindow
|
||||||
|
actionPill.state = .active
|
||||||
|
actionPill.wantsLayer = true
|
||||||
|
actionPill.layer?.cornerRadius = 18
|
||||||
|
actionPill.layer?.masksToBounds = true
|
||||||
|
actionPill.layer?.borderWidth = 0.5
|
||||||
|
for view in [openButton, returnKey] {
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
actionPill.addSubview(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
let footer = NSStackView(views: [statusLabel, NSView(), actionPill])
|
||||||
footer.orientation = .horizontal
|
footer.orientation = .horizontal
|
||||||
|
footer.alignment = .centerY
|
||||||
footer.spacing = 12
|
footer.spacing = 12
|
||||||
for view in [searchField, scrollView, emptyLabel, footer] {
|
for view in [header, sectionLabel, scrollView, emptyLabel, footer] {
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
content.addSubview(view)
|
content.addSubview(view)
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
searchField.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
|
header.topAnchor.constraint(equalTo: content.topAnchor, constant: 20),
|
||||||
searchField.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||||||
searchField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
|
header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
|
||||||
scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 12),
|
header.heightAnchor.constraint(equalToConstant: 32),
|
||||||
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
symbol.widthAnchor.constraint(equalToConstant: 22),
|
||||||
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
symbol.heightAnchor.constraint(equalToConstant: 22),
|
||||||
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
|
sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16),
|
||||||
|
sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||||||
|
scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 6),
|
||||||
|
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
|
||||||
|
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
|
||||||
|
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8),
|
||||||
emptyLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
|
emptyLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
|
||||||
emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
|
emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
|
||||||
emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16),
|
emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16),
|
||||||
emptyLabel.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -16),
|
emptyLabel.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -16),
|
||||||
footer.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
footer.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||||||
footer.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
|
footer.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
|
||||||
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12),
|
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
|
||||||
|
footer.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
actionPill.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
openButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14),
|
||||||
|
openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||||||
|
returnKey.leadingAnchor.constraint(equalTo: openButton.trailingAnchor, constant: 8),
|
||||||
|
returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
|
||||||
|
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||||||
|
returnKey.widthAnchor.constraint(equalToConstant: 18),
|
||||||
|
returnKey.heightAnchor.constraint(equalToConstant: 18),
|
||||||
])
|
])
|
||||||
|
window.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||||||
|
let border = NSColor.separatorColor.cgColor
|
||||||
|
content.layer?.borderColor = border
|
||||||
|
returnKey.layer?.borderColor = border
|
||||||
|
actionPill.layer?.borderColor = border
|
||||||
|
tint.layer?.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.85).cgColor
|
||||||
|
}
|
||||||
window.initialFirstResponder = searchField
|
window.initialFirstResponder = searchField
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +298,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
|
||||||
|
let identifier = NSUserInterfaceItemIdentifier("applicationRow")
|
||||||
|
let view = (tableView.makeView(withIdentifier: identifier, owner: self) as? LauncherRowView)
|
||||||
|
?? LauncherRowView()
|
||||||
|
view.identifier = identifier
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> NSTableCellView {
|
||||||
let cell = NSTableCellView()
|
let cell = NSTableCellView()
|
||||||
cell.identifier = identifier
|
cell.identifier = identifier
|
||||||
@@ -214,21 +313,29 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
icon.imageScaling = .scaleProportionallyDown
|
icon.imageScaling = .scaleProportionallyDown
|
||||||
icon.setAccessibilityElement(false)
|
icon.setAccessibilityElement(false)
|
||||||
let label = NSTextField(labelWithString: "")
|
let label = NSTextField(labelWithString: "")
|
||||||
|
label.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
label.lineBreakMode = .byTruncatingTail
|
label.lineBreakMode = .byTruncatingTail
|
||||||
|
let kind = NSTextField(labelWithString: "Application")
|
||||||
|
kind.font = .systemFont(ofSize: 14)
|
||||||
|
kind.textColor = .secondaryLabelColor
|
||||||
|
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
kind.setAccessibilityElement(false)
|
||||||
cell.imageView = icon
|
cell.imageView = icon
|
||||||
cell.textField = label
|
cell.textField = label
|
||||||
for view in [icon, label] {
|
for view in [icon, label, kind] {
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
cell.addSubview(view)
|
cell.addSubview(view)
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
|
icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
|
||||||
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||||
icon.widthAnchor.constraint(equalToConstant: 28),
|
icon.widthAnchor.constraint(equalToConstant: 22),
|
||||||
icon.heightAnchor.constraint(equalToConstant: 28),
|
icon.heightAnchor.constraint(equalToConstant: 22),
|
||||||
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
|
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
|
||||||
label.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
label.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||||
label.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
|
label.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
|
||||||
|
kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
|
||||||
|
kind.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
|
||||||
])
|
])
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
@@ -278,3 +385,27 @@ private final class ApplicationTableView: NSTableView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class LauncherPanel: NSPanel {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
override var canBecomeMain: Bool { true }
|
||||||
|
|
||||||
|
override func performClose(_ sender: Any?) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class LauncherRowView: NSTableRowView {
|
||||||
|
override func drawSelection(in dirtyRect: NSRect) {
|
||||||
|
let selection = NSBezierPath(roundedRect: bounds, xRadius: 10, yRadius: 10)
|
||||||
|
NSColor.unemphasizedSelectedContentBackgroundColor.withAlphaComponent(0.55).setFill()
|
||||||
|
selection.fill()
|
||||||
|
if NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast {
|
||||||
|
NSColor.labelColor.setStroke()
|
||||||
|
selection.lineWidth = 1
|
||||||
|
selection.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user