mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
cleanup
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,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> = []
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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+",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user