Skip to content
4 changes: 3 additions & 1 deletion macos/Sources/OmarchyVMHelper/OmarchyLauncherControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ final class OmarchyActionButton: NSButton {
foreground = isPointerDown || isPointerInside
? OmarchyStartMenuTheme.background
: OmarchyStartMenuTheme.danger
border = OmarchyStartMenuTheme.danger
border = isPointerDown || isPointerInside
? OmarchyStartMenuTheme.danger
: OmarchyStartMenuTheme.lighterBackground
}
}

Expand Down
89 changes: 68 additions & 21 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
private var pendingResetSpaceEstimate: String?
private var resetConfirmationPrompt: ResetConfirmationPrompt?
private weak var startMenuScrollView: NSScrollView?
private var preferredContentHeight: CGFloat = 832
private(set) var portForwardingEditor: PortForwardingEditor?
private(set) var resourceEditor: VMResourceEditor?
private weak var immersiveCaption: NSTextField?
Expand Down Expand Up @@ -313,15 +314,22 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
}

func prepareForPresentation(visibleFrame: NSRect?) {
let scrollOffset = startMenuScrollView?.contentView.bounds.origin.y ?? 0
render()
if let visibleFrame {
// The resources row adds one 72pt row to the menu that previously
// fit at 760. At 690 the launch button cleared the bottom edge by
// 15pt, which any difference in system font metrics turned into a
// button clipped off the window; on displays shorter than the
// window the content scrolls rather than clips.
let availableHeight = max(480, visibleFrame.height - 32)
window.setContentSize(NSSize(width: 600, height: min(832, availableHeight)))
let availableContent = window.contentRect(
forFrameRect: visibleFrame.insetBy(dx: 0, dy: 16)
)
window.setContentSize(NSSize(
width: 600,
height: min(preferredContentHeight, max(1, availableContent.height))
))
content.layoutSubtreeIfNeeded()
if let scrollView = startMenuScrollView, let document = scrollView.documentView {
let maximumOffset = max(0, document.frame.height - scrollView.contentView.bounds.height)
scrollView.contentView.scroll(to: NSPoint(x: 0, y: min(scrollOffset, maximumOffset)))
scrollView.reflectScrolledClipView(scrollView.contentView)
}
}
}

Expand Down Expand Up @@ -708,12 +716,6 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
reset.heightAnchor.constraint(equalToConstant: 30).isActive = true
reset.widthAnchor.constraint(greaterThanOrEqualToConstant: 154).isActive = true

let resetViews: [NSView] = [reset]
let resetSection = NSStackView(views: resetViews)
resetSection.orientation = .vertical
resetSection.alignment = .centerX
resetSection.spacing = 4

let launchButtonTitle = launchInProgress ? "Launching Omarchy…" : "Launch Omarchy"
let launchButton = OmarchyActionButton(
title: launchButtonTitle,
Expand Down Expand Up @@ -745,14 +747,48 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
launchButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 500),
])

let resetHeading = sectionHeading("RESET")
let resetSymbol = NSImageView()
resetSymbol.image = NSImage(systemSymbolName: "arrow.counterclockwise", accessibilityDescription: nil)
resetSymbol.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 19, weight: .medium)
resetSymbol.contentTintColor = OmarchyStartMenuTheme.accent
resetSymbol.identifier = NSUserInterfaceItemIdentifier("reset-symbol")
resetSymbol.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
resetSymbol.widthAnchor.constraint(equalToConstant: 26),
resetSymbol.heightAnchor.constraint(equalToConstant: 26),
])
let resetTitle = NSTextField(labelWithString: "Factory reset")
resetTitle.font = .monospacedSystemFont(ofSize: 13, weight: .bold)
resetTitle.textColor = OmarchyStartMenuTheme.foreground
let resetDetail = NSTextField(wrappingLabelWithString: "Erase this VM and return it to factory settings.")
resetDetail.font = .monospacedSystemFont(ofSize: 10, weight: .regular)
resetDetail.textColor = OmarchyStartMenuTheme.muted
resetDetail.maximumNumberOfLines = 2
let resetLabels = NSStackView(views: [resetTitle, resetDetail])
resetLabels.orientation = .vertical
resetLabels.alignment = .leading
resetLabels.spacing = 3
resetLabels.setContentHuggingPriority(.defaultLow, for: .horizontal)
resetLabels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
resetDetail.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
resetDetail.trailingAnchor.constraint(lessThanOrEqualTo: resetLabels.trailingAnchor).isActive = true
let resetRow = NSStackView(views: [resetSymbol, resetLabels, reset])
resetRow.orientation = .horizontal
resetRow.alignment = .centerY
resetRow.spacing = 12
resetRow.translatesAutoresizingMaskIntoConstraints = false
resetRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).isActive = true
let resetCard = themedCard(containing: resetRow, identifier: "reset-card")

let stack = NSStackView(views: [
headingStack,
permissionHeading,
permissionCard,
integrationHeading,
integrationCard,
launchButton,
resetSection,
resetHeading,
resetCard,
])
stack.orientation = .vertical
stack.alignment = .leading
Expand All @@ -761,10 +797,16 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
stack.setCustomSpacing(6, after: permissionHeading)
stack.setCustomSpacing(16, after: permissionCard)
stack.setCustomSpacing(6, after: integrationHeading)
stack.setCustomSpacing(32, after: integrationCard)
stack.setCustomSpacing(24, after: launchButton)
stack.setCustomSpacing(6, after: resetHeading)
stack.translatesAutoresizingMaskIntoConstraints = false

let actions = NSStackView(views: [launchButton])
actions.orientation = .vertical
actions.alignment = .leading
actions.spacing = 12
actions.identifier = NSUserInterfaceItemIdentifier("start-menu-actions")
actions.translatesAutoresizingMaskIntoConstraints = false

let document = StartMenuDocumentView()
document.translatesAutoresizingMaskIntoConstraints = false
document.addSubview(stack)
Expand All @@ -779,27 +821,32 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
scrollView.identifier = NSUserInterfaceItemIdentifier("start-menu-scroll")
scrollView.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(scrollView)
content.addSubview(actions)
startMenuScrollView = scrollView

NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: content.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: content.bottomAnchor),
scrollView.bottomAnchor.constraint(equalTo: actions.topAnchor, constant: -12),
actions.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 42),
actions.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -42),
actions.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20),
document.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
document.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.contentView.heightAnchor),
stack.leadingAnchor.constraint(equalTo: document.leadingAnchor, constant: 42),
stack.trailingAnchor.constraint(equalTo: document.trailingAnchor, constant: -42),
stack.topAnchor.constraint(equalTo: document.topAnchor, constant: 26),
stack.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -32),
stack.bottomAnchor.constraint(equalTo: document.bottomAnchor),
permissionCard.widthAnchor.constraint(equalTo: stack.widthAnchor),
integrationCard.widthAnchor.constraint(equalTo: stack.widthAnchor),
resetSection.widthAnchor.constraint(equalTo: stack.widthAnchor),
launchButton.widthAnchor.constraint(equalTo: stack.widthAnchor),
resetCard.widthAnchor.constraint(equalTo: stack.widthAnchor),
launchButton.widthAnchor.constraint(equalTo: actions.widthAnchor),
])

content.layoutSubtreeIfNeeded()
document.layoutSubtreeIfNeeded()
preferredContentHeight = ceil(stack.fittingSize.height + actions.fittingSize.height + 58)
let maximumOffset = max(
0,
document.frame.height - scrollView.contentView.bounds.height
Expand Down
44 changes: 40 additions & 4 deletions macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,49 @@ struct StartMenuWindowWidthTests {
try expectDetailsFit(["permission-detail-externaldrive"], in: menu)
}

@Test("Launch stays fixed while Reset follows integrations in the scrolling settings")
func launchStaysOutsideSettings() throws {
_ = NSApplication.shared
let menu = makeMenu(storageState: { StorageLocationMenuState(
containerPath: nil, stateRoot: nil, displayPath: "Default location",
volumeName: nil, isDefault: true, isExternal: false,
problem: nil, warning: nil, isEnvironmentOverride: false
) }, permissionsGranted: false)
defer { menu.dismiss() }
menu.prepareForPresentation(visibleFrame: nil)
let content = try #require(menu.window.contentView)
let scroll = try #require(descendant(withIdentifier: "start-menu-scroll", in: content))
let actions = try #require(descendant(withIdentifier: "start-menu-actions", in: content) as? NSStackView)
let launch = try #require(descendant(withIdentifier: "launch-button", in: actions) as? NSButton)
let resetCard = try #require(descendant(withIdentifier: "reset-card", in: scroll))
let reset = try #require(descendant(withIdentifier: "reset-button", in: resetCard) as? NSButton)
let settings = try #require(resetCard.superview as? NSStackView)
let integrations = try #require(descendant(withIdentifier: "integration-card", in: scroll))
let integrationIndex = try #require(settings.arrangedSubviews.firstIndex(of: integrations))
let resetIndex = try #require(settings.arrangedSubviews.firstIndex(of: resetCard))

#expect(scroll.superview === content)
#expect(actions.superview === content)
#expect(descendant(withIdentifier: "launch-button", in: scroll) == nil)
#expect(descendant(withIdentifier: "reset-button", in: actions) == nil)
#expect(descendant(withIdentifier: "permission-card", in: scroll) != nil)
#expect(descendant(withIdentifier: "integration-card", in: scroll) != nil)
#expect(actions.arrangedSubviews.first === launch)
#expect(actions.arrangedSubviews.count == 1)
#expect(resetIndex > integrationIndex)
#expect(launch.keyEquivalent == "\r")
#expect(launch.isEnabled)
#expect(reset.isEnabled)
}

private func makeMenu(
storageState: @escaping () -> StorageLocationMenuState
storageState: @escaping () -> StorageLocationMenuState,
permissionsGranted: Bool = true
) -> StartMenuWindow {
StartMenuWindow(
accessibilityStatus: { true },
microphoneStatus: { .authorized },
cameraStatus: { .authorized },
accessibilityStatus: { permissionsGranted },
microphoneStatus: { permissionsGranted ? .authorized : .notDetermined },
cameraStatus: { permissionsGranted ? .authorized : .notDetermined },
requestAccessibility: {},
requestMicrophone: { completion in completion(true) },
requestCamera: { completion in completion(true) },
Expand Down
Loading