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
52 changes: 52 additions & 0 deletions herdi-mac/Sources/HerdiMacApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ class HerdiAppDelegate: NSObject, NSApplicationDelegate {
paused.isEnabled = false
menu.addItem(paused)
}
menu.addItem(textSizeItem())

menu.addItem(.separator())

Expand Down Expand Up @@ -244,6 +245,57 @@ class HerdiAppDelegate: NSObject, NSApplicationDelegate {
rebuildMenu()
}

// MARK: - Text size

private func textSizeItem() -> NSMenuItem {
let scale = TextSize.current
let item = NSMenuItem(title: "Text Size: \(TextSize.percent(scale))", action: nil, keyEquivalent: "")
item.image = NSImage(systemSymbolName: "textformat.size", accessibilityDescription: nil)

let submenu = NSMenu()
let larger = NSMenuItem(title: "Larger", action: #selector(increaseTextSize), keyEquivalent: "=")
larger.target = self
submenu.addItem(larger)

let smaller = NSMenuItem(title: "Smaller", action: #selector(decreaseTextSize), keyEquivalent: "-")
smaller.target = self
submenu.addItem(smaller)

submenu.addItem(.separator())

for step in TextSize.steps {
let preset = NSMenuItem(title: TextSize.percent(step), action: #selector(selectTextSize(_:)), keyEquivalent: "")
preset.target = self
preset.representedObject = step
preset.state = step == scale ? .on : .off
submenu.addItem(preset)
}

item.submenu = submenu
return item
}

@objc private func increaseTextSize() {
guard let next = TextSize.larger else { return }
applyTextSize(next)
}

@objc private func decreaseTextSize() {
guard let previous = TextSize.smaller else { return }
applyTextSize(previous)
}

@objc private func selectTextSize(_ sender: NSMenuItem) {
guard let step = sender.representedObject as? Double else { return }
applyTextSize(step)
}

private func applyTextSize(_ scale: Double) {
TextSize.current = scale
panelController?.reloadLayout()
rebuildMenu()
}

@objc private func toggleLaunchAtLogin() {
let current = UserDefaults.standard.bool(forKey: "launchAtLogin")
let newValue = !current
Expand Down
59 changes: 58 additions & 1 deletion herdi-mac/Sources/NotchContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ private enum HoverTiming {
static let prehoverScale: CGFloat = 1.003
}

/// The height the expanded content asks for before it is scaled.
private struct ContentHeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}

// MARK: - NotchPanelView (root view inside the panel)

struct NotchPanelView: View {
Expand All @@ -22,10 +30,16 @@ struct NotchPanelView: View {
let notchHeight: CGFloat
let notchW: CGFloat
let screenWidth: CGFloat
/// How much larger than drawn the content below the notch is shown. 1 leaves everything as
/// laid out; the notch bar itself is never scaled, so it keeps lining up with the hardware.
let uiScale: CGFloat

@State private var hoverPhase: HoverPhase = .collapsed
@State private var hoverTimer: Timer?
@State private var isHovered = false
/// Height the expanded content asks for at scale 1, so the scaled copy can be given the room
/// it actually takes. Nil until the first measurement, when the frame is left unconstrained.
@State private var contentHeight: CGFloat?

private var blocked: [Agent] { relay.agents.filter { $0.status == .blocked } }
private var working: [Agent] { relay.agents.filter { $0.status == .working } }
Expand All @@ -38,7 +52,7 @@ struct NotchPanelView: View {

/// Panel width adapts to state
private var panelWidth: CGFloat {
let maxWidth = min(580, screenWidth - 40)
let maxWidth = min(580 * uiScale, screenWidth - 40)
if !isActive { return notchW + 60 }
if shouldShowExpanded { return maxWidth }
// Collapsed: notch width + wings for status indicators
Expand Down Expand Up @@ -66,9 +80,13 @@ struct NotchPanelView: View {
}
)
.frame(height: notchHeight)
.contentShape(Rectangle())
.onTapGesture(perform: toggleFromBar)
} else {
IdleBar(relay: relay, notchHeight: notchHeight)
.frame(height: notchHeight)
.contentShape(Rectangle())
.onTapGesture(perform: toggleFromBar)
}

// Expanded content below notch
Expand All @@ -78,6 +96,20 @@ struct NotchPanelView: View {
.padding(.horizontal, 12)

expandedContent
// Lay the content out at the panel's own width, then magnify the finished
// layout: text wraps where it would unscaled, and every size grows by the
// same factor rather than only those the type system reaches.
.frame(width: panelWidth / uiScale)
.background(
GeometryReader { proxy in
Color.clear.preference(key: ContentHeightKey.self, value: proxy.size.height)
}
)
.scaleEffect(uiScale, anchor: .top)
.frame(width: panelWidth, height: contentHeight.map { $0 * uiScale })
.onPreferenceChange(ContentHeightKey.self) { height in
if height > 0 { contentHeight = height }
}
.transition(.blurFade.combined(with: .move(edge: .top)))
}
}
Expand Down Expand Up @@ -140,6 +172,31 @@ struct NotchPanelView: View {
}
}

// MARK: - Click Logic

/// A click on the bar opens the panel at once, or closes it when it is open. The hover timer
/// is cancelled either way, so a pending hover does not reopen what the click just closed.
private func toggleFromBar() {
hoverTimer?.invalidate()
switch controller.surface {
case .collapsed:
hoverPhase = .expanded
withAnimation(NotchAnimation.open) { controller.surface = .sessionList }
case .sessionList:
hoverPhase = .collapsed
// A click on the bar is a deliberate close, so remember what is blocked right now.
// Otherwise the poll pops the panel back up as an approval card within a second and
// the click looks like it did nothing. A later question still opens: the mark is
// keyed on the prompt the agent is showing.
for agent in blocked { controller.dismissedBlocked[agent.id] = agent.prompt ?? "" }
withAnimation(NotchAnimation.close) { controller.surface = .collapsed }
case .approval(let agentId):
hoverPhase = .collapsed
controller.collapse(dismissing: agentId,
prompt: relay.agents.first(where: { $0.id == agentId })?.prompt)
}
}

// MARK: - Hover Logic

private func handleHover(_ hovering: Bool) {
Expand Down
40 changes: 27 additions & 13 deletions herdi-mac/Sources/NotchPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,26 +266,29 @@ final class PanelWindowController: NSObject, NSWindowDelegate, ObservableObject
}
}

/// The window has to grow with the text, or the larger steps would be cut off by its edge.
/// Both sides stay inside the screen, so the panel never reaches past it.
private func panelSize(for screen: NSScreen) -> NSSize {
let maxH: CGFloat = 420
let width = min(580, screen.frame.width - 40)
let scale = TextSize.current
let maxH = min(420 * scale, screen.frame.height - ScreenDetector.topBarHeight(for: screen))
let width = min(580 * scale, screen.frame.width - 40)
return NSSize(width: width, height: maxH)
}

private func makeHostingView(for screen: NSScreen) -> NotchHostingView<NotchPanelView> {
let hasNotch = ScreenDetector.hasNotch(screen)
let notchH = ScreenDetector.topBarHeight(for: screen)
let notchW = ScreenDetector.notchWidth(for: screen)

let rootView = NotchPanelView(
private func makeRootView(for screen: NSScreen) -> NotchPanelView {
NotchPanelView(
relay: relay,
controller: self,
hasNotch: hasNotch,
notchHeight: notchH,
notchW: notchW,
screenWidth: screen.frame.width
hasNotch: ScreenDetector.hasNotch(screen),
notchHeight: ScreenDetector.topBarHeight(for: screen),
notchW: ScreenDetector.notchWidth(for: screen),
screenWidth: screen.frame.width,
uiScale: TextSize.current
)
let view = NotchHostingView(rootView: rootView)
}

private func makeHostingView(for screen: NSScreen) -> NotchHostingView<NotchPanelView> {
let view = NotchHostingView(rootView: makeRootView(for: screen))
view.sizingOptions = []
view.translatesAutoresizingMaskIntoConstraints = true
return view
Expand All @@ -300,6 +303,17 @@ final class PanelWindowController: NSObject, NSWindowDelegate, ObservableObject
panel.setFrame(NSRect(x: x, y: y, width: size.width, height: size.height), display: true)
}

/// Hand the view tree the new scale and resize the window around it.
///
/// The root view is replaced in place rather than the whole hosting view: a new hosting view
/// drops the panel's SwiftUI state, and a hover timer already scheduled against the old tree
/// would then fire against state nothing is showing.
func reloadLayout() {
guard let hostingView else { return }
hostingView.rootView = makeRootView(for: ScreenDetector.preferredScreen)
updatePosition()
}

private func handleScreenChange() {
let screen = ScreenDetector.preferredScreen
let contentView = makeHostingView(for: screen)
Expand Down
39 changes: 39 additions & 0 deletions herdi-mac/Sources/TextSize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

/// How much larger than its drawn size the expanded panel is shown.
///
/// The notch bar is left alone: its height and width come from the screen, and a bar that no
/// longer lines up with the physical notch looks broken. Only the content below the divider is
/// scaled, together with the window that holds it, so nothing is clipped at the larger steps.
///
/// The scale multiplies the laid-out content rather than each font size. The panel's type sizes
/// are literals in dozens of places, and `Dynamic Type` does not reach `Font.system(size:)`, so
/// magnifying the finished layout is the one way to resize all of it consistently.
enum TextSize {
private static let key = "herdi_ui_scale"

static let `default`: Double = 1.2
static let steps: [Double] = stride(from: 1.0, through: 2.0, by: 0.1).map { onStep($0) }

/// Stored scale, rounded to a step and clamped to the range the menu offers. A value written
/// by hand with `defaults write` cannot push the panel off the screen.
static var current: Double {
get {
let stored = UserDefaults.standard.double(forKey: key)
guard stored > 0 else { return `default` }
return min(max(onStep(stored), steps.first ?? 1.0), steps.last ?? 2.0)
}
set { UserDefaults.standard.set(onStep(newValue), forKey: key) }
}

/// The next step up, or nil at the largest.
static var larger: Double? { steps.first { $0 > current } }

/// The next step down, or nil at the smallest.
static var smaller: Double? { steps.last { $0 < current } }

static func percent(_ scale: Double) -> String { "\(Int((scale * 100).rounded()))%" }

/// One decimal, so a stored 1.2000000000000002 still matches the 1.2 step.
private static func onStep(_ scale: Double) -> Double { (scale * 10).rounded() / 10 }
}
Loading