diff --git a/Sources/Runway/App/StatusItemController.swift b/Sources/Runway/App/StatusItemController.swift index 1ad84278a..5699de051 100644 --- a/Sources/Runway/App/StatusItemController.swift +++ b/Sources/Runway/App/StatusItemController.swift @@ -275,7 +275,7 @@ final class StatusItemController: NSObject { } /// Right-click / control-click on the status item: a native menu mirroring the Settings and Quit - /// items in the popover footer's Options menu (same titles, symbols, and ⌘ shortcuts). Assigning + /// items in the popover footer's gear options menu (same titles, symbols, and ⌘ shortcuts). Assigning /// `statusItem.menu` for the span of one `performClick` shows the menu anchored under the item and /// highlights the button, then clearing it restores the left-click toggle behavior. private func showContextMenu() { diff --git a/Sources/Runway/Support/LiquidGlassFallbacks.swift b/Sources/Runway/Support/LiquidGlassFallbacks.swift index 304b25393..b3082e427 100644 --- a/Sources/Runway/Support/LiquidGlassFallbacks.swift +++ b/Sources/Runway/Support/LiquidGlassFallbacks.swift @@ -42,7 +42,7 @@ extension View { } /// A single interactive Liquid Glass surface (in the given shape) drawn behind a *whole control* — - /// the footer's Options menu button wraps its plain-styled label in one + /// the footer's gear options menu button wraps its plain-styled label in one /// `interactiveGlass(in: Capsule())` so it sits on one continuous capsule. Apply it to the /// container, and keep the control `.buttonStyle(.plain)` so this modifier owns the surface — the /// system `.buttonStyle(.glass)` renders flat on a `Menu` (its own button chrome wins), and per-segment diff --git a/Sources/Runway/Support/PopoverDismissReader.swift b/Sources/Runway/Support/PopoverDismissReader.swift index 1f0d4d3f6..9105fbf77 100644 --- a/Sources/Runway/Support/PopoverDismissReader.swift +++ b/Sources/Runway/Support/PopoverDismissReader.swift @@ -12,7 +12,7 @@ import AppKit /// — so it stays in sync, reopens in one click, and trips the controller's visibility reset /// (cancelling edit mode + the jiggle). /// - **Return**: `onReturn` navigates into or back out of Customize (the same affordance the footer's -/// Options ▸ Customize item carries). Consuming the key here is also what stops a bare +/// gear options menu's Customize item carries). Consuming the key here is also what stops a bare /// Return from falling through and dismissing the popover. struct PopoverKeyReader: NSViewRepresentable { /// Called first on Esc. Return `true` when the press was handled in-popover (Esc then does @@ -22,7 +22,7 @@ struct PopoverKeyReader: NSViewRepresentable { /// `false` lets the key fall through to a focused control. var onReturn: @MainActor () -> Bool = { false } /// Called on ⌘, (Settings). Handled on this always-on monitor — the same one as Esc/Return — so it - /// works from every screen, including Settings, whose footer has no Settings action. The Options + /// works from every screen, including Settings, whose footer has no Settings action. The gear options /// menu's Settings item carries ⌘, only as a *label*: while that menu is open the item handles /// it, while it's closed this monitor does, so they never both fire. var onSettings: @MainActor () -> Bool = { false } @@ -52,7 +52,7 @@ struct PopoverKeyReader: NSViewRepresentable { /// Whether a bare-key keyDown belongs to the popover: its key window must *be* the panel. The /// panel is a non-activating key window that takes focus the instant it opens, so a foreign key - /// window (an open About panel, a tracking `NSMenu` from the Options menu or a Settings picker) — or + /// window (an open About panel, a tracking `NSMenu` from the gear options menu or a Settings picker) — or /// no key window at all — is correctly *not* the popover's, and Esc/Return leave it alone instead /// of hijacking it. (An earlier build also claimed a nil key window, to paper over `NSPopover`'s /// activation race; the `NSPanel` removed that race, so the strict match is correct and safer.) @@ -114,7 +114,7 @@ struct PopoverKeyReader: NSViewRepresentable { guard let self, let window = self.window, window.isVisible else { return false } // The key must target the popover — its key window must be the panel, so a key // pressed while a menu / About panel owns focus is left alone (see `keyTargetsPopover`). - // This is also what hands ⌘, to an open Options menu's own item instead of here. + // This is also what hands ⌘, to an open options menu's own item instead of here. guard PopoverKeyReader.keyTargetsPopover( eventWindowID: eventWindowID, popoverWindowID: ObjectIdentifier(window) diff --git a/Sources/Runway/Views/ClosureMenuItem.swift b/Sources/Runway/Views/ClosureMenuItem.swift index f44781d57..3faf66b01 100644 --- a/Sources/Runway/Views/ClosureMenuItem.swift +++ b/Sources/Runway/Views/ClosureMenuItem.swift @@ -5,7 +5,7 @@ import AppKit /// standard for menu shortcuts like ⌘Q). /// /// Used by the status-item right-click menu (`StatusItemController`), which is a real `NSMenu`. The -/// footer's "Options" menu is a SwiftUI `Menu` and does not need this. +/// footer's gear options menu is a SwiftUI `Menu` and does not need this. @MainActor final class ClosureMenuItem: NSMenuItem { private let handler: () -> Void diff --git a/Sources/Runway/Views/DashboardView.swift b/Sources/Runway/Views/DashboardView.swift index 5a9ffe4b9..ed5f203db 100644 --- a/Sources/Runway/Views/DashboardView.swift +++ b/Sources/Runway/Views/DashboardView.swift @@ -13,7 +13,7 @@ import SwiftUI /// `.scrollEdgeEffectStyle(.soft)`, macOS 26+) — Apple's blurred boundary, not a custom gradient or a /// material bar. On macOS 15 the footer/top bar still pin via `safeAreaInset`, just without the blur /// (content scrolls flush). The panel **auto-fits its content**: each screen publishes its intrinsic -/// height (`ScrollContentHeightKey` + the measured footer), and the visual panel — a height-framed, +/// height (`ScrollContentHeightKey` + the fixed chrome heights), and the visual panel — a height-framed, /// corner-clipped card pinned to the top of a fixed-size transparent window (see /// `PanelHeightController`) — animates to that on SwiftUI's clock, with the AppKit backdrop following /// via `drivesPanelHeight` / `PanelHeightModifier`. The destination is the only live screen tree @@ -39,7 +39,10 @@ struct DashboardView: View { /// morph target (`heightCoordinator.measuredIdeal` / `.target(for:)`). Written from the geometry /// actions below. The animation itself — `animatedHeight`, the slide, the `withAnimation` spring — /// stays in this view; the coordinator holds only the deterministic measurement. - @State private var heightCoordinator = PanelHeightCoordinator(topBarHeight: Self.topBarHeight) + @State private var heightCoordinator = PanelHeightCoordinator( + topBarHeight: Self.topBarHeight, + footerHeight: Self.footerHeight + ) /// Horizontal screen-switch slide: 0 shows the outgoing screen, 1 the incoming one. Drives the /// page offset so the screens slide between modes on one spring. @State private var slideProgress: CGFloat = 1 @@ -75,6 +78,10 @@ struct DashboardView: View { private static let popoverWidth: CGFloat = 320 /// Fixed height of the Customize / Settings back nav bar — the bar pins itself to exactly this height. private static let topBarHeight: CGFloat = 44 + /// Fixed height of the footer bar (Dashboard and Settings; Customize shows none). Like the top + /// bar, the footer is fixed-height chrome: the height coordinator sums this constant into each + /// screen's morph target, the scroll spacer reserves it, and the overlay bar fills it. + private static let footerHeight: CGFloat = 40 /// A compact directional entrance communicates hierarchy without keeping a second full screen tree /// alive. The opaque popover surface fills the small uncovered strip while the page settles. private static let screenEntranceDistance: CGFloat = 36 @@ -120,7 +127,7 @@ struct DashboardView: View { .background( // Esc backs out of Customize / Settings first; only from the dashboard does it close // the popover. Return opens Customize from the dashboard (the same affordance the - // footer's Options ▸ Customize menu item carries) and returns to the + // footer's gear options menu's Customize item carries) and returns to the // dashboard from Customize or Settings — matching Esc and the back navigation, // never jumping Settings → Customize. Always consumed, so a bare Return can't fall // through and dismiss the popover. @@ -149,7 +156,7 @@ struct DashboardView: View { }, // ⌘, toggles Settings, on this always-on monitor so it fires from every screen — // including Settings, whose footer has no Settings action. Handling it here (and - // consuming it) also lets the Options menu's Settings item carry ⌘, as a label + // consuming it) also lets the gear options menu's Settings item carry ⌘, as a label // without a second SwiftUI registration fighting it. onSettings: { withAnimation(Motion.modeSwitch) { @@ -395,14 +402,21 @@ struct DashboardView: View { ) } .pinnedFooter(spacing: 0) { + // The footer lives in the safe-area bar, NOT as a bottom-aligned overlay on the height + // frame: the bar's position is re-derived from the per-frame layout of the animated + // frame, so it hugs the panel's bottom edge on every interpolated frame of a morph. An + // overlay's position animates as its own attribute instead — when a content change + // lands in one transaction and the height retargets in a second (a provider appears, a + // caret estimate gets corrected), the overlay's spring runs phase-shifted from the + // frame growth and the footer visibly trails the edge, sliding over content to catch + // up (verified frame-by-frame; the safe-area bar shows no such detach). PopoverFooter( screen: layout.screen, layout: layout, dataStore: dataStore, - horizontalPadding: Self.footerHorizontalPadding - ) { screen, height in - heightCoordinator.setFooter(height, for: screen) - } + horizontalPadding: Self.footerHorizontalPadding, + height: Self.footerHeight + ) } } diff --git a/Sources/Runway/Views/HeaderView.swift b/Sources/Runway/Views/HeaderView.swift index 713650d66..0bb6a6713 100644 --- a/Sources/Runway/Views/HeaderView.swift +++ b/Sources/Runway/Views/HeaderView.swift @@ -1,16 +1,17 @@ import AppKit import SwiftUI -/// The dashboard footer's trailing control: a single **Options ⌄** menu button in Liquid Glass. The -/// earlier split button ("Customize" + separate chevron) confused people — two tap targets in one -/// capsule read as one — so everything now lives in one obvious menu: Customize / Settings / Share -/// Screenshot / Check for Updates / About / Quit. Customize leads the menu because it's the screen -/// users reach for most; Settings stays one click away (and always via ⌘,). +/// The dashboard footer's trailing control: a single **gear** menu button in Liquid Glass. An earlier +/// split button ("Customize" + separate chevron) confused people, and the "Options ⌄" capsule that +/// replaced it claimed more footer than one control deserved — so everything lives in one compact, +/// obvious gear menu: Customize / Settings / Share Screenshot / Check for Updates / About / Quit. +/// Customize leads the menu because it's the screen users reach for most; Settings stays one click +/// away (and always via ⌘,). /// -/// The capsule is a `.buttonStyle(.plain)` `Menu` with one `interactiveGlass(in: Capsule())` treatment +/// The gear is a `.buttonStyle(.plain)` `Menu` with one `interactiveGlass(in: Circle())` treatment /// behind it — the system `.buttonStyle(.glass)` renders flat on a `Menu` (its own button chrome wins), /// so the treatment goes on the container. Increase Transparency adds an adaptive frosted base beneath -/// the glass for contrast; macOS 15 uses that frosted capsule as its fallback. The menu renders in its +/// the glass for contrast; macOS 15 uses that frosted circle as its fallback. The menu renders in its /// own `NSMenu`-backed window, which the panel's outside-click policy keeps the popover open for. /// /// Only the dashboard shows this; the Customize and Settings screens carry their own top-leading back @@ -33,48 +34,43 @@ struct HeaderView: View { /// per-page), so this control shows only when that's `.dashboard` and swaps in place on a switch. let screen: PopoverScreen - /// Control height, so the capsule matches the footer's other chrome. + /// Control diameter, so the gear matches the footer's other chrome. private static let controlHeight: CGFloat = 28 var body: some View { leadingControl } - /// On the dashboard, the Options menu button on one glass capsule. + /// On the dashboard, the gear menu button on one glass circle. @ViewBuilder private var leadingControl: some View { if screen == .dashboard { optionsButton .fixedSize() .interactiveGlass( - in: Capsule(), + in: Circle(), reinforced: transparency.effectiveStyle.needsChromeLegibilityBacking ) } } - /// The Options pull-down: label plus its own chevron glyph. `.menuStyle(.button)` + - /// `.buttonStyle(.plain)` strip the menu chrome so `interactiveGlass` owns the surface; - /// `.menuIndicator(.hidden)` drops the built-in arrow in favor of our styled chevron. + /// The options pull-down: a bare gear glyph. `.menuStyle(.button)` + `.buttonStyle(.plain)` strip + /// the menu chrome so `interactiveGlass` owns the surface; `.menuIndicator(.hidden)` drops the + /// built-in arrow — the gear alone is the affordance. private var optionsButton: some View { Menu { menuItems } label: { - HStack(spacing: 5) { - Text("Options") - .font(.system(size: 13, weight: .semibold)) - Image(systemName: "chevron.down") - .font(.system(size: 11, weight: .semibold)) - } - .padding(.leading, 14) - .padding(.trailing, 12) - .frame(height: Self.controlHeight) - .contentShape(Rectangle()) + Image(systemName: "gearshape") + .font(.system(size: 13, weight: .medium)) + .frame(width: Self.controlHeight, height: Self.controlHeight) + .contentShape(Circle()) } .menuStyle(.button) .buttonStyle(.plain) .menuIndicator(.hidden) .fixedSize() + .accessibilityLabel("Options") } /// The menu's items, mirroring their in-popover entry points. Customize leads, then Settings. diff --git a/Sources/Runway/Views/PanelHeightCoordinator.swift b/Sources/Runway/Views/PanelHeightCoordinator.swift index 720d793e3..069520c16 100644 --- a/Sources/Runway/Views/PanelHeightCoordinator.swift +++ b/Sources/Runway/Views/PanelHeightCoordinator.swift @@ -2,26 +2,29 @@ import CoreGraphics import Observation /// The popover's auto-fit height *computation*, split out of `DashboardView`: per-screen measured -/// pieces summed into each screen's ideal window height (the morph target), clamped to the panel's -/// allowed range. The view keeps the animation itself — `animatedHeight`, the screen-switch slide, and -/// the `withAnimation` spring — so this holds only the deterministic measurement/target logic (which is -/// now unit-testable), not the timing-sensitive animation clock. +/// scroll content plus the fixed chrome heights summed into each screen's ideal window height (the +/// morph target), clamped to the panel's allowed range. The view keeps the animation itself — +/// `animatedHeight`, the screen-switch slide, and the `withAnimation` spring — so this holds only the +/// deterministic measurement/target logic (which is now unit-testable), not the timing-sensitive +/// animation clock. /// /// Held as `@State` by the view; `@Observable` so `measuredIdeal` changes drive the view's morph -/// `onChange`. The measured parts are written from the view's geometry callbacks via the setters. +/// `onChange`. The measured part is written from the view's geometry callback via the setter; the top +/// bar and footer are fixed-height chrome, so they're init constants rather than measurements. @MainActor @Observable final class PanelHeightCoordinator { /// The panel height each screen wants (top bar + footer + scroll content) — the morph target the - /// view animates toward. `private(set)`: written only through the measurement setters below. + /// view animates toward. `private(set)`: written only through the measurement setter below. private(set) var measuredIdeal: [PopoverScreen: CGFloat] = [:] @ObservationIgnored private var measuredScrollContent: [PopoverScreen: CGFloat] = [:] - @ObservationIgnored private var measuredFooter: [PopoverScreen: CGFloat] = [:] @ObservationIgnored private let topBarHeight: CGFloat + @ObservationIgnored private let footerHeight: CGFloat - init(topBarHeight: CGFloat) { + init(topBarHeight: CGFloat, footerHeight: CGFloat) { self.topBarHeight = topBarHeight + self.footerHeight = footerHeight } /// Record a screen's measured scroll-content height (from the view's geometry action) and recompose @@ -31,20 +34,14 @@ final class PanelHeightCoordinator { recomposeIdeal(for: screen) } - /// Record a screen's measured footer height (Dashboard and Settings have different content) and - /// recompose. - func setFooter(_ height: CGFloat, for screen: PopoverScreen) { - measuredFooter[screen] = height - recomposeIdeal(for: screen) - } - - /// Sum a screen's measured parts into its ideal panel height. The dashboard shows no top bar; other - /// screens pin it to `topBarHeight`. A zero/absent scroll content leaves the ideal unset (not yet - /// measured), so the view keeps the controller's opening guess until a real measurement lands. + /// Sum a screen's measured content and fixed chrome into its ideal panel height. The dashboard + /// shows no top bar; Customize shows no footer. A zero/absent scroll content leaves the ideal unset + /// (not yet measured), so the view keeps the controller's opening guess until a real measurement + /// lands. private func recomposeIdeal(for screen: PopoverScreen) { guard let content = measuredScrollContent[screen], content > 0 else { return } let topBar: CGFloat = screen == .dashboard ? 0 : topBarHeight - let footer = measuredFooter[screen] ?? 0 + let footer: CGFloat = screen == .customize ? 0 : footerHeight measuredIdeal[screen] = topBar + footer + content } diff --git a/Sources/Runway/Views/PopoverFooter.swift b/Sources/Runway/Views/PopoverFooter.swift index 84053423b..1644c60b5 100644 --- a/Sources/Runway/Views/PopoverFooter.swift +++ b/Sources/Runway/Views/PopoverFooter.swift @@ -1,44 +1,48 @@ import SwiftUI -/// Fixed popover footer chrome: app identity, refresh status, dashboard actions, and copy confirmation. +/// Fixed popover footer chrome: a single compact line with the app identity, the next-update +/// countdown, and the gear options menu (plus the transient copy confirmation and pin-limit notice). /// It uses the destination screen so both pages mounted during a slide draw the same footer. +/// +/// The footer is a **fixed-height safe-area bar** (`pinnedFooter` in `DashboardView.screenView`): +/// the bar's position is re-derived from the per-frame layout of the animated panel frame, which is +/// what keeps it hugging the panel's bottom edge on every interpolated frame of a height morph. (A +/// bottom-aligned overlay on the height frame was tried and reverted — an overlay's position animates +/// as its own attribute, and when content changes land in one transaction with the height retarget in +/// a second, that spring runs phase-shifted and the footer visibly trails the moving edge.) struct PopoverFooter: View { let screen: PopoverScreen let layout: LayoutStore let dataStore: WidgetDataStore let horizontalPadding: CGFloat - let onHeightChange: (PopoverScreen, CGFloat) -> Void + /// The bar's fixed height — `DashboardView.footerHeight`, the same constant the height + /// coordinator sums into each screen's morph target and the scroll spacer reserves. + let height: CGFloat @Environment(\.popoverIsVisible) private var popoverIsVisible @ViewBuilder var body: some View { - Group { - if screen == .customize { - EmptyView() - } else { - HStack(alignment: .center, spacing: 8) { - footerIdentity - Spacer(minLength: 8) - HeaderView(screen: screen) - } + if screen != .customize { + HStack(alignment: .center, spacing: 10) { + footerIdentity + Spacer(minLength: 8) + nextUpdateButton + HeaderView(screen: screen) } - } - .padding(.horizontal, horizontalPadding) - .padding(.vertical, 12) - .frame(maxWidth: .infinity) - .barGlass() - .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.height } action: { height in - onHeightChange(screen, height) - } - .overlay(alignment: .top) { - if screen == .dashboard, layout.shareConfirmation { - shareCopiedPill - .offset(y: -34) + .padding(.horizontal, horizontalPadding) + .frame(height: height) + .frame(maxWidth: .infinity) + .barGlass() + .overlay(alignment: .top) { + if screen == .dashboard, layout.shareConfirmation { + shareCopiedPill + .offset(y: -34) + } } + .animation(Motion.spring, value: layout.shareConfirmation) + .animation(Motion.spring, value: layout.shareConfirmationTrigger) } - .animation(Motion.spring, value: layout.shareConfirmation) - .animation(Motion.spring, value: layout.shareConfirmationTrigger) } private var shareCopiedPill: some View { @@ -50,22 +54,26 @@ struct PopoverFooter: View { ) } + /// The leading identity line — the app version, swapped for the transient pin-limit notice while + /// one is showing. private var footerIdentity: some View { - VStack(alignment: .leading, spacing: 0) { - Text("Runway \(AppInfo.version)") + Group { if let notice = layout.pinLimitNotice { Text(notice) .foregroundStyle(Theme.notice) .denyShake(trigger: layout.pinNoticeShakeTrigger, shakeOnAppear: true) } else { - nextUpdateButton + Text("Runway \(AppInfo.version)") } } .font(.caption2) .foregroundStyle(.secondary) + .lineLimit(1) .animation(Motion.spring, value: layout.pinLimitNotice) } + /// The compact countdown — a small refresh glyph plus "3m" / "45s" until the next update, swapped + /// for a mini spinner while a refresh is in flight. Clicking it (or ⌘R) refreshes immediately. private var nextUpdateButton: some View { Button { refreshNow() @@ -88,15 +96,27 @@ struct PopoverFooter: View { } private func updateStatusLabel(now: Date) -> some View { - HStack(spacing: 5) { - Text(updateStatusText(now: now)) - .monospacedDigit() - .contentTransition(.numericText()) + HStack(spacing: 4) { if isUpdating { ProgressView() .controlSize(.mini) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 9, weight: .semibold)) + // The countdown must sit perfectly still: no rolling-digit transition, and a slot + // reserved for the widest value ("59s") so a tick that drops a digit can't nudge the + // glyph or the gear sideways. The text just swaps in place each second. + ZStack(alignment: .leading) { + Text("59s") + .monospacedDigit() + .hidden() + Text(countdownText(now: now)) + .monospacedDigit() + } } } + .font(.caption2) + .foregroundStyle(.secondary) } private var isUpdating: Bool { @@ -108,15 +128,14 @@ struct PopoverFooter: View { Task { await dataStore.refreshAll(force: true) } } - private func updateStatusText(now: Date) -> String { - if isUpdating { return "Updating…" } + private func countdownText(now: Date) -> String { let base = dataStore.lastRefreshAt ?? now let remaining = max(0, base.addingTimeInterval(RefreshSetting.interval).timeIntervalSince(now)) let totalSeconds = Int(remaining.rounded(.up)) if totalSeconds >= 60 { let minutes = Int((Double(totalSeconds) / 60).rounded(.up)) - return "Next update in \(minutes)m" + return "\(minutes)m" } - return "Next update in \(totalSeconds)s" + return "\(totalSeconds)s" } } diff --git a/Tests/RunwayTests/PanelHeightCoordinatorTests.swift b/Tests/RunwayTests/PanelHeightCoordinatorTests.swift index c22fbc009..dc4606b8a 100644 --- a/Tests/RunwayTests/PanelHeightCoordinatorTests.swift +++ b/Tests/RunwayTests/PanelHeightCoordinatorTests.swift @@ -2,40 +2,43 @@ import XCTest @testable import Runway /// The popover auto-fit height computation, now unit-testable after being split out of DashboardView: -/// each screen's ideal = (top bar unless dashboard) + footer + scroll content, and `target` clamps it. +/// each screen's ideal = (top bar unless dashboard) + (footer unless customize) + scroll content, and +/// `target` clamps it. @MainActor final class PanelHeightCoordinatorTests: XCTestCase { private let topBar: CGFloat = 44 + private let footer: CGFloat = 40 + + private func makeCoordinator() -> PanelHeightCoordinator { + PanelHeightCoordinator(topBarHeight: topBar, footerHeight: footer) + } func testDashboardIdealOmitsTopBar() { - let c = PanelHeightCoordinator(topBarHeight: topBar) + let c = makeCoordinator() c.setScrollContent(300, for: .dashboard) - c.setFooter(40, for: .dashboard) // Dashboard has no top bar: ideal = content + footer. - XCTAssertEqual(c.measuredIdeal[.dashboard], 340) + XCTAssertEqual(c.measuredIdeal[.dashboard], 300 + 40) } - func testOtherScreensIncludeTopBar() { - let c = PanelHeightCoordinator(topBarHeight: topBar) + func testCustomizeOmitsFooter() { + let c = makeCoordinator() c.setScrollContent(300, for: .customize) - c.setFooter(40, for: .customize) - // Customize/Settings pin the fixed top bar: ideal = topBar + footer + content. - XCTAssertEqual(c.measuredIdeal[.customize], 44 + 40 + 300) + // Customize pins the fixed top bar but shows no footer: ideal = topBar + content. + XCTAssertEqual(c.measuredIdeal[.customize], 44 + 300) } - func testFooterDefaultsToZeroUntilMeasured() { - let c = PanelHeightCoordinator(topBarHeight: topBar) + func testSettingsIncludesTopBarAndFooter() { + let c = makeCoordinator() c.setScrollContent(300, for: .settings) - XCTAssertEqual(c.measuredIdeal[.settings], 44 + 300) + // Settings pins both fixed bars: ideal = topBar + footer + content. + XCTAssertEqual(c.measuredIdeal[.settings], 44 + 40 + 300) } func testIdealUnsetUntilScrollContentMeasured() { - let c = PanelHeightCoordinator(topBarHeight: topBar) - // A footer measurement alone (no scroll content yet) leaves the ideal unset — the view keeps the - // controller's opening size until real content lands. - c.setFooter(40, for: .dashboard) + let c = makeCoordinator() XCTAssertNil(c.measuredIdeal[.dashboard]) - // A zero-height content measurement is also ignored. + // A zero-height content measurement is ignored — the view keeps the controller's opening size + // until real content lands. c.setScrollContent(0, for: .dashboard) XCTAssertNil(c.measuredIdeal[.dashboard]) } @@ -46,28 +49,28 @@ final class PanelHeightCoordinatorTests: XCTestCase { let previousClamp = MenuBarPopover.clampHeight defer { MenuBarPopover.clampHeight = previousClamp } - let c = PanelHeightCoordinator(topBarHeight: topBar) + let c = makeCoordinator() XCTAssertNil(c.target(for: .dashboard)) MenuBarPopover.clampHeight = nil c.setScrollContent(300, for: .dashboard) - // No clamp hook installed → target is the raw ideal. - XCTAssertEqual(c.target(for: .dashboard), 300) + // No clamp hook installed → target is the raw ideal (content + footer). + XCTAssertEqual(c.target(for: .dashboard), 340) // With the panel's hook installed, the target is floored and capped at both edges. MenuBarPopover.clampHeight = { min(max($0, 400), 600) } - XCTAssertEqual(c.target(for: .dashboard), 400) // 300 floored to the panel minimum + XCTAssertEqual(c.target(for: .dashboard), 400) // 340 floored to the panel minimum c.setScrollContent(900, for: .dashboard) - XCTAssertEqual(c.target(for: .dashboard), 600) // 900 capped at the screen max + XCTAssertEqual(c.target(for: .dashboard), 600) // 940 capped at the screen max c.setScrollContent(500, for: .dashboard) - XCTAssertEqual(c.target(for: .dashboard), 500) // in-range ideals pass through + XCTAssertEqual(c.target(for: .dashboard), 540) // in-range ideals pass through } func testLaterMeasurementRecomposesIdeal() { - let c = PanelHeightCoordinator(topBarHeight: topBar) + let c = makeCoordinator() c.setScrollContent(300, for: .dashboard) - XCTAssertEqual(c.measuredIdeal[.dashboard], 300) + XCTAssertEqual(c.measuredIdeal[.dashboard], 340) c.setScrollContent(500, for: .dashboard) // content grew (rows loaded) - XCTAssertEqual(c.measuredIdeal[.dashboard], 500) + XCTAssertEqual(c.measuredIdeal[.dashboard], 540) } } diff --git a/docs/dashboard.md b/docs/dashboard.md index 53914190f..9ba76f351 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -59,17 +59,17 @@ Provider headers: **Hide \ · Refresh \ · Customize…** Copy a clean, branded PNG of one provider's usage to your clipboard, ready to paste into a chat, a tweet, or a doc. There are two ways to reach it: - Right-click a provider header and choose **Share Screenshot**. -- Open the footer's **Options** menu and choose **Share Screenshot** ▸ *\*. The submenu lists every provider currently showing on the dashboard. +- Open the footer's **gear** menu and choose **Share Screenshot** ▸ *\*. The submenu lists every provider currently showing on the dashboard. The image is a flexible-height PNG using the app's look — the provider's mark and name up top, the metric rows you currently see for that provider, and a small Runway mark centered at the bottom. It follows your Light/Dark appearance and shows everything on the card as-is (nothing is hidden or blurred). ## Footer -The bar pinned to the bottom of the popover. On the left: the app version, and a live "Next update in …" countdown you can click (or press **⌘R**) to refresh right away. On the right: an **Options** menu button. It holds everything in one place — **Customize**, **Settings**, **Share Screenshot** (submenu of providers), **Check for Updates…**, **About Runway**, and **Quit Runway**. +The compact bar pinned to the bottom of the popover — it stays glued to the panel's bottom edge as the popover grows and shrinks. On the left: the app version. On the right: a live countdown to the next update (like `5m`) you can click (or press **⌘R**) to refresh right away, and a **gear** menu button. The gear holds everything in one place — **Customize**, **Settings**, **Share Screenshot** (submenu of providers), **Check for Updates…**, **About Runway**, and **Quit Runway**. ## Customize -Open Customize from the footer's **Options** menu (or press **Return**). It's a two-level screen: a list of providers, then a provider's detail. +Open Customize from the footer's **gear** menu (or press **Return**). It's a two-level screen: a list of providers, then a provider's detail. The **provider list** shows every provider with a switch to turn it on or off, a count of its metrics, and a chevron into its detail. Turn a provider off and it stays in the list, greyed — its metrics hide from the dashboard and menu bar but keep their setup for when you turn it back on. Drag enabled providers by their grip to reorder; tap a row to open its detail. On a fresh install only the providers detected on your Mac start on (see "First launch" above); this list is where you add the rest. diff --git a/docs/refreshing.md b/docs/refreshing.md index 83f521e2e..83bc0a0c2 100644 --- a/docs/refreshing.md +++ b/docs/refreshing.md @@ -4,9 +4,9 @@ - All enabled providers refresh together: once at launch, then every 5 minutes (a fixed cadence — there's no setting for it). Opening the popover does not start a second automatic pass. Providers fetch in parallel, so fast cards update without waiting for a slow one. The batch itself still finishes only after every provider returns; notifications, history sync, and the next five-minute wait begin after that point. - Turning a provider on (yourself in Customize, or automatically by first-launch/new-provider detection) fetches it promptly instead of waiting out the interval — even when the change lands in the middle of a refresh that's already running. -- The Dashboard and Settings footer shows `Next update in Nm`. **Clicking it (or pressing ⌘R while that footer is present)** refreshes immediately, skipping the cache. +- The Dashboard and Settings footer shows a compact countdown to the next update (like `5m` or `45s`). **Clicking it (or pressing ⌘R while that footer is present)** refreshes immediately, skipping the cache. - The one-shot `runway` command reuses this same persisted cache for five minutes, refreshes missing or stale entries without starting the app, and exits. `runway --force` runs the same forced provider refresh as ⌘R regardless of cache age. -- While a provider is fetching, a small spinner appears next to its name (and one shows in the footer beside the countdown), so you can tell a refresh is in flight rather than wondering if the numbers are stale. +- While a provider is fetching, a small spinner appears next to its name (and the footer countdown becomes one), so you can tell a refresh is in flight rather than wondering if the numbers are stale. - With [iCloud Sync](icloud-sync.md) on, a refresh batch writes one machine-history file after the whole batch finishes. Manual provider refreshes write after that provider finishes, and adjacent changes are debounced into one write. diff --git a/docs/settings.md b/docs/settings.md index 9cd0567fd..54fe005a3 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,6 +1,6 @@ # Settings -Settings lives inside the popover — there is no separate window. Open it from the footer's **Options** menu, with ⌘, while the popover is showing, or by right-clicking the menu bar icon and choosing Settings. The dashboard slides over to the Settings screen, which carries a back button in its top-left corner. Go back with that button, the ⌘, shortcut, or Esc (Esc always backs out to the dashboard first — pressing it again closes the popover). +Settings lives inside the popover — there is no separate window. Open it from the footer's **gear** menu, with ⌘, while the popover is showing, or by right-clicking the menu bar icon and choosing Settings. The dashboard slides over to the Settings screen, which carries a back button in its top-left corner. Go back with that button, the ⌘, shortcut, or Esc (Esc always backs out to the dashboard first — pressing it again closes the popover). ## General @@ -27,7 +27,7 @@ time; it also reports unavailable iCloud, loading, write, and malformed-file sta | Icon Style | Text / Bars | How starred metrics render in the menu bar. See [Menu bar](menu-bar.md). | | Theme | System / Light / Dark | App-wide appearance override for the popover. | | Time Format | Auto / 12-hour / 24-hour | How exact times read (e.g. "Resets today at 6:38 PM" vs "18:38"). Auto follows the system. | -| Increase Transparency | Off / On | Off (default) keeps the popover a solid panel. On makes it translucent so your desktop shows through, while keeping the numbers and Options control legible with adaptive frosted surfaces. It pauses automatically when you have the macOS **Reduce Transparency** or **Increase Contrast** accessibility setting turned on (a note explains why), so it never works against those preferences. | +| Increase Transparency | Off / On | Off (default) keeps the popover a solid panel. On makes it translucent so your desktop shows through, while keeping the numbers and footer controls legible with adaptive frosted surfaces. It pauses automatically when you have the macOS **Reduce Transparency** or **Increase Contrast** accessibility setting turned on (a note explains why), so it never works against those preferences. | ## Usage Display