mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
Fix modal button order, Return/Escape binding, and expand ModalKind (#126)
* Bind Return to a modal's primary action and Escape to Cancel * Render Cancel on the leading edge of modal buttons * Split ModalKind into warning and error, and let custom carry its own color * Replace pill icons with a colored status dot * feat(modals): Highlight severe confirmations in red * feat(ui): Add modal tooltips and refine tones Use hover labels for dialog shortcuts, align warning and error styling, and default report dialogs to their settings action when available.
This commit is contained in:
@@ -126,11 +126,22 @@ Never break these without an explicit task to do so.
|
||||
- **Hotkeys persist under legacy `KeyboardShortcuts_<name>` UserDefaults keys** (from the removed
|
||||
KeyboardShortcuts package) so old bindings survive. See [hotkeys.md](docs/hotkeys.md).
|
||||
- **Tinycast presents its own dialogs, never `NSAlert` / `NSSlider` / system popovers.** Every
|
||||
confirmation, failure report, value prompt and transient readout goes through
|
||||
`ModalWindowController` (owned by `AppCore`; reachable elsewhere via `AppCore.showNotice` /
|
||||
`askConfirmation`). Presentation is `async`, so there is no nested run loop, and the presenter
|
||||
refuses a second dialog while one is up that, not a flag, is what stops a held hotkey stacking
|
||||
dialogs. **↵ belongs to Cancel on every destructive dialog.** See
|
||||
confirmation, failure report and value prompt goes through `ModalWindowController` (owned by
|
||||
`AppCore`; reachable elsewhere via `AppCore.showNotice` / `askConfirmation`). Presentation is
|
||||
`async`, so there is no nested run loop, and the presenter refuses a second dialog while one is up
|
||||
that, not a flag, is what stops a held hotkey stacking dialogs. **↵ runs the primary action, Escape
|
||||
cancels, and Cancel always renders leading** (the left button), matching macOS convention. A
|
||||
dialog's tone is one of five `ModalKind` cases (`.info` / `.success` / `.warning` / `.error` /
|
||||
`.custom(Color)`), which drives its glyph's tint and default icon. **`.warning` is a
|
||||
confirmation before something happens; `.error` is a report that something already went
|
||||
wrong.** Don't conflate the two — even though both now share the same red tint (only the default
|
||||
icon's triangle-vs-circle shape tells them apart), the semantic split still governs which one a
|
||||
caller reaches for. A button never prints its key cap; hovering it shows a `Tooltip`
|
||||
(`Core/Tooltip.swift`) instead, styled like the palette's own keycap chips. A transient readout is
|
||||
a HUD, not a dialog: `ModalWindowController`'s
|
||||
square box is volume/mute only, since that one needs an actual level; every other success/info
|
||||
confirmation (system commands, Custom Commands, Snippets) goes through `HUDWindowController`'s
|
||||
pill, a leading `statusDot` tinted by the same `ModalKind` standing in for an icon. See
|
||||
[ui.md](docs/ui.md#modals--hud).
|
||||
- **Read [`docs/ui.md`](docs/ui.md) before any restyle or new view.** `Core/Theme.swift` is the single
|
||||
design-token source.
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
C7D8BB669C3DBC0D26EB6E32 /* WindowActionMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B940224413903867EF5A60E /* WindowActionMemory.swift */; };
|
||||
C7DD1345A6BFB1D9EB22FCEE /* RunningApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B432D84D8F9C10521342199 /* RunningApps.swift */; };
|
||||
C8F4555ECA895F63A34A327C /* EmojiGridGeometry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DABCE058D38F493D58F6E072 /* EmojiGridGeometry.swift */; };
|
||||
CAD10A49DBDEFD2A9C03A7BE /* Tooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586627A59FDF9011136939A5 /* Tooltip.swift */; };
|
||||
CFB516312C4694DDFD99DBCA /* SettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68952539C0BD045BA175CBB6 /* SettingsRootView.swift */; };
|
||||
D322804853973E1C19F2A408 /* AppIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA7CE23482F7DF563A268806 /* AppIndex.swift */; };
|
||||
D3CA50C1CFABC31E6BC7B73C /* CalcPercent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A65B4EC5AE7E22594BFC5E /* CalcPercent.swift */; };
|
||||
@@ -152,6 +153,7 @@
|
||||
52ADC36002F1A5B04D61B4D3 /* ClipboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardView.swift; sourceTree = "<group>"; };
|
||||
5392500F86E23C099D33561D /* Tinycast.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tinycast.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
555E9C7EA62BDB40891A5751 /* PalettePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PalettePanel.swift; sourceTree = "<group>"; };
|
||||
586627A59FDF9011136939A5 /* Tooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tooltip.swift; sourceTree = "<group>"; };
|
||||
596ADD747B4546B1E3340094 /* ShortcutRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutRecorder.swift; sourceTree = "<group>"; };
|
||||
5A002AC0DE601E1F65D20C4A /* SnippetTemplateEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnippetTemplateEngine.swift; sourceTree = "<group>"; };
|
||||
5A2EF9C70F8BC9571089D5EB /* AppLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLauncher.swift; sourceTree = "<group>"; };
|
||||
@@ -277,6 +279,7 @@
|
||||
AFE4F95DF8572910DFE602BA /* SystemCommandRunner.swift */,
|
||||
7BC2DEA0A052116A92F9A539 /* Theme.swift */,
|
||||
A31288EC60144BD31C495FE2 /* ThinScrollbar.swift */,
|
||||
586627A59FDF9011136939A5 /* Tooltip.swift */,
|
||||
825AC18CE5DAFBEDA0CF3209 /* VisibilityStore.swift */,
|
||||
EB20A38DA4C5603DF7430B3C /* VisualEffectView.swift */,
|
||||
);
|
||||
@@ -664,6 +667,7 @@
|
||||
85BCD328F402C7E09461D87F /* ThinScrollbar.swift in Sources */,
|
||||
68FCC30764DD44ED72EFB6BA /* TinycastApp.swift in Sources */,
|
||||
A7508663E4B5D80572E7BAAC /* TinycastModalView.swift in Sources */,
|
||||
CAD10A49DBDEFD2A9C03A7BE /* Tooltip.swift in Sources */,
|
||||
693E88D22D00CF6FD9CD7413 /* VisibilityStore.swift in Sources */,
|
||||
A2EA931B8B8078A1D1162B53 /* VisualEffectView.swift in Sources */,
|
||||
C7D8BB669C3DBC0D26EB6E32 /* WindowActionMemory.swift in Sources */,
|
||||
|
||||
@@ -452,7 +452,8 @@ final class AppCore: ObservableObject {
|
||||
await !modals.confirm(
|
||||
title: Self.confirmationTitle(command),
|
||||
message: Self.confirmationMessage(command),
|
||||
confirmTitle: command.name, destructive: true)
|
||||
confirmTitle: command.name, destructive: true,
|
||||
kind: Self.confirmationKind(command))
|
||||
{
|
||||
return
|
||||
}
|
||||
@@ -469,7 +470,7 @@ final class AppCore: ObservableObject {
|
||||
let state = try SystemCommandRunner.outputState()
|
||||
modals.showVolumeHUD(level: state.level, muted: state.muted)
|
||||
} else if let feedback {
|
||||
modals.showToast(symbol: feedback.symbol, title: feedback.title)
|
||||
hud.show(message: feedback.title, kind: feedback.isNoOp ? .info : .success)
|
||||
}
|
||||
} catch let failure as SystemCommandFailure {
|
||||
await presentFailure(name: command.name, failure: failure)
|
||||
@@ -504,13 +505,25 @@ final class AppCore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most confirmations read as the usual `.warning` orange; the four that end the session
|
||||
/// (`.restart`, `.shutDown`, `.logOut`) or destroy data outright (`.emptyTrash`) read `.error`
|
||||
/// red instead, so their weight on screen matches their weight in practice.
|
||||
private static func confirmationKind(_ command: SystemCommand) -> ModalKind {
|
||||
switch command.id {
|
||||
case .restart, .shutDown, .logOut, .emptyTrash: return .error
|
||||
default: return .warning
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dialogs
|
||||
//
|
||||
// Routed through `AppCore` so `modals` stays the single owner; flows outside the palette (the backup
|
||||
// actions) reach the same dialogs instead of falling back to an `NSAlert`.
|
||||
|
||||
func showNotice(title: String, message: String, symbol: String = "info.circle") async {
|
||||
await modals.notice(title: title, message: message, symbol: symbol)
|
||||
func showNotice(
|
||||
title: String, message: String, symbol: String? = nil, kind: ModalKind = .info
|
||||
) async {
|
||||
await modals.notice(title: title, message: message, symbol: symbol, kind: kind)
|
||||
}
|
||||
|
||||
func askConfirmation(
|
||||
|
||||
@@ -42,8 +42,7 @@ enum BackupActions {
|
||||
guard await confirmExecutableImport(commands: commandCount, shortcuts: shortcutCount)
|
||||
else { return }
|
||||
await present(
|
||||
title: "Settings Imported", message: summaryText(backup.apply()),
|
||||
symbol: "checkmark.circle")
|
||||
title: "Settings Imported", message: summaryText(backup.apply()), kind: .success)
|
||||
} catch {
|
||||
await present(title: "Import Failed", message: error.localizedDescription)
|
||||
}
|
||||
@@ -152,8 +151,8 @@ enum BackupActions {
|
||||
}
|
||||
|
||||
private static func present(
|
||||
title: String, message: String, symbol: String = "exclamationmark.triangle"
|
||||
title: String, message: String, kind: ModalKind = .error
|
||||
) async {
|
||||
await AppCore.shared.showNotice(title: title, message: message, symbol: symbol)
|
||||
await AppCore.shared.showNotice(title: title, message: message, kind: kind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Transient, non-interactive confirmation flashed at the bottom of the active screen after something succeeds. Any feature can use it — snippets confirm an insertion, custom commands confirm a run.
|
||||
/// Transient, non-interactive confirmation flashed at the bottom of the active screen after something succeeds. Any feature can use it — snippets confirm an insertion, custom commands confirm a run. A leading status dot, not an icon, carries the `ModalKind` tint: lighter weight for a glance-and-forget surface than the dialogs' full symbol.
|
||||
@MainActor
|
||||
final class HUDWindowController {
|
||||
private let settings: AppSettings
|
||||
@@ -12,15 +12,10 @@ final class HUDWindowController {
|
||||
self.settings = settings
|
||||
}
|
||||
|
||||
func show(
|
||||
message: String,
|
||||
symbol: String = "checkmark.circle.fill",
|
||||
tint: Color = .green
|
||||
) {
|
||||
func show(message: String, kind: ModalKind = .success) {
|
||||
dismissalTask?.cancel()
|
||||
let panel = panel ?? makePanel()
|
||||
let host = NSHostingView(
|
||||
rootView: HUDView(message: message, symbol: symbol, tint: tint))
|
||||
let host = NSHostingView(rootView: HUDView(message: message, tint: kind.tint))
|
||||
// The capsule is only as wide as its message, so the panel takes its size from SwiftUI rather than a fixed frame.
|
||||
panel.contentView = host
|
||||
panel.setContentSize(host.fittingSize)
|
||||
@@ -71,23 +66,22 @@ final class HUDWindowController {
|
||||
|
||||
private struct HUDView: View {
|
||||
let message: String
|
||||
let symbol: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: symbol)
|
||||
.font(.callout)
|
||||
.foregroundStyle(tint)
|
||||
Circle()
|
||||
.fill(tint)
|
||||
.frame(width: Theme.Size.statusDot, height: Theme.Size.statusDot)
|
||||
Text(message)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.white)
|
||||
.font(Theme.Typography.bar)
|
||||
.foregroundStyle(Color.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
.frame(maxWidth: Theme.Size.hudMaxWidth, alignment: .leading)
|
||||
.fixedSize()
|
||||
.glassEffect(.regular, in: Capsule())
|
||||
.frosted(in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,48 @@ struct ModalAction {
|
||||
var role: Role = .normal
|
||||
}
|
||||
|
||||
/// A dialog's visual tone, which drives its leading glyph's tint, default icon, and the pill's
|
||||
/// status dot. `.warning` is a confirmation asking before something happens; `.error` is a report
|
||||
/// that something already went wrong — both share the same red tint (severity reads the same either
|
||||
/// way), so the shape of the default icon is what tells them apart. `.custom` is the template for a
|
||||
/// one-off dialog that doesn't fit the other four: it carries its own tint and, via
|
||||
/// `ModalRequest.symbol`, its own icon, rather than deriving either.
|
||||
enum ModalKind: Sendable {
|
||||
case info
|
||||
case success
|
||||
case warning
|
||||
case error
|
||||
case custom(Color)
|
||||
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .info: return .secondary
|
||||
case .success: return Theme.Colors.success
|
||||
case .warning: return Theme.Colors.destructive
|
||||
case .error: return Theme.Colors.destructive
|
||||
case .custom(let color): return color
|
||||
}
|
||||
}
|
||||
|
||||
var defaultSymbol: String {
|
||||
switch self {
|
||||
case .info: return "info.circle"
|
||||
case .success: return "checkmark.circle.fill"
|
||||
case .warning: return "exclamationmark.triangle.fill"
|
||||
case .error: return "exclamationmark.circle.fill"
|
||||
case .custom: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ModalRequest {
|
||||
let title: String
|
||||
var message: String?
|
||||
var symbol: String = "exclamationmark.triangle"
|
||||
/// Falls back to `kind.defaultSymbol` when unset; a `.custom` kind should always set this explicitly.
|
||||
var symbol: String? = nil
|
||||
var kind: ModalKind = .warning
|
||||
var actions: [ModalAction]
|
||||
/// The button ↵ fires. Destructive dialogs point it at Cancel, so a reflexive second Return can't run the very thing the user asked to be warned about (same rule the old `NSAlert` gate used).
|
||||
/// The button ↵ fires, normally the primary/confirm action.
|
||||
var defaultIndex: Int
|
||||
/// Resolved when the modal goes away without a choice: Esc, or losing key status to a click elsewhere.
|
||||
var cancelIndex: Int
|
||||
@@ -99,51 +135,49 @@ final class ModalWindowController: NSObject, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/// The transient HUD's content. One panel serves both kinds so a volume change and a confirmation can never overlap on screen.
|
||||
final class HUDState: ObservableObject {
|
||||
enum Content {
|
||||
case volume(level: Double, muted: Bool)
|
||||
case message(symbol: String, title: String)
|
||||
}
|
||||
|
||||
@Published var content: Content = .volume(level: 0, muted: false)
|
||||
}
|
||||
|
||||
private var panel: ModalPanel?
|
||||
private var continuation: CheckedContinuation<Int, Never>?
|
||||
private var volume = VolumeState(level: 0)
|
||||
private var hud: ModalPanel?
|
||||
private let hudState = HUDState()
|
||||
/// The volume HUD's own level/muted, distinct from `volume` above: that one is the live Set Volume slider's binding, this is a snapshot for the read-only bar.
|
||||
private let hudVolume = VolumeState(level: 0)
|
||||
private var hudDismissal: Task<Void, Never>?
|
||||
|
||||
func confirm(title: String, message: String?, confirmTitle: String, destructive: Bool) async
|
||||
-> Bool
|
||||
{
|
||||
/// `kind` defaults to the usual `.warning`/`.info` split by `destructive`, but a caller can pass
|
||||
/// `.error` instead to make a particularly severe confirmation (data loss, ending the session)
|
||||
/// read as more alarming than a routine one, without that dialog claiming something already went wrong.
|
||||
func confirm(
|
||||
title: String, message: String?, confirmTitle: String, destructive: Bool,
|
||||
kind: ModalKind? = nil
|
||||
) async -> Bool {
|
||||
let request = ModalRequest(
|
||||
title: title, message: message,
|
||||
symbol: destructive ? "exclamationmark.triangle" : "questionmark.circle",
|
||||
title: title, message: message, kind: kind ?? (destructive ? .warning : .info),
|
||||
actions: [
|
||||
ModalAction(title: confirmTitle, role: destructive ? .destructive : .normal),
|
||||
ModalAction(title: "Cancel", role: .cancel),
|
||||
],
|
||||
defaultIndex: 1, cancelIndex: 1)
|
||||
defaultIndex: 0, cancelIndex: 1)
|
||||
return await present(request) == 0
|
||||
}
|
||||
|
||||
func notice(title: String, message: String, symbol: String) async {
|
||||
func notice(title: String, message: String, symbol: String? = nil, kind: ModalKind = .info)
|
||||
async
|
||||
{
|
||||
let request = ModalRequest(
|
||||
title: title, message: message, symbol: symbol,
|
||||
title: title, message: message, symbol: symbol, kind: kind,
|
||||
actions: [ModalAction(title: "OK", role: .cancel)], defaultIndex: 0, cancelIndex: 0)
|
||||
_ = await present(request)
|
||||
}
|
||||
|
||||
/// A failure report. Returns true when the user asked to be taken to the relevant settings.
|
||||
/// A failure report: something already went wrong, as opposed to `confirm`'s "about to happen".
|
||||
/// Returns true when the user asked to be taken to the relevant settings.
|
||||
func report(title: String, message: String, settingsTitle: String?) async -> Bool {
|
||||
var actions = [ModalAction(title: "OK", role: .cancel)]
|
||||
if let settingsTitle { actions.append(ModalAction(title: settingsTitle)) }
|
||||
// ↵ lands on the settings action when there is one to take, not on the OK dismissal.
|
||||
let request = ModalRequest(
|
||||
title: title, message: message, symbol: "exclamationmark.triangle",
|
||||
actions: actions, defaultIndex: 0, cancelIndex: 0)
|
||||
title: title, message: message, kind: .error,
|
||||
actions: actions, defaultIndex: actions.count - 1, cancelIndex: 0)
|
||||
return await present(request) == 1
|
||||
}
|
||||
|
||||
@@ -151,6 +185,7 @@ final class ModalWindowController: NSObject, NSWindowDelegate {
|
||||
volume = VolumeState(level: Double(current))
|
||||
let request = ModalRequest(
|
||||
title: "Set Volume", message: "Choose the output volume.", symbol: "speaker.wave.2",
|
||||
kind: .info,
|
||||
actions: [
|
||||
ModalAction(title: "Set Volume"),
|
||||
ModalAction(title: "Cancel", role: .cancel),
|
||||
@@ -160,21 +195,13 @@ final class ModalWindowController: NSObject, NSWindowDelegate {
|
||||
return Float32(volume.level)
|
||||
}
|
||||
|
||||
/// Feedback for the volume and mute commands, which otherwise change the output with nothing on screen, since macOS only draws its own HUD for real media keys.
|
||||
/// Feedback for the volume and mute commands, which otherwise change the output with nothing on screen, since macOS only draws its own HUD for real media keys. Success/info toasts for other commands go through `HUDWindowController`'s pill instead, since this box's whole point is showing the level.
|
||||
func showVolumeHUD(level: Float32, muted: Bool) {
|
||||
showHUD(.volume(level: Double(level), muted: muted))
|
||||
}
|
||||
|
||||
/// Confirmation for a command whose effect is invisible (Empty Trash, a toggle). Without it a successful run is indistinguishable from nothing happening.
|
||||
func showToast(symbol: String, title: String) {
|
||||
showHUD(.message(symbol: symbol, title: title))
|
||||
}
|
||||
|
||||
private func showHUD(_ content: HUDState.Content) {
|
||||
hudState.content = content
|
||||
hudVolume.level = Double(level)
|
||||
hudVolume.muted = muted
|
||||
if hud == nil {
|
||||
let view = hostingView(
|
||||
TinycastHUDView(state: hudState), width: Theme.Size.hudWidth,
|
||||
VolumeHUDView(state: hudVolume), width: Theme.Size.hudWidth,
|
||||
minHeight: Theme.Size.hudHeight)
|
||||
let panel = ModalPanel(content: view, acceptsKey: false)
|
||||
place(panel, anchor: .hud)
|
||||
@@ -183,7 +210,7 @@ final class ModalWindowController: NSObject, NSWindowDelegate {
|
||||
}
|
||||
hudDismissal?.cancel()
|
||||
hudDismissal = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(1500))
|
||||
try? await Task.sleep(for: .seconds(Theme.Duration.hud))
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.dismissHUD()
|
||||
}
|
||||
|
||||
@@ -23,13 +23,11 @@ struct SystemCommandFailure: LocalizedError, Sendable {
|
||||
|
||||
struct SystemCommandFeedback: Sendable {
|
||||
let title: String
|
||||
let symbol: String
|
||||
/// Set when the command found nothing to do; it reads as information rather than a completed change.
|
||||
let isNoOp: Bool
|
||||
|
||||
init(_ title: String, symbol: String, isNoOp: Bool = false) {
|
||||
init(_ title: String, isNoOp: Bool = false) {
|
||||
self.title = title
|
||||
self.symbol = symbol
|
||||
self.isNoOp = isNoOp
|
||||
}
|
||||
}
|
||||
@@ -109,15 +107,11 @@ enum SystemCommandRunner {
|
||||
let result = try runAppleScript(
|
||||
"tell application \"System Events\" to tell appearance preferences to set dark mode to not dark mode")
|
||||
let dark = result?.booleanValue ?? false
|
||||
return SystemCommandFeedback(
|
||||
dark ? "Dark Appearance" : "Light Appearance",
|
||||
symbol: dark ? "moon.fill" : "sun.max.fill")
|
||||
return SystemCommandFeedback(dark ? "Dark Appearance" : "Light Appearance")
|
||||
case .toggleStageManager:
|
||||
let on = try await toggleDefault(
|
||||
domain: "com.apple.WindowManager", key: "GloballyEnabled")
|
||||
return SystemCommandFeedback(
|
||||
on ? "Stage Manager On" : "Stage Manager Off",
|
||||
symbol: "squares.leading.rectangle")
|
||||
return SystemCommandFeedback(on ? "Stage Manager On" : "Stage Manager Off")
|
||||
case .openTrash:
|
||||
let trash = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash")
|
||||
guard NSWorkspace.shared.open(trash) else {
|
||||
@@ -131,18 +125,16 @@ enum SystemCommandRunner {
|
||||
try runAppleScript("tell application \"Finder\" to count items of trash")?
|
||||
.int32Value ?? 0
|
||||
guard items > 0 else {
|
||||
return SystemCommandFeedback(
|
||||
"Trash Is Already Empty", symbol: "trash", isNoOp: true)
|
||||
return SystemCommandFeedback("Trash Is Already Empty", isNoOp: true)
|
||||
}
|
||||
try runAppleScript("tell application \"Finder\" to empty trash")
|
||||
return SystemCommandFeedback("Trash Emptied", symbol: "trash")
|
||||
return SystemCommandFeedback("Trash Emptied")
|
||||
case .ejectAllDisks:
|
||||
let ejected = try ejectAllDisks()
|
||||
guard ejected > 0 else {
|
||||
return SystemCommandFeedback("No Disks to Eject", symbol: "eject", isNoOp: true)
|
||||
return SystemCommandFeedback("No Disks to Eject", isNoOp: true)
|
||||
}
|
||||
return SystemCommandFeedback(
|
||||
ejected == 1 ? "1 Disk Ejected" : "\(ejected) Disks Ejected", symbol: "eject")
|
||||
return SystemCommandFeedback(ejected == 1 ? "1 Disk Ejected" : "\(ejected) Disks Ejected")
|
||||
case .toggleHiddenFiles:
|
||||
let shown = try await toggleDefault(
|
||||
domain: "com.apple.finder", key: "AppleShowAllFiles")
|
||||
@@ -150,31 +142,27 @@ enum SystemCommandRunner {
|
||||
if output.status != 0 && output.status != 1 {
|
||||
throw processFailure(output, executable: "killall")
|
||||
}
|
||||
return SystemCommandFeedback(
|
||||
shown ? "Hidden Files Shown" : "Hidden Files Hidden",
|
||||
symbol: shown ? "eye" : "eye.slash")
|
||||
return SystemCommandFeedback(shown ? "Hidden Files Shown" : "Hidden Files Hidden")
|
||||
case .hideOtherApps:
|
||||
hideOtherApps(except: previousApp)
|
||||
case .unhideAllApps:
|
||||
let hidden = NSWorkspace.shared.runningApplications.filter(\.isHidden)
|
||||
for app in hidden { app.unhide() }
|
||||
guard !hidden.isEmpty else {
|
||||
return SystemCommandFeedback("Nothing Was Hidden", symbol: "eye", isNoOp: true)
|
||||
return SystemCommandFeedback("Nothing Was Hidden", isNoOp: true)
|
||||
}
|
||||
return SystemCommandFeedback("All Apps Unhidden", symbol: "eye")
|
||||
return SystemCommandFeedback("All Apps Unhidden")
|
||||
case .quitAllApps:
|
||||
for app in AppLauncher.quitAllTargets() { app.terminate() }
|
||||
case .dismissNotifications:
|
||||
let dismissed = try await dismissNotifications()
|
||||
guard dismissed > 0 else {
|
||||
return SystemCommandFeedback(
|
||||
"No Notifications", symbol: "bell.slash", isNoOp: true)
|
||||
return SystemCommandFeedback("No Notifications", isNoOp: true)
|
||||
}
|
||||
return SystemCommandFeedback("Notifications Dismissed", symbol: "bell.slash")
|
||||
return SystemCommandFeedback("Notifications Dismissed")
|
||||
case .toggleBluetooth:
|
||||
let on = try await toggleBluetooth()
|
||||
return SystemCommandFeedback(
|
||||
on ? "Bluetooth On" : "Bluetooth Off", symbol: "bluetooth")
|
||||
return SystemCommandFeedback(on ? "Bluetooth On" : "Bluetooth Off")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ enum Theme {
|
||||
/// Tinycast's own modal: fixed width, height measured from the SwiftUI content.
|
||||
static let modalWidth: CGFloat = 420
|
||||
/// Leading glyph on a modal, larger than a row icon because it carries the dialog's tone (warning / question).
|
||||
static let modalIcon: CGFloat = 26
|
||||
static let modalIcon: CGFloat = 32
|
||||
/// Transient volume HUD shown after any volume or mute command.
|
||||
static let hudWidth: CGFloat = 200
|
||||
static let hudHeight: CGFloat = 92
|
||||
@@ -86,6 +86,8 @@ enum Theme {
|
||||
enum Duration {
|
||||
/// How long the confirmation HUD stays on screen.
|
||||
static let hud: TimeInterval = 1.6
|
||||
/// Fade-in/out for a hover `Tooltip`.
|
||||
static let tooltip: TimeInterval = 0.15
|
||||
}
|
||||
|
||||
/// System text styles (not hardcoded sizes) so the UI honors Dynamic Type.
|
||||
@@ -124,8 +126,12 @@ enum Theme {
|
||||
static let cardStroke = Color.white.opacity(0.10)
|
||||
/// Whitish tint layered into the Liquid Glass floating controls (action group + menu circle) so the glass reads frosted rather than clear.
|
||||
static let glassFrost = Color.white.opacity(0.05)
|
||||
/// The violet of the app mark. The one non-white hue in the system, used only to tint the About support callout.
|
||||
/// The violet of the app mark, used only to tint the About support callout.
|
||||
static let brand = Color(red: 0.525, green: 0.231, blue: 1.0)
|
||||
/// Destructive tint: a destructive button's label, and a modal's leading glyph for `.warning`/`.error` kind dialogs — both read equally severe; only the default icon's shape tells them apart.
|
||||
static let destructive = Color.red
|
||||
/// Success tint: a modal's leading glyph for a `.success` kind dialog.
|
||||
static let success = Color.green
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A small hover-triggered label in Tinycast's own dark vocabulary, standing in for a
|
||||
/// system `.help()` tooltip on the modal's borderless panel.
|
||||
private struct TooltipModifier: ViewModifier {
|
||||
let text: String?
|
||||
@State private var hovered = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onHover { hovered = text != nil && $0 }
|
||||
.overlay(alignment: .top) {
|
||||
if let text, hovered {
|
||||
Text(text)
|
||||
.font(Theme.Typography.keyCap)
|
||||
.foregroundStyle(Theme.Colors.textSecondary)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xxs)
|
||||
.background(Capsule().fill(Theme.Colors.controlSurface))
|
||||
.overlay(Capsule().strokeBorder(Theme.Colors.border, lineWidth: 1))
|
||||
.fixedSize()
|
||||
.offset(y: -Theme.Spacing.xxl)
|
||||
.transition(.opacity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.animation(.easeOut(duration: Theme.Duration.tooltip), value: hovered)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Hover label matching the palette's own keycap-chip styling, used where a system
|
||||
/// `.help()` tooltip would look out of place on Tinycast's own chrome.
|
||||
func tooltip(_ text: String?) -> some View {
|
||||
modifier(TooltipModifier(text: text))
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,12 @@ struct TinycastModalView: View {
|
||||
let onChoose: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xl) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xxl) {
|
||||
HStack(alignment: .top, spacing: Theme.Spacing.lg) {
|
||||
Image(systemName: request.symbol)
|
||||
Image(systemName: request.symbol ?? request.kind.defaultSymbol)
|
||||
.font(.system(size: Theme.Size.modalIcon, weight: .regular))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(isDestructive ? Color.red : Color.secondary)
|
||||
.foregroundStyle(request.kind.tint)
|
||||
.frame(width: Theme.Size.modalIcon)
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text(request.title)
|
||||
@@ -33,9 +33,10 @@ struct TinycastModalView: View {
|
||||
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
Spacer(minLength: 0)
|
||||
ForEach(request.actions.indices, id: \.self) { index in
|
||||
ForEach(visualOrder, id: \.self) { index in
|
||||
ModalButton(
|
||||
action: request.actions[index],
|
||||
kind: request.kind,
|
||||
keyCap: keyCap(for: index),
|
||||
onActivate: { onChoose(index) }
|
||||
)
|
||||
@@ -49,11 +50,16 @@ struct TinycastModalView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.Radius.modal, style: .continuous))
|
||||
}
|
||||
|
||||
private var isDestructive: Bool {
|
||||
request.actions.contains { $0.role == .destructive }
|
||||
/// Cancel renders leading, matching macOS convention and the panel's Escape/Return keys, while `onChoose(index)` still dispatches against `request.actions`' original order so callers never have to think about display position.
|
||||
private var visualOrder: [Int] {
|
||||
request.actions.indices.sorted { rank(of: $0) < rank(of: $1) }
|
||||
}
|
||||
|
||||
/// Only the two keys the panel actually handles are advertised, so a printed cap can't drift from the behavior.
|
||||
private func rank(of index: Int) -> Int {
|
||||
request.actions[index].role == .cancel ? 0 : 1
|
||||
}
|
||||
|
||||
/// Only the two keys the panel actually handles are advertised, so a hover tooltip can't drift from the behavior.
|
||||
private func keyCap(for index: Int) -> String? {
|
||||
if index == request.defaultIndex { return "↵" }
|
||||
if index == request.cancelIndex { return "esc" }
|
||||
@@ -63,28 +69,25 @@ struct TinycastModalView: View {
|
||||
|
||||
private struct ModalButton: View {
|
||||
let action: ModalAction
|
||||
let kind: ModalKind
|
||||
let keyCap: String?
|
||||
let onActivate: () -> Void
|
||||
@State private var hovered = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: onActivate) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Text(action.title)
|
||||
.font(Theme.Typography.bar)
|
||||
.foregroundStyle(action.role == .destructive ? Color.red : Color.primary)
|
||||
if let keyCap {
|
||||
KeyCapChip(text: keyCap, style: .outline)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
.frame(height: Theme.Size.menuButton)
|
||||
.contentShape(Capsule())
|
||||
.background(Capsule().fill(hovered ? Theme.Colors.menuHover : Color.clear))
|
||||
Text(action.title)
|
||||
.font(Theme.Typography.bar)
|
||||
.foregroundStyle(action.role == .cancel ? Theme.Colors.textSecondary : kind.tint)
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
.frame(height: Theme.Size.menuButton)
|
||||
.contentShape(Capsule())
|
||||
.background(Capsule().fill(hovered ? Theme.Colors.menuHover : Color.clear))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovered = $0 }
|
||||
.frosted(in: Capsule())
|
||||
.tooltip(keyCap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +145,9 @@ struct VolumeSlider: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The transient readout: a volume bar, or a one-line confirmation for a command whose effect is invisible. Glass, because it is a floating control rather than a surface with content.
|
||||
struct TinycastHUDView: View {
|
||||
@ObservedObject var state: ModalWindowController.HUDState
|
||||
/// The transient volume readout shown after a volume or mute command, since macOS only draws its own HUD for real media keys. A success/info confirmation for every other command is `HUDWindowController`'s pill instead, not this box, because that one has an actual level to show. Glass, because it is a floating control rather than a surface with content.
|
||||
struct VolumeHUDView: View {
|
||||
@ObservedObject var state: ModalWindowController.VolumeState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
@@ -152,39 +155,23 @@ struct TinycastHUDView: View {
|
||||
.font(.system(size: Theme.Size.modalIcon, weight: .regular))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(Color.primary)
|
||||
switch state.content {
|
||||
case .volume(let level, let muted):
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Theme.Colors.controlSurface)
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(muted ? 0.35 : 0.85))
|
||||
.frame(width: fill(level: muted ? 0 : level))
|
||||
}
|
||||
.frame(height: Theme.Size.volumeTrackHeight)
|
||||
case .message(_, let title):
|
||||
Text(title)
|
||||
.font(Theme.Typography.rowTrailing)
|
||||
.foregroundStyle(Theme.Colors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Theme.Colors.controlSurface)
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(state.muted ? 0.35 : 0.85))
|
||||
.frame(width: fill(level: state.muted ? 0 : state.level))
|
||||
}
|
||||
.frame(height: Theme.Size.volumeTrackHeight)
|
||||
}
|
||||
.padding(Theme.Spacing.xxl)
|
||||
.frame(width: Theme.Size.hudWidth, height: Theme.Size.hudHeight)
|
||||
.glassEffect(
|
||||
.regular, in: RoundedRectangle(cornerRadius: Theme.Radius.modal, style: .continuous))
|
||||
.frosted(in: RoundedRectangle(cornerRadius: Theme.Radius.modal, style: .continuous))
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch state.content {
|
||||
case .volume(let level, let muted):
|
||||
if muted || level == 0 { return "speaker.slash.fill" }
|
||||
return level < 0.5 ? "speaker.wave.1.fill" : "speaker.wave.3.fill"
|
||||
case .message(let symbol, _):
|
||||
return symbol
|
||||
}
|
||||
if state.muted || state.level == 0 { return "speaker.slash.fill" }
|
||||
return state.level < 0.5 ? "speaker.wave.1.fill" : "speaker.wave.3.fill"
|
||||
}
|
||||
|
||||
private func fill(level: Double) -> CGFloat {
|
||||
|
||||
@@ -74,11 +74,10 @@ is dropped while the actual error survives.
|
||||
|
||||
`AppCore.runCustomCommand(id:)` is the one funnel both palette activation and the global hotkey reach,
|
||||
so the gate lives there and neither path can bypass it. The palette hides before the dialog it is a
|
||||
floating panel and would sit above it. The dialog shows the command text as well as its name, and ↵ is
|
||||
bound to **Cancel**: the command is one ↵ away in the palette, and a reflexive second ↵ must not fire
|
||||
something the user asked to be warned about. The gate is Tinycast's own modal, not an `NSAlert`
|
||||
([ui.md](ui.md#modals--hud)): presentation is `async` with no nested run loop, and the presenter itself
|
||||
refuses a second dialog while one is up, so a held shortcut can't stack them.
|
||||
floating panel and would sit above it. The dialog shows the command text as well as its name; ↵ runs
|
||||
it and Escape cancels, with Cancel rendered on the left of the two buttons. The gate is Tinycast's own
|
||||
modal, not an `NSAlert` ([ui.md](ui.md#modals--hud)): presentation is `async` with no nested run loop,
|
||||
and the presenter itself refuses a second dialog while one is up, so a held shortcut can't stack them.
|
||||
|
||||
### Reporting
|
||||
|
||||
@@ -94,7 +93,7 @@ on grepping stderr, since 127 is equally a plain typo. The command string itself
|
||||
Foundation-only harness. Verify by hand:
|
||||
|
||||
1. Activating a gated command from the palette hides the palette *before* the dialog appears.
|
||||
2. ↵ at the dialog cancels; clicking **Run** runs.
|
||||
2. ↵ at the dialog runs the command; Escape or clicking **Cancel** cancels.
|
||||
3. Pressing the command's hotkey while its dialog is up does not stack a second dialog.
|
||||
4. A gated command triggered by hotkey with no palette open still confirms.
|
||||
5. An rc-file-only alias with the flag off shows the 127 hint, and **Open Settings…** opens the pane.
|
||||
|
||||
+10
-5
@@ -66,17 +66,22 @@ Those routes run only on explicit activation. Automation, Accessibility or Bluet
|
||||
requested at first use, and denial produces an alert linking to the relevant System Settings pane.
|
||||
Tinycast remains locked to dark appearance even when Toggle System Appearance changes macOS.
|
||||
|
||||
Restart, Shut Down, Log Out, Empty Trash and Quit All Applications confirm before execution, with
|
||||
Return assigned to Cancel. Every dialog is Tinycast's own: confirmations, failure reports and the Set
|
||||
Restart, Shut Down, Log Out, Empty Trash and Quit All Applications confirm before execution: ↵ runs
|
||||
the action, Escape cancels. Every dialog is Tinycast's own: confirmations, failure reports and the Set
|
||||
Volume slider all render through `ModalWindowController` rather than an `NSAlert`
|
||||
(see [ui.md](ui.md#modals--hud)). Volume and mute commands also show Tinycast's transient volume HUD,
|
||||
since macOS only draws its own for real media keys.
|
||||
|
||||
A command whose effect is invisible reports back through the same HUD rather than finishing silently:
|
||||
A command whose effect is invisible reports back through a pill (`HUDWindowController`, the same one
|
||||
Custom Commands and Snippets confirm through) rather than finishing silently:
|
||||
`SystemCommandRunner.run` returns a `SystemCommandFeedback` naming the state it landed in
|
||||
(`Trash Emptied`, `Hidden Files Shown`, `Dark Appearance`, `Bluetooth Off`, `3 Disks Ejected`), and
|
||||
`AppCore` shows it. Commands that are their own confirmation, such as Show Desktop, Hide Others, Quit All and the
|
||||
power actions, return nothing.
|
||||
`AppCore` shows it with a `ModalKind` derived from the feedback's `isNoOp` flag: `.success` when
|
||||
something actually changed, `.info` when there was nothing to do, shown as the pill's leading status
|
||||
dot rather than a per-command icon, since the message already names the state. Commands that are
|
||||
their own confirmation, such as Show Desktop, Hide Others,
|
||||
Quit All and the power actions, return nothing. Volume and mute are the one case that stays on the
|
||||
palette's own square HUD, since that one has an actual level to show, not just a message.
|
||||
|
||||
**Nothing-to-do is an outcome, not a failure.** Empty Trash asks Finder for `count items of trash`
|
||||
first and reports `Trash Is Already Empty`, because Finder raises an error when told to empty an empty
|
||||
|
||||
+6
-2
@@ -173,8 +173,12 @@ The confirmation is per snippet and off by default: the only gate is `show_confi
|
||||
from the snippet's editor in **Settings → Snippets**. Nothing about it reaches settings backups.
|
||||
The feature switch — which carries keyword-monitoring consent — is likewise excluded from backups.
|
||||
|
||||
`HUDWindowController` is shared rather than snippet-specific — it takes a message, a symbol and a tint,
|
||||
so a custom command confirms a run through the same panel.
|
||||
`HUDWindowController` is shared rather than snippet-specific. It takes a message and a `ModalKind`
|
||||
(defaulting to `.success`), the same kind vocabulary `ModalWindowController`'s dialogs use, so a
|
||||
custom command confirms a run through the same panel and the same tint rules; system commands'
|
||||
success/info feedback uses it too (see [launcher.md](launcher.md#system-commands)). Its leading
|
||||
status dot, not an icon, carries the tint. Its capsule uses `Theme.frosted(in:)`, the same
|
||||
whitish-tinted glass as the rest of the app's floating controls (see [ui.md](ui.md#liquid-glass)).
|
||||
|
||||
After either launcher or keyword delivery is confirmed, Tinycast may show a brief non-activating,
|
||||
click-through overlay with the snippet name. The AppCore-owned controller replaces and restarts a
|
||||
|
||||
+55
-15
@@ -159,7 +159,7 @@ leading gap. Headers are non-selectable display rows, so selection (keyed by id)
|
||||
|
||||
Glass is **only** for floating controls, never the main surface.
|
||||
|
||||
- `View.frosted(in:)` = `glassEffect(.regular.interactive().tint(glassFrost), in:)` + `.tint(.clear)` — interactive lensing with a whitish frost tint (`glassFrost`) so the glass reads brighter than clear. Used on the action-group capsule and the menu circle; tune the frost amount via the `glassFrost` token, not per call site.
|
||||
- `View.frosted(in:)` = `glassEffect(.regular.interactive().tint(glassFrost), in:)` + `.tint(.clear)` — interactive lensing with a whitish frost tint (`glassFrost`) so the glass reads brighter than clear. Used on the action-group capsule, the menu circle, the modal's buttons, and both HUDs (`VolumeHUDView`, `HUDWindowController`'s capsule); a HUD floats alone over the desktop with nothing dark behind it, so plain untinted `glassEffect` reads flat there even though it's fine inside the palette. Tune the frost amount via the `glassFrost` token, not per call site.
|
||||
- **Menus are in-window overlays, not system popovers.** `.contextMenu`/`NSMenu` stall clicks for seconds inside a `LazyVStack` and spill outside the panel. Use `PopoverMenu` anchored to a bottom corner via `.overlay`, inset `menuInset` (8pt) so its own corner isn't clipped by the panel's.
|
||||
- **`PopoverMenu`** uses `glassEffect(.regular, in: RoundedRectangle(menuPanel 16))` with **no hand-tuned shadow** — Tahoe glass carries its own elevation; adding a drop shadow reads heavy and non-native.
|
||||
- `PopoverMenuRow`: leading glyph, label, trailing shortcut glyph, `menuHover` fill on hover, `menuRow 10` corner. Menus animate in with `.opacity + .scale(0.96)` from the anchored corner, `easeOut 0.14`.
|
||||
@@ -169,7 +169,7 @@ Glass is **only** for floating controls, never the main surface.
|
||||
|
||||
---
|
||||
|
||||
## Modals & HUD `Core/ModalWindowController.swift`, `Features/Modal/TinycastModalView.swift`
|
||||
## Modals & HUD `Core/ModalWindowController.swift`, `Features/Modal/TinycastModalView.swift`, `Core/HUDWindowController.swift`
|
||||
|
||||
Tinycast owns its dialogs; `NSAlert` is never used. `ModalWindowController` is owned by `AppCore` (the
|
||||
sole owner rule) and is the only presenter, so every confirmation in the app looks and behaves alike.
|
||||
@@ -178,14 +178,43 @@ sole owner rule) and is the only presenter, so every confirmation in the app loo
|
||||
`clipShape(RoundedRectangle(modal 20))`, in that order at `modalWidth 420`. Glass is reserved for
|
||||
the buttons, matching the "glass only on floating controls" rule. The HUD is the exception: it is a
|
||||
floating control with no content of its own, so it is stock `glassEffect` throughout.
|
||||
- **Layout.** Leading tone glyph (`modalIcon 26`, red when the dialog is destructive), title
|
||||
(`.headline`) + wrapped secondary message, optional accessory, then right-aligned buttons.
|
||||
- **Layout.** Leading tone glyph (`modalIcon 26`, tinted by the dialog's `ModalKind`), title
|
||||
(`.headline`) + wrapped secondary message, optional accessory, then buttons at the trailing edge
|
||||
with **Cancel rendered leading** among them, matching macOS convention.
|
||||
`TinycastModalView.visualOrder` reorders only the display; `onChoose(index)` still dispatches
|
||||
against `ModalRequest.actions`' original order, so a caller never has to think about layout
|
||||
position when it builds a request.
|
||||
- **Kind.** `ModalKind` is `.info`, `.success`, `.warning`, `.error`, or `.custom(Color)`. The first
|
||||
four carry a fixed tint and a default dialog icon (`ModalKind.defaultSymbol`, used whenever
|
||||
`ModalRequest.symbol` is left `nil`): `.info` secondary-gray/`info.circle`, `.success`
|
||||
green/`checkmark.circle.fill`, `.warning` red/`exclamationmark.triangle.fill`, `.error`
|
||||
red/`exclamationmark.circle.fill`. `.info` stays gray rather than system blue on purpose, since a
|
||||
hue here should mark a state the way the other three do, not just decorate an otherwise neutral
|
||||
message. **`.custom(Color)` is the template for a one-off dialog that doesn't fit the other
|
||||
four: it supplies its own tint via the associated color and its own icon via `ModalRequest.symbol`,
|
||||
rather than deriving either.** Nothing constructs it yet.
|
||||
**`.warning` is a confirmation asking before something happens; `.error` is a report that
|
||||
something already went wrong.** `.warning` and `.error` share the same red tint — a warning and an
|
||||
error read equally severe — so the default icon's shape (triangle vs. circle) is what distinguishes
|
||||
them, not color. Every `report()` dialog is `.error`. Most `confirm()` dialogs are `.warning`, but
|
||||
`confirm(kind:)` lets a caller opt a particularly severe confirmation into `.error`'s icon without it
|
||||
claiming something already failed: `AppCore.confirmationKind` does this for Restart, Shut Down, Log
|
||||
Out and Empty Trash, since those end the session or destroy data outright, while every other
|
||||
confirmation (Quit All Applications, a custom command) stays `.warning`. A completed import is
|
||||
`.success`; a value prompt like Set Volume is `.info`. `HUDWindowController.show(message:kind:)`
|
||||
(the pill; see below) takes the same `ModalKind` for its status dot, so the pill and the dialogs
|
||||
speak one tint vocabulary even though they render it differently. `AppCore` derives that `kind` for
|
||||
a system command from `SystemCommandFeedback.isNoOp`, so "Trash Emptied" reads `.success` and
|
||||
"Trash Is Already Empty" reads `.info`, rather than every pill defaulting to the same green dot
|
||||
regardless of whether anything happened.
|
||||
- **Keys.** `ModalPanel.sendEvent` intercepts Esc and ↵ directly instead of relying on SwiftUI
|
||||
`onKeyPress`, so the keys work without anything inside the dialog holding focus. Buttons print only
|
||||
the caps the panel actually handles (`↵`, `esc`), so a printed cap can't drift from behavior.
|
||||
**↵ goes to Cancel on every destructive dialog** the triggering command is one ↵ away in the
|
||||
palette, and a reflexive second press must not run it. Arrow keys step the volume slider by the same
|
||||
1/16 the volume commands use; click-away resolves as a dismissal.
|
||||
`onKeyPress`, so the keys work without anything inside the dialog holding focus. Buttons don't print
|
||||
a key cap; hovering one shows a `Tooltip` (`Core/Tooltip.swift`) with the cap the panel actually
|
||||
handles (`↵`, `esc`), styled like the palette's own `KeyCapChip` but hover-triggered instead of
|
||||
always-on, so a shown cap can't drift from behavior. **↵ runs the dialog's primary action; Escape
|
||||
cancels**, on every dialog including destructive ones.
|
||||
Arrow keys step the volume slider by the same 1/16 the volume commands use; click-away resolves as
|
||||
a dismissal.
|
||||
- **Async, not modal.** Presentation is `async` (`withCheckedContinuation`), so there is no nested run
|
||||
loop. A held hotkey can't stack dialogs: while one is up, a second request resolves immediately as a
|
||||
dismissal which is why the old `isConfirmingCommand` re-entrancy flag is gone.
|
||||
@@ -195,12 +224,23 @@ sole owner rule) and is the only presenter, so every confirmation in the app loo
|
||||
- **`VolumeSlider`** is hand-drawn (track `volumeTrackHeight 6`, knob `volumeKnob 16`, `controlSurface`
|
||||
rail under a white-0.85 fill) with a monospaced-digit percentage in a fixed slot so the track doesn't
|
||||
resize between `0%` and `100%`. A click anywhere on the track jumps the level.
|
||||
- **`TinycastHUDView`** is the transient readout, in two flavours over one shared panel (so a volume
|
||||
change and a confirmation can never overlap): a **volume bar** for the volume/mute commands macOS
|
||||
only draws its own HUD for real media keys, so a CoreAudio change would otherwise be silent and a
|
||||
**one-line message** confirming a command whose effect is invisible (`Trash Emptied`,
|
||||
`Hidden Files Shown`, `Bluetooth Off`). It is non-key, auto-dismisses after ~1.5s, and a repeat
|
||||
command refreshes the live content instead of stacking a second panel.
|
||||
- **`VolumeHUDView`** is the square, non-key readout for the volume/mute commands, since macOS only
|
||||
draws its own HUD for real media keys and a CoreAudio change would otherwise be silent. It exists
|
||||
because a level needs an actual bar, not a one-line message; auto-dismisses after
|
||||
`Duration.hud` (~1.6s), and a repeat command refreshes the live level instead of stacking a second
|
||||
panel. Its icon stays neutral (`Color.primary`), since a level isn't a success/info/warning
|
||||
statement.
|
||||
- **`HUDWindowController`'s pill** (`Core/HUDWindowController.swift`) is every *other* transient
|
||||
confirmation: Custom Commands and Snippets confirming a run, and every system command whose effect
|
||||
is invisible (`Trash Emptied`, `Hidden Files Shown`, `Bluetooth Off`). One capsule shape, sized to
|
||||
its message (`hudMaxWidth 420` ceiling), `frosted(in: Capsule())`, with a leading `statusDot` (6pt,
|
||||
`Circle().fill(kind.tint)`, the same token `SettingsRow`'s status dot uses) in place of an icon. No
|
||||
per-command icon (a trash can, an eye) survives here, deliberately: the message already names the
|
||||
resulting state ("Trash Emptied"), so the dot only needs to carry the tint, not a symbol, and stays
|
||||
lighter than the dialogs' 26pt icon since a pill has nothing to decide, only to glance at. Leading,
|
||||
not trailing like `SettingsRow`'s dot, since a pill is read left to right in one glance rather than
|
||||
scanned as part of a longer row. Auto-dismisses after `Duration.hud`, same as the volume HUD, and a
|
||||
repeat call replaces rather than stacks.
|
||||
|
||||
## Scrollbars — `Core/ThinScrollbar.swift`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user