Skip to content
Open
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
70 changes: 41 additions & 29 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,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 @@ -333,15 +334,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 @@ -711,12 +719,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 @@ -769,12 +771,17 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
footer.allowsEditingTextAttributes = true
footer.translatesAutoresizingMaskIntoConstraints = false

let footerContainer = NSView()
footerContainer.addSubview(footer)
footer.identifier = NSUserInterfaceItemIdentifier("start-menu-attribution")
let secondaryActions = NSView()
secondaryActions.addSubview(reset)
secondaryActions.addSubview(footer)
NSLayoutConstraint.activate([
footer.centerXAnchor.constraint(equalTo: footerContainer.centerXAnchor),
footer.topAnchor.constraint(equalTo: footerContainer.topAnchor),
footer.bottomAnchor.constraint(equalTo: footerContainer.bottomAnchor),
reset.centerXAnchor.constraint(equalTo: secondaryActions.centerXAnchor),
reset.topAnchor.constraint(equalTo: secondaryActions.topAnchor),
reset.bottomAnchor.constraint(equalTo: secondaryActions.bottomAnchor),
footer.trailingAnchor.constraint(equalTo: secondaryActions.trailingAnchor),
footer.centerYAnchor.constraint(equalTo: reset.centerYAnchor),
footer.leadingAnchor.constraint(greaterThanOrEqualTo: reset.trailingAnchor, constant: 12),
])

let stack = NSStackView(views: [
Expand All @@ -783,9 +790,6 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
permissionCard,
integrationHeading,
integrationCard,
resetSection,
launchButton,
footerContainer,
])
stack.orientation = .vertical
stack.alignment = .leading
Expand All @@ -794,11 +798,15 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
stack.setCustomSpacing(6, after: permissionHeading)
stack.setCustomSpacing(16, after: permissionCard)
stack.setCustomSpacing(6, after: integrationHeading)
stack.setCustomSpacing(12, after: integrationCard)
stack.setCustomSpacing(12, after: resetSection)
stack.setCustomSpacing(8, after: launchButton)
stack.translatesAutoresizingMaskIntoConstraints = false

let actions = NSStackView(views: [launchButton, secondaryActions])
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 @@ -813,28 +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: -20),
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),
footerContainer.widthAnchor.constraint(equalTo: stack.widthAnchor),
secondaryActions.widthAnchor.constraint(equalTo: actions.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
58 changes: 54 additions & 4 deletions macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,63 @@ struct StartMenuWindowWidthTests {
try expectDetailsFit(["permission-detail-externaldrive"], in: menu)
}

@Test("Launch stays visible without scrolling on short and tall displays")
func actionsStayVisible() throws {
_ = NSApplication.shared
for screenHeight: CGFloat in [500, 700, 900, 1400] {
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() }
let screen = NSRect(x: 0, y: 0, width: 1440, height: screenHeight)
menu.prepareForPresentation(visibleFrame: screen)
let content = try #require(menu.window.contentView)
content.layoutSubtreeIfNeeded()
let scroll = try #require(descendant(withIdentifier: "start-menu-scroll", in: content) as? NSScrollView)
let launch = try #require(descendant(withIdentifier: "launch-button", in: content) as? NSButton)
let reset = try #require(descendant(withIdentifier: "reset-button", in: content))
let launchFrame = launch.convert(launch.bounds, to: content)
let resetFrame = reset.convert(reset.bounds, to: content)
let attribution = try #require(descendant(withIdentifier: "start-menu-attribution", in: content))
let attributionFrame = try #require(attribution.superview).convert(
attribution.alignmentRect(forFrame: attribution.frame), to: content
)
#expect(abs(resetFrame.midX - launchFrame.midX) < 0.5)
#expect(abs(attributionFrame.maxX - launchFrame.maxX) < 0.5)
// AppKit can round an odd-height label to a half-point center offset.
#expect(abs(attributionFrame.midY - resetFrame.midY) <= 0.5)
#expect(attributionFrame.minX > resetFrame.maxX)
#expect(content.bounds.contains(launchFrame))
#expect(content.bounds.contains(resetFrame))
#expect(launchFrame.minY > resetFrame.maxY)
#expect(descendant(withIdentifier: "launch-button", in: scroll) == nil)
#expect(launch.keyEquivalent == "\r")
#expect(menu.window.frame.height <= screen.height - 32 + 0.5)
let document = try #require(scroll.documentView)
#expect(scroll.contentView.bounds.origin.y == 0)
if screenHeight == 1400 {
#expect(document.frame.height <= scroll.contentView.bounds.height + 0.5)
#expect(content.bounds.height > 832)
} else if screenHeight == 500 {
#expect(document.frame.height > scroll.contentView.bounds.height)
}
let originalLaunchFrame = launchFrame
scroll.contentView.scroll(to: NSPoint(x: 0, y: max(0, document.frame.height - scroll.contentView.bounds.height)))
scroll.reflectScrolledClipView(scroll.contentView)
#expect(launch.convert(launch.bounds, to: content) == originalLaunchFrame)
}
}

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