This commit is contained in:
abue-ammar
2026-07-09 00:08:25 +06:00
parent 1c2a1d3ce7
commit bdc220fdc4
40 changed files with 182 additions and 420 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Tinycast
A tiny, fully native macOS launcher — the core of Raycast, without the bloat.
A tiny, fully native macOS launcher — the essentials, without the bloat.
<!-- Screenshot placeholder — drop the real image at docs/screenshot.png -->
<p align="center">
+1 -3
View File
@@ -3,9 +3,7 @@ import SwiftUI
@main
struct TinycastApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
// `@AppStorage` is the idiomatic driver for scene state persisted in UserDefaults. Unlike
// observing a shared `@Published` object from the `App`, it only republishes when the value
// actually changes — no scene ⇄ binding feedback loop. The Settings toggle writes the same key.
// `@AppStorage` republishes only when the value changes, avoiding a scene ⇄ binding feedback loop.
@AppStorage(SettingsKey.showInMenuBar) private var showInMenuBar = true
// Channel-aware: "Tinycast", "Tinycast Alpha", or "Tinycast Beta".
+1 -3
View File
@@ -72,9 +72,7 @@ final class AppCore: ObservableObject {
func start() {
NSApp.setActivationPolicy(.accessory)
// Tinycast is always dark — the Liquid Glass palette is tuned for a deep, Raycast-style
// dark surface, and forcing the appearance keeps the material from rendering washed-out
// when the system is in Light mode.
// Force dark: the Liquid Glass material is tuned for a deep dark surface and renders washed-out in Light mode.
NSApp.appearance = NSAppearance(named: .darkAqua)
clipboardStore.maxAge = settings.clipboardRetention.maxAge
+12 -27
View File
@@ -21,8 +21,7 @@ struct AppEntry: Identifiable, Hashable, Sendable {
}
}
/// The global-hotkey action that opens this entry, or `nil` when it has no bundle ID
/// (nothing stable to key the binding on). Commands have no bundle ID, so no hotkeys.
/// The global-hotkey action that opens this entry, or `nil` when it has no bundle ID to key the binding on.
var hotKeyAction: HotKeyAction? {
guard let bundleID else { return nil }
switch kind {
@@ -41,18 +40,10 @@ struct AppEntry: Identifiable, Hashable, Sendable {
}
}
/// Caches app icons by file path so list rows don't re-hit `NSWorkspace` on every render.
///
/// `NSWorkspace` returns a multi-representation icon with reps up to 512/1024px, but we only ever draw
/// it at ≤24pt — caching those raw is what spiked Settings' App-Hotkeys list to hundreds of MB. So we
/// downsample each icon once to a small fixed bitmap and byte-bound the cache (system-evicted under
/// pressure, like `ImageThumbnail`).
/// Caches app icons by file path, downsampled to a small fixed bitmap and byte-bounded, so list rows don't re-hit `NSWorkspace` or balloon memory.
@MainActor
enum IconCache {
// Icons display at ≤24pt, so 48pt (2× for Retina) is plenty crisp. Keeping each icon this small is
// also what stops the launcher from ballooning: a `LazyVStack` scrolled to the bottom materializes
// every app row and pins its icon, so per-icon size sets the ceiling. At ~36KB the whole app set is
// a shared ~18MB (fits the cache with no eviction) instead of 500 distinct 256KB copies (~128MB).
// 48pt (2× Retina) is plenty for the ≤24pt draw size, and keeping each icon small caps launcher memory since a scrolled `LazyVStack` pins every row's icon.
private static let displayPixel: CGFloat = 48
private static let cache: NSCache<NSString, NSImage> = {
@@ -69,8 +60,7 @@ enum IconCache {
return icon
}
/// Command "icons": an SF Symbol centered on a rounded tile, rendered once into the same small
/// bitmap shape as app icons so `AppRow`/`ShortcutRow` treat every entry identically.
/// Command "icons": an SF Symbol on a rounded tile, in the same bitmap shape as app icons so rows treat every entry identically.
static func symbolIcon(named name: String) -> NSImage {
let key = "symbol:" + name as NSString
if let cached = cache.object(forKey: key) { return cached }
@@ -100,8 +90,7 @@ enum IconCache {
return icon
}
/// Rasterize the multi-rep workspace icon into one `displayPixel`-square bitmap so the cache holds
/// ~64–256KB per app instead of multi-MB. Returns the image and its decoded byte cost.
/// Rasterize the multi-rep workspace icon into one `displayPixel`-square bitmap, returning it and its decoded byte cost.
private static func downsampled(_ source: NSImage) -> (NSImage, Int) {
let scale = NSScreen.main?.backingScaleFactor ?? 2
let pixels = Int(displayPixel * scale)
@@ -113,7 +102,9 @@ enum IconCache {
bytesPerRow: 0, bitsPerPixel: 0)
else { return (source, fallbackCost) }
rep.size = NSSize(width: displayPixel, height: displayPixel)
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return (source, fallbackCost) }
guard let ctx = NSGraphicsContext(bitmapImageRep: rep) else {
return (source, fallbackCost)
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
@@ -131,8 +122,7 @@ enum IconCache {
final class AppIndex: ObservableObject {
@Published private(set) var apps: [AppEntry] = []
/// One-entry memo so repeated renders for the same query (e.g. while hovering moves the
/// selection) reuse the ranking instead of re-running the fuzzy match every frame.
/// One-entry memo so repeated renders for the same query reuse the ranking instead of re-matching every frame.
private var matchCache: (query: String, result: [AppEntry])?
func refresh() async {
@@ -175,10 +165,7 @@ final class AppIndex: ObservableObject {
kind: .application))
}
}
// Apps (sorted by name), then Settings panes, then Commands (each sorted by name). The
// launcher's sectioned list relies on this invariant: with an empty query the results are
// contiguous runs of favorites/apps/panes/commands, so the flat selection index maps 1:1
// onto sectioned rows.
// Apps, then Settings panes, then Commands — the sectioned launcher relies on this order so its flat selection index maps 1:1 onto rows.
let apps = result.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
@@ -213,8 +200,7 @@ final class AppIndex: ObservableObject {
}
enum FuzzyMatch {
/// Tiered relevance score; higher is better. Returns nil when the query doesn't match at all.
/// Tiers are spaced far enough apart that a better kind always beats a worse one.
/// Tiered relevance score (higher is better), or nil when the query doesn't match; tiers are spaced so a better kind always wins.
static func score(query: String, candidate: String) -> Int? {
let q = query.lowercased()
let c = candidate.lowercased()
@@ -238,8 +224,7 @@ enum FuzzyMatch {
return !before.isLetter && !before.isNumber
}
/// Subsequence match with bonuses for consecutive hits and word boundaries.
/// Returns nil when `q` is not a subsequence of `c`. Always below the substring tier.
/// Subsequence match with bonuses for consecutive hits and word boundaries, or nil when `q` isn't a subsequence of `c`.
private static func subsequenceScore(_ q: [Character], _ c: [Character]) -> Int? {
var qi = 0
var score = 0
+1 -3
View File
@@ -1,9 +1,7 @@
import Foundation
extension Bundle {
/// The app's channel-aware display name — "Tinycast", "Tinycast Alpha", "Tinycast Beta".
/// Driven by CFBundleDisplayName/CFBundleName in the generated Info.plist (see
/// Packaging/make-app.sh), so UI chrome reflects which channel is running.
/// The app's channel-aware display name ("Tinycast", "Tinycast Alpha", "Tinycast Beta"), driven by CFBundleDisplayName/CFBundleName in the generated Info.plist.
var appDisplayName: String {
(object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (object(forInfoDictionaryKey: "CFBundleName") as? String)
+7 -15
View File
@@ -16,29 +16,20 @@ struct CalcResult: Equatable, Sendable {
let payload: Payload
}
/// Entry point: turns a raw launcher query into a calculator answer, or nil when the query is not
/// calculator input at all (app names, bare numbers, half-typed expressions). The pipeline is
/// pre-filter → number-base conversion → unit conversion → arithmetic expression; every stage is
/// pure and allocation-light so live per-keystroke evaluation stays well under a millisecond.
///
/// These files (Core/Calculator/*) are Foundation-only on purpose: `Tools/calc-test.swift`
/// compiles them directly into a standalone test binary.
/// Entry point turning a raw query into a calculator answer (or nil when it isn't calculator input), via a pure pre-filter → base → unit → arithmetic pipeline; kept Foundation-only so `Tools/calc-test.swift` compiles it standalone.
enum CalcEngine {
static func evaluate(_ raw: String) -> CalcResult? {
let query = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty, query.count <= 256 else { return nil }
// Cheap reject before tokenizing: calculator input always carries a digit or a constant
// ("pi", "e"). App searches that slip through ("preview") still cost only a failed
// tokenize/parse — this filter just keeps the common case near-free.
guard query.contains(where: { $0.isASCII && $0.isNumber })
// Cheap reject before tokenizing: calculator input always carries a digit or a constant, keeping the common app-search case near-free.
guard
query.contains(where: { $0.isASCII && $0.isNumber })
|| query.lowercased().contains("e") || query.contains("π")
else { return nil }
guard let tokens = CalcTokenizer.tokenize(query), !tokens.isEmpty else { return nil }
// A lone literal or constant ("45", "3.14", "pi") is far more likely an app search than a
// calculation — no card. The exception is a radix literal ("0xff"), where echoing the
// decimal value is genuinely useful.
// A lone literal or constant is more likely an app search than a calculation, so no card — except a radix literal ("0xff"), where echoing the decimal is useful.
if tokens.count == 1 {
if case .intLiteral(let value, let radix) = tokens[0], radix != 10 {
let display = CalcFormatter.grouped(String(value))
@@ -112,7 +103,8 @@ enum CalcEngine {
let sourceText = query.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? query
return CalcResult(
expression: sourceText,
payload: .value(display: output, copyText: output.replacingOccurrences(of: ",", with: "")))
payload: .value(
display: output, copyText: output.replacingOccurrences(of: ",", with: "")))
}
/// Light cleanup of the typed expression for the card: collapse whitespace and use the pretty
+1 -3
View File
@@ -1,8 +1,6 @@
import Foundation
/// Hand-rolled, locale-independent number formatting: `.` decimal point, `,` thousands grouping.
/// Deterministic on purpose — the standalone test harness asserts exact strings, and the palette
/// always renders the same answer regardless of system locale.
/// Hand-rolled, locale-independent number formatting (`.` decimal, `,` grouping), deterministic so the test harness can assert exact strings and the palette renders identically across locales.
enum CalcFormatter {
/// Human-facing: ≤10 significant digits, trailing zeros trimmed, thousands separators.
static func display(_ value: Double) -> String {
+10 -6
View File
@@ -4,8 +4,7 @@ enum CalcToken: Equatable, Sendable {
case number(Double)
/// Radix-prefixed integer literal (0xff / 0b1010 / 0o777), kept exact for base conversion.
case intLiteral(UInt64, radix: Int)
/// Lowercased word: function, constant, unit, or connector. `²`/`³` fold to "2"/"3" so `m²`
/// and `m2` are the same ident; `°` is kept (`°c`).
/// Lowercased word (function, constant, unit, or connector); `²`/`³` fold to "2"/"3" so `m²` and `m2` match, while `°` is kept.
case ident(String)
case op(Character) // + - * / ^ ! % ( )
case arrow // -> or →
@@ -114,8 +113,7 @@ enum CalcTokenizer {
}
}
/// Precedence-climbing evaluator over the token stream — evaluates while parsing, no AST.
/// Returns nil for anything malformed or non-finite; the caller shows no card.
/// Precedence-climbing evaluator over the token stream (evaluates while parsing, no AST), returning nil for anything malformed or non-finite.
enum CalcParser {
static func evaluate(_ tokens: [CalcToken]) -> Double? {
var parser = Parser(tokens: tokens)
@@ -186,8 +184,14 @@ private struct Parser {
let result: Double
switch op {
// `450 + 20%` reads as a relative change: 450 * 1.2. With a plain rhs it's ordinary math.
case "+": result = rhs.isPercent ? lhs.effective * (1 + rhs.value / 100) : lhs.effective + rhs.effective
case "-": result = rhs.isPercent ? lhs.effective * (1 - rhs.value / 100) : lhs.effective - rhs.effective
case "+":
result =
rhs.isPercent
? lhs.effective * (1 + rhs.value / 100) : lhs.effective + rhs.effective
case "-":
result =
rhs.isPercent
? lhs.effective * (1 - rhs.value / 100) : lhs.effective - rhs.effective
case "*": result = lhs.effective * rhs.effective
case "/": result = lhs.effective / rhs.effective
case "^": result = pow(lhs.effective, rhs.effective)
+14 -14
View File
@@ -16,9 +16,7 @@ enum UnitCategory: String, CaseIterable, Sendable {
}
}
/// One unit as an affine map onto its category's base unit: `base = value * factor + offset`.
/// `offset` is zero everywhere except temperature (base Kelvin), which is why conversion below is
/// affine rather than a plain ratio — °C/°F/K come out right with no special-casing.
/// One unit as an affine map onto its category's base unit (`base = value * factor + offset`); `offset` is nonzero only for temperature, so °C/°F/K convert with no special-casing.
struct UnitDef: Equatable, Sendable {
let symbol: String // canonical display form: "mi", "°F", "GiB"
let category: UnitCategory
@@ -39,13 +37,7 @@ enum CalcUnits {
case mismatch(from: UnitDef, to: UnitDef)
}
/// Detects `expr unit (to|in|->) unit` in the token stream. Returns nil when the query is not
/// a unit conversion at all; `.mismatch` only when both units are known but incompatible — the
/// one case that deserves a friendly error instead of silence.
///
/// The connector must be the second-to-last token and the last token a known unit. Taking the
/// *last* qualifying position resolves "in" doubling as inches: in "10 in in cm" the first
/// "in" is the unit and the second the connector.
/// Detects `expr unit (to|in|->) unit` (connector second-to-last, known unit last), returning `.mismatch` only when both units are known but incompatible; matching the last position lets "in" double as inches.
static func parseConversion(_ tokens: [CalcToken]) -> ConversionParse? {
guard tokens.count >= 4, isConnector(tokens[tokens.count - 2]),
case .ident(let toName) = tokens[tokens.count - 1],
@@ -78,10 +70,16 @@ enum CalcUnits {
}
// Length (base: meter)
add(UnitDef("mm", .length, 0.001), ["mm", "millimeter", "millimeters", "millimetre", "millimetres"])
add(UnitDef("cm", .length, 0.01), ["cm", "centimeter", "centimeters", "centimetre", "centimetres"])
add(
UnitDef("mm", .length, 0.001),
["mm", "millimeter", "millimeters", "millimetre", "millimetres"])
add(
UnitDef("cm", .length, 0.01),
["cm", "centimeter", "centimeters", "centimetre", "centimetres"])
add(UnitDef("m", .length, 1), ["m", "meter", "meters", "metre", "metres"])
add(UnitDef("km", .length, 1000), ["km", "kilometer", "kilometers", "kilometre", "kilometres"])
add(
UnitDef("km", .length, 1000),
["km", "kilometer", "kilometers", "kilometre", "kilometres"])
add(UnitDef("in", .length, 0.0254), ["in", "inch", "inches"])
add(UnitDef("ft", .length, 0.3048), ["ft", "foot", "feet"])
add(UnitDef("yd", .length, 0.9144), ["yd", "yard", "yards"])
@@ -122,7 +120,9 @@ enum CalcUnits {
add(UnitDef("ha", .area, 10000), ["ha", "hectare", "hectares"])
// Volume (base: liter; US customary)
add(UnitDef("mL", .volume, 0.001), ["ml", "milliliter", "milliliters", "millilitre", "millilitres"])
add(
UnitDef("mL", .volume, 0.001),
["ml", "milliliter", "milliliters", "millilitre", "millilitres"])
add(UnitDef("L", .volume, 1), ["l", "liter", "liters", "litre", "litres"])
add(UnitDef("cup", .volume, 0.2365882365), ["cup", "cups"])
add(UnitDef("tbsp", .volume, 0.01478676478125), ["tbsp", "tablespoon", "tablespoons"])
+1 -4
View File
@@ -8,10 +8,7 @@ struct CalcHistoryEntry: Identifiable, Codable, Hashable, Sendable {
let createdAt: Date
}
/// Persists recent calculations as a JSON file — same shape as before (a small `@Published` array
/// written back on every mutation) but on disk under `~/Library/Caches/<bundle-id>/`, alongside
/// `ClipboardStore`'s sqlite db and image blobs, so `brew uninstall --zap` (which trashes that whole
/// directory) removes it too. Capped, so the file stays tiny and search is a plain scan.
/// Persists recent calculations as a capped JSON file under `~/Library/Caches/<bundle-id>/`, alongside `ClipboardStore`'s data so `brew uninstall --zap` removes it too.
@MainActor
final class CalculatorHistoryStore: ObservableObject {
private static let cap = 200
+3 -9
View File
@@ -39,9 +39,7 @@ struct ClipboardItem: Identifiable, Hashable, Sendable {
}
}
/// How long clipboard history is kept before entries are pruned. Raw value is the age in days,
/// which is also what gets persisted to UserDefaults. `forever` is -1 (not 0) because an unset
/// UserDefaults key reads as 0 and must keep falling through to the default.
/// How long clipboard history is kept before pruning; raw value is the age in days persisted to UserDefaults, and `forever` is -1 so an unset key (0) falls through to the default.
enum ClipboardRetention: Int, CaseIterable, Identifiable, Sendable {
case day = 1
case week = 7
@@ -70,10 +68,7 @@ enum ClipboardRetention: Int, CaseIterable, Identifiable, Sendable {
}
}
/// SQLite-backed clipboard history: rows + FTS5 index in `clipboard.sqlite3`, image blobs on disk.
/// The trigram tokenizer gives case-insensitive *substring* search over the full table, while
/// `items` mirrors only the newest rows for browsing. If the database can't be opened the store
/// degrades to session-only in-memory history.
/// SQLite-backed clipboard history (rows + trigram FTS5 index in `clipboard.sqlite3`, image blobs on disk), degrading to session-only in-memory history if the database can't be opened.
@MainActor
final class ClipboardStore: ObservableObject {
@Published private(set) var items: [ClipboardItem] = []
@@ -113,8 +108,7 @@ final class ClipboardStore: ObservableObject {
private var deleteStaleStmt: OpaquePointer?
init() {
// Stored under ~/Library/Caches/<bundle-id>, the same convention Raycast uses — clipboard
// history is regenerable, and the in-app "Clear History" button is the durable control.
// Stored under ~/Library/Caches/<bundle-id> since clipboard history is regenerable; "Clear History" is the durable control.
let bundleID = Bundle.main.bundleIdentifier ?? "com.tinycast.app"
let base = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
+4 -7
View File
@@ -1,9 +1,6 @@
import Foundation
/// App-internal launcher actions, surfaced as a "Commands" category alongside apps and Settings
/// panes. Each command is a synthetic `AppEntry` (kind `.command`, no bundle ID), so fuzzy match,
/// favorites, visibility filtering and the Settings toggles all work through the existing
/// `AppEntry` plumbing. Dispatch happens in `AppCore.runCommand`.
/// App-internal launcher actions surfaced as a "Commands" category; each is a synthetic `AppEntry` (kind `.command`, no bundle ID) so existing `AppEntry` plumbing applies, with dispatch in `AppCore.runCommand`.
enum CommandID: String, CaseIterable, Sendable {
case calculatorHistory = "command:calculator-history"
case clipboardHistory = "command:clipboard-history"
@@ -33,14 +30,14 @@ enum CommandID: String, CaseIterable, Sendable {
}
enum CommandRegistry {
/// Sorted by name to keep the AppIndex sort invariant (each kind is a contiguous,
/// alphabetized run). The URL is a placeholder — command entries are never launched from disk.
/// Sorted by name to keep the AppIndex sort invariant; the URL is a placeholder since commands are never launched from disk.
nonisolated static let all: [AppEntry] =
CommandID.allCases
.map { id in
AppEntry(
id: id.rawValue, name: id.name,
url: URL(string: "tinycast://" + id.rawValue.replacingOccurrences(of: ":", with: "/"))!,
url: URL(
string: "tinycast://" + id.rawValue.replacingOccurrences(of: ":", with: "/"))!,
bundleID: nil, kind: .command)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+1 -2
View File
@@ -1,7 +1,6 @@
import Foundation
/// Persists the user's favorite apps as an ordered list of keys (bundle id, or file path when an
/// app has no bundle id). Favorites pin to the top of the launcher when the search is empty.
/// Persists favorite apps as an ordered list of keys (bundle id, or file path when none), pinned to the top of the launcher when the search is empty.
@MainActor
final class FavoritesStore: ObservableObject {
private let defaults = UserDefaults.standard
+3 -10
View File
@@ -1,10 +1,6 @@
import Carbon.HIToolbox
/// C entry point for the Carbon event handler. It can't capture context, so the owning
/// `HotKeyCenter` rides along in `userData`. Carbon dispatches hot-key events on the main
/// event target (the main thread), which is what makes `assumeIsolated` sound here. The raw
/// `EventRef` is decoded to a plain `EventHotKeyID` value before crossing into actor code, so
/// the pointer never leaves this shim.
/// C entry point for the Carbon handler: the owning `HotKeyCenter` rides in `userData`, and since Carbon dispatches on the main thread the `EventRef` is decoded to a plain `EventHotKeyID` before `assumeIsolated` crosses into actor code.
private func hotKeyCarbonEventHandler(
_: EventHandlerCallRef?, event: EventRef?, userData: UnsafeMutableRawPointer?
) -> OSStatus {
@@ -24,9 +20,7 @@ private func hotKeyCarbonEventHandler(
return MainActor.assumeIsolated { center.handle(hotKeyID) }
}
/// The Carbon layer and nothing else: turns `KeyShortcut`s into system-wide
/// `RegisterEventHotKey` registrations and routes their events to plain closures.
/// Which shortcuts exist and what they do is `HotKeyManager`'s business.
/// The Carbon layer only: turns `KeyShortcut`s into system-wide `RegisterEventHotKey` registrations routed to closures (which shortcuts exist is `HotKeyManager`'s business).
@MainActor
final class HotKeyCenter {
private struct Entry {
@@ -55,8 +49,7 @@ final class HotKeyCenter {
}
}
/// Registers (or re-registers) `shortcut` under `id`. Any previous registration for the
/// same id is dropped first, so changing a binding never leaks the old combo.
/// Registers (or re-registers) `shortcut` under `id`, dropping any previous registration first so changing a binding never leaks the old combo.
func register(id: String, shortcut: KeyShortcut, onKeyDown: @escaping () -> Void) {
unregister(id: id)
nextCarbonID += 1
+5 -10
View File
@@ -1,11 +1,7 @@
import AppKit
import Carbon.HIToolbox
/// A global keyboard shortcut: a hardware key plus modifier keys, stored in Carbon's encoding.
///
/// The Carbon representation (virtual key code + `cmdKey`-style modifier mask) is what
/// `RegisterEventHotKey` consumes, and it is also the on-disk JSON shape the previously used
/// KeyboardShortcuts package persisted — keeping it means existing users' bindings load unchanged.
/// A global keyboard shortcut stored in Carbon's encoding (virtual key code + `cmdKey` mask), which is what `RegisterEventHotKey` consumes and also the legacy on-disk JSON shape, so existing bindings load unchanged.
struct KeyShortcut: Hashable, Sendable {
let carbonKeyCode: Int
let carbonModifiers: Int
@@ -27,8 +23,7 @@ struct KeyShortcut: Hashable, Sendable {
self.init(carbonKeyCode: keyCode, carbonModifiers: Self.carbonModifiers(from: flags))
}
/// One string per keycap in canonical macOS order (⌃⌥⇧⌘), the key glyph last —
/// e.g. `["⌃", "⌥", "T"]`. Feeds the launcher rows and the settings recorder.
/// One string per keycap in canonical macOS order (⌃⌥⇧⌘) with the key glyph last, feeding the launcher rows and settings recorder.
@MainActor var keycaps: [String] {
Self.modifierSymbols(from: modifierFlags) + [keyGlyph]
}
@@ -97,7 +92,8 @@ struct KeyShortcut: Hashable, Sendable {
guard
let source = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?
.takeRetainedValue(),
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData)
let layoutDataPointer = TISGetInputSourceProperty(
source, kTISPropertyUnicodeKeyLayoutData)
else { return nil }
let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self)
@@ -147,8 +143,7 @@ enum HotKeyAction: Hashable, Sendable {
case app(bundleID: String)
case settingsPane(bundleID: String)
/// UserDefaults key holding the shortcut JSON. The `KeyboardShortcuts_` prefix is a fossil
/// of the package this replaced — kept verbatim so existing bindings need no migration.
/// UserDefaults key holding the shortcut JSON; the `KeyboardShortcuts_` prefix is a fossil of the replaced package, kept verbatim so existing bindings need no migration.
var defaultsKey: String {
switch self {
case .togglePalette: "KeyboardShortcuts_togglePalette"
+4 -11
View File
@@ -7,11 +7,7 @@ final class HotKeyManager: ObservableObject {
var onTogglePalette: (() -> Void)?
var onToggleClipboard: (() -> Void)?
/// The recorder currently capturing keystrokes, or `nil`. This single published value is
/// what makes the recorders glitch-free: "which field is recording" is plain app state, so
/// clicking another field is just a state flip — there is no first-responder handoff.
/// While any recorder is active every Carbon registration is paused, so the combo being
/// typed can't trigger the palette (Carbon consumes hotkeys before local monitors see them).
/// The recorder currently capturing keystrokes, or `nil`; keeping this as plain app state makes recorders glitch-free, and any active recorder pauses Carbon so the typed combo can't fire a hotkey.
@Published var recordingAction: HotKeyAction? {
didSet { center.isPaused = recordingAction != nil }
}
@@ -39,8 +35,7 @@ final class HotKeyManager: ObservableObject {
}
func shortcut(for action: HotKeyAction) -> KeyShortcut? {
// The stored value is a JSON *string* (the replaced package's format). Anything else —
// e.g. the boolean sentinel old package versions could write — reads as unbound.
// The stored value is a JSON *string* (a legacy package format); anything else reads as unbound.
guard
let json = UserDefaults.standard.string(forKey: action.defaultsKey),
let data = json.data(using: .utf8)
@@ -48,8 +43,7 @@ final class HotKeyManager: ObservableObject {
return try? JSONDecoder().decode(KeyShortcut.self, from: data)
}
/// Persists (or clears, when `nil`) the binding and swaps the live Carbon registration.
/// Publishing lets the launcher list and every recorder re-render immediately.
/// Persists (or clears, when `nil`) the binding, swaps the live Carbon registration, and publishes so the launcher and recorders re-render.
func setShortcut(_ shortcut: KeyShortcut?, for action: HotKeyAction) {
objectWillChange.send()
if let shortcut,
@@ -76,8 +70,7 @@ final class HotKeyManager: ObservableObject {
}
}
/// The display name of whatever `shortcut` is already bound to (other than `action`
/// itself), or `nil` if it's free. Drives the recorder's "Used by …" message.
/// The display name of whatever else `shortcut` is bound to (or `nil` if free), driving the recorder's "Used by …" message.
func conflictOwner(of shortcut: KeyShortcut, excluding action: HotKeyAction) -> String? {
var candidates: [HotKeyAction] = [.togglePalette, .toggleClipboard]
candidates += boundBundleIDs.map { .app(bundleID: $0) }
+8 -27
View File
@@ -1,31 +1,19 @@
import AppKit
import ImageIO
/// Downsampled, memory-capped image loading for the clipboard UI.
///
/// Clipboard images on disk can be full-resolution screenshots. Decoding those at full size just to
/// draw a 32pt row thumbnail wastes RAM and CPU, so we ask ImageIO for a thumbnail at the exact pixel
/// size we need and cache the results in an `NSCache` (which the system evicts under memory pressure).
/// Downsampled, memory-capped image loading for the clipboard UI: ImageIO decodes each on-disk image to exactly the pixel size needed and caches it in a system-evicted `NSCache`.
enum ImageThumbnail {
/// `NSCache` is documented thread-safe ("you can add, remove, and query items in the cache from
/// different threads without having to lock the cache yourself") but is not annotated `Sendable`,
/// so shared-across-threads use — a decode on a detached task populating what the main actor
/// reads — needs the guarantee asserted once, here.
/// `NSCache` is thread-safe but not annotated `Sendable`, so cross-thread use (a detached decode populating what the main actor reads) needs the guarantee asserted once here.
private final class ImageCache: NSCache<NSString, NSImage>, @unchecked Sendable {}
/// Small row thumbnails (≤ `rowThreshold` px on the longest edge). These are cheap to keep, so the
/// cache survives palette dismissals — reopening the clipboard list then draws instantly with no
/// decode flash. Bounded by *bytes* so it can't drift.
/// Small row thumbnails (≤ `rowThreshold` px), byte-bounded and kept warm across palette dismissals so re-opening draws instantly.
private static let rowCache: ImageCache = {
let cache = ImageCache()
cache.totalCostLimit = 8 * 1024 * 1024
return cache
}()
/// Large previews (> `rowThreshold` px). A single full-screen screenshot preview decodes to a
/// multi-MB bitmap, so this is bounded by *bytes* — not object count, which was the leak: 80 huge
/// bitmaps were all "allowed" — and is purged the moment the palette closes (see `purgePreviews`).
/// Byte-bounding is what keeps browsing memory flat no matter how many items the user clicks.
/// Large previews (> `rowThreshold` px), byte-bounded (not object-count, which leaked) and purged on palette close so browsing memory stays flat.
private static let previewCache: ImageCache = {
let cache = ImageCache()
cache.totalCostLimit = 48 * 1024 * 1024
@@ -43,23 +31,17 @@ enum ImageThumbnail {
"\(url.path)#\(Int(maxPixel))" as NSString
}
/// Frees the large preview bitmaps — called when the palette is dismissed so idle RAM drops back
/// near baseline. The row-thumbnail cache is intentionally left warm for an instant re-open.
/// Frees the large preview bitmaps on palette dismiss; row thumbnails stay warm for an instant re-open.
static func purgePreviews() {
previewCache.removeAllObjects()
}
/// Cache-only lookup — never touches disk. Lets views render an already-decoded thumbnail on the
/// same frame (no flash) while reserving the disk decode for the async path below.
/// Cache-only lookup (never touches disk) so views render an already-decoded thumbnail on the same frame.
static func cached(_ url: URL, maxPixel: CGFloat) -> NSImage? {
pick(maxPixel).object(forKey: cacheKey(url, maxPixel))
}
/// Decodes off the main thread, then returns the result read back from the (thread-safe) cache on
/// the caller. Decoding a full-res clipboard screenshot is expensive, so doing it inline in a view
/// body would stall the UI when the clipboard first appears; this keeps that work off the main
/// actor. The `NSImage` itself never crosses the actor boundary — the detached task only populates
/// the cache — so it stays clean under strict concurrency.
/// Decodes off the main thread and reads the result back from the thread-safe cache; the `NSImage` never crosses the actor boundary, keeping it clean under strict concurrency.
static func loadAsync(_ url: URL, maxPixel: CGFloat) async -> NSImage? {
if let cached = cached(url, maxPixel: maxPixel) { return cached }
await Task.detached(priority: .userInitiated) {
@@ -68,8 +50,7 @@ enum ImageThumbnail {
return cached(url, maxPixel: maxPixel)
}
/// A thumbnail no larger than `maxPixel` on its longest edge. Cached per (path, size). Decodes
/// synchronously — call off the main thread (see `loadAsync`) for anything user-facing.
/// A thumbnail no larger than `maxPixel` on its longest edge, cached per (path, size); decodes synchronously, so call off the main thread for anything user-facing.
static func load(_ url: URL, maxPixel: CGFloat) -> NSImage? {
let cache = pick(maxPixel)
let key = cacheKey(url, maxPixel)
+1 -6
View File
@@ -1,11 +1,6 @@
import Foundation
/// RAII handle for a block-based `NotificationCenter` observation: dropping the token removes the
/// observer. Block-based observations aren't cleaned up automatically (unlike selector-based ones),
/// but a `@MainActor` class can't touch its non-`Sendable` token from its nonisolated `deinit` under
/// Swift 6 — so ownership lives here instead, where the deinit and the token share one isolation
/// domain. `removeObserver` is documented thread-safe, so this is sound wherever the last release
/// happens.
/// RAII handle for a block-based `NotificationCenter` observation (dropping the token removes the observer), owned here so the thread-safe `removeObserver` runs from a nonisolated `deinit` a `@MainActor` class couldn't reach under Swift 6.
final class NotificationToken {
private let center: NotificationCenter
private let token: any NSObjectProtocol
+1 -4
View File
@@ -4,10 +4,7 @@ import SwiftUI
/// Borderless floating panel that hosts the SwiftUI command palette.
final class PalettePanel: NSPanel {
/// Called for a bare backspace before the event reaches the field editor; return true to
/// consume it. Needed for "backspace in an empty search backs out to the root launcher":
/// the focused field editor handles plain backspace itself (a no-op when the field is empty),
/// so SwiftUI `onKeyPress` handlers up the hierarchy never see it — unlike ⌘⌫ or the arrows.
/// Called for a bare backspace before it reaches the field editor (return true to consume); the field editor swallows plain backspace itself, so SwiftUI `onKeyPress` up the hierarchy never sees it.
var onBareBackspace: (() -> Bool)?
override func sendEvent(_ event: NSEvent) {
+2 -8
View File
@@ -44,11 +44,7 @@ final class PaletteWindowController: NSObject, NSWindowDelegate {
hide(restoreFocus: false)
}
/// Re-bump focusToken once the panel has *actually* become key. Activating an accessory app
/// from a global hotkey is asynchronous — `NSApp.activate` + `makeKeyAndOrderFront` in `show()`
/// can return before AppKit finishes handing the window key status, so setting @FocusState right
/// after those calls can lose the race and never take. This notification is the one point we
/// know for certain the window is key, so refocusing here always sticks.
/// Re-bump focusToken once the panel has *actually* become key, since activating an accessory app from a hotkey is async and refocusing right after `show()` can lose the race.
func windowDidBecomeKey(_ notification: Notification) {
core.palette.focusToken = UUID()
}
@@ -69,9 +65,7 @@ final class PaletteWindowController: NSObject, NSWindowDelegate {
.environmentObject(core.hotKeys)
let panel = PalettePanel(rootView: root)
panel.delegate = self
// Backspace in an already-empty search backs out of a sub-screen (clipboard / calculator
// history) to a fresh root launcher, Raycast-style. `prepare` clears query/selection and
// bumps focusToken so the field re-focuses.
// Backspace in an already-empty search backs out of a sub-screen to a fresh root launcher; `prepare` clears state and re-focuses the field.
panel.onBareBackspace = { [weak self] in
guard let vm = self?.core.palette, vm.mode != .launcher, vm.query.isEmpty else {
return false
+4 -9
View File
@@ -15,16 +15,13 @@ enum Paster {
}
}
/// Put the item on the system pasteboard without pasting. The internal marker keeps our own
/// poller from re-capturing it, so the history is left unchanged.
/// Put the item on the pasteboard without pasting; the internal marker keeps our poller from re-capturing it.
@MainActor
static func copy(_ item: ClipboardItem, store: ClipboardStore) {
write(item, store: store)
}
/// Put a plain string on the system pasteboard *without* the internal marker: the clipboard
/// poller captures it, so a copied calculator answer shows up in clipboard history like any
/// other copy (Raycast behavior).
/// Put a plain string on the pasteboard *without* the internal marker, so a copied calculator answer flows into clipboard history like any other copy.
@MainActor
static func copyPlainText(_ text: String) {
let pb = NSPasteboard.general
@@ -33,8 +30,7 @@ enum Paster {
pb.setString(text, forType: .string)
}
/// Paste into `app` *without* activating it, by delivering the ⌘V straight to that process.
/// This leaves Tinycast frontmost, so the palette can stay open with no focus flicker.
/// Paste into `app` *without* activating it (⌘V delivered straight to its process), leaving Tinycast frontmost so the palette stays open.
@MainActor
static func pasteInPlace(
_ item: ClipboardItem, store: ClipboardStore, into app: NSRunningApplication?
@@ -66,8 +62,7 @@ enum Paster {
pb.setData(Data(), forType: ClipboardManager.internalType)
}
/// Synthesize ⌘V. When `pid` is given, the event is delivered to that process only (keeping the
/// current app frontmost); otherwise it goes through the system tap to whatever is frontmost.
/// Synthesize ⌘V — delivered to `pid` alone when given, otherwise through the system tap to whatever is frontmost.
@MainActor
private static func postCommandV(toPid pid: pid_t? = nil) {
guard Permissions.ensureAccessibility() else { return }
+1 -4
View File
@@ -1,9 +1,6 @@
import SwiftUI
/// Right-click handler. SwiftUI's `.contextMenu` opens at the cursor; we want the actions popover
/// anchored to a fixed point, so we capture the right-click ourselves and present separately.
/// Used as an `.overlay` whose `hitTest` only claims right-mouse events — left-clicks (launch) pass
/// straight through to the SwiftUI row beneath.
/// Right-click handler used as an `.overlay` whose `hitTest` claims only right-mouse events (left-clicks pass through), so the actions popover can anchor to a fixed point instead of the cursor.
struct RightClickCatcher: NSViewRepresentable {
let action: () -> Void
+1 -2
View File
@@ -1,7 +1,6 @@
import AppKit
/// Tracks which apps are currently running so the launcher can show a running indicator.
/// Updates live from NSWorkspace launch/terminate notifications.
/// Tracks running apps for the launcher's running indicator, updating live from NSWorkspace launch/terminate notifications.
@MainActor
final class RunningAppsMonitor: ObservableObject {
@Published private(set) var runningBundleIDs: Set<String> = []
+6 -9
View File
@@ -1,8 +1,6 @@
import Foundation
/// Enumerates the System Settings panes (the `.appex` bundles that back each pane on
/// macOS 13+) and turns them into launchable `AppEntry` values. Runs inside `AppIndex.scan()`
/// off the main actor.
/// Enumerates the System Settings panes (the `.appex` bundles behind each) into launchable `AppEntry` values, off the main actor inside `AppIndex.scan()`.
enum SettingsPaneScanner {
private static let extensionsDir = URL(
fileURLWithPath: "/System/Library/ExtensionKit/Extensions")
@@ -35,8 +33,9 @@ enum SettingsPaneScanner {
let name = displayName(appexURL: url, info: info, bundleID: bundleID)
else { continue }
result.append(
AppEntry(id: url.path, name: name, url: url, bundleID: bundleID,
kind: .systemSettings))
AppEntry(
id: url.path, name: name, url: url, bundleID: bundleID,
kind: .systemSettings))
}
return result.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
@@ -50,8 +49,7 @@ enum SettingsPaneScanner {
return ns as? String == settingsExtensionPoint
}
/// Override table → localized loctable name → Info.plist names. Returns `nil` (skip the
/// pane) when nothing usable is found.
/// Override table → localized loctable name → Info.plist names, returning `nil` (skip the pane) when nothing usable is found.
private static func displayName(
appexURL: URL, info: [String: Any], bundleID: String
) -> String? {
@@ -60,8 +58,7 @@ enum SettingsPaneScanner {
return (info["CFBundleDisplayName"] as? String) ?? (info["CFBundleName"] as? String)
}
/// Localized `CFBundleDisplayName` from `Contents/Resources/InfoPlist.loctable` — a plist
/// dictionary keyed by locale. Tries the user's preferred languages, then English.
/// Localized `CFBundleDisplayName` from `InfoPlist.loctable` (keyed by locale), trying the user's preferred languages then English.
private static func loctableName(appexURL: URL) -> String? {
let url = appexURL.appendingPathComponent("Contents/Resources/InfoPlist.loctable")
guard let table = plist(at: url) else { return nil }
+5 -11
View File
@@ -1,7 +1,6 @@
import SwiftUI
/// Central design tokens for the palette UI. Spacing, sizing, radii, typography and colors live
/// here so the look stays consistent and visual tweaks happen in one place.
/// Central design tokens for the palette UI, so visual tweaks happen in one place.
enum Theme {
enum Spacing {
static let xs: CGFloat = 4
@@ -36,7 +35,7 @@ enum Theme {
static let settingsRowIcon: CGFloat = 20
}
/// System text styles (not hardcoded point sizes) so the UI honors Dynamic Type and matches
/// System text styles (not hardcoded sizes) so the UI honors Dynamic Type.
enum Typography {
static let searchField = Font.system(size: 20, weight: .regular)
static let headerIcon = Font.system(size: 18, weight: .medium)
@@ -51,24 +50,19 @@ enum Theme {
}
enum Colors {
/// Raycast-style selection: a soft, neutral translucent fill (not a saturated accent
/// block). In the always-dark palette this reads as a gentle lighter row. The same token
/// is shared by the launcher and clipboard so the two lists look identical.
/// Selection fill: a soft neutral translucent layer shared by launcher and clipboard so both lists look identical.
static let selection = Color.primary.opacity(0.12)
/// Mouse hover — a fainter layer that follows the cursor, visually distinct from selection.
static let rowHover = Color.primary.opacity(0.06)
static let menuHover = Color.primary.opacity(0.10)
/// Settings grouped "card": a faint raised surface with a hairline border. The border color
/// doubles as the inset divider between rows in a card.
/// Settings grouped "card": a faint raised surface whose hairline border doubles as the inset row divider.
static let cardFill = Color.primary.opacity(0.04)
static let cardStroke = Color.primary.opacity(0.08)
}
}
extension View {
/// A floating Tahoe Liquid Glass control surface (the action pill + menu button). The glass
/// sits over the app list, so `.interactive()` gives the native lensing/press response and
/// `.tint(.clear)` keeps the material untinted on macOS.
/// A floating Liquid Glass control surface (action pill + menu button), interactive for native lensing and untinted via `.tint(.clear)`.
func frosted(in shape: some Shape) -> some View {
glassEffect(.regular.interactive(), in: shape)
.tint(.clear)
+18 -88
View File
@@ -1,27 +1,10 @@
import AppKit
import SwiftUI
/// A thin, auto-hiding overlay scrollbar drawn entirely in SwiftUI, tuned to feel like Raycast's: a
/// hairline thumb that appears while scrolling and fades out, plus a hover-reveal along the trailing
/// edge that fades in a subtle rail and fattens the thumb so it can be grabbed and dragged.
///
/// It's a pure floating overlay — no layout changes — and never reintroduces the native `NSScroller`
/// (whose overlay flashes on every panel re-show). Three independent interaction signals drive it;
/// `visible` and `expanded` are *derived* from them so hover, scroll and drag never clobber each other.
///
/// The SwiftUI layer only *draws*; every pointer interaction (hover, thumb drag, wheel) lives in one
/// AppKit view, `ScrollbarInteraction`. That split is deliberate, not incidental:
/// - Dragging must scroll the backing `NSScrollView` directly. `ScrollPosition.scrollTo(y:)` is
/// silently ignored on macOS once the user has scrolled manually (the position flips to
/// "positioned by user"), which froze the thumb.
/// - The thumb must not carry a SwiftUI gesture. One makes `NSHostingView.hitTest` claim that region,
/// and the hosting view is an *ancestor* of the `NSScrollView` — so wheel events over the thumb
/// bubbled up past the scroll view and died instead of scrolling.
/// A thin auto-hiding SwiftUI overlay scrollbar (hairline thumb while scrolling plus a hover-reveal rail), with all pointer handling isolated in the AppKit `ScrollbarInteraction` view to sidestep SwiftUI/AppKit event-routing gaps.
struct ThinScrollbar: ViewModifier {
private struct Metrics: Equatable {
/// Raw `contentOffset.y` — equals the clip view's origin, so with `safeAreaInset` bars it is
/// `-insetTop` at rest and tops out at `content − viewport − insetTop`. Normalize with
/// `insetTop` before mapping to a track fraction, or the thumb never reaches the rail's end.
/// Raw `contentOffset.y`; with `safeAreaInset` bars it rests at `-insetTop`, so normalize by `insetTop` before mapping to a track fraction.
var offset: CGFloat = 0
var insetTop: CGFloat = 0
var content: CGFloat = 0
@@ -58,8 +41,7 @@ struct ThinScrollbar: ViewModifier {
func body(content: Content) -> some View {
content
.scrollIndicators(.hidden) // drop the native scroller (and its flash) entirely
// Geometry drives the thumb's size/position; it changes on layout too, so it never
// touches visibility.
// Geometry drives the thumb's size/position, never its visibility.
.onScrollGeometryChange(for: Metrics.self) { geo in
Metrics(
offset: geo.contentOffset.y,
@@ -70,19 +52,13 @@ struct ThinScrollbar: ViewModifier {
} action: { _, new in
metrics = new
}
// Scrolling reveals the thumb (not the rail) and re-hides a beat after it stops.
// A thumb drag scrolls the clip view directly (no user phase), so its handlers own
// visibility.
// Scrolling reveals the thumb (not the rail) and re-hides a beat after it stops; a thumb drag has no scroll phase, so its own handlers own visibility.
.onScrollPhaseChange { _, phase in
guard !isDragging else { return }
phase == .idle ? scheduleScrollStop() : beganScrolling()
}
.overlay(alignment: .topTrailing) { bar }
// All pointer handling for the whole trailing strip, via one tracking view laid over
// everything. It hit-tests as transparent except over the visible thumb, so content
// clicks fall through; over the thumb it owns the drag and forwards wheel events to the
// scroll view. Because it sits *above* the thumb, moving onto the thumb still reads as
// "in zone" — so the rail never flickers the way a content-level SwiftUI hover did.
// One tracking view over the whole trailing strip owns all pointer handling: transparent except over the thumb, where it takes the drag and forwards wheel events, and never flickers the rail the way a content-level hover did.
.overlay {
ScrollbarInteraction(
edgeWidth: hoverZone,
@@ -198,26 +174,13 @@ extension View {
modifier(ThinScrollbar())
}
/// Attach *inside* a `ScrollView` (on its content) to remove the native scrollers, so our
/// `thinScrollbar` is the only one shown. Scrolling via trackpad/wheel/keyboard is unaffected.
/// Attach *inside* a `ScrollView` (on its content) to remove the native scrollers so only our `thinScrollbar` shows.
func hideNativeScrollers() -> some View {
background(NativeScrollerHider().frame(width: 0, height: 0))
}
}
/// Clears the backing `NSScrollView`'s scrollers. `.scrollIndicators(.hidden)` alone can't hide a
/// *legacy* (always-on) scroller when the system setting is "Always show scroll bars", so we remove
/// them on the AppKit view directly — only the widget goes; scrollability stays.
///
/// It also switches the scroller *style* to `.overlay`. A legacy scroller reserves a fixed strip of
/// layout width on the trailing edge even after its widget is hidden; overlay scrollers float over the
/// content and reserve zero width. Without this the content keeps an empty right-hand gutter.
///
/// macOS re-derives the preferred scroller style at runtime (unlock, appearance change, a mouse being
/// plugged/unplugged) and posts `preferredScrollerStyleDidChangeNotification`; AppKit reacts by
/// resetting every scroll view back to that system style — which re-adds the legacy scroller under our
/// custom bar. We observe that notification and re-assert the overlay/hidden config, so the fix is
/// event-driven rather than a one-shot that silently regresses.
/// Forces the backing `NSScrollView` to a hidden `.overlay` scroller (removing the always-on legacy widget and its reserved gutter), re-asserting it on `preferredScrollerStyleDidChangeNotification` since macOS otherwise resets it.
private struct NativeScrollerHider: NSViewRepresentable {
func makeNSView(context: Context) -> NSView { HiderView() }
func updateNSView(_ nsView: NSView, context: Context) {
@@ -249,8 +212,7 @@ private struct NativeScrollerHider: NSViewRepresentable {
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
object: nil, queue: .main
) { [weak self] _ in
// AppKit resets this scroll view to the system style *on* this notification; re-assert
// ours on the next tick, after its own handler has run.
// Re-assert on the next tick, after AppKit's own handler has reset the style.
DispatchQueue.main.async { self?.applyOverlayStyle() }
}
styleChangeObserver = NotificationToken(token, center: .default)
@@ -269,8 +231,7 @@ private struct NativeScrollerHider: NSViewRepresentable {
DispatchQueue.main.async { [weak self] in self?.applyOverlayStyle() }
return
}
// Idempotent: bail once the scroll view is already in the target state so re-runs (update,
// repeated notifications) are cheap and don't churn layout.
// Idempotent: bail once already in the target state so re-runs don't churn layout.
guard
scrollView.scrollerStyle != .overlay
|| scrollView.hasVerticalScroller
@@ -285,25 +246,7 @@ private struct NativeScrollerHider: NSViewRepresentable {
}
}
/// The single AppKit view that owns every pointer interaction for the scrollbar. Laid over the whole
/// scroll view (as a sibling of the backing `NSScrollView` inside the hosting view), it plays three
/// roles that must live in one place to avoid the SwiftUI/AppKit event-routing gaps described on
/// `ThinScrollbar`:
///
/// - **Hover**: an `NSTrackingArea` reports whether the pointer is within `edgeWidth` of the trailing
/// edge. Because this view sits above the thumb, the cursor resting *on* the thumb still reads as
/// "in zone" — flicker-free, unlike a content-level SwiftUI hover.
/// - **Thumb drag & track click**: `hitTest` returns `self` inside the thumb's grab strip while the
/// bar is visible, and over the whole trailing strip while the rail is expanded — so a click aimed
/// at the scrollbar can never fall through and activate the row beneath. A track click jumps the
/// thumb to the pointer (native "jump to spot") and continues as a drag. Scrolling is done on the
/// backing `NSScrollView`'s clip view directly — the one channel that always works, unlike
/// `ScrollPosition.scrollTo(y:)` after a manual scroll — and the legal offset range comes from
/// `NSClipView.constrainBoundsRect`, which accounts for the content insets that `safeAreaInset`
/// bars (palette header/footer) put on the scroll view. Raw `[0, content − viewport]` clamping is
/// wrong there: the at-rest origin is `-topInset`, and the bottom inset's travel would be lost.
/// - **Wheel**: owning those hit-tests means wheel events over the bar land here; they are forwarded
/// to the scroll view (an AppKit sibling the responder chain would otherwise skip).
/// The single AppKit view (a sibling of the backing `NSScrollView`) that owns hover tracking, thumb drag/track-click, and wheel forwarding, driving the clip view directly since SwiftUI's scroll APIs and responder chain don't reach it reliably.
private struct ScrollbarInteraction: NSViewRepresentable {
let edgeWidth: CGFloat
let inset: CGFloat
@@ -362,9 +305,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
// MARK: Hit testing
/// Opaque over the visible thumb's grab strip, and over the whole trailing strip while the
/// rail is expanded (so a click aimed at the scrollbar never activates the row beneath);
/// transparent everywhere else so content clicks and wheel events fall straight through.
/// Opaque over the thumb's grab strip (and the whole trailing strip while the rail is expanded), transparent elsewhere so content clicks and wheel events fall through.
override func hitTest(_ point: NSPoint) -> NSView? {
guard let superview else { return nil }
let p = convert(point, from: superview)
@@ -373,8 +314,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
return nil
}
/// Full trailing-strip width (not just the drawn capsule) at the thumb's vertical span, so
/// the hairline thumb is comfortably grabbable — like the fattened native overlay knob.
/// Full trailing-strip width at the thumb's vertical span, so the hairline thumb is comfortably grabbable.
private var grabRect: CGRect {
guard thumbGrabbable else { return .null }
return CGRect(
@@ -383,8 +323,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
// MARK: Wheel routing
/// We only receive wheel events when the cursor is over the bar (see `hitTest`); hand them
/// to the scroll view, which the responder chain would otherwise bypass (it's a sibling).
/// Wheel events only reach us over the bar; hand them to the sibling scroll view the responder chain would otherwise bypass.
override func scrollWheel(with event: NSEvent) {
if let target = targetScrollView() {
target.scrollWheel(with: event)
@@ -398,8 +337,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
override func mouseDown(with event: NSEvent) {
guard let target = targetScrollView() else { return }
let p = convert(event.locationInWindow, from: nil)
// Track click (outside the thumb's span): jump so the thumb centers on the pointer,
// *then* anchor — the click seamlessly continues as a drag of the jumped-to thumb.
// Track click outside the thumb's span jumps the thumb to center on the pointer, then continues as a drag.
if !(thumbY...(thumbY + thumbHeight)).contains(p.y) {
jumpThumb(toPointerY: p.y, in: target)
}
@@ -413,8 +351,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
let travel = trackTravel
guard maxOffset > minOffset, travel > 0 else { return }
let p = convert(event.locationInWindow, from: nil)
// Map thumb travel (points along the track) back to content offset, anchored at the
// mouse-down state so clamped over-drags don't accumulate drift.
// Map thumb travel back to content offset, anchored at mouse-down so clamped over-drags don't drift.
let offset =
dragStart.offset + (p.y - dragStart.pointerY) / travel * (maxOffset - minOffset)
scroll(target, toOffset: min(maxOffset, max(minOffset, offset)))
@@ -439,10 +376,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
scroll(target, toOffset: minOffset + fraction * (maxOffset - minOffset))
}
/// The clip view's legal `bounds.origin.y` range, via AppKit's own constraint logic. This is
/// what makes the drag inset-correct: with `safeAreaInset` bars the at-rest origin is
/// `-topInset` and the max extends past `content − viewport` by the bottom inset — a raw
/// `[0, content − viewport]` clamp snaps content under the header and can't reach the end.
/// The clip view's legal `bounds.origin.y` range via AppKit's own constraint logic, which keeps the drag inset-correct where a raw `[0, content − viewport]` clamp isn't.
private func offsetRange(of clip: NSClipView) -> (min: CGFloat, max: CGFloat) {
var probe = clip.bounds
probe.origin.y = -1_000_000_000
@@ -460,9 +394,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
target.reflectScrolledClipView(clip)
}
/// The `NSScrollView` backing the SwiftUI `ScrollView` this overlay covers. As an overlay we
/// are its sibling, not its descendant, so `enclosingScrollView` can't find it — walk up and
/// take the nearest scroll view in a surrounding subtree, cached weakly across events.
/// The backing `NSScrollView`, found by walking up to the nearest scroll view in a sibling subtree (since `enclosingScrollView` can't reach a sibling) and cached weakly.
private func targetScrollView() -> NSScrollView? {
if let scrollView, scrollView.window === window { return scrollView }
var branch: NSView = self
@@ -492,9 +424,7 @@ private struct ScrollbarInteraction: NSViewRepresentable {
override func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea)
// `.inVisibleRect` keeps the area pinned to the (resizing) bounds; the rect arg is
// ignored. `.activeAlways` so hover reveals the rail in non-key windows too (Settings/
// About behind another window), like the native overlay scroller.
// `.inVisibleRect` pins the area to the resizing bounds; `.activeAlways` so hover reveals the rail in non-key windows too.
addTrackingArea(
NSTrackingArea(
rect: .zero,
+1 -4
View File
@@ -1,9 +1,6 @@
import Foundation
/// Persists which launcher items and categories the user has hidden from the launcher list
/// (managed from Settings → Shortcuts). Everything is visible by default; only exclusions are
/// stored. Hiding affects the launcher list only — favorites keys and hotkey bindings persist
/// and keep working while hidden.
/// Persists which launcher items and categories the user has hidden (only exclusions are stored); hiding affects the launcher list only, leaving favorites and hotkey bindings intact.
@MainActor
final class VisibilityStore: ObservableObject {
private let defaults = UserDefaults.standard
+2 -10
View File
@@ -74,9 +74,7 @@ private struct AboutLinkButton: View {
}
}
/// Hosts auxiliary SwiftUI windows (About, Settings) for the accessory app. Each window is torn
/// down on close so its SwiftUI tree — and any timers it drives — deallocates instead of lingering
/// for the app's lifetime. Reopening rebuilds instantly (the views read live state).
/// Hosts auxiliary SwiftUI windows (About, Settings), torn down on close so their SwiftUI trees deallocate instead of lingering, and rebuilt instantly on reopen from live state.
@MainActor
final class AuxWindowController: NSObject, NSWindowDelegate {
private var windows: [String: NSWindow] = [:]
@@ -114,13 +112,7 @@ final class AuxWindowController: NSObject, NSWindowDelegate {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
// A plain `NSWindow` can only become key while the app is active. When opened from the menu
// bar the app isn't active yet, and `NSApp.activate` is processed asynchronously — so the
// synchronous `makeKeyAndOrderFront` above can land *before* the app is active, leaving the
// window visible but not key. The first click then just makes the window key (NSTextField
// doesn't accept first-mouse) instead of focusing the control under it, which is why the
// hotkey recorder's key capture didn't arm on the first click. Re-asserting key on the next
// runloop, after activation has taken effect, makes the window truly key up front.
// A plain `NSWindow` only becomes key while the app is active, but `NSApp.activate` from the menu bar is async, so the synchronous `makeKeyAndOrderFront` above can land first; re-asserting key on the next runloop makes the window truly key up front.
DispatchQueue.main.async {
window.makeKeyAndOrderFront(nil)
}
@@ -1,8 +1,6 @@
import SwiftUI
/// Full-width list of past calculations, mirroring `ClipboardList`'s structure (date-bucket
/// sections, instant single-tap select, double-tap activate, right-click actions). Each row
/// carries both sides of the calculation, so there's no preview pane.
/// Full-width list of past calculations, mirroring `ClipboardList`'s structure; each row carries both sides of the calculation, so there's no preview pane.
struct CalculatorHistoryList: View {
let results: [CalcHistoryEntry]
let selectedID: CalcHistoryEntry.ID?
@@ -34,8 +32,7 @@ struct CalculatorHistoryList: View {
}
}
/// Entries are newest-first, so grouping is a walk that emits a date header whenever the
/// bucket changes — same shape as the clipboard list. A live calculation pins above them.
/// Entries are newest-first, so grouping walks and emits a date header whenever the bucket changes (same as the clipboard list); a live calculation pins above them.
private var rows: [Row] {
var rows: [Row] = []
if let calc { rows = [.header("Calculator"), .calc(calc)] }
@@ -53,11 +53,7 @@ struct ClipboardList: View {
imageURL: store.imageURL(for: item)
)
.contentShape(Rectangle())
// Single click selects *instantly* — the double-click-to-paste gesture
// is `.simultaneousGesture`, so the single tap never waits on the
// double-click timeout to disambiguate. Right-click uses the lightweight
// catcher — not SwiftUI's `.contextMenu`, which stalls clicks for
// seconds inside a LazyVStack on macOS.
// Single click selects instantly (double-click-to-paste is a `.simultaneousGesture`, so the tap never waits on the double-click timeout); right-click uses the lightweight catcher, since `.contextMenu` stalls clicks for seconds in a LazyVStack.
.onTapGesture { onSelect(item) }
.simultaneousGesture(
TapGesture(count: 2).onEnded {
@@ -82,8 +78,7 @@ struct ClipboardList: View {
}
}
/// Coarse date buckets for grouping clipboard and calculator-history entries into sections,
/// mirroring Raycast's Today / Yesterday / This Week / … grouping. Ordered newest-first by raw value.
/// Coarse date buckets (Today / Yesterday / This Week / …) for sectioning clipboard and calculator-history entries, ordered newest-first by raw value.
enum DateBucket: Int {
case today, yesterday, thisWeek, thisMonth, earlier
@@ -112,8 +107,7 @@ enum DateBucket: Int {
}
}
/// Actions popover for a clipboard entry — shown anchored bottom-right on right-click, mirroring the
/// launcher's `AppActionsMenu`. Same stock Liquid Glass `PopoverMenu` surface.
/// Actions popover for a clipboard entry, anchored bottom-right on right-click, mirroring the launcher's `AppActionsMenu`.
struct ClipboardActionsMenu: View {
let item: ClipboardItem
let dismiss: () -> Void
@@ -170,8 +164,7 @@ private struct ClipboardRow: View {
let item: ClipboardItem
let selected: Bool
let imageURL: URL?
/// Hover lives on the row itself so a mouse sweep repaints only the rows entering/leaving — it
/// never invalidates the parent list body. Mirrors the launcher's `AppRow`.
/// Hover lives on the row itself so a mouse sweep repaints only the rows entering/leaving, mirroring the launcher's `AppRow`.
@State private var hovered = false
/// Selection wins over hover when a row is both; otherwise hover shows its fainter layer.
@@ -243,10 +236,7 @@ private struct ClipboardRow: View {
}
}
/// Renders a downsampled clipboard thumbnail, decoding misses off the main thread so the UI never
/// stalls when the clipboard first appears. Cache hits resolve on the first task tick; misses show
/// `placeholder` until the background decode completes. `content` styles the loaded image per site
/// (row thumbnail vs. large preview).
/// Renders a downsampled clipboard thumbnail, decoding misses off the main thread (cache hits resolve on the first tick, misses show `placeholder`); `content` styles the loaded image per site.
private struct AsyncThumbnail<Content: View, Placeholder: View>: View {
let url: URL?
let maxPixel: CGFloat
@@ -330,10 +320,7 @@ struct ClipboardPreview: View {
}
}
/// Raycast-style "Information" block under the preview: label/value rows split by hairlines.
/// Everything that touches disk or walks the full text (dimensions, file size, character/word
/// counts) is gathered off the main actor per selection, so clicking through huge entries never
/// hitches the palette.
/// The "Information" block under the preview (label/value rows split by hairlines); disk- or full-text-touching details are gathered off the main actor per selection so clicking huge entries never hitches.
private struct ClipboardInfoSection: View {
let item: ClipboardItem
let imageURL: URL?
@@ -354,8 +341,7 @@ private struct ClipboardInfoSection: View {
var id: String { label }
}
/// "Today at 1:22:57 AM" — relative day name plus exact time. DateFormatter is expensive to
/// build, so it's shared; @MainActor because the view only reads it from body.
/// Relative day name plus exact time ("Today at 1:22:57 AM"); shared because `DateFormatter` is expensive to build.
@MainActor private static let copiedFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
@@ -423,8 +409,7 @@ private struct ClipboardInfoSection: View {
return rows
}
/// Source app name + icon, resolved from the recorded bundle ID. Launch Services lookup is a
/// quick main-thread call and the icon comes from the shared `IconCache`, so no extra caching.
/// Source app name + icon from the recorded bundle ID; the Launch Services lookup is a quick main-thread call and the icon comes from the shared `IconCache`.
private var source: (name: String, icon: NSImage)? {
guard let bundleID = item.sourceBundleID,
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID)
@@ -15,8 +15,7 @@ enum CalcMemo {
}
}
/// The Raycast-style inline answer card pinned above the app results: expression → result,
/// or a friendly message when a conversion is impossible. Selectable like a row; Enter copies.
/// The inline answer card pinned above the app results (expression → result, or a friendly message on impossible conversion); selectable like a row, Enter copies.
struct CalculatorCard: View {
let result: CalcResult
let selected: Bool
@@ -106,8 +106,7 @@ struct LauncherList: View {
}
}
/// Section label above a group of rows. Shared by the launcher (Favorites/Applications) and the
/// clipboard (Today/Yesterday/…) so both lists use one identical header + row layout.
/// Section label above a group of rows, shared by the launcher and clipboard so both lists use one identical header + row layout.
struct SectionHeader: View {
let title: String
var body: some View {
@@ -139,7 +138,7 @@ private struct AppRow: View {
return .clear
}
/// Raycast-style keycaps for this entry's hotkey, or `nil` if none is bound.
/// Keycaps for this entry's hotkey, or `nil` if none is bound.
private var shortcutCaps: [String]? {
guard let action = app.hotKeyAction,
let shortcut = hotKeys.shortcut(for: action)
@@ -185,8 +184,7 @@ private struct AppRow: View {
}
}
/// A single Raycast-style keycap (one modifier symbol or the key) shown next to an app with a
/// bound hotkey.
/// A single keycap (one modifier symbol or the key) shown next to an app with a bound hotkey.
private struct KeyCap: View {
let text: String
@@ -207,8 +205,7 @@ private struct KeyCap: View {
}
}
/// Actions popover content for a launcher app — shown anchored at the bottom-right on right-click
/// or from the Actions pill. Styled like Raycast's actions menu.
/// Actions popover content for a launcher app, anchored at the bottom-right on right-click or from the Actions pill.
struct AppActionsMenu: View {
let app: AppEntry
let dismiss: () -> Void
+1 -3
View File
@@ -1,8 +1,6 @@
import SwiftUI
/// Raycast-style menu, reused by the bottom-left app menu and the row actions menu. Rendered as an
/// in-window overlay anchored to a bottom corner (not a system popover), so it stays clipped inside
/// the palette. The surface is stock Tahoe Liquid Glass so it reads like a native macOS menu.
/// In-window overlay menu (not a system popover), anchored to a bottom corner so it stays clipped inside the palette, with a stock Liquid Glass surface.
struct PopoverMenu<Content: View>: View {
var header: String? = nil
@ViewBuilder var content: Content
+8 -19
View File
@@ -11,15 +11,12 @@ struct RootPaletteView: View {
@FocusState private var searchFocused: Bool
@State private var showActions = false
@State private var showAppMenu = false
/// Bumped only when the selection should pull the scroll view with it — keyboard navigation and
/// list resets. Mouse selection (click / right-click) targets an already-visible row, so it never
/// bumps this and the list stays put.
/// Bumped only when the selection should pull the scroll view with it (keyboard nav, list resets); mouse selection targets a visible row, so it leaves this and the list put.
@State private var scrollToken = UUID()
private var isQueryEmpty: Bool { vm.query.trimmingCharacters(in: .whitespaces).isEmpty }
/// Ordered launcher results — the single source of truth for the list, selection and activation.
/// Empty query pins favorites to the top; otherwise plain ranked matches.
/// Ordered launcher results (the single source of truth for list, selection and activation): empty query pins favorites to the top, otherwise plain ranked matches.
private var appResults: [AppEntry] {
// Visibility filtering stays downstream of `matches` so its one-deep memo cache is
// never keyed on hidden state; hidden favorites drop out here too.
@@ -31,9 +28,7 @@ struct RootPaletteView: View {
private var clipResults: [ClipboardItem] { store.search(vm.query) }
private var histResults: [CalcHistoryEntry] { calcHistory.search(vm.query) }
/// Inline calculator answer for the current query — live in the launcher *and* in the
/// Calculator History search (Raycast lets you do math right from history). When present it
/// occupies flat selection index 0 and every row access below shifts by `calcCount`.
/// Inline calculator answer for the current query, live in both the launcher and Calculator History search; when present it occupies flat selection index 0 so rows shift by `calcCount`.
private var calcResult: CalcResult? {
vm.mode != .clipboard ? CalcMemo.evaluate(vm.query) : nil
}
@@ -51,8 +46,7 @@ struct RootPaletteView: View {
private var selection: Int { resultCount == 0 ? 0 : min(max(vm.selection, 0), resultCount - 1) }
var body: some View {
// Filter once per render for the active mode only; event handlers (rare) use the computed
// properties above. Avoids running the matcher/search several times for a single render.
// Filter once per render for the active mode only, so the matcher/search doesn't run several times per render (rare event handlers use the computed properties above).
let apps = vm.mode == .launcher ? appResults : []
let clips = vm.mode == .clipboard ? clipResults : []
let hist = vm.mode == .calculatorHistory ? histResults : []
@@ -70,9 +64,7 @@ struct RootPaletteView: View {
let selectedClip = clips.indices.contains(sel) ? clips[sel] : nil
let selectedHist = hist.indices.contains(sel - offset) ? hist[sel - offset] : nil
// The results layer fills the whole panel; the search header and action bar float on top
// as translucent Liquid Glass bars (via safeAreaInset). The list scrolls *behind* them and
// stays faintly visible through the glass, with no hard dividers.
// The results layer fills the whole panel; the header and action bar float over it as translucent Liquid Glass (via safeAreaInset) with the list scrolling faintly behind, no hard dividers.
return content(
apps: apps, clips: clips, hist: hist, calc: calc, selection: sel,
favoriteCount: favoriteCount, showSections: showSections
@@ -169,8 +161,7 @@ struct RootPaletteView: View {
private var header: some View {
HStack(alignment: .center, spacing: Theme.Spacing.md) {
// Clipboard and Calculator History are sub-screens of the root search, so their header
// icon is a back chevron (Raycast style) instead of a mode glyph.
// Clipboard and Calculator History are sub-screens of the root search, so their header icon is a back chevron instead of a mode glyph.
if vm.mode != .launcher {
Button(action: exitToLauncher) {
Image(systemName: "chevron.left")
@@ -371,8 +362,7 @@ struct RootPaletteView: View {
}
}
/// Pill label for the current selection. Reads the memoized `appResults`, so the extra
/// lookup during render is cheap.
/// Pill label for the current selection; reads the memoized `appResults`, so the extra render lookup is cheap.
private var actionPillLabel: String {
switch vm.mode {
case .clipboard:
@@ -415,8 +405,7 @@ struct RootPaletteView: View {
}
}
/// Inset of the menu panels from the window's bottom corners. Kept just inside the panel's
/// rounded corner so the menu's own corner isn't clipped.
/// Inset of the menu panels from the window's bottom corners, kept just inside the rounded corner so the menu's own corner isn't clipped.
private static let menuInset: CGFloat = 8
private static let menuAnimation: Animation = .easeOut(duration: 0.14)
@@ -44,11 +44,7 @@ struct SettingsRootView: View {
HStack(spacing: 0) {
sidebar
// A plain conditional swap — not a `TabView`. macOS's TabView is backed by `NSTabView`,
// which re-hosts each tab's view on selection; that re-hosting is the visible flicker and
// it also tears down the hotkey `Recorder`'s window membership mid-interaction (so the
// first recording attempt fails). Switching content this way keeps each pane's hosting
// view stable, fixing both.
// A plain conditional swap, not a `TabView`: `NSTabView` re-hosts each tab on selection, which flickers and tears down the hotkey recorder's window membership mid-interaction — swapping this way keeps each pane's hosting view stable.
Group {
switch tab {
case .general: GeneralSettingsView()
@@ -58,9 +54,7 @@ struct SettingsRootView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Only the *background* bleeds to every window edge (under the titlebar, down to the
// bottom corners). The pane content keeps the safe area, so it lays out below the
// transparent titlebar automatically — no manual titlebar spacer, no exposed dark bar.
// Only the background bleeds to every window edge; the pane content keeps the safe area, so it lays out below the transparent titlebar with no manual spacer.
.background(
VisualEffectView(material: .contentBackground, blending: .behindWindow)
.ignoresSafeArea()
@@ -89,10 +83,7 @@ struct SettingsRootView: View {
.padding(.horizontal, Theme.Spacing.md)
.frame(width: Theme.Size.settingsSidebar)
.frame(maxHeight: .infinity)
// Bleed the sidebar material to every edge (up under the traffic lights, down to the bottom
// corners) so it reads as one continuous surface behind the safe-area content. The trailing
// hairline lives in the same edge-to-edge background — a plain `Divider` in the HStack would
// stop at the safe area, leaving the titlebar band without a seam.
// Bleed the sidebar material to every edge so it reads as one continuous surface, with the trailing hairline in the same background since a plain `Divider` would stop at the safe area.
.background(
ZStack(alignment: .trailing) {
VisualEffectView(material: .sidebar, blending: .behindWindow)
@@ -2,11 +2,7 @@ import AppKit
import Carbon.HIToolbox
import SwiftUI
/// Raycast-style shortcut recorder. Deliberately *not* a focusable control: which recorder is
/// active is just `HotKeyManager.recordingAction`, so clicking any recorder — including while
/// another one is recording — is a plain state flip with no first-responder handoff (the source
/// of the old package's multi-click glitch). Keystrokes are captured by a local event monitor
/// owned by `CaptureSession` while this recorder is the active one.
/// Shortcut recorder that's deliberately *not* a focusable control: the active recorder is just `HotKeyManager.recordingAction`, so clicking one is a plain state flip with no first-responder handoff, and `CaptureSession` captures keystrokes via a local monitor while it's active.
struct ShortcutRecorder: View {
let action: HotKeyAction
@@ -106,9 +102,7 @@ struct ShortcutRecorder: View {
}
}
/// Owns the local event monitors for the one active recording. Created per recorder but only
/// live between `start()` and `stop()`; everything runs on the main actor (local NSEvent
/// monitors deliver on the main thread).
/// Owns the local event monitors for the one active recording, live only between `start()` and `stop()` and entirely on the main actor.
@MainActor
private final class CaptureSession: ObservableObject {
/// Modifiers currently held, for the live "⌃⌥…" preview while recording.
@@ -124,28 +118,33 @@ private final class CaptureSession: ObservableObject {
stop()
heldModifiers = NSEvent.modifierFlags.intersection([.command, .option, .control, .shift])
// The handlers below run on the main thread (local monitors deliver where the event
// is dispatched), but AppKit's API predates actor annotations — hence assumeIsolated.
// Only Sendable pieces of the event (key code, flags) cross into the isolated closure.
if let monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown, handler: {
[weak self, weak hotKeys] event in
let keyCode = Int(event.keyCode)
let flags = event.modifierFlags
MainActor.assumeIsolated {
guard let self, let hotKeys else { return }
self.handleKeyDown(keyCode: keyCode, flags: flags, action: action, hotKeys: hotKeys)
}
return nil // always consume: no beeps, no leaking keys to the window
}) {
// The handlers run on the main thread but AppKit predates actor annotations, hence assumeIsolated; only Sendable event pieces (key code, flags) cross in.
if let monitor = NSEvent.addLocalMonitorForEvents(
matching: .keyDown,
handler: {
[weak self, weak hotKeys] event in
let keyCode = Int(event.keyCode)
let flags = event.modifierFlags
MainActor.assumeIsolated {
guard let self, let hotKeys else { return }
self.handleKeyDown(
keyCode: keyCode, flags: flags, action: action, hotKeys: hotKeys)
}
return nil // always consume: no beeps, no leaking keys to the window
})
{
monitors.append(monitor)
}
if let monitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged, handler: {
[weak self] event in
let flags = event.modifierFlags.intersection([.command, .option, .control, .shift])
MainActor.assumeIsolated { self?.heldModifiers = flags }
return event
}) {
if let monitor = NSEvent.addLocalMonitorForEvents(
matching: .flagsChanged,
handler: {
[weak self] event in
let flags = event.modifierFlags.intersection([.command, .option, .control, .shift])
MainActor.assumeIsolated { self?.heldModifiers = flags }
return event
})
{
monitors.append(monitor)
}
@@ -192,7 +191,7 @@ private final class CaptureSession: ObservableObject {
hotKeys.recordingAction = nil
return
}
// Plain Delete clears the existing binding (matches Raycast and the old recorder).
// Plain Delete clears the existing binding.
if bareKey, keyCode == kVK_Delete || keyCode == kVK_ForwardDelete {
hotKeys.setShortcut(nil, for: action)
hotKeys.recordingAction = nil
+6 -6
View File
@@ -1,10 +1,10 @@
# Raycast — Style Reference
# Tinycast — Style Reference
> Midnight command center, coral neon
**Theme:** dark
Raycast reads as a dark power-tool cockpit: an almost-black canvas (#040506) with barely-visible elevation steps, a single warm coral accent (#ff6363) that carries brand identity, and quiet white/gray typography in Inter. Components are defined less by shadows and more by hairline borders, inset highlight strokes, and the distinctive 'keyboard key' inner-shadow treatment that makes cards feel pressed and tactile rather than floating. Color is rationed — the page is 98% achromatic, and the coral appears only in the logo, hero artwork, AI badge, and the occasional warm-tinted surface. Navigation floats with a glass-blur effect, and most interactive surfaces are neutral light-gray buttons on dark, not chromatic CTAs. The hero abandons the system entirely with large red/blue gradient geometry, then the rest of the page returns to the austere dark surface — contrast through atmosphere, not decoration.
Tinycast reads as a dark power-tool cockpit: an almost-black canvas (#040506) with barely-visible elevation steps, a single warm coral accent (#ff6363) that carries brand identity, and quiet white/gray typography in Inter. Components are defined less by shadows and more by hairline borders, inset highlight strokes, and the distinctive 'keyboard key' inner-shadow treatment that makes cards feel pressed and tactile rather than floating. Color is rationed — the page is 98% achromatic, and the coral appears only in the logo, hero artwork, AI badge, and the occasional warm-tinted surface. Navigation floats with a glass-blur effect, and most interactive surfaces are neutral light-gray buttons on dark, not chromatic CTAs. The hero abandons the system entirely with large red/blue gradient geometry, then the rest of the page returns to the austere dark surface — contrast through atmosphere, not decoration.
## Tokens — Colors
@@ -30,7 +30,7 @@ Raycast reads as a dark power-tool cockpit: an almost-black canvas (#040506) wit
## Tokens — Typography
### Inter — Primary interface typeface — body text at 16px/400, nav and card labels at 13–14px/500, subheadings at 18–22px/400, section headings at 32–56px in the 400–600 range, and display at 64px/600. Inter's neutral geometry carries the developer-tool seriousness; weight 400 at the 56px hero headline is an anti-convention choice — most brands shout with 700, Raycast whispers with regular weight and lets the size do the work · `--font-inter`
### Inter — Primary interface typeface — body text at 16px/400, nav and card labels at 13–14px/500, subheadings at 18–22px/400, section headings at 32–56px in the 400–600 range, and display at 64px/600. Inter's neutral geometry carries the developer-tool seriousness; weight 400 at the 56px hero headline is an anti-convention choice — most brands shout with 700, Tinycast whispers with regular weight and lets the size do the work · `--font-inter`
- **Substitute:** system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif
- **Weights:** 400, 500, 600
@@ -38,7 +38,7 @@ Raycast reads as a dark power-tool cockpit: an almost-black canvas (#040506) wit
- **Line height:** 0.91–1.71
- **Letter spacing:** 0.0040em at 56px, 0.0100em at 20px, 0.0140em at 14px, 0.0080em at 13px, 0.0730em at 11px (uppercase eyebrow)
- **OpenType features:** `'calt', 'kern', 'liga', 'ss03'`
- **Role:** Primary interface typeface — body text at 16px/400, nav and card labels at 13–14px/500, subheadings at 18–22px/400, section headings at 32–56px in the 400–600 range, and display at 64px/600. Inter's neutral geometry carries the developer-tool seriousness; weight 400 at the 56px hero headline is an anti-convention choice — most brands shout with 700, Raycast whispers with regular weight and lets the size do the work
- **Role:** Primary interface typeface — body text at 16px/400, nav and card labels at 13–14px/500, subheadings at 18–22px/400, section headings at 32–56px in the 400–600 range, and display at 64px/600. Inter's neutral geometry carries the developer-tool seriousness; weight 400 at the 56px hero headline is an anti-convention choice — most brands shout with 700, Tinycast whispers with regular weight and lets the size do the work
### GeistMono — Monospace for version strings, technical micro-labels, terminal-style text — appears in footer metadata (v1.104.21), command-line hints, and 10px uppercase eyebrow tags. Geist Mono's compact, slightly geometric forms make the tiny labels feel engineered rather than decorative · `--font-geistmono`
@@ -147,7 +147,7 @@ Raycast reads as a dark power-tool cockpit: an almost-black canvas (#040506) wit
**Role:** Top-level site navigation
Floating pill-shaped bar with backdrop-blur(48px), 1px solid border at #363739, 8px radius, transparent dark fill (sits over #040506). Logo at left (red diamond + 'Raycast' wordmark in white, 13px/500), nav links centered at 13–14px in #9c9c9d, and a light-gray Download button at right. Padding approximately 8px vertical, 16px horizontal internally.
Floating pill-shaped bar with backdrop-blur(48px), 1px solid border at #363739, 8px radius, transparent dark fill (sits over #040506). Logo at left (red diamond + 'Tinycast' wordmark in white, 13px/500), nav links centered at 13–14px in #9c9c9d, and a light-gray Download button at right. Padding approximately 8px vertical, 16px horizontal internally.
### Neutral Filled Button (Download CTA)
@@ -271,7 +271,7 @@ Quick Color Reference:
Example Component Prompts:
1. Glass Navigation Bar: Floating pill nav with backdrop-filter blur(48px), 1px solid #363739 border, 8px radius, transparent dark fill. Left: red diamond logo (#ff6363) + 'Raycast' wordmark in #ffffff at 13px Inter 500. Center: ghost nav links in #9c9c9d at 13px Inter 500 (Store, Pro, AI, iOS, Windows, Teams, Enterprise, Blog, Pricing) with no padding. Right: neutral filled button — Mist #e6e6e6 fill, Iron #454647 text 'Download' at 13px/500, 8px radius, 8px 12px padding, with a 15px Apple icon.
1. Glass Navigation Bar: Floating pill nav with backdrop-filter blur(48px), 1px solid #363739 border, 8px radius, transparent dark fill. Left: red diamond logo (#ff6363) + 'Tinycast' wordmark in #ffffff at 13px Inter 500. Center: ghost nav links in #9c9c9d at 13px Inter 500 (Store, Pro, AI, iOS, Windows, Teams, Enterprise, Blog, Pricing) with no padding. Right: neutral filled button — Mist #e6e6e6 fill, Iron #454647 text 'Download' at 13px/500, 8px radius, 8px 12px padding, with a 15px Apple icon.
2. Hero Section: Full-bleed dark (#040506) background. Centered headline 'Your shortcut to everything.' at 56px Inter 400, #ffffff, letter-spacing 0.22px. Subheadline at 16px Inter 400, #9c9c9d, max-width ~480px. Behind the text: large abstract red/blue gradient composition — diagonal coral (#ff6363 with 40px blur) bars cutting across, blue radial gradient wash (rgba(4,63,150,0.7) to rgba(6,18,37,0.25)) at 50% 26%. Below: two neutral filled buttons side by side (Mist fill, Iron text) with Apple/Windows icons, then a Geist Mono 12px footer line: 'v1.104.21 | macOS 13+ | Install via homebrew' in #6a6b6c.
+1 -2
View File
@@ -78,8 +78,7 @@ export function Nav() {
</button>
</div>
{/* Mobile menu — the pill expands downward, Raycast-style. Always
mounted so the drawer can animate open and closed. */}
{/* Mobile menu, always mounted so the drawer can animate open and closed. */}
<div
id="mobile-nav"
className="nav-drawer md:hidden"
+1 -1
View File
@@ -3,7 +3,7 @@
export const site = {
name: "Tinycast",
tagline: "The core of Raycast, without the bloat.",
tagline: "The essentials, without the bloat.",
repo: "https://github.com/abue-ammar/tinycast",
version: "v0.1.0",
platform: "macOS 26+",
+1 -2
View File
@@ -62,8 +62,7 @@
--text-heading--line-height: 1.15;
--text-heading--font-weight: 500;
/* Hero display — sized and weighted like Raycast's hero (large, semibold,
near-neutral tracking). Weight and leading ride along with the class. */
/* Hero display: large, semibold, near-neutral tracking. */
--text-display: clamp(2.75rem, 2.1rem + 3.4vw, 4rem); /* 44→64px — hero h1 */
--text-display--line-height: 1.06;
--text-display--letter-spacing: -0.01em;