From eb75f652a7eda55258534ebd081f18318a7a083d Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 19:11:37 +0200 Subject: [PATCH] style: match Raycast launcher layout and appearance --- README.md | 9 +- .../Conjure/LauncherWindowController.swift | 185 +++++++++++++++--- 2 files changed, 164 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 17a4423..522ef28 100644 --- a/README.md +++ b/README.md @@ -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. - Up/Down changes the selection without leaving search. - 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. - Standard macOS text-editing shortcuts work in the search field. +- Drag the panel background to move it. ## Discovery and search @@ -36,10 +37,12 @@ Search is case- and accent-insensitive. Ranking is exact name, prefix, substring ## 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/ConjureKit`: filesystem discovery and search ranking. - `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:)). diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index 97f50ca..02cf2c1 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -14,14 +14,19 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private var isLaunching = false init() { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 640, height: 480), - styleMask: [.titled, .closable, .miniaturizable, .resizable], + let window = LauncherPanel( + contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), + styleMask: [.borderless], backing: .buffered, defer: false ) 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.center() super.init(window: window) @@ -41,6 +46,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe NSApp.activate() window?.makeKeyAndOrderFront(nil) window?.makeFirstResponder(searchField) + (searchField.currentEditor() as? NSTextView)?.insertionPointColor = .labelColor refreshApplications() } @@ -50,21 +56,56 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } private func configureContent(in window: NSWindow) { - guard let content = window.contentView else { return } - searchField.placeholderString = "Search applications" - searchField.controlSize = .large - searchField.font = .systemFont(ofSize: NSFont.systemFontSize(for: .large)) + let content = NSVisualEffectView() + content.material = .hudWindow + content.blendingMode = .behindWindow + 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.sendsWholeSearchString = false searchField.delegate = self 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")) column.resizingMask = .autoresizingMask tableView.addTableColumn(column) tableView.headerView = nil - tableView.style = .inset - tableView.rowHeight = 44 + tableView.style = .plain + tableView.backgroundColor = .clear + tableView.rowHeight = 38 + tableView.intercellSpacing = .zero tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle tableView.allowsMultipleSelection = false tableView.allowsEmptySelection = false @@ -85,43 +126,93 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe scrollView.documentView = tableView scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true + scrollView.scrollerStyle = .overlay 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.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) emptyLabel.textColor = .secondaryLabelColor emptyLabel.alignment = .center - openButton.bezelStyle = .rounded + + openButton.isBordered = false + openButton.font = .systemFont(ofSize: 14, weight: .medium) + openButton.contentTintColor = .labelColor openButton.keyEquivalent = "\r" openButton.target = self openButton.action = #selector(openSelection) openButton.isEnabled = false 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.alignment = .centerY footer.spacing = 12 - for view in [searchField, scrollView, emptyLabel, footer] { + for view in [header, sectionLabel, scrollView, emptyLabel, footer] { view.translatesAutoresizingMaskIntoConstraints = false content.addSubview(view) } NSLayoutConstraint.activate([ - searchField.topAnchor.constraint(equalTo: content.topAnchor, constant: 16), - searchField.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), - searchField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), - scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 12), - scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12), + header.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), + header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), + header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), + header.heightAnchor.constraint(equalToConstant: 32), + symbol.widthAnchor.constraint(equalToConstant: 22), + symbol.heightAnchor.constraint(equalToConstant: 22), + 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.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor), emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16), emptyLabel.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -16), footer.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), - footer.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), - footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12), + footer.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8), + 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 } @@ -207,6 +298,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe 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 { let cell = NSTableCellView() cell.identifier = identifier @@ -214,21 +313,29 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe icon.imageScaling = .scaleProportionallyDown icon.setAccessibilityElement(false) let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 14, weight: .medium) 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.textField = label - for view in [icon, label] { + for view in [icon, label, kind] { view.translatesAutoresizingMaskIntoConstraints = false cell.addSubview(view) } NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8), icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor), - icon.widthAnchor.constraint(equalToConstant: 28), - icon.heightAnchor.constraint(equalToConstant: 28), + 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(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 } @@ -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() + } + } +}