From 8e837d0b65b7be5f0eba446755bb15fe06a9685d Mon Sep 17 00:00:00 2001 From: ruvelro Date: Sun, 19 Jul 2026 22:33:22 +0200 Subject: [PATCH] Add window layouts to the radial menu --- .../RadialMenu/RadialMenuService.swift | 18 +++++++++-- .../RadialMenu/RadialMenuSupport.swift | 25 ++++++++++++---- .../WindowLayout/WindowLayoutSupport.swift | 23 ++++++++++++++ .../UI/MenuPanel/PanelWindowLayoutView.swift | 30 +------------------ .../UI/RadialMenu/RadialMenuView.swift | 5 ++++ .../UI/Settings/RadialMenuSettings.swift | 18 ++++++++++- Tests/MetricsTests.swift | 16 +++++++++- 7 files changed, 95 insertions(+), 40 deletions(-) diff --git a/Sources/Vorssaint/Services/RadialMenu/RadialMenuService.swift b/Sources/Vorssaint/Services/RadialMenu/RadialMenuService.swift index 763c3980..84faeadf 100644 --- a/Sources/Vorssaint/Services/RadialMenu/RadialMenuService.swift +++ b/Sources/Vorssaint/Services/RadialMenu/RadialMenuService.swift @@ -241,6 +241,7 @@ final class RadialMenuService: ObservableObject { items.compactMap { item in var item = item if let tool = item.tool, !tool.feature.isAvailable { return nil } + if item.kind == .windowLayout, !AppFeature.windowLayout.isAvailable { return nil } if item.kind == .submenu { item.children = availableItems(item.children) if item.children.isEmpty { return nil } @@ -492,6 +493,8 @@ final class RadialMenuService: ObservableObject { } case .tool: if let tool = item.tool { run(tool) } + case .windowLayout: + if let action = item.windowLayoutAction { run(action) } case .submenu: break } @@ -515,9 +518,18 @@ final class RadialMenuService: ObservableObject { } } - // MARK: - Synthetic keys (need Accessibility, asked once and in context) + private func run(_ action: WindowLayoutAction) { + guard AppFeature.windowLayout.isAvailable, ensureAccessibilityPermission() else { return } + // Let the non-activating wheel disappear before resolving the window + // that was active behind it, matching the delay used by visual tools. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + if case .failure = WindowLayoutService.shared.apply(action) { NSSound.beep() } + } + } + + // MARK: - Accessibility-gated actions (asked once and in context) - private func ensurePostingTrust() -> Bool { + private func ensureAccessibilityPermission() -> Bool { guard AXIsProcessTrusted() else { if promptedForAccessibility { NSSound.beep() @@ -534,7 +546,7 @@ final class RadialMenuService: ObservableObject { /// the synthetic key merges with the still-held modifiers (checked every /// 15 ms for up to ~1.5 s, with an extra beat once clean). private func postWhenModifiersReleased(attempt: Int, then post: @escaping () -> Void) { - guard ensurePostingTrust() else { return } + guard ensureAccessibilityPermission() else { return } let held = CGEventSource.flagsState(.combinedSessionState) .intersection([.maskCommand, .maskAlternate, .maskShift, .maskControl]) if held.isEmpty || attempt >= 100 { diff --git a/Sources/Vorssaint/Services/RadialMenu/RadialMenuSupport.swift b/Sources/Vorssaint/Services/RadialMenu/RadialMenuSupport.swift index fb4d670a..bafe7c14 100644 --- a/Sources/Vorssaint/Services/RadialMenu/RadialMenuSupport.swift +++ b/Sources/Vorssaint/Services/RadialMenu/RadialMenuSupport.swift @@ -5,11 +5,11 @@ import CoreGraphics import Foundation /// One action on the wheel. `payload` carries the target: an app or file path, -/// a link, a tool or media identifier, or a shortcut storage value. Submenus -/// keep their actions in `children`. +/// a link, tool, media or window-layout identifier, or a shortcut storage +/// value. Submenus keep their actions in `children`. struct RadialMenuItem: Codable, Identifiable, Equatable { enum Kind: String, Codable, CaseIterable { - case app, file, url, shortcut, tool, media, submenu + case app, file, url, shortcut, tool, windowLayout, media, submenu } var id = UUID() @@ -27,6 +27,10 @@ struct RadialMenuItem: Codable, Identifiable, Equatable { kind == .media ? RadialMenuMediaKey(rawValue: payload) : nil } + var windowLayoutAction: WindowLayoutAction? { + kind == .windowLayout ? WindowLayoutAction(rawValue: payload) : nil + } + /// The symbol drawn when the user picked none. App and file items prefer /// their real file icons in the UI; these are the fallbacks. var defaultSymbolName: String { @@ -36,6 +40,7 @@ struct RadialMenuItem: Codable, Identifiable, Equatable { case .url: return "link" case .shortcut: return "command" case .tool: return tool?.symbolName ?? "wrench.and.screwdriver" + case .windowLayout: return windowLayoutAction?.symbolName ?? AppFeature.windowLayout.symbolName case .media: switch mediaKey { case .previousTrack: return "backward.fill" @@ -170,6 +175,7 @@ enum RadialMenuSupport { case .url: return normalizedURL(item.payload) != nil case .shortcut: return GlobalShortcut(storageValue: item.payload) != nil case .tool: return item.tool != nil + case .windowLayout: return item.windowLayoutAction != nil case .media: return item.mediaKey != nil case .submenu: return true } @@ -260,17 +266,24 @@ enum RadialMenuSupport { RadialMenuItem(kind: .media, payload: RadialMenuMediaKey.previousTrack.rawValue), ] - /// True when any item, at any level, posts synthetic key events and so - /// needs the Accessibility permission. + /// True when any item, at any level, controls keyboard input or windows + /// and therefore needs the Accessibility permission. static func needsAccessibility(_ items: [RadialMenuItem]) -> Bool { items.contains { item in switch item.kind { - case .shortcut, .media: return true + case .shortcut, .windowLayout, .media: return true case .submenu: return needsAccessibility(item.children) default: return false } } } + + static func usesWindowLayout(_ items: [RadialMenuItem]) -> Bool { + items.contains { item in + item.kind == .windowLayout + || (item.kind == .submenu && usesWindowLayout(item.children)) + } + } } /// Shared wheel dimensions, points. The service positions the panel and maps diff --git a/Sources/Vorssaint/Services/WindowLayout/WindowLayoutSupport.swift b/Sources/Vorssaint/Services/WindowLayout/WindowLayoutSupport.swift index 233c09c0..981f7140 100644 --- a/Sources/Vorssaint/Services/WindowLayout/WindowLayoutSupport.swift +++ b/Sources/Vorssaint/Services/WindowLayout/WindowLayoutSupport.swift @@ -179,6 +179,29 @@ enum WindowLayoutAction: String, CaseIterable, Identifiable { case .nextDisplay: return text.nextDisplay } } + + /// Shared by every place that presents an individual placement action. + var symbolName: String { + switch self { + case .leftHalf, .leftThird: return "rectangle.leftthird.inset.filled" + case .rightHalf, .rightThird: return "rectangle.rightthird.inset.filled" + case .topHalf: return "rectangle.topthird.inset.filled" + case .bottomHalf: return "rectangle.bottomthird.inset.filled" + case .centerThird: return "rectangle.center.inset.filled" + case .leftTwoThirds: return "rectangle.leadinghalf.filled" + case .rightTwoThirds: return "rectangle.trailinghalf.filled" + case .topLeftSixth, .topLeft: return "arrow.up.left" + case .topCenterSixth: return "arrow.up" + case .topRightSixth, .topRight: return "arrow.up.right" + case .bottomLeftSixth, .bottomLeft: return "arrow.down.left" + case .bottomCenterSixth: return "arrow.down" + case .bottomRightSixth, .bottomRight: return "arrow.down.right" + case .maximize: return "arrow.up.left.and.arrow.down.right" + case .center: return "scope" + case .nextDisplay: return "arrow.right.to.line" + case .restore: return "arrow.uturn.backward" + } + } } enum WindowLayoutGeometry { diff --git a/Sources/Vorssaint/UI/MenuPanel/PanelWindowLayoutView.swift b/Sources/Vorssaint/UI/MenuPanel/PanelWindowLayoutView.swift index c11fb891..8d4e2ce0 100644 --- a/Sources/Vorssaint/UI/MenuPanel/PanelWindowLayoutView.swift +++ b/Sources/Vorssaint/UI/MenuPanel/PanelWindowLayoutView.swift @@ -167,7 +167,7 @@ struct PanelWindowLayoutView: View { VStack(spacing: 2) { Label(title(for: action), systemImage: editingActions ? (hidden.contains(action) ? "eye.slash" : "eye") - : symbol(for: action)) + : action.symbolName) .font(.system(size: 10.5, weight: .semibold)) .lineLimit(1) .minimumScaleFactor(0.82) @@ -196,34 +196,6 @@ struct PanelWindowLayoutView: View { action.title(text) } - private func symbol(for action: WindowLayoutAction) -> String { - switch action { - case .leftHalf: return "rectangle.leftthird.inset.filled" - case .rightHalf: return "rectangle.rightthird.inset.filled" - case .topHalf: return "rectangle.topthird.inset.filled" - case .bottomHalf: return "rectangle.bottomthird.inset.filled" - case .leftThird: return "rectangle.leftthird.inset.filled" - case .centerThird: return "rectangle.center.inset.filled" - case .rightThird: return "rectangle.rightthird.inset.filled" - case .leftTwoThirds: return "rectangle.leadinghalf.filled" - case .rightTwoThirds: return "rectangle.trailinghalf.filled" - case .topLeftSixth: return "arrow.up.left" - case .topCenterSixth: return "arrow.up" - case .topRightSixth: return "arrow.up.right" - case .bottomLeftSixth: return "arrow.down.left" - case .bottomCenterSixth: return "arrow.down" - case .bottomRightSixth: return "arrow.down.right" - case .topLeft: return "arrow.up.left" - case .topRight: return "arrow.up.right" - case .bottomLeft: return "arrow.down.left" - case .bottomRight: return "arrow.down.right" - case .maximize: return "arrow.up.left.and.arrow.down.right" - case .center: return "scope" - case .nextDisplay: return "arrow.right.to.line" - case .restore: return "arrow.uturn.backward" - } - } - private var resultMessage: String? { switch service.lastResult { case .success(let restored): return restored ? text.restored : text.done diff --git a/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift b/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift index dc55dd9b..5f6e270b 100644 --- a/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift +++ b/Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift @@ -199,6 +199,11 @@ extension RadialMenuItem { case .tool: guard let tool else { return text.kindTool } return tool.feature.hubTitle(L10n.shared.s, hub: FeatureStrings.hub(L10n.shared.language)) + case .windowLayout: + guard let windowLayoutAction else { + return FeatureStrings.windowLayout(L10n.shared.language).title + } + return windowLayoutAction.title(FeatureStrings.windowLayout(L10n.shared.language)) case .media: switch mediaKey { case .playPause: return text.mediaPlayPause diff --git a/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift b/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift index 83491b57..7e59a08a 100644 --- a/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift +++ b/Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift @@ -87,7 +87,9 @@ struct RadialMenuSettings: View { !permissions.accessibility { Section { PermissionRow(kind: .accessibility) - Text(text.permissionCaption) + Text(RadialMenuSupport.usesWindowLayout(items) + ? FeatureStrings.windowLayout(l10n.language).missingPermission + : text.permissionCaption) .font(.caption) .foregroundStyle(.secondary) } @@ -223,6 +225,7 @@ private struct RadialItemRow: View { case .url: return text.kindURL case .shortcut: return text.kindShortcut case .tool: return text.kindTool + case .windowLayout: return FeatureStrings.windowLayout(L10n.shared.language).title case .media: return text.kindMedia case .submenu: return text.kindSubmenu } @@ -370,6 +373,10 @@ private struct RadialItemEditor: View { if !availableTools.isEmpty { Text(text.kindTool).tag(RadialMenuItem.Kind.tool) } + if AppFeature.windowLayout.isAvailable { + Text(FeatureStrings.windowLayout(l10n.language).title) + .tag(RadialMenuItem.Kind.windowLayout) + } Text(text.kindMedia).tag(RadialMenuItem.Kind.media) if allowsSubmenu { Text(text.kindSubmenu).tag(RadialMenuItem.Kind.submenu) @@ -428,6 +435,7 @@ private struct RadialItemEditor: View { item.kind = kind switch kind { case .tool: item.payload = availableTools.first?.rawValue ?? "" + case .windowLayout: item.payload = WindowLayoutAction.leftHalf.rawValue case .media: item.payload = RadialMenuMediaKey.playPause.rawValue default: item.payload = "" } @@ -465,6 +473,14 @@ private struct RadialItemEditor: View { .tag(tool.rawValue) } } + case .windowLayout: + let windowText = FeatureStrings.windowLayout(l10n.language) + Picker(windowText.title, selection: $item.payload) { + ForEach(WindowLayoutAction.allCases) { action in + Label(action.title(windowText), systemImage: action.symbolName) + .tag(action.rawValue) + } + } case .media: Picker(text.mediaLabel, selection: $item.payload) { Text(text.mediaPlayPause).tag(RadialMenuMediaKey.playPause.rawValue) diff --git a/Tests/MetricsTests.swift b/Tests/MetricsTests.swift index 45db8885..e3b038ee 100644 --- a/Tests/MetricsTests.swift +++ b/Tests/MetricsTests.swift @@ -5473,6 +5473,7 @@ struct MetricsTests { RadialMenuItem(kind: .url, payload: "not a link"), RadialMenuItem(kind: .shortcut, payload: "garbage"), RadialMenuItem(kind: .tool, payload: "unknownTool"), + RadialMenuItem(kind: .windowLayout, payload: "unknownLayout"), RadialMenuItem(kind: .media, payload: "unknownKey"), ]).isEmpty, "slices that cannot run are dropped instead of rendering dead") @@ -5497,10 +5498,23 @@ struct MetricsTests { "digit-after-colon means a port only when the prefix looks like a host") expect(RadialMenuSupport.needsAccessibility([starter[3]]) == false && RadialMenuSupport.needsAccessibility(starter) + && RadialMenuSupport.needsAccessibility([ + RadialMenuItem(kind: .windowLayout, payload: WindowLayoutAction.leftThird.rawValue), + ]) && RadialMenuSupport.needsAccessibility([ RadialMenuItem(kind: .submenu, children: [RadialMenuItem(kind: .shortcut, payload: "command:8")]), ]), - "only wheels that press keys need Accessibility, submenus included") + "keyboard and window actions need Accessibility, submenus included") + let radialLayout = RadialMenuItem(kind: .windowLayout, + payload: WindowLayoutAction.leftThird.rawValue) + expect(RadialMenuSupport.sanitized([radialLayout]) == [radialLayout] + && radialLayout.windowLayoutAction == .leftThird + && radialLayout.effectiveSymbolName == WindowLayoutAction.leftThird.symbolName + && WindowLayoutAction.allCases.allSatisfy { !$0.symbolName.isEmpty } + && RadialMenuSupport.usesWindowLayout([ + RadialMenuItem(kind: .submenu, children: [radialLayout]), + ]), + "window-layout slices keep a valid placement and its automatic icon") expect(RadialMenuMediaKey.playPause.auxKeyType == 16 && RadialMenuMediaKey.previousTrack.auxKeyType == 20 && RadialMenuMediaKey.nextTrack.auxKeyType == 19,