diff --git a/CHANGELOG.md b/CHANGELOG.md index f00a0e2..dba1f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ The appcast generator reads this file. Each release is a `## ` heading, and everything under it until the next heading becomes that version's release notes in the update feed. +## Unreleased + +- Remember Focus Mode globally across launches and apply toggles to all open simulators. + New devices follow the shared preference; normal window frames remain per device. + Previously enabled per-device focus preferences migrate automatically. + +- Window → Focus Mode (⇧⌘F) hides the header and fits the device into the desktop, + without entering full screen. Toggle it off to restore the previous window; ⌥⌘-drag + moves the focused window. Menus, device shortcuts and the separate bezel toggle remain available. + ## 0.2.0 - The camera control works on every iPhone that has one, not only the iPhone Duo, and Device, diff --git a/README.md b/README.md index b186aca..feb3cef 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,17 @@ options. +## Focus Mode + +Choose **Window → Focus Mode** (⇧⌘F) to hide the header and fill the available desktop +without entering macOS full screen. The phone keeps its proportions, with a small resize +margin. Toggle it again to restore the previous window. Hold ⌥⌘ and drag to move the +window; device controls remain in the menus and their usual keyboard shortcuts work. +Hide device bezels separately with ⌘B for more screen space. Focus Mode is shared by all simulator windows and remembered across launches. +Toggling it updates all open devices; new devices follow the same preference. Each +device keeps its own normal window frame for toggling back. Focus Mode uses fit sizing; leave +it before choosing a fixed scale preset. + ## Current limitations - **The windows are invisible to accessibility**: VoiceOver, window managers and scripting tools see the app and its menus but not its windows. diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 2efb26f..74e5b4d 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -256,6 +256,9 @@ struct ODHubViewer: ParsableCommand { guard let target = front() else { return } target.controller.setKeepOnTop(!target.controller.isKeptOnTop) }, + toggleFocusMode: { + manager.toggleFocusMode() + }, pasteToDevice: { guard let target = front(), let text = NSPasteboard.general.string(forType: .string) else { return } @@ -522,6 +525,7 @@ struct ODHubViewer: ParsableCommand { case .latencyOverlay: return controller.isLatencyOverlayVisible case .bezel: return controller.isBezelEnabled case .keepOnTop: return controller.isKeptOnTop + case .focusMode: return manager.isFocused case .increasedContrast: if let known = controller.increasesContrast { return known } let read = SimctlService().increasesContrast(udid: target.udid) diff --git a/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift b/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift index 961a0ba..21cf920 100644 --- a/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift +++ b/engine/Sources/OpenDeviceHubViewer/DevicePresentationView.swift @@ -10,6 +10,9 @@ public final class DevicePresentationView: NSView { public let model: DuoModelView? private var isFullScreen = false + public var isFocused = false { + didSet { bar.isHidden = isFocused; needsLayout = true } + } public init(bar: DeviceControlBar, chrome: DeviceChromeView, model: DuoModelView? = nil) { self.bar = bar @@ -37,7 +40,7 @@ public final class DevicePresentationView: NSView { } public var currentLayout: PresentationLayout { - PresentationLayout(contentSize: bounds.size, isFullScreen: isFullScreen) + PresentationLayout(contentSize: bounds.size, isFullScreen: isFullScreen, isFocused: isFocused) } /// The content covers the whole window, including the few points AppKit keeps at the edges for diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift index c91298c..f8888c4 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindow.swift @@ -14,7 +14,16 @@ final class DeviceWindow: NSWindow { /// A drag that starts on one of the device's corners resizes the window, keeping the device's /// shape. AppKit's own edge resize still works; this is the handle that is easy to hit. + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + override func sendEvent(_ event: NSEvent) { + if event.type == .leftMouseDown, + event.modifierFlags.contains([.command, .option]), + (contentView as? DevicePresentationView)?.isFocused == true { + performDrag(with: event) + return + } if resize != nil { switch event.type { case .leftMouseDragged: diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 92e9248..fc3b993 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -65,6 +65,40 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate private var parallelOffset = CGSize(width: 0.12, height: 0) private var dragEdge: TouchEvent.Edge = .none private var orientation: DeviceOrientation = .portrait + public private(set) var isFocused = false + private var frameBeforeFocus: CGRect? + private var styleBeforeFocus: NSWindow.StyleMask? + private var toolbarBeforeFocus: NSToolbar? + + /// A reversible, ordinary desktop window with no header and only its resize margin. + public func setFocused(_ focused: Bool, activate: Bool = true) { + guard let window, focused != isFocused, + !window.styleMask.contains(.fullScreen) else { return } + isFocused = focused + presentationView.isFocused = focused + if focused { + frameBeforeFocus = window.frame + frameStore.save(uprightFrame(of: window), for: frameKey) + styleBeforeFocus = window.styleMask + toolbarBeforeFocus = window.toolbar + window.toolbar = nil + window.styleMask = [.borderless, .resizable, .miniaturizable] + window.contentResizeIncrements = NSSize(width: 1, height: 1) + if let visible = (window.screen ?? NSScreen.main)?.visibleFrame { + window.setFrame(visible, display: true) + } + } else { + if let styleBeforeFocus { window.styleMask = styleBeforeFocus } + window.toolbar = toolbarBeforeFocus + toolbarBeforeFocus = nil + if let frameBeforeFocus { window.setFrame(frameBeforeFocus, display: true) } + frameBeforeFocus = nil + styleBeforeFocus = nil + } + if activate { window.makeKeyAndOrderFront(nil) } + presentationView.needsLayout = true + } + private var scaleMode: ScaleMode private let deviceName: String private var pendingSend: Task? @@ -352,6 +386,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate @discardableResult public func applyScaleMode(_ mode: ScaleMode) -> ScaleApplication { scaleMode = mode + if isFocused { presentationView.needsLayout = true; return .noFixedSize } guard let window else { return .unavailable } let device = DeviceMetrics( pixelSize: session.pixelSize, @@ -626,7 +661,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate /// Fit mode has no size of its own, so a sideways turn reshapes the window itself, keeping the /// device at the size it is drawn. A foldable's viewport is never reshaped. private func reshape(from previous: DeviceOrientation) { - guard scaleMode == .fit, modelView == nil, let window, + guard !isFocused, scaleMode == .fit, modelView == nil, let window, !window.styleMask.contains(.fullScreen) else { return } let content = PresentationLayout.contentSizeTurned( from: window.contentRect(forFrameRect: window.frame).size, @@ -690,6 +725,9 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate // A unified toolbar makes the title bar taller, which would otherwise come out of the // device's own height, so the window is sized again now that it is there. applyScaleMode(scaleMode) + // Restore after toolbar installation and normal window placement, so leaving Focus Mode + // has a complete toolbar and a normal frame to return to. + if frameStore.isFocused { setFocused(true) } } /// Offers a finished recording for dragging out of the window. @@ -698,7 +736,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate } public func rememberFrame() { - guard tracksFrameChanges, + guard !isFocused, tracksFrameChanges, let window, !window.styleMask.contains(.fullScreen) else { return } frameStore.save(uprightFrame(of: window), for: frameKey) @@ -736,6 +774,7 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate (window as? DeviceWindow)?.constrainsToScreen = false presentationView.setFullScreen(false) toolbar?.setFoldModes(visible: false) + setFocused(frameStore.isFocused, activate: false) // The device may have been turned while full screen, where the window is not resized, so // the shape it comes back to is whatever the current orientation asks for. applyScaleMode(scaleMode) diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index c4bc9aa..1fa1c34 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -30,6 +30,18 @@ public final class DeviceWindowManager { public var openCount: Int { controllers.count } + public var isFocused: Bool { frameStore.isFocused } + + public func toggleFocusMode() { + let focused = !isFocused + frameStore.setFocused(focused) + let front = NSApp.keyWindow + for controller in controllers.values { + controller.setFocused(focused, activate: false) + } + front?.makeKey() + } + /// Current screenshot preference, read by per-window toolbar actions at invocation time. public var savesScreenshotsToClipboard: Bool { settings.savesScreenshotsToClipboard } diff --git a/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift b/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift index 3558519..9486784 100644 --- a/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift +++ b/engine/Sources/OpenDeviceHubViewer/PresentationLayout.swift @@ -49,9 +49,18 @@ public struct PresentationLayout: Equatable { /// Full screen hands the window to AppKit, which puts its own chrome at the top, so the bar /// squares off and the device takes the rest. - public init(contentSize: CGSize, isFullScreen: Bool = false) { + public init(contentSize: CGSize, isFullScreen: Bool = false, isFocused: Bool = false) { let width = max(contentSize.width, 0) let height = max(contentSize.height, 0) + if isFocused { + bar = .zero + cornerRadius = 0 + let border = Self.resizeBorder + device = CGRect(x: border, y: border, + width: max(width - border * 2, 0), + height: max(height - border * 2, 0)) + return + } bar = CGRect( x: 0, y: max(height - Self.barHeight, 0), diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 16c7b7f..ea8e1cf 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -32,12 +32,14 @@ public enum ViewerMenu { case latencyOverlay case bezel case keepOnTop + case focusMode } public struct Actions { public var setScaleMode: (ScaleMode) -> Void public var toggleBezel: () -> Void public var toggleKeepOnTop: () -> Void + public var toggleFocusMode: () -> Void public var pasteToDevice: () -> Void public var toggleAppearance: () -> Void public var saveScreenshot: () -> Void @@ -88,6 +90,7 @@ public enum ViewerMenu { setScaleMode: @escaping (ScaleMode) -> Void, toggleBezel: @escaping () -> Void, toggleKeepOnTop: @escaping () -> Void, + toggleFocusMode: @escaping () -> Void = {}, pasteToDevice: @escaping () -> Void, toggleAppearance: @escaping () -> Void, saveScreenshot: @escaping () -> Void, @@ -134,6 +137,7 @@ public enum ViewerMenu { self.setScaleMode = setScaleMode self.toggleBezel = toggleBezel self.toggleKeepOnTop = toggleKeepOnTop + self.toggleFocusMode = toggleFocusMode self.pasteToDevice = pasteToDevice self.toggleAppearance = toggleAppearance self.saveScreenshot = saveScreenshot @@ -457,6 +461,9 @@ public enum ViewerMenu { windowMenu.addItem(.separator()) windowMenu.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") .keyEquivalentModifierMask = [.control, .command] + let focus = target.item("Focus Mode", #selector(MenuTarget.focusMode), "f", [.command, .shift]) + target.track(focus, as: .focusMode) + windowMenu.addItem(focus) let bezel = target.item("Show Device Bezels", #selector(MenuTarget.bezel), "b", []) target.track(bezel, as: .bezel) windowMenu.addItem(bezel) @@ -689,6 +696,7 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { ]) } + @objc func focusMode() { actions.toggleFocusMode() } @objc func bezel() { actions.toggleBezel() } @objc func keepOnTop() { actions.toggleKeepOnTop() } @objc func saveScreenshot() { actions.saveScreenshot() } diff --git a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift index f71f90b..ee497bf 100644 --- a/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift +++ b/engine/Sources/OpenDeviceHubViewer/WindowFrameStore.swift @@ -73,17 +73,36 @@ public struct WindowFrameStore: Sendable { storage.setText(WindowFrameCodec.encode(frame), forKey: prefix + udid) } + /// Shared across devices. Migrate an enabled legacy device preference once. + public var isFocused: Bool { + let key = prefix + "focus.enabled" + if let saved = storage.text(forKey: key) { return saved == "true" } + let enabled = storage.keys(withPrefix: prefix + "focus.").contains { + storage.text(forKey: $0) == "true" + } + setFocused(enabled) + return enabled + } + + public func setFocused(_ focused: Bool) { + for key in storage.keys(withPrefix: prefix + "focus.") { + storage.removeText(forKey: key) + } + storage.setText(focused ? "true" : "false", forKey: prefix + "focus.enabled") + } + public func forget(_ udid: String) { + storage.removeText(forKey: prefix + "focus." + udid) storage.removeText(forKey: prefix + udid) } /// How many windows would reopen where they were left. public var rememberedCount: Int { - storage.keys(withPrefix: prefix).count + storage.keys(withPrefix: prefix).filter { !$0.hasPrefix(prefix + "focus.") }.count } public func forgetAll() { - for key in storage.keys(withPrefix: prefix) { + for key in storage.keys(withPrefix: prefix) where !key.hasPrefix(prefix + "focus.") { storage.removeText(forKey: key) } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift b/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift index 38c22d4..4efec9b 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/FrontDeviceTests.swift @@ -42,6 +42,35 @@ final class FrontDeviceTests: XCTestCase { XCTAssertEqual(manager.frontmostUDID, "second") } + func testFocusToggleUpdatesAllWindowsAndNewDevicesFollowIt() throws { + func installToolbar(_ controller: DeviceWindowController) { + controller.setToolbarActions(DeviceToolbarActions( + goHome: {}, saveScreenshot: {}, stopRecording: {}, rotate: { _ in } + )) + } + let first = try open("first") + let second = try open("second") + installToolbar(first) + installToolbar(second) + let firstFrame = try XCTUnwrap(first.window).frame + let secondFrame = try XCTUnwrap(second.window).frame + manager.toggleFocusMode() + XCTAssertTrue(manager.isFocused) + XCTAssertTrue(first.isFocused) + XCTAssertTrue(second.isFocused) + let third = try open("third") + installToolbar(third) + XCTAssertTrue(third.isFocused, "new devices must follow the shared preference") + manager.toggleFocusMode() + XCTAssertFalse(first.isFocused) + XCTAssertFalse(second.isFocused) + XCTAssertFalse(third.isFocused) + XCTAssertEqual(first.window?.frame, firstFrame) + XCTAssertEqual(second.window?.frame, secondFrame) + XCTAssertNotNil(first.window?.toolbar) + XCTAssertNotNil(second.window?.toolbar) + } + private func open(_ udid: String) throws -> DeviceWindowController { try manager.open( device: DeviceInfo( diff --git a/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift index 86b6189..20c788c 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift @@ -7,6 +7,22 @@ import OpenDeviceHubEngine final class MenuSettingsTests: XCTestCase { private var previousMenu: NSMenu? + func testFocusModeCanBeFoundAndItsCheckmarkFollowsTheFrontDevice() throws { + var focused = false + var harness = MenuHarness() + harness.isOn = { $0 == .focusMode && focused } + let target = harness.install() + let (item, _) = try XCTUnwrap(MenuHarness.item(titled: "Focus Mode")) + XCTAssertTrue(target.validateMenuItem(item)) + XCTAssertEqual(item.state, .off) + XCTAssertEqual(item.keyEquivalent, "f") + XCTAssertEqual(item.keyEquivalentModifierMask, [.command, .shift]) + focused = true + XCTAssertTrue(target.validateMenuItem(item)) + XCTAssertEqual(item.state, .on) + } + + override func setUp() async throws { previousMenu = NSApplication.shared.mainMenu } @@ -78,6 +94,7 @@ final class MenuSettingsTests: XCTestCase { .latencyOverlay: "Show Click to Frame Latency", .bezel: "Show Device Bezels", .keepOnTop: "Stay On Top", + .focusMode: "Focus Mode", ] XCTAssertEqual(Set(titles.keys), Set(ViewerMenu.DeviceSetting.allCases)) var inFront: Set = [] diff --git a/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift b/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift index 1e336e7..0c2ff31 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/PresentationLayoutTests.swift @@ -2,6 +2,17 @@ import XCTest @testable import OpenDeviceHubViewer final class PresentationLayoutTests: XCTestCase { + func testFocusModeReclaimsHeaderAndMarginsWhileKeepingResizeEdges() { + let size = CGSize(width: 1024, height: 644) + let normal = PresentationLayout(contentSize: size) + let focused = PresentationLayout(contentSize: size, isFocused: true) + XCTAssertEqual(focused.bar, .zero) + XCTAssertEqual(focused.device, CGRect(x: 5, y: 5, width: 1014, height: 634)) + XCTAssertGreaterThan(focused.device.height, normal.device.height) + let tiny = PresentationLayout(contentSize: CGSize(width: 2, height: 2), isFocused: true) + XCTAssertEqual(tiny.device.size, .zero) + } + private let device = CGSize(width: 402, height: 874) func testTheBarFloatsInsetFromEveryEdgeItTouches() { diff --git a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift index 59b7c3b..db1ec43 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/WindowManagementTests.swift @@ -103,6 +103,38 @@ final class WindowFrameStoreTests: XCTestCase { WindowFrameStore(storage: InMemoryFrameStorage(), prefix: "t.") } + func testGlobalFocusSurvivesRelaunchWithoutChangingDeviceFrames() { + let storage = InMemoryFrameStorage() + let store = WindowFrameStore(storage: storage, prefix: "t.") + let normal = CGRect(x: 20, y: 30, width: 400, height: 600) + store.save(normal, for: "A") + store.setFocused(true) + let restored = WindowFrameStore(storage: storage, prefix: "t.") + XCTAssertTrue(restored.isFocused) + XCTAssertEqual(restored.frame(for: "A"), normal) + XCTAssertEqual(restored.rememberedCount, 1) + restored.setFocused(false) + XCTAssertFalse(store.isFocused) + XCTAssertEqual(store.frame(for: "A"), normal) + store.setFocused(true) + store.forget("A") + XCTAssertTrue(store.isFocused) + store.forgetAll() + XCTAssertTrue(store.isFocused, "resetting window positions must not reset global focus") + } + + func testLegacyFocusMigratesAndExplicitOffWins() { + let storage = InMemoryFrameStorage() + storage.setText("true", forKey: "t.focus.A") + let store = WindowFrameStore(storage: storage, prefix: "t.") + XCTAssertTrue(store.isFocused) + XCTAssertNil(storage.text(forKey: "t.focus.A")) + store.setFocused(false) + storage.setText("true", forKey: "t.focus.B") + XCTAssertFalse(store.isFocused) + XCTAssertEqual(store.rememberedCount, 0) + } + func testItCountsWhatItRemembers() { let store = makeStore() XCTAssertEqual(store.rememberedCount, 0)