Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/Runway/App/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Runway/Support/LiquidGlassFallbacks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions Sources/Runway/Support/PopoverDismissReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/Runway/Views/ClosureMenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions Sources/Runway/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
)
}
}

Expand Down
42 changes: 19 additions & 23 deletions Sources/Runway/Views/HeaderView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
35 changes: 16 additions & 19 deletions Sources/Runway/Views/PanelHeightCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
Loading