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
18 changes: 15 additions & 3 deletions Sources/Vorssaint/Services/RadialMenu/RadialMenuService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}
Expand All @@ -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()
Expand All @@ -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 {
Expand Down
25 changes: 19 additions & 6 deletions Sources/Vorssaint/Services/RadialMenu/RadialMenuSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions Sources/Vorssaint/Services/WindowLayout/WindowLayoutSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 1 addition & 29 deletions Sources/Vorssaint/UI/MenuPanel/PanelWindowLayoutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions Sources/Vorssaint/UI/RadialMenu/RadialMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion Sources/Vorssaint/UI/Settings/RadialMenuSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = ""
}
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion Tests/MetricsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand Down