From 68a0ba8ba40792a9f183dbaf933d122f0e2b3877 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 31 Jul 2026 22:36:28 +0200 Subject: [PATCH 01/21] feat(displays): add custom overlay placement mode --- .../DisplaysSettingsPane+PreviewCard.swift | 45 ++++++++- .../Displays/DisplaysSettingsPane.swift | 69 ++++++++++---- .../DisplaysSettingsPaneViewModel.swift | 24 +++++ ...oardVisualizerSettings+PlacementMode.swift | 26 ++++++ .../Keyboard/KeyboardVisualizerSettings.swift | 26 ++++++ .../KeyboardVisualizerSettingsKeys.swift | 3 + .../Keyboard/KeyboardVisualizerWindow.swift | 53 +++++++++++ .../Keyty/Platform/Screens/Screen.swift | 12 +++ .../Resources/de.lproj/Localizable.strings | 10 +- .../Resources/en.lproj/Localizable.strings | 10 +- .../Resources/es.lproj/Localizable.strings | 10 +- .../Resources/fr.lproj/Localizable.strings | 10 +- .../Resources/ja.lproj/Localizable.strings | 10 +- .../Resources/uk.lproj/Localizable.strings | 10 +- .../zh-Hans.lproj/Localizable.strings | 10 +- .../DisplaysSettingsPaneViewModelTests.swift | 92 +++++++++++++++++++ .../KeyboardVisualizerSettingsTests.swift | 34 +++++++ 17 files changed, 430 insertions(+), 24 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift create mode 100644 Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift index 2131ba1..5a26065 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift @@ -13,8 +13,11 @@ extension DisplaysSettingsPane { let screens: [Screen] let selectedScreen: Screen let anchor: KeyboardVisualizerAnchor + let placementMode: KeyboardVisualizerSettings.PlacementMode let stackAxis: KeyboardVisualizerStackAxis let windowPadding: Double + let customPositionX: Double + let customPositionY: Double let onSelectScreen: (Screen) -> Void private let previewHeight = Spacing.grid(48) @@ -64,7 +67,7 @@ private extension DisplaysSettingsPane.PreviewCard { RoundedRectangle(cornerRadius: Radius.sm, style: .continuous) .fill(Color.Theme.Accent.controlAccent) .frame(width: markerSize.width, height: markerSize.height) - .offset(self.anchorOffset(in: size, for: screen, markerSize: markerSize)) + .offset(self.markerOffset(in: size, for: screen, markerSize: markerSize)) .shadow(color: Color.Theme.Shadow.displayMarker, radius: 6, y: 2) } } @@ -126,6 +129,15 @@ private extension DisplaysSettingsPane.PreviewCard { return min(1, max(minimumScale, displayScale)) } + func markerOffset(in size: CGSize, for screen: Screen, markerSize: CGSize) -> CGSize { + switch self.placementMode { + case .anchored: + return self.anchorOffset(in: size, for: screen, markerSize: markerSize) + case .custom: + return self.customPositionOffset(in: size, markerSize: markerSize) + } + } + func anchorOffset(in size: CGSize, for screen: Screen, markerSize: CGSize) -> CGSize { let halfMarkerWidth = markerSize.width / 2 let halfMarkerHeight = markerSize.height / 2 @@ -159,6 +171,37 @@ private extension DisplaysSettingsPane.PreviewCard { return CGSize(width: x, height: y) } + + func customPositionOffset(in size: CGSize, markerSize: CGSize) -> CGSize { + let anchorX = size.width * CGFloat(self.customPositionX) - size.width / 2 + let anchorY = size.height / 2 - size.height * CGFloat(self.customPositionY) + let markerAnchorX = self.markerAnchorOffsetX(for: markerSize) + let markerAnchorY = self.markerAnchorOffsetY(for: markerSize) + + return CGSize(width: anchorX - markerAnchorX, height: anchorY - markerAnchorY) + } + + func markerAnchorOffsetX(for markerSize: CGSize) -> CGFloat { + switch self.anchor.horizontal { + case .leading: + return -markerSize.width / 2 + case .center: + return 0 + case .trailing: + return markerSize.width / 2 + } + } + + func markerAnchorOffsetY(for markerSize: CGSize) -> CGFloat { + switch self.anchor.vertical { + case .top: + return -markerSize.height / 2 + case .middle: + return 0 + case .bottom: + return markerSize.height / 2 + } + } } // MARK: - Layout diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 7989c3a..fd77d8a 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -20,8 +20,11 @@ struct DisplaysSettingsPane: View { screens: self.model.screens, selectedScreen: self.model.selectedScreen, anchor: self.model.selectedAnchor, + placementMode: self.model.placementMode, stackAxis: self.model.stackAxis, windowPadding: self.model.windowPadding, + customPositionX: self.model.customPositionX, + customPositionY: self.model.customPositionY, onSelectScreen: { screen in self.model.selectedScreenID = screen.id } @@ -46,29 +49,63 @@ struct DisplaysSettingsPane: View { Divider() SettingsControlRow( - title: L10n.Displays.anchorLabel, - subtitle: L10n.Displays.anchorSubtitle + title: L10n.Displays.placementLabel, + subtitle: L10n.Displays.placementSubtitle ) { - KeyboardVisualizerAnchorPicker( - selection: $model.selectedAnchor, - accessibilityLabel: L10n.Displays.anchorLabel - ) + Picker("", selection: self.$model.placementMode) { + ForEach(KeyboardVisualizerSettings.PlacementMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .labelsHidden() + .accessibilityLabel(L10n.Displays.placementLabel) + .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) } Divider() - SettingsControlRow( - title: L10n.Displays.marginLabel, - subtitle: L10n.Displays.marginSubtitle - ) { - SettingsSliderControl( - value: $model.windowPadding, - range: model.paddingRange, - step: model.paddingStep, - accessibilityLabel: L10n.Displays.marginLabel - ) + if self.model.isCustomPlacement { + self.customPlacementControls + } else { + SettingsControlRow( + title: L10n.Displays.anchorLabel, + subtitle: L10n.Displays.anchorSubtitle + ) { + KeyboardVisualizerAnchorPicker( + selection: self.$model.selectedAnchor, + accessibilityLabel: L10n.Displays.anchorLabel + ) + } + + Divider() + + SettingsControlRow( + title: L10n.Displays.marginLabel, + subtitle: L10n.Displays.marginSubtitle + ) { + SettingsSliderControl( + value: self.$model.windowPadding, + range: self.model.paddingRange, + step: self.model.paddingStep, + accessibilityLabel: L10n.Displays.marginLabel + ) + } } } } } } + +private extension DisplaysSettingsPane { + var customPlacementControls: some View { + SettingsControlRow( + title: L10n.Displays.CustomPosition.setLabel, + subtitle: L10n.Displays.CustomPosition.setSubtitle + ) { + Button(self.model.isSettingCustomPosition ? L10n.Displays.CustomPosition.stopSettingButton : L10n.Displays.CustomPosition.startSettingButton) { + self.model.toggleCustomPositionSetting() + } + .controlSize(.small) + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index d27f946..4549916 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -18,6 +18,7 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { let paddingStep: Double = Double(Spacing.md) @Published private(set) var screens: [Screen] + @Published private(set) var isSettingCustomPosition = false @Published var selectedScreen: Screen { didSet { self.keyboardVisualizerSettings.screenID = self.selectedScreen.id } @@ -32,14 +33,30 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { didSet { self.keyboardVisualizerSettings.anchor = self.selectedAnchor } } + @Published var placementMode: KeyboardVisualizerSettings.PlacementMode { + didSet { self.keyboardVisualizerSettings.placementMode = self.placementMode } + } + @Published var windowPadding: Double { didSet { self.keyboardVisualizerSettings.windowPadding = CGFloat(self.windowPadding) } } + @Published var customPositionX: Double { + didSet { self.keyboardVisualizerSettings.customPositionX = CGFloat(self.customPositionX) } + } + + @Published var customPositionY: Double { + didSet { self.keyboardVisualizerSettings.customPositionY = CGFloat(self.customPositionY) } + } + var stackAxis: KeyboardVisualizerStackAxis { self.keyboardVisualizerSettings.stackAxis } + var isCustomPlacement: Bool { + self.placementMode == .custom + } + init( screensService: any ScreenServiceProvider = ScreensService.shared, keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings() @@ -56,7 +73,10 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.screens = screensService.screens self.selectedScreen = selectedScreen self.selectedAnchor = keyboardVisualizerSettings.anchor + self.placementMode = keyboardVisualizerSettings.placementMode self.windowPadding = Double(keyboardVisualizerSettings.windowPadding) + self.customPositionX = Double(keyboardVisualizerSettings.customPositionX) + self.customPositionY = Double(keyboardVisualizerSettings.customPositionY) self.screensService.screensDidChange .receive(on: RunLoop.main) @@ -67,6 +87,10 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { } .store(in: &self.cancellables) } + + func toggleCustomPositionSetting() { + self.isSettingCustomPosition.toggle() + } } private extension DisplaysSettingsPaneViewModel { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift new file mode 100644 index 0000000..529b09a --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift @@ -0,0 +1,26 @@ +// +// KeyboardVisualizerSettings+PlacementMode.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +extension KeyboardVisualizerSettings { + enum PlacementMode: Int, CaseIterable { + /// Positions the overlay from a screen anchor plus edge inset. + case anchored + + /// Positions the overlay from a normalized point on the selected display. + case custom + + var label: String { + switch self { + case .anchored: + return L10n.Displays.Placement.anchored + case .custom: + return L10n.Displays.Placement.custom + } + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index 657d5cf..b3378c5 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -24,7 +24,12 @@ protocol KeyboardVisualizerSettingsProtocol: AnyObject { var mediaTheme: KeyboardVisualizerTheme { get set } var mouseTheme: KeyboardVisualizerTheme { get set } var groupBackgroundTheme: KeyboardVisualizerTheme { get set } + var anchor: KeyboardVisualizerAnchor { get set } + var placementMode: KeyboardVisualizerSettings.PlacementMode { get set } + var customPositionX: CGFloat { get set } + var customPositionY: CGFloat { get set } + var screenID: CGDirectDisplayID { get set } var scale: CGFloat { get set } var windowPadding: CGFloat { get set } @@ -170,6 +175,27 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS } } + @Stored(.enum(KeyboardVisualizerSettingsKeys.placementMode, default: .anchored)) + var placementMode: PlacementMode { + didSet { + self.placementChangesSubject.send(()) + } + } + + @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionX, default: 0.5, clamp: 0...1)) + var customPositionX: CGFloat { + didSet { + self.placementChangesSubject.send(()) + } + } + + @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionY, default: 0.5, clamp: 0...1)) + var customPositionY: CGFloat { + didSet { + self.placementChangesSubject.send(()) + } + } + /// Target display the window pins to, identified by `CGDirectDisplayID`. `0` means the /// main screen (and is the fallback whenever the stored display is not connected). @Stored(.custom( diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift index 01ffaf8..a08efdf 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift @@ -27,6 +27,9 @@ enum KeyboardVisualizerSettingsKeys { static let mouseTheme = "keyboard_visualizer.mouseTheme" static let groupBackgroundTheme = "keyboard_visualizer.groupBackgroundTheme" static let anchor = "keyboard_visualizer.anchor" + static let placementMode = "keyboard_visualizer.placementMode" + static let customPositionX = "keyboard_visualizer.customPositionX" + static let customPositionY = "keyboard_visualizer.customPositionY" static let screenID = "keyboard_visualizer.screenID" static let scale = "keyboard_visualizer.scale" static let windowPadding = "keyboard_visualizer.windowPadding" diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index bc00d48..442f407 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -162,6 +162,15 @@ final class KeyboardVisualizerWindow: NSWindow { return NSRect(origin: self.frame.origin, size: size) } + switch self.settings.placementMode { + case .anchored: + return self.anchoredFrame(size: size, in: area) + case .custom: + return self.customFrame(size: size, in: area) + } + } + + private func anchoredFrame(size: NSSize, in area: CGRect) -> NSRect { let anchor = self.settings.anchor let margin = self.settings.windowPadding @@ -183,6 +192,50 @@ final class KeyboardVisualizerWindow: NSWindow { return NSRect(x: x, y: y, width: size.width, height: size.height) } + private func customFrame(size: NSSize, in area: CGRect) -> NSRect { + let anchorPoint = CGPoint( + x: area.minX + area.width * self.settings.customPositionX, + y: area.minY + area.height * self.settings.customPositionY + ) + let origin = CGPoint( + x: anchorPoint.x - self.anchorOffsetX(for: size), + y: anchorPoint.y - self.anchorOffsetY(for: size) + ) + let clampedOrigin = CGPoint( + x: self.clampedOriginValue(origin.x, minimum: area.minX, maximum: area.maxX - size.width), + y: self.clampedOriginValue(origin.y, minimum: area.minY, maximum: area.maxY - size.height) + ) + + return NSRect(origin: clampedOrigin, size: size) + } + + private func clampedOriginValue(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat { + guard maximum >= minimum else { return minimum } + return min(max(value, minimum), maximum) + } + + private func anchorOffsetX(for size: NSSize) -> CGFloat { + switch self.settings.anchor.horizontal { + case .leading: + return 0 + case .center: + return size.width / 2 + case .trailing: + return size.width + } + } + + private func anchorOffsetY(for size: NSSize) -> CGFloat { + switch self.settings.anchor.vertical { + case .bottom: + return 0 + case .middle: + return size.height / 2 + case .top: + return size.height + } + } + private func resolvedVisibleFrame() -> CGRect? { self.screensService.visibleFrame(for: self.settings.screenID) ?? self.screensService.mainVisibleFrame() diff --git a/Apps/Keyty/Sources/Keyty/Platform/Screens/Screen.swift b/Apps/Keyty/Sources/Keyty/Platform/Screens/Screen.swift index 2249511..35788df 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Screens/Screen.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Screens/Screen.swift @@ -14,6 +14,18 @@ struct Screen: Identifiable, Equatable { let wallpaperImageURL: URL? let frame: CGRect + init( + id: CGDirectDisplayID, + displayName: String, + wallpaperImageURL: URL?, + frame: CGRect + ) { + self.id = id + self.displayName = displayName + self.wallpaperImageURL = wallpaperImageURL + self.frame = frame + } + init(_ nsScreen: NSScreen) { self.id = nsScreen.displayID self.displayName = nsScreen.localizedName diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index e095af5..588e769 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -39,10 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Bildschirm"; "displays.display_subtitle" = "Bildschirm für die Tastatureinblendung"; +"displays.placement_label" = "Placement"; +"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; +"displays.placement.anchored" = "Anchored"; +"displays.placement.custom" = "Custom"; "displays.anchor_label" = "Verankerung"; -"displays.anchor_subtitle" = "Position der Tastatureinblendung"; +"displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Bildschirmabstand"; "displays.margin_subtitle" = "Abstand zwischen der Einblendung und dem ausgewählten Bildschirmrand."; +"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; +"displays.custom_position.start_setting_button" = "Start Setting…"; +"displays.custom_position.stop_setting_button" = "Stop Setting"; /* General settings pane */ "general.appearance_section_title" = "App"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 4f762d3..451f23d 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -39,10 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Display"; "displays.display_subtitle" = "Display to host the keyboard overlay"; +"displays.placement_label" = "Placement"; +"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; +"displays.placement.anchored" = "Anchored"; +"displays.placement.custom" = "Custom"; "displays.anchor_label" = "Anchor"; -"displays.anchor_subtitle" = "Keyboard overlay anchor position"; +"displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Screen Inset"; "displays.margin_subtitle" = "Distance between the overlay and the selected screen edge."; +"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; +"displays.custom_position.start_setting_button" = "Start Setting…"; +"displays.custom_position.stop_setting_button" = "Stop Setting"; /* General settings pane */ "general.appearance_section_title" = "App"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index ff00119..af01767 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -39,10 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Pantalla"; "displays.display_subtitle" = "Pantalla en la que se muestra la superposición del teclado"; +"displays.placement_label" = "Placement"; +"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; +"displays.placement.anchored" = "Anchored"; +"displays.placement.custom" = "Custom"; "displays.anchor_label" = "Anclaje"; -"displays.anchor_subtitle" = "Posición de anclaje de la superposición del teclado"; +"displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Margen de pantalla"; "displays.margin_subtitle" = "Distancia entre la superposición y el borde seleccionado de la pantalla."; +"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; +"displays.custom_position.start_setting_button" = "Start Setting…"; +"displays.custom_position.stop_setting_button" = "Stop Setting"; /* General settings pane */ "general.appearance_section_title" = "Aplicación"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index c8e8a47..5496485 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -39,10 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Écran"; "displays.display_subtitle" = "Écran sur lequel afficher la superposition du clavier"; +"displays.placement_label" = "Placement"; +"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; +"displays.placement.anchored" = "Anchored"; +"displays.placement.custom" = "Custom"; "displays.anchor_label" = "Ancrage"; -"displays.anchor_subtitle" = "Position d’ancrage de la superposition du clavier"; +"displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Marge de l’écran"; "displays.margin_subtitle" = "Distance entre la superposition et le bord d’écran sélectionné."; +"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; +"displays.custom_position.start_setting_button" = "Start Setting…"; +"displays.custom_position.stop_setting_button" = "Stop Setting"; /* General settings pane */ "general.appearance_section_title" = "Application"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index ac6e740..d6c437e 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -39,10 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "ディスプレイ"; "displays.display_subtitle" = "キーボードオーバーレイを表示するディスプレイ"; +"displays.placement_label" = "Placement"; +"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; +"displays.placement.anchored" = "Anchored"; +"displays.placement.custom" = "Custom"; "displays.anchor_label" = "基準位置"; -"displays.anchor_subtitle" = "キーボードオーバーレイの基準位置"; +"displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "画面端からの余白"; "displays.margin_subtitle" = "オーバーレイと選択した画面端との間隔です。"; +"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; +"displays.custom_position.start_setting_button" = "Start Setting…"; +"displays.custom_position.stop_setting_button" = "Stop Setting"; /* General settings pane */ "general.appearance_section_title" = "アプリ"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 6b92bb0..3f6b19a 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -39,10 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Дисплей"; "displays.display_subtitle" = "Дисплей, на якому показуватиметься накладка клавіатури"; +"displays.placement_label" = "Placement"; +"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; +"displays.placement.anchored" = "Anchored"; +"displays.placement.custom" = "Custom"; "displays.anchor_label" = "Прив’язка"; -"displays.anchor_subtitle" = "Положення накладки клавіатури"; +"displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Відступ від краю"; "displays.margin_subtitle" = "Відстань між накладкою та вибраним краєм екрана."; +"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; +"displays.custom_position.start_setting_button" = "Start Setting…"; +"displays.custom_position.stop_setting_button" = "Stop Setting"; /* General settings pane */ "general.appearance_section_title" = "Програма"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 787b623..6f0d0c1 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -39,10 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "显示器"; "displays.display_subtitle" = "用于显示键盘叠加层的显示器"; +"displays.placement_label" = "Placement"; +"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; +"displays.placement.anchored" = "Anchored"; +"displays.placement.custom" = "Custom"; "displays.anchor_label" = "锚点"; -"displays.anchor_subtitle" = "键盘叠加层的锚点位置"; +"displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "屏幕边距"; "displays.margin_subtitle" = "叠加层与所选屏幕边缘之间的距离。"; +"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; +"displays.custom_position.start_setting_button" = "Start Setting…"; +"displays.custom_position.stop_setting_button" = "Stop Setting"; /* General settings pane */ "general.appearance_section_title" = "应用"; diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift new file mode 100644 index 0000000..4151bf9 --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -0,0 +1,92 @@ +// +// DisplaysSettingsPaneViewModelTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Combine +import XCTest +@testable import Keyty + +final class DisplaysSettingsPaneViewModelTests: XCTestCase { + private var store: InMemoryKeyValueStore! + private var keyboardVisualizerSettings: KeyboardVisualizerSettings! + private var screensService: TestScreenService! + private var model: DisplaysSettingsPaneViewModel! + + override func setUp() { + super.setUp() + self.store = InMemoryKeyValueStore() + self.keyboardVisualizerSettings = KeyboardVisualizerSettings(store: self.store) + self.screensService = TestScreenService() + self.model = DisplaysSettingsPaneViewModel( + screensService: self.screensService, + keyboardVisualizerSettings: self.keyboardVisualizerSettings + ) + } + + override func tearDown() { + self.model = nil + self.screensService = nil + self.keyboardVisualizerSettings = nil + self.store = nil + super.tearDown() + } + + func testToggleCustomPositionSettingFlipsLocalFlagOnly() { + self.keyboardVisualizerSettings.customPositionX = 0.25 + self.keyboardVisualizerSettings.customPositionY = 0.75 + + self.model.toggleCustomPositionSetting() + + XCTAssertTrue(self.model.isSettingCustomPosition) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) + + self.model.toggleCustomPositionSetting() + + XCTAssertFalse(self.model.isSettingCustomPosition) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) + } +} + +private final class TestScreenService: ScreenServiceProvider { + private let screensSubject: CurrentValueSubject<[Screen], Never> + + let screens: [Screen] + + var screensDidChange: AnyPublisher<[Screen], Never> { + self.screensSubject.eraseToAnyPublisher() + } + + init() { + self.screens = [ + Screen( + id: 1, + displayName: "Display", + wallpaperImageURL: nil, + frame: CGRect(x: 0, y: 0, width: 1920, height: 1080) + ), + ] + self.screensSubject = CurrentValueSubject(self.screens) + } + + func display(for id: CGDirectDisplayID) -> Screen? { + self.screens.first { $0.id == id } + } + + func mainDisplay() -> Screen? { + self.screens.first + } + + func visibleFrame(for id: CGDirectDisplayID) -> CGRect? { + self.display(for: id)?.frame + } + + func mainVisibleFrame() -> CGRect? { + self.mainDisplay()?.frame + } +} diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift index 226d372..765b9b0 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift @@ -38,6 +38,9 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(store.bool(forKey: KeyboardVisualizerSettingsKeys.usesCustomThemePalette), false) XCTAssertEqual(store.integer(forKey: KeyboardVisualizerSettingsKeys.style), KeycapStyle.apple.rawValue) XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.scale), 1.0, accuracy: 0.0001) + XCTAssertEqual(store.integer(forKey: KeyboardVisualizerSettingsKeys.placementMode), KeyboardVisualizerSettings.PlacementMode.anchored.rawValue) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionX), 0.5, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionY), 0.5, accuracy: 0.0001) XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.windowPadding), Double(Size.KeyboardVisualizer.windowPadding), accuracy: 0.0001) XCTAssertEqual(store.bool(forKey: KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes), false) XCTAssertEqual(store.bool(forKey: KeyboardVisualizerSettingsKeys.showSpecialKeys), true) @@ -124,6 +127,37 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(settings.windowPadding, KeyboardVisualizerSettings.minWindowPadding, accuracy: 0.0001) } + func testPersistsPlacementMode() { + settings.placementMode = .custom + + XCTAssertEqual(settings.placementMode, .custom) + XCTAssertEqual(store.integer(forKey: KeyboardVisualizerSettingsKeys.placementMode), KeyboardVisualizerSettings.PlacementMode.custom.rawValue) + } + + func testCustomPositionClamps() { + settings.customPositionX = 1.5 + settings.customPositionY = -0.5 + + XCTAssertEqual(settings.customPositionX, 1, accuracy: 0.0001) + XCTAssertEqual(settings.customPositionY, 0, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionX), 1, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionY), 0, accuracy: 0.0001) + } + + func testPublishesPlacementChangesForCustomPlacementSettings() { + var receivedCount = 0 + let cancellable = settings.placementChanges.sink { _ in + receivedCount += 1 + } + + settings.placementMode = .custom + settings.customPositionX = 0.25 + settings.customPositionY = 0.75 + + XCTAssertEqual(receivedCount, 3) + cancellable.cancel() + } + func testPersistsSharedTimingSettings() { settings.fadeDelay = 3.5 settings.fadeDuration = 0.45 From e32ab3e30c64f06d1f93ea4fd66f6c3014a9c699 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 31 Jul 2026 23:14:37 +0200 Subject: [PATCH 02/21] feat(displays): add custom keyboard visualizer placement --- .../App/Composition/AppUIContainer.swift | 11 ++ .../Domain/Keyboard/KeyboardKeyCode.swift | 2 + .../DisplaysSettingsPane+PreviewCard.swift | 32 +--- .../Displays/DisplaysSettingsPane.swift | 14 +- .../DisplaysSettingsPaneViewModel.swift | 31 +++- .../Settings/SettingsPaneRegistry.swift | 10 +- .../Settings/SettingsWindowController.swift | 6 +- .../KeyboardVisualizerGroupView.swift | 4 + ...dVisualizerPlacementWindowController.swift | 153 ++++++++++++++++++ .../Keyboard/KeyboardVisualizerWindow.swift | 29 +--- .../DisplaysSettingsPaneViewModelTests.swift | 82 +++++++++- 11 files changed, 312 insertions(+), 62 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift index 2a04115..62fe55e 100644 --- a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift +++ b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift @@ -11,6 +11,7 @@ import Sparkle @MainActor final class AppUIContainer { let aboutWindowController: AboutWindowController + let keyboardVisualizerPlacementWindowController: KeyboardVisualizerPlacementWindowController let permissionsOnboardingWindowController: PermissionsOnboardingWindowController let settingsWindowController: SettingsWindowController @@ -20,6 +21,10 @@ final class AppUIContainer { updater: SPUUpdater ) { aboutWindowController = AboutWindowController() + let keyboardVisualizerPlacementWindowController = KeyboardVisualizerPlacementWindowController( + settings: settings.keyboardVisualizerSettings + ) + self.keyboardVisualizerPlacementWindowController = keyboardVisualizerPlacementWindowController permissionsOnboardingWindowController = PermissionsOnboardingWindowController( permissionsService: services.permissionsService ) @@ -30,6 +35,12 @@ final class AppUIContainer { pointerRingSettings: settings.pointerRingSettings, pointerIconSettings: settings.pointerIconSettings, keyboardVisualizerSettings: settings.keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: { [weak keyboardVisualizerPlacementWindowController] in + keyboardVisualizerPlacementWindowController?.startSettingPosition() + }, + stopSettingKeyboardVisualizerPosition: { [weak keyboardVisualizerPlacementWindowController] in + keyboardVisualizerPlacementWindowController?.stopSettingPosition() + }, permissionsService: services.permissionsService, updater: updater ) diff --git a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift index a2afeaa..d24cb34 100644 --- a/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift +++ b/Apps/Keyty/Sources/Keyty/Domain/Keyboard/KeyboardKeyCode.swift @@ -17,6 +17,8 @@ enum KeyboardKeyCode: UInt16 { // MARK: - Letters & punctuation case a = 0x00 + case s = 0x01 + case t = 0x11 case e = 0x0E case digit7 = 0x1A case minus = 0x1B diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift index 5a26065..7d59a5a 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift @@ -173,34 +173,10 @@ private extension DisplaysSettingsPane.PreviewCard { } func customPositionOffset(in size: CGSize, markerSize: CGSize) -> CGSize { - let anchorX = size.width * CGFloat(self.customPositionX) - size.width / 2 - let anchorY = size.height / 2 - size.height * CGFloat(self.customPositionY) - let markerAnchorX = self.markerAnchorOffsetX(for: markerSize) - let markerAnchorY = self.markerAnchorOffsetY(for: markerSize) - - return CGSize(width: anchorX - markerAnchorX, height: anchorY - markerAnchorY) - } - - func markerAnchorOffsetX(for markerSize: CGSize) -> CGFloat { - switch self.anchor.horizontal { - case .leading: - return -markerSize.width / 2 - case .center: - return 0 - case .trailing: - return markerSize.width / 2 - } - } - - func markerAnchorOffsetY(for markerSize: CGSize) -> CGFloat { - switch self.anchor.vertical { - case .top: - return -markerSize.height / 2 - case .middle: - return 0 - case .bottom: - return markerSize.height / 2 - } + CGSize( + width: size.width * CGFloat(self.customPositionX) - size.width / 2, + height: size.height / 2 - size.height * CGFloat(self.customPositionY) + ) } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index fd77d8a..4275788 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -11,8 +11,18 @@ import SwiftUI struct DisplaysSettingsPane: View { @StateObject private var model: DisplaysSettingsPaneViewModel - init(keyboardVisualizerSettings: KeyboardVisualizerSettings) { - _model = StateObject(wrappedValue: DisplaysSettingsPaneViewModel(keyboardVisualizerSettings: keyboardVisualizerSettings)) + init( + keyboardVisualizerSettings: KeyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {}, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {} + ) { + _model = StateObject( + wrappedValue: DisplaysSettingsPaneViewModel( + keyboardVisualizerSettings: keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, + stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition + ) + ) } var body: some View { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 4549916..c3cf010 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -9,9 +9,12 @@ import Combine import SwiftUI +@MainActor final class DisplaysSettingsPaneViewModel: ObservableObject { private let screensService: any ScreenServiceProvider private let keyboardVisualizerSettings: KeyboardVisualizerSettings + private let startSettingKeyboardVisualizerPosition: @MainActor () -> Void + private let stopSettingKeyboardVisualizerPosition: @MainActor () -> Void private var cancellables = Set() let paddingRange: ClosedRange = Double(KeyboardVisualizerSettings.minWindowPadding)...Double(KeyboardVisualizerSettings.maxWindowPadding) @@ -34,7 +37,12 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { } @Published var placementMode: KeyboardVisualizerSettings.PlacementMode { - didSet { self.keyboardVisualizerSettings.placementMode = self.placementMode } + didSet { + self.keyboardVisualizerSettings.placementMode = self.placementMode + if self.placementMode != .custom { + self.stopCustomPositionSetting() + } + } } @Published var windowPadding: Double { @@ -59,7 +67,9 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { init( screensService: any ScreenServiceProvider = ScreensService.shared, - keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings() + keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), + startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {}, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {} ) { guard let selectedScreen = Self.initialSelectedScreen( screensService: screensService, @@ -70,6 +80,8 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.screensService = screensService self.keyboardVisualizerSettings = keyboardVisualizerSettings + self.startSettingKeyboardVisualizerPosition = startSettingKeyboardVisualizerPosition + self.stopSettingKeyboardVisualizerPosition = stopSettingKeyboardVisualizerPosition self.screens = screensService.screens self.selectedScreen = selectedScreen self.selectedAnchor = keyboardVisualizerSettings.anchor @@ -89,11 +101,24 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { } func toggleCustomPositionSetting() { - self.isSettingCustomPosition.toggle() + if self.isSettingCustomPosition { + self.stopCustomPositionSetting() + } else { + self.startSettingKeyboardVisualizerPosition() + self.isSettingCustomPosition = true + } } } private extension DisplaysSettingsPaneViewModel { + func stopCustomPositionSetting() { + guard self.isSettingCustomPosition else { return } + self.stopSettingKeyboardVisualizerPosition() + self.customPositionX = Double(self.keyboardVisualizerSettings.customPositionX) + self.customPositionY = Double(self.keyboardVisualizerSettings.customPositionY) + self.isSettingCustomPosition = false + } + static func initialSelectedScreen( screensService: any ScreenServiceProvider, keyboardVisualizerSettings: KeyboardVisualizerSettings diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index aa07c7f..8fd2a4f 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift @@ -27,6 +27,8 @@ struct SettingsPaneRegistry { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, permissionsService: any PermissionsService, updater: SPUUpdater ) { @@ -71,7 +73,13 @@ struct SettingsPaneRegistry { title: SettingsPaneIdentifier.displays.label, systemImageName: SettingsPaneIdentifier.displays.sfSymbolName, makeView: { - AnyView(DisplaysSettingsPane(keyboardVisualizerSettings: keyboardVisualizerSettings)) + AnyView( + DisplaysSettingsPane( + keyboardVisualizerSettings: keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, + stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition + ) + ) } ), SettingsPaneEntry( diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 1b1c618..9577ffd 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -25,6 +25,8 @@ final class SettingsWindowController: NSWindowController { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, permissionsService: any PermissionsService, updater: SPUUpdater ) { @@ -35,6 +37,8 @@ final class SettingsWindowController: NSWindowController { pointerRingSettings: pointerRingSettings, pointerIconSettings: pointerIconSettings, keyboardVisualizerSettings: keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, + stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition, permissionsService: permissionsService, updater: updater ) @@ -52,7 +56,7 @@ final class SettingsWindowController: NSWindowController { @available(*, unavailable) required init?(coder: NSCoder) { - fatalError("Use init(shortcutManager:appSettings:pointerRingVisualizer:pointerRingSettings:pointerIconSettings:keyboardVisualizerSettings:permissionsService:updater:) instead.") + fatalError("Use init(shortcutManager:appSettings:pointerRingVisualizer:pointerRingSettings:pointerIconSettings:keyboardVisualizerSettings:startSettingKeyboardVisualizerPosition:stopSettingKeyboardVisualizerPosition:permissionsService:updater:) instead.") } override func showWindow(_ sender: Any?) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift index 8eb1ded..f81a32c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerGroupView.swift @@ -50,6 +50,10 @@ final class KeyboardVisualizerGroupView: NSView { self.preferredSize } + override var mouseDownCanMoveWindow: Bool { + true + } + init(items: [KeycapItem], settings: KeyboardVisualizerSettings = KeyboardVisualizerSettings()) { self.items = items self.settings = settings diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift new file mode 100644 index 0000000..da15556 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -0,0 +1,153 @@ +// +// KeyboardVisualizerPlacementWindowController.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +@MainActor +final class KeyboardVisualizerPlacementWindowController: NSWindowController { + private let settings: KeyboardVisualizerSettings + private let screensService: any ScreenServiceProvider + + init( + settings: KeyboardVisualizerSettings, + screensService: any ScreenServiceProvider = ScreensService.shared + ) { + self.settings = settings + self.screensService = screensService + super.init(window: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("Use init(settings:screensService:) instead.") + } +} + +// MARK: - Public API +extension KeyboardVisualizerPlacementWindowController { + func startSettingPosition() { + if let window = self.window { + window.orderFrontRegardless() + return + } + + guard let visibleFrame = self.resolvedVisibleFrame() else { return } + + let contentView = KeyboardVisualizerGroupView(items: Self.previewItems(settings: self.settings), settings: self.settings) + let size = contentView.preferredSize + let frame = Self.frame( + forNormalizedPosition: CGPoint(x: self.settings.customPositionX, y: self.settings.customPositionY), + in: visibleFrame, + size: size + ) + let window = Window(frame: frame, contentView: contentView) + self.window = window + window.orderFrontRegardless() + } + + func stopSettingPosition() { + guard let window = self.window else { return } + + if let visibleFrame = self.resolvedVisibleFrame() { + let position = Self.normalizedPosition( + for: CGPoint(x: window.frame.midX, y: window.frame.midY), + in: visibleFrame + ) + self.settings.customPositionX = position.x + self.settings.customPositionY = position.y + } + + window.close() + self.window = nil + } +} + +extension KeyboardVisualizerPlacementWindowController { + static func frame( + forNormalizedPosition position: CGPoint, + in area: CGRect, + size: CGSize + ) -> CGRect { + let center = CGPoint( + x: area.minX + area.width * position.x, + y: area.minY + area.height * position.y + ) + let origin = CGPoint( + x: Self.clamped(center.x - size.width / 2, minimum: area.minX, maximum: area.maxX - size.width), + y: Self.clamped(center.y - size.height / 2, minimum: area.minY, maximum: area.maxY - size.height) + ) + + return CGRect(origin: origin, size: size) + } + + static func normalizedPosition(for point: CGPoint, in area: CGRect) -> CGPoint { + CGPoint( + x: Self.clamped((point.x - area.minX) / max(area.width, 1), minimum: 0, maximum: 1), + y: Self.clamped((point.y - area.minY) / max(area.height, 1), minimum: 0, maximum: 1) + ) + } + + static func previewItems(settings: KeyboardVisualizerSettings) -> [KeycapItem] { + [ + Self.previewItem(keyCode: .t, symbol: "T", settings: settings), + Self.previewItem(keyCode: .e, symbol: "E", settings: settings), + Self.previewItem(keyCode: .s, symbol: "S", settings: settings), + Self.previewItem(keyCode: .t, symbol: "T", settings: settings), + ] + } +} + +private extension KeyboardVisualizerPlacementWindowController { + static func previewItem( + keyCode: KeyboardKeyCode, + symbol: String, + settings: KeyboardVisualizerSettings + ) -> KeycapItem { + let identity = KeycapIdentity.keyCode(keyCode.rawValue) + + return KeycapItem( + identity: identity, + legend: .character(symbol), + state: KeycapState(isPressed: true), + appearance: settings.palette.appearance(for: identity) + ) + } + + func resolvedVisibleFrame() -> CGRect? { + self.screensService.visibleFrame(for: self.settings.screenID) + ?? self.screensService.mainVisibleFrame() + } + + static func clamped(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat { + guard maximum >= minimum else { return minimum } + return min(max(value, minimum), maximum) + } +} + +extension KeyboardVisualizerPlacementWindowController { + final class Window: NSPanel { + init(frame: CGRect, contentView: NSView) { + super.init( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + self.level = .screenSaver + self.isOpaque = false + self.backgroundColor = .clear + self.hasShadow = true + self.isMovableByWindowBackground = true + self.isReleasedWhenClosed = false + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + contentView.frame = CGRect(origin: .zero, size: frame.size) + self.contentView = contentView + } + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index 442f407..113e4aa 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -193,13 +193,13 @@ final class KeyboardVisualizerWindow: NSWindow { } private func customFrame(size: NSSize, in area: CGRect) -> NSRect { - let anchorPoint = CGPoint( + let center = CGPoint( x: area.minX + area.width * self.settings.customPositionX, y: area.minY + area.height * self.settings.customPositionY ) let origin = CGPoint( - x: anchorPoint.x - self.anchorOffsetX(for: size), - y: anchorPoint.y - self.anchorOffsetY(for: size) + x: center.x - size.width / 2, + y: center.y - size.height / 2 ) let clampedOrigin = CGPoint( x: self.clampedOriginValue(origin.x, minimum: area.minX, maximum: area.maxX - size.width), @@ -213,29 +213,6 @@ final class KeyboardVisualizerWindow: NSWindow { guard maximum >= minimum else { return minimum } return min(max(value, minimum), maximum) } - - private func anchorOffsetX(for size: NSSize) -> CGFloat { - switch self.settings.anchor.horizontal { - case .leading: - return 0 - case .center: - return size.width / 2 - case .trailing: - return size.width - } - } - - private func anchorOffsetY(for size: NSSize) -> CGFloat { - switch self.settings.anchor.vertical { - case .bottom: - return 0 - case .middle: - return size.height / 2 - case .top: - return size.height - } - } - private func resolvedVisibleFrame() -> CGRect? { self.screensService.visibleFrame(for: self.settings.screenID) ?? self.screensService.mainVisibleFrame() diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index 4151bf9..f8074a4 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -10,10 +10,13 @@ import Combine import XCTest @testable import Keyty +@MainActor final class DisplaysSettingsPaneViewModelTests: XCTestCase { private var store: InMemoryKeyValueStore! private var keyboardVisualizerSettings: KeyboardVisualizerSettings! private var screensService: TestScreenService! + private var startSettingCallCount = 0 + private var stopSettingCallCount = 0 private var model: DisplaysSettingsPaneViewModel! override func setUp() { @@ -21,14 +24,24 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { self.store = InMemoryKeyValueStore() self.keyboardVisualizerSettings = KeyboardVisualizerSettings(store: self.store) self.screensService = TestScreenService() + self.startSettingCallCount = 0 + self.stopSettingCallCount = 0 self.model = DisplaysSettingsPaneViewModel( screensService: self.screensService, - keyboardVisualizerSettings: self.keyboardVisualizerSettings + keyboardVisualizerSettings: self.keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: { [weak self] in + self?.startSettingCallCount += 1 + }, + stopSettingKeyboardVisualizerPosition: { [weak self] in + self?.stopSettingCallCount += 1 + } ) } override func tearDown() { self.model = nil + self.stopSettingCallCount = 0 + self.startSettingCallCount = 0 self.screensService = nil self.keyboardVisualizerSettings = nil self.store = nil @@ -51,6 +64,73 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) } + + func testToggleCustomPositionSettingStartsAndStopsPlacementController() { + self.model.toggleCustomPositionSetting() + + XCTAssertTrue(self.model.isSettingCustomPosition) + XCTAssertEqual(self.startSettingCallCount, 1) + XCTAssertEqual(self.stopSettingCallCount, 0) + + self.model.toggleCustomPositionSetting() + + XCTAssertFalse(self.model.isSettingCustomPosition) + XCTAssertEqual(self.startSettingCallCount, 1) + XCTAssertEqual(self.stopSettingCallCount, 1) + } + + func testChangingPlacementModeStopsPositionSetting() { + self.model.placementMode = .custom + self.model.toggleCustomPositionSetting() + + self.model.placementMode = .anchored + + XCTAssertFalse(self.model.isSettingCustomPosition) + XCTAssertEqual(self.stopSettingCallCount, 1) + } +} + +@MainActor +final class KeyboardVisualizerPlacementWindowControllerTests: XCTestCase { + func testFramePlacesHandleCenterAtNormalizedPosition() { + let area = CGRect(x: 100, y: 200, width: 800, height: 600) + let size = CGSize(width: 120, height: 80) + + let frame = KeyboardVisualizerPlacementWindowController.frame( + forNormalizedPosition: CGPoint(x: 0.25, y: 0.75), + in: area, + size: size + ) + + XCTAssertEqual(frame.midX, 300, accuracy: 0.0001) + XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) + } + + func testFrameClampsHandleInsideVisibleFrame() { + let area = CGRect(x: 100, y: 200, width: 800, height: 600) + let size = CGSize(width: 120, height: 80) + + let frame = KeyboardVisualizerPlacementWindowController.frame( + forNormalizedPosition: CGPoint(x: 0, y: 1), + in: area, + size: size + ) + + XCTAssertEqual(frame.minX, area.minX, accuracy: 0.0001) + XCTAssertEqual(frame.maxY, area.maxY, accuracy: 0.0001) + } + + func testNormalizedPositionClampsPointToVisibleFrame() { + let area = CGRect(x: 100, y: 200, width: 800, height: 600) + + let position = KeyboardVisualizerPlacementWindowController.normalizedPosition( + for: CGPoint(x: 980, y: 140), + in: area + ) + + XCTAssertEqual(position.x, 1, accuracy: 0.0001) + XCTAssertEqual(position.y, 0, accuracy: 0.0001) + } } private final class TestScreenService: ScreenServiceProvider { From 54204cfc9c2c14b89bea5892a3ea3f069188f1e1 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 31 Jul 2026 23:16:53 +0200 Subject: [PATCH 03/21] feat(displays): add custom keyboard visualizer placement --- .../Features/Settings/Displays/DisplaysSettingsPane.swift | 2 +- .../Visualizers/Keyboard/KeyboardVisualizerSettings.swift | 4 ++++ .../Sources/Keyty/Resources/de.lproj/Localizable.strings | 2 +- .../Sources/Keyty/Resources/en.lproj/Localizable.strings | 2 +- .../Sources/Keyty/Resources/es.lproj/Localizable.strings | 2 +- .../Sources/Keyty/Resources/fr.lproj/Localizable.strings | 2 +- .../Sources/Keyty/Resources/ja.lproj/Localizable.strings | 2 +- .../Sources/Keyty/Resources/uk.lproj/Localizable.strings | 2 +- .../Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings | 2 +- 9 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 4275788..2a4a84a 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -109,7 +109,7 @@ struct DisplaysSettingsPane: View { private extension DisplaysSettingsPane { var customPlacementControls: some View { SettingsControlRow( - title: L10n.Displays.CustomPosition.setLabel, + title: L10n.Displays.CustomPosition.label, subtitle: L10n.Displays.CustomPosition.setSubtitle ) { Button(self.model.isSettingCustomPosition ? L10n.Displays.CustomPosition.stopSettingButton : L10n.Displays.CustomPosition.startSettingButton) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index b3378c5..b5c16e0 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -11,14 +11,18 @@ import Combine protocol KeyboardVisualizerSettingsProtocol: AnyObject { var isEnabled: Bool { get set } + var stackAxis: KeyboardVisualizerStackAxis { get set } var maxCount: Int { get set } + var fadeDelay: CGFloat { get set } var fadeDuration: CGFloat { get set } + var theme: KeyboardVisualizerTheme { get set } var legendColorMode: KeyboardLegendColorMode { get set } var customLegendColor: NSColor { get set } var usesCustomThemePalette: Bool { get set } + var modifierTheme: KeyboardVisualizerTheme { get set } var specialTheme: KeyboardVisualizerTheme { get set } var mediaTheme: KeyboardVisualizerTheme { get set } diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index 588e769..cec8b70 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -47,7 +47,7 @@ "displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Bildschirmabstand"; "displays.margin_subtitle" = "Abstand zwischen der Einblendung und dem ausgewählten Bildschirmrand."; -"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Start Setting…"; "displays.custom_position.stop_setting_button" = "Stop Setting"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 451f23d..415ddc9 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -47,7 +47,7 @@ "displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Screen Inset"; "displays.margin_subtitle" = "Distance between the overlay and the selected screen edge."; -"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Start Setting…"; "displays.custom_position.stop_setting_button" = "Stop Setting"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index af01767..7a535fc 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -47,7 +47,7 @@ "displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Margen de pantalla"; "displays.margin_subtitle" = "Distancia entre la superposición y el borde seleccionado de la pantalla."; -"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Start Setting…"; "displays.custom_position.stop_setting_button" = "Stop Setting"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 5496485..3be2077 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -47,7 +47,7 @@ "displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Marge de l’écran"; "displays.margin_subtitle" = "Distance entre la superposition et le bord d’écran sélectionné."; -"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Start Setting…"; "displays.custom_position.stop_setting_button" = "Stop Setting"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index d6c437e..3694a34 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -47,7 +47,7 @@ "displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "画面端からの余白"; "displays.margin_subtitle" = "オーバーレイと選択した画面端との間隔です。"; -"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Start Setting…"; "displays.custom_position.stop_setting_button" = "Stop Setting"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 3f6b19a..6d1c516 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -47,7 +47,7 @@ "displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "Відступ від краю"; "displays.margin_subtitle" = "Відстань між накладкою та вибраним краєм екрана."; -"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Start Setting…"; "displays.custom_position.stop_setting_button" = "Stop Setting"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 6f0d0c1..4e707dc 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -47,7 +47,7 @@ "displays.anchor_subtitle" = "Overlay alignment and growth point."; "displays.margin_label" = "屏幕边距"; "displays.margin_subtitle" = "叠加层与所选屏幕边缘之间的距离。"; -"displays.custom_position.set_label" = "Set Position"; +"displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Start Setting…"; "displays.custom_position.stop_setting_button" = "Stop Setting"; From 65c072ba1492a9c725a0371cecfc8098ef5afe88 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 14:44:02 +0200 Subject: [PATCH 04/21] feat(displays): sync custom placement with dragged display --- .../Displays/DisplaysSettingsPane.swift | 5 +- .../DisplaysSettingsPaneViewModel.swift | 12 +-- .../Settings/SettingsPaneRegistry.swift | 2 +- .../Settings/SettingsWindowController.swift | 2 +- ...dVisualizerPlacementWindowController.swift | 82 ++++++++++++++++--- .../DisplaysSettingsPaneViewModelTests.swift | 55 +++++++++++++ 6 files changed, 138 insertions(+), 20 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 2a4a84a..0eeb5a8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -14,7 +14,7 @@ struct DisplaysSettingsPane: View { init( keyboardVisualizerSettings: KeyboardVisualizerSettings, startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {}, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {} + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement? = { nil } ) { _model = StateObject( wrappedValue: DisplaysSettingsPaneViewModel( @@ -115,7 +115,8 @@ private extension DisplaysSettingsPane { Button(self.model.isSettingCustomPosition ? L10n.Displays.CustomPosition.stopSettingButton : L10n.Displays.CustomPosition.startSettingButton) { self.model.toggleCustomPositionSetting() } - .controlSize(.small) + .controlSize(.regular) + .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) } } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index c3cf010..88152a7 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -14,7 +14,7 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { private let screensService: any ScreenServiceProvider private let keyboardVisualizerSettings: KeyboardVisualizerSettings private let startSettingKeyboardVisualizerPosition: @MainActor () -> Void - private let stopSettingKeyboardVisualizerPosition: @MainActor () -> Void + private let stopSettingKeyboardVisualizerPosition: @MainActor () -> KeyboardVisualizerPlacement? private var cancellables = Set() let paddingRange: ClosedRange = Double(KeyboardVisualizerSettings.minWindowPadding)...Double(KeyboardVisualizerSettings.maxWindowPadding) @@ -69,7 +69,7 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { screensService: any ScreenServiceProvider = ScreensService.shared, keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {}, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {} + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement? = { nil } ) { guard let selectedScreen = Self.initialSelectedScreen( screensService: screensService, @@ -113,9 +113,11 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { private extension DisplaysSettingsPaneViewModel { func stopCustomPositionSetting() { guard self.isSettingCustomPosition else { return } - self.stopSettingKeyboardVisualizerPosition() - self.customPositionX = Double(self.keyboardVisualizerSettings.customPositionX) - self.customPositionY = Double(self.keyboardVisualizerSettings.customPositionY) + if let placement = self.stopSettingKeyboardVisualizerPosition() { + self.selectedScreenID = placement.screenID + self.customPositionX = Double(placement.positionX) + self.customPositionY = Double(placement.positionY) + } self.isSettingCustomPosition = false } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index 8fd2a4f..b42951d 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift @@ -28,7 +28,7 @@ struct SettingsPaneRegistry { pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement?, permissionsService: any PermissionsService, updater: SPUUpdater ) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 9577ffd..830a3e6 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -26,7 +26,7 @@ final class SettingsWindowController: NSWindowController { pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement?, permissionsService: any PermissionsService, updater: SPUUpdater ) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index da15556..34b72de 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -8,6 +8,12 @@ import AppKit +struct KeyboardVisualizerPlacement { + let screenID: CGDirectDisplayID + let positionX: CGFloat + let positionY: CGFloat +} + @MainActor final class KeyboardVisualizerPlacementWindowController: NSWindowController { private let settings: KeyboardVisualizerSettings @@ -50,20 +56,13 @@ extension KeyboardVisualizerPlacementWindowController { window.orderFrontRegardless() } - func stopSettingPosition() { - guard let window = self.window else { return } - - if let visibleFrame = self.resolvedVisibleFrame() { - let position = Self.normalizedPosition( - for: CGPoint(x: window.frame.midX, y: window.frame.midY), - in: visibleFrame - ) - self.settings.customPositionX = position.x - self.settings.customPositionY = position.y - } + func stopSettingPosition() -> KeyboardVisualizerPlacement? { + guard let window = self.window else { return nil } + let placement = self.placement(for: CGPoint(x: window.frame.midX, y: window.frame.midY)) window.close() self.window = nil + return placement } } @@ -92,6 +91,25 @@ extension KeyboardVisualizerPlacementWindowController { ) } + static func placement( + for point: CGPoint, + in visibleFrames: [(screenID: CGDirectDisplayID, frame: CGRect)] + ) -> KeyboardVisualizerPlacement? { + guard let visibleFrame = visibleFrames.first(where: { $0.frame.contains(point) }) + ?? Self.nearestVisibleFrame(to: point, in: visibleFrames) + else { + return nil + } + + let position = Self.normalizedPosition(for: point, in: visibleFrame.frame) + + return KeyboardVisualizerPlacement( + screenID: visibleFrame.screenID, + positionX: position.x, + positionY: position.y + ) + } + static func previewItems(settings: KeyboardVisualizerSettings) -> [KeycapItem] { [ Self.previewItem(keyCode: .t, symbol: "T", settings: settings), @@ -123,6 +141,48 @@ private extension KeyboardVisualizerPlacementWindowController { ?? self.screensService.mainVisibleFrame() } + func placement(for point: CGPoint) -> KeyboardVisualizerPlacement? { + Self.placement(for: point, in: self.visibleFrames()) + } + + func visibleFrames() -> [(screenID: CGDirectDisplayID, frame: CGRect)] { + self.screensService.screens.compactMap { screen in + guard let frame = self.screensService.visibleFrame(for: screen.id) else { return nil } + return (screen.id, frame) + } + } + + static func nearestVisibleFrame( + to point: CGPoint, + in visibleFrames: [(screenID: CGDirectDisplayID, frame: CGRect)] + ) -> (screenID: CGDirectDisplayID, frame: CGRect)? { + visibleFrames.min { lhs, rhs in + Self.squaredDistance(from: point, to: lhs.frame) < Self.squaredDistance(from: point, to: rhs.frame) + } + } + + static func squaredDistance(from point: CGPoint, to frame: CGRect) -> CGFloat { + let dx: CGFloat + if point.x < frame.minX { + dx = frame.minX - point.x + } else if point.x > frame.maxX { + dx = point.x - frame.maxX + } else { + dx = 0 + } + + let dy: CGFloat + if point.y < frame.minY { + dy = frame.minY - point.y + } else if point.y > frame.maxY { + dy = point.y - frame.maxY + } else { + dy = 0 + } + + return dx * dx + dy * dy + } + static func clamped(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat { guard maximum >= minimum else { return minimum } return min(max(value, minimum), maximum) diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index f8074a4..c5639b0 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -17,6 +17,7 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { private var screensService: TestScreenService! private var startSettingCallCount = 0 private var stopSettingCallCount = 0 + private var placementToReturn: KeyboardVisualizerPlacement? private var model: DisplaysSettingsPaneViewModel! override func setUp() { @@ -26,6 +27,7 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { self.screensService = TestScreenService() self.startSettingCallCount = 0 self.stopSettingCallCount = 0 + self.placementToReturn = nil self.model = DisplaysSettingsPaneViewModel( screensService: self.screensService, keyboardVisualizerSettings: self.keyboardVisualizerSettings, @@ -34,12 +36,14 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { }, stopSettingKeyboardVisualizerPosition: { [weak self] in self?.stopSettingCallCount += 1 + return self?.placementToReturn } ) } override func tearDown() { self.model = nil + self.placementToReturn = nil self.stopSettingCallCount = 0 self.startSettingCallCount = 0 self.screensService = nil @@ -88,6 +92,24 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertFalse(self.model.isSettingCustomPosition) XCTAssertEqual(self.stopSettingCallCount, 1) } + + func testStoppingCustomPositionSettingAppliesReturnedPlacement() { + self.placementToReturn = KeyboardVisualizerPlacement( + screenID: 2, + positionX: 0.25, + positionY: 0.75 + ) + + self.model.toggleCustomPositionSetting() + self.model.toggleCustomPositionSetting() + + XCTAssertEqual(self.model.selectedScreenID, 2) + XCTAssertEqual(self.keyboardVisualizerSettings.screenID, 2) + XCTAssertEqual(self.model.customPositionX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionY, 0.75, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) + } } @MainActor @@ -131,6 +153,33 @@ final class KeyboardVisualizerPlacementWindowControllerTests: XCTestCase { XCTAssertEqual(position.x, 1, accuracy: 0.0001) XCTAssertEqual(position.y, 0, accuracy: 0.0001) } + + func testPlacementUsesScreenContainingPoint() { + let placement = KeyboardVisualizerPlacementWindowController.placement( + for: CGPoint(x: 2500, y: 540), + in: Self.visibleFrames + ) + + XCTAssertEqual(placement?.screenID, 2) + XCTAssertEqual(placement?.positionX ?? 0, 0.3021, accuracy: 0.0001) + XCTAssertEqual(placement?.positionY ?? 0, 0.5, accuracy: 0.0001) + } + + func testPlacementFallsBackToNearestScreen() { + let placement = KeyboardVisualizerPlacementWindowController.placement( + for: CGPoint(x: 3950, y: 540), + in: Self.visibleFrames + ) + + XCTAssertEqual(placement?.screenID, 2) + XCTAssertEqual(placement?.positionX ?? 0, 1, accuracy: 0.0001) + XCTAssertEqual(placement?.positionY ?? 0, 0.5, accuracy: 0.0001) + } + + private static let visibleFrames: [(screenID: CGDirectDisplayID, frame: CGRect)] = [ + (1, CGRect(x: 0, y: 0, width: 1920, height: 1080)), + (2, CGRect(x: 1920, y: 0, width: 1920, height: 1080)), + ] } private final class TestScreenService: ScreenServiceProvider { @@ -150,6 +199,12 @@ private final class TestScreenService: ScreenServiceProvider { wallpaperImageURL: nil, frame: CGRect(x: 0, y: 0, width: 1920, height: 1080) ), + Screen( + id: 2, + displayName: "Display 2", + wallpaperImageURL: nil, + frame: CGRect(x: 1920, y: 0, width: 1920, height: 1080) + ), ] self.screensSubject = CurrentValueSubject(self.screens) } From 078b1310654cce8a4e0b0ca0223f148acb255a2b Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 14:50:00 +0200 Subject: [PATCH 05/21] feat(displays): persist custom position after dragging --- .../App/Composition/AppUIContainer.swift | 4 +-- .../Displays/DisplaysSettingsPane.swift | 2 +- .../DisplaysSettingsPaneViewModel.swift | 18 ++++++++---- .../Settings/SettingsPaneRegistry.swift | 2 +- .../Settings/SettingsWindowController.swift | 2 +- ...dVisualizerPlacementWindowController.swift | 28 ++++++++++++++++++- .../DisplaysSettingsPaneViewModelTests.swift | 26 ++++++++++++++++- 7 files changed, 69 insertions(+), 13 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift index 62fe55e..333a7f2 100644 --- a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift +++ b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift @@ -35,8 +35,8 @@ final class AppUIContainer { pointerRingSettings: settings.pointerRingSettings, pointerIconSettings: settings.pointerIconSettings, keyboardVisualizerSettings: settings.keyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: { [weak keyboardVisualizerPlacementWindowController] in - keyboardVisualizerPlacementWindowController?.startSettingPosition() + startSettingKeyboardVisualizerPosition: { [weak keyboardVisualizerPlacementWindowController] onPlacementChanged in + keyboardVisualizerPlacementWindowController?.startSettingPosition(onPlacementChanged: onPlacementChanged) }, stopSettingKeyboardVisualizerPosition: { [weak keyboardVisualizerPlacementWindowController] in keyboardVisualizerPlacementWindowController?.stopSettingPosition() diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 0eeb5a8..d15e034 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -13,7 +13,7 @@ struct DisplaysSettingsPane: View { init( keyboardVisualizerSettings: KeyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {}, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void = { _ in }, stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement? = { nil } ) { _model = StateObject( diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 88152a7..d5e6039 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -13,7 +13,7 @@ import SwiftUI final class DisplaysSettingsPaneViewModel: ObservableObject { private let screensService: any ScreenServiceProvider private let keyboardVisualizerSettings: KeyboardVisualizerSettings - private let startSettingKeyboardVisualizerPosition: @MainActor () -> Void + private let startSettingKeyboardVisualizerPosition: @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void private let stopSettingKeyboardVisualizerPosition: @MainActor () -> KeyboardVisualizerPlacement? private var cancellables = Set() @@ -68,7 +68,7 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { init( screensService: any ScreenServiceProvider = ScreensService.shared, keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), - startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void = {}, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void = { _ in }, stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement? = { nil } ) { guard let selectedScreen = Self.initialSelectedScreen( @@ -104,7 +104,9 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { if self.isSettingCustomPosition { self.stopCustomPositionSetting() } else { - self.startSettingKeyboardVisualizerPosition() + self.startSettingKeyboardVisualizerPosition { [weak self] placement in + self?.applyPlacement(placement) + } self.isSettingCustomPosition = true } } @@ -114,13 +116,17 @@ private extension DisplaysSettingsPaneViewModel { func stopCustomPositionSetting() { guard self.isSettingCustomPosition else { return } if let placement = self.stopSettingKeyboardVisualizerPosition() { - self.selectedScreenID = placement.screenID - self.customPositionX = Double(placement.positionX) - self.customPositionY = Double(placement.positionY) + self.applyPlacement(placement) } self.isSettingCustomPosition = false } + func applyPlacement(_ placement: KeyboardVisualizerPlacement) { + self.selectedScreenID = placement.screenID + self.customPositionX = Double(placement.positionX) + self.customPositionY = Double(placement.positionY) + } + static func initialSelectedScreen( screensService: any ScreenServiceProvider, keyboardVisualizerSettings: KeyboardVisualizerSettings diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index b42951d..c0033b0 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift @@ -27,7 +27,7 @@ struct SettingsPaneRegistry { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void, stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement?, permissionsService: any PermissionsService, updater: SPUUpdater diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 830a3e6..1d12dd4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -25,7 +25,7 @@ final class SettingsWindowController: NSWindowController { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: @escaping @MainActor () -> Void, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void, stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement?, permissionsService: any PermissionsService, updater: SPUUpdater diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 34b72de..90f8d39 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -14,10 +14,13 @@ struct KeyboardVisualizerPlacement { let positionY: CGFloat } +typealias KeyboardVisualizerPlacementChangeHandler = @MainActor (KeyboardVisualizerPlacement) -> Void + @MainActor final class KeyboardVisualizerPlacementWindowController: NSWindowController { private let settings: KeyboardVisualizerSettings private let screensService: any ScreenServiceProvider + private var onPlacementChanged: KeyboardVisualizerPlacementChangeHandler? init( settings: KeyboardVisualizerSettings, @@ -36,7 +39,9 @@ final class KeyboardVisualizerPlacementWindowController: NSWindowController { // MARK: - Public API extension KeyboardVisualizerPlacementWindowController { - func startSettingPosition() { + func startSettingPosition(onPlacementChanged: @escaping KeyboardVisualizerPlacementChangeHandler) { + self.onPlacementChanged = onPlacementChanged + if let window = self.window { window.orderFrontRegardless() return @@ -52,6 +57,9 @@ extension KeyboardVisualizerPlacementWindowController { size: size ) let window = Window(frame: frame, contentView: contentView) + window.onMoveEnded = { [weak self] in + self?.notifyPlacementChanged() + } self.window = window window.orderFrontRegardless() } @@ -61,6 +69,7 @@ extension KeyboardVisualizerPlacementWindowController { let placement = self.placement(for: CGPoint(x: window.frame.midX, y: window.frame.midY)) window.close() + self.onPlacementChanged = nil self.window = nil return placement } @@ -145,6 +154,16 @@ private extension KeyboardVisualizerPlacementWindowController { Self.placement(for: point, in: self.visibleFrames()) } + func notifyPlacementChanged() { + guard let window = self.window, + let placement = self.placement(for: CGPoint(x: window.frame.midX, y: window.frame.midY)) + else { + return + } + + self.onPlacementChanged?(placement) + } + func visibleFrames() -> [(screenID: CGDirectDisplayID, frame: CGRect)] { self.screensService.screens.compactMap { screen in guard let frame = self.screensService.visibleFrame(for: screen.id) else { return nil } @@ -191,6 +210,8 @@ private extension KeyboardVisualizerPlacementWindowController { extension KeyboardVisualizerPlacementWindowController { final class Window: NSPanel { + var onMoveEnded: (() -> Void)? + init(frame: CGRect, contentView: NSView) { super.init( contentRect: frame, @@ -209,5 +230,10 @@ extension KeyboardVisualizerPlacementWindowController { contentView.frame = CGRect(origin: .zero, size: frame.size) self.contentView = contentView } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + self.onMoveEnded?() + } } } diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index c5639b0..bf25d3d 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -18,6 +18,7 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { private var startSettingCallCount = 0 private var stopSettingCallCount = 0 private var placementToReturn: KeyboardVisualizerPlacement? + private var placementChangeHandler: KeyboardVisualizerPlacementChangeHandler? private var model: DisplaysSettingsPaneViewModel! override func setUp() { @@ -28,11 +29,13 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { self.startSettingCallCount = 0 self.stopSettingCallCount = 0 self.placementToReturn = nil + self.placementChangeHandler = nil self.model = DisplaysSettingsPaneViewModel( screensService: self.screensService, keyboardVisualizerSettings: self.keyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: { [weak self] in + startSettingKeyboardVisualizerPosition: { [weak self] onPlacementChanged in self?.startSettingCallCount += 1 + self?.placementChangeHandler = onPlacementChanged }, stopSettingKeyboardVisualizerPosition: { [weak self] in self?.stopSettingCallCount += 1 @@ -43,6 +46,7 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { override func tearDown() { self.model = nil + self.placementChangeHandler = nil self.placementToReturn = nil self.stopSettingCallCount = 0 self.startSettingCallCount = 0 @@ -110,6 +114,26 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) } + + func testPlacementChangeWhileSettingAppliesPlacement() { + self.model.toggleCustomPositionSetting() + + self.placementChangeHandler?( + KeyboardVisualizerPlacement( + screenID: 2, + positionX: 0.4, + positionY: 0.6 + ) + ) + + XCTAssertTrue(self.model.isSettingCustomPosition) + XCTAssertEqual(self.model.selectedScreenID, 2) + XCTAssertEqual(self.keyboardVisualizerSettings.screenID, 2) + XCTAssertEqual(self.model.customPositionX, 0.4, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionY, 0.6, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.4, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.6, accuracy: 0.0001) + } } @MainActor From f6229401b5f5bfbd43db0a306dba6d9508f94082 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 14:56:48 +0200 Subject: [PATCH 06/21] fix(displays): finish custom positioning on pane exit --- .../Displays/DisplaysSettingsPane.swift | 127 +++++++++--------- .../DisplaysSettingsPaneViewModel.swift | 4 + .../DisplaysSettingsPaneViewModelTests.swift | 27 ++++ 3 files changed, 97 insertions(+), 61 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index d15e034..2351337 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -26,83 +26,88 @@ struct DisplaysSettingsPane: View { } var body: some View { - DisplaysSettingsPane.PreviewCard( - screens: self.model.screens, - selectedScreen: self.model.selectedScreen, - anchor: self.model.selectedAnchor, - placementMode: self.model.placementMode, - stackAxis: self.model.stackAxis, - windowPadding: self.model.windowPadding, - customPositionX: self.model.customPositionX, - customPositionY: self.model.customPositionY, - onSelectScreen: { screen in - self.model.selectedScreenID = screen.id - } - ) - - SettingsStack { - SettingsSectionView { - SettingsControlRow( - title: L10n.Displays.displayLabel, - subtitle: L10n.Displays.displaySubtitle - ) { - Picker("", selection: $model.selectedScreenID) { - ForEach(model.screens) { screen in - Text(screen.displayName).tag(screen.id) - } - } - .labelsHidden() - .accessibilityLabel(L10n.Displays.displayLabel) - .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) + Group { + DisplaysSettingsPane.PreviewCard( + screens: self.model.screens, + selectedScreen: self.model.selectedScreen, + anchor: self.model.selectedAnchor, + placementMode: self.model.placementMode, + stackAxis: self.model.stackAxis, + windowPadding: self.model.windowPadding, + customPositionX: self.model.customPositionX, + customPositionY: self.model.customPositionY, + onSelectScreen: { screen in + self.model.selectedScreenID = screen.id } + ) - Divider() - - SettingsControlRow( - title: L10n.Displays.placementLabel, - subtitle: L10n.Displays.placementSubtitle - ) { - Picker("", selection: self.$model.placementMode) { - ForEach(KeyboardVisualizerSettings.PlacementMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) + SettingsStack { + SettingsSectionView { + SettingsControlRow( + title: L10n.Displays.displayLabel, + subtitle: L10n.Displays.displaySubtitle + ) { + Picker("", selection: $model.selectedScreenID) { + ForEach(model.screens) { screen in + Text(screen.displayName).tag(screen.id) + } } + .labelsHidden() + .accessibilityLabel(L10n.Displays.displayLabel) + .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) } - .labelsHidden() - .accessibilityLabel(L10n.Displays.placementLabel) - .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) - } - Divider() + Divider() - if self.model.isCustomPlacement { - self.customPlacementControls - } else { SettingsControlRow( - title: L10n.Displays.anchorLabel, - subtitle: L10n.Displays.anchorSubtitle + title: L10n.Displays.placementLabel, + subtitle: L10n.Displays.placementSubtitle ) { - KeyboardVisualizerAnchorPicker( - selection: self.$model.selectedAnchor, - accessibilityLabel: L10n.Displays.anchorLabel - ) + Picker("", selection: self.$model.placementMode) { + ForEach(KeyboardVisualizerSettings.PlacementMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) + } + } + .labelsHidden() + .accessibilityLabel(L10n.Displays.placementLabel) + .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) } Divider() - SettingsControlRow( - title: L10n.Displays.marginLabel, - subtitle: L10n.Displays.marginSubtitle - ) { - SettingsSliderControl( - value: self.$model.windowPadding, - range: self.model.paddingRange, - step: self.model.paddingStep, - accessibilityLabel: L10n.Displays.marginLabel - ) + if self.model.isCustomPlacement { + self.customPlacementControls + } else { + SettingsControlRow( + title: L10n.Displays.anchorLabel, + subtitle: L10n.Displays.anchorSubtitle + ) { + KeyboardVisualizerAnchorPicker( + selection: self.$model.selectedAnchor, + accessibilityLabel: L10n.Displays.anchorLabel + ) + } + + Divider() + + SettingsControlRow( + title: L10n.Displays.marginLabel, + subtitle: L10n.Displays.marginSubtitle + ) { + SettingsSliderControl( + value: self.$model.windowPadding, + range: self.model.paddingRange, + step: self.model.paddingStep, + accessibilityLabel: L10n.Displays.marginLabel + ) + } } } } } + .onDisappear { + self.model.finishCustomPositionSetting() + } } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index d5e6039..134835a 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -110,6 +110,10 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.isSettingCustomPosition = true } } + + func finishCustomPositionSetting() { + self.stopCustomPositionSetting() + } } private extension DisplaysSettingsPaneViewModel { diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index bf25d3d..0c51e05 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -97,6 +97,33 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertEqual(self.stopSettingCallCount, 1) } + func testFinishingCustomPositionSettingStopsAndAppliesReturnedPlacement() { + self.placementToReturn = KeyboardVisualizerPlacement( + screenID: 2, + positionX: 0.3, + positionY: 0.7 + ) + + self.model.toggleCustomPositionSetting() + self.model.finishCustomPositionSetting() + + XCTAssertFalse(self.model.isSettingCustomPosition) + XCTAssertEqual(self.stopSettingCallCount, 1) + XCTAssertEqual(self.model.selectedScreenID, 2) + XCTAssertEqual(self.keyboardVisualizerSettings.screenID, 2) + XCTAssertEqual(self.model.customPositionX, 0.3, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionY, 0.7, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.3, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.7, accuracy: 0.0001) + } + + func testFinishingCustomPositionSettingDoesNothingWhenNotSetting() { + self.model.finishCustomPositionSetting() + + XCTAssertFalse(self.model.isSettingCustomPosition) + XCTAssertEqual(self.stopSettingCallCount, 0) + } + func testStoppingCustomPositionSettingAppliesReturnedPlacement() { self.placementToReturn = KeyboardVisualizerPlacement( screenID: 2, From c2641e9ff6e69309d3f8204c010add495f1cf1f6 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 15:07:42 +0200 Subject: [PATCH 07/21] refactor(displays): fold custom placement into anchor picker --- .../Displays/DisplaysSettingsPane.swift | 33 ++++++++--------- .../DisplaysSettingsPaneViewModel.swift | 36 +++++++++++++++++++ .../DisplaysSettingsPaneViewModelTests.swift | 30 ++++++++++++++++ 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 2351337..8ee82cc 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -60,34 +60,24 @@ struct DisplaysSettingsPane: View { Divider() SettingsControlRow( - title: L10n.Displays.placementLabel, - subtitle: L10n.Displays.placementSubtitle + title: L10n.Displays.anchorLabel, + subtitle: L10n.Displays.anchorSubtitle ) { - Picker("", selection: self.$model.placementMode) { - ForEach(KeyboardVisualizerSettings.PlacementMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) + Picker("", selection: self.anchorSelection) { + ForEach(DisplaysAnchorSelection.allCases, id: \.self) { selection in + Text(selection.label).tag(selection) } } .labelsHidden() - .accessibilityLabel(L10n.Displays.placementLabel) + .accessibilityLabel(L10n.Displays.anchorLabel) .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) } - Divider() - if self.model.isCustomPlacement { + Divider() + self.customPlacementControls } else { - SettingsControlRow( - title: L10n.Displays.anchorLabel, - subtitle: L10n.Displays.anchorSubtitle - ) { - KeyboardVisualizerAnchorPicker( - selection: self.$model.selectedAnchor, - accessibilityLabel: L10n.Displays.anchorLabel - ) - } - Divider() SettingsControlRow( @@ -124,4 +114,11 @@ private extension DisplaysSettingsPane { .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) } } + + var anchorSelection: Binding { + Binding( + get: { self.model.anchorSelection }, + set: { self.model.anchorSelection = $0 } + ) + } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 134835a..8c30cb4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -9,6 +9,22 @@ import Combine import SwiftUI +enum DisplaysAnchorSelection: Hashable { + case anchor(KeyboardVisualizerAnchor) + case custom + + static let allCases: [DisplaysAnchorSelection] = KeyboardVisualizerAnchor.allCases.map(Self.anchor) + [.custom] + + var label: String { + switch self { + case .anchor(let anchor): + return "\(anchor.symbol) \(anchor.label)" + case .custom: + return L10n.Displays.Placement.custom + } + } +} + @MainActor final class DisplaysSettingsPaneViewModel: ObservableObject { private let screensService: any ScreenServiceProvider @@ -65,6 +81,26 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.placementMode == .custom } + var anchorSelection: DisplaysAnchorSelection { + get { + switch self.placementMode { + case .anchored: + return .anchor(self.selectedAnchor) + case .custom: + return .custom + } + } + set { + switch newValue { + case .anchor(let anchor): + self.selectedAnchor = anchor + self.placementMode = .anchored + case .custom: + self.placementMode = .custom + } + } + } + init( screensService: any ScreenServiceProvider = ScreensService.shared, keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index 0c51e05..a748c65 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -97,6 +97,36 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertEqual(self.stopSettingCallCount, 1) } + func testAnchorSelectionReflectsPlacementMode() { + self.model.selectedAnchor = .topLeft + self.model.placementMode = .anchored + + XCTAssertEqual(self.model.anchorSelection, .anchor(.topLeft)) + + self.model.placementMode = .custom + + XCTAssertEqual(self.model.anchorSelection, .custom) + } + + func testSettingAnchorSelectionUpdatesAnchoredPlacement() { + self.model.placementMode = .custom + + self.model.anchorSelection = .anchor(.topRight) + + XCTAssertEqual(self.model.placementMode, .anchored) + XCTAssertEqual(self.keyboardVisualizerSettings.placementMode, .anchored) + XCTAssertEqual(self.model.selectedAnchor, .topRight) + XCTAssertEqual(self.keyboardVisualizerSettings.anchor, .topRight) + } + + func testSettingCustomAnchorSelectionUpdatesCustomPlacement() { + self.model.anchorSelection = .custom + + XCTAssertEqual(self.model.placementMode, .custom) + XCTAssertEqual(self.keyboardVisualizerSettings.placementMode, .custom) + XCTAssertEqual(self.model.anchorSelection, .custom) + } + func testFinishingCustomPositionSettingStopsAndAppliesReturnedPlacement() { self.placementToReturn = KeyboardVisualizerPlacement( screenID: 2, From a67c58b7c2c67795661dc472ecdc05e0dc9ca000 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 15:21:37 +0200 Subject: [PATCH 08/21] refactor(keyboard): remove position picker --- .../Settings/Keyboard/KeyboardSettingsPane.swift | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift index 304dd77..354ef3c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift @@ -169,15 +169,6 @@ struct KeyboardSettingsPane: View { title: L10n.KeyboardVisualizer.layoutSectionTitle, subtitle: L10n.KeyboardVisualizer.layoutSectionSubtitle ) { - SettingsControlRow(title: L10n.KeyboardVisualizer.anchorLabel, subtitle: L10n.KeyboardVisualizer.anchorSubtitle) { - KeyboardVisualizerAnchorPicker( - selection: self.$model.anchor, - accessibilityLabel: L10n.KeyboardVisualizer.anchorLabel - ) - } - - Divider() - SettingsControlRow(title: L10n.KeyboardVisualizer.sizeLabel, subtitle: L10n.KeyboardVisualizer.sizeSubtitle) { SettingsSliderControl( value: self.$model.scale, From 1b4a8633ff5a791bdd9d2025be2127add061b26c Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 15:22:09 +0200 Subject: [PATCH 09/21] chore(l10n): update custom position labels --- .../Sources/Keyty/Resources/de.lproj/Localizable.strings | 6 +++--- .../Sources/Keyty/Resources/en.lproj/Localizable.strings | 6 +++--- .../Sources/Keyty/Resources/es.lproj/Localizable.strings | 6 +++--- .../Sources/Keyty/Resources/fr.lproj/Localizable.strings | 6 +++--- .../Sources/Keyty/Resources/ja.lproj/Localizable.strings | 6 +++--- .../Sources/Keyty/Resources/uk.lproj/Localizable.strings | 6 +++--- .../Keyty/Resources/zh-Hans.lproj/Localizable.strings | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index cec8b70..31b7914 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -49,8 +49,8 @@ "displays.margin_subtitle" = "Abstand zwischen der Einblendung und dem ausgewählten Bildschirmrand."; "displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Start Setting…"; -"displays.custom_position.stop_setting_button" = "Stop Setting"; +"displays.custom_position.start_setting_button" = "Arrange..."; +"displays.custom_position.stop_setting_button" = "Apply"; /* General settings pane */ "general.appearance_section_title" = "App"; @@ -80,7 +80,7 @@ "keyboard_visualizer.theme_section_title" = "Thema"; "keyboard_visualizer.theme_section_subtitle" = "Weise jedem Tastentyp eine eigene Farbpalette zu."; "keyboard_visualizer.layout_section_title" = "Layout"; -"keyboard_visualizer.layout_section_subtitle" = "Lege die Bildschirmposition und Größe der Einblendung fest."; +"keyboard_visualizer.layout_section_subtitle" = "Lege fest, wie viel Platz die Einblendung einnimmt."; "keyboard_visualizer.history_section_title" = "Verlauf"; "keyboard_visualizer.history_section_subtitle" = "Lege fest, wie viele Tastenkappen sichtbar bleiben und wie aufeinanderfolgende Gruppen gestapelt werden."; "keyboard_visualizer.timing_section_title" = "Zeitverhalten"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 415ddc9..a27fe73 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -49,8 +49,8 @@ "displays.margin_subtitle" = "Distance between the overlay and the selected screen edge."; "displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Start Setting…"; -"displays.custom_position.stop_setting_button" = "Stop Setting"; +"displays.custom_position.start_setting_button" = "Arrange..."; +"displays.custom_position.stop_setting_button" = "Apply"; /* General settings pane */ "general.appearance_section_title" = "App"; @@ -80,7 +80,7 @@ "keyboard_visualizer.theme_section_title" = "Theme"; "keyboard_visualizer.theme_section_subtitle" = "Give each key type its own color palette."; "keyboard_visualizer.layout_section_title" = "Layout"; -"keyboard_visualizer.layout_section_subtitle" = "Control screen anchor and how much space the overlay occupies."; +"keyboard_visualizer.layout_section_subtitle" = "Control how much space the overlay occupies."; "keyboard_visualizer.history_section_title" = "History"; "keyboard_visualizer.history_section_subtitle" = "Control how many keycaps remain visible and how consecutive groups stack."; "keyboard_visualizer.timing_section_title" = "Timing"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index 7a535fc..59e3fdf 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -49,8 +49,8 @@ "displays.margin_subtitle" = "Distancia entre la superposición y el borde seleccionado de la pantalla."; "displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Start Setting…"; -"displays.custom_position.stop_setting_button" = "Stop Setting"; +"displays.custom_position.start_setting_button" = "Arrange..."; +"displays.custom_position.stop_setting_button" = "Apply"; /* General settings pane */ "general.appearance_section_title" = "Aplicación"; @@ -80,7 +80,7 @@ "keyboard_visualizer.theme_section_title" = "Tema"; "keyboard_visualizer.theme_section_subtitle" = "Asigna una paleta de colores distinta a cada tipo de tecla."; "keyboard_visualizer.layout_section_title" = "Disposición"; -"keyboard_visualizer.layout_section_subtitle" = "Controla el anclaje en pantalla y el espacio que ocupa la superposición."; +"keyboard_visualizer.layout_section_subtitle" = "Controla el espacio que ocupa la superposición."; "keyboard_visualizer.history_section_title" = "Historial"; "keyboard_visualizer.history_section_subtitle" = "Controla cuántas teclas permanecen visibles y cómo se apilan los grupos consecutivos."; "keyboard_visualizer.timing_section_title" = "Duración"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 3be2077..fa8e57c 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -49,8 +49,8 @@ "displays.margin_subtitle" = "Distance entre la superposition et le bord d’écran sélectionné."; "displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Start Setting…"; -"displays.custom_position.stop_setting_button" = "Stop Setting"; +"displays.custom_position.start_setting_button" = "Arrange..."; +"displays.custom_position.stop_setting_button" = "Apply"; /* General settings pane */ "general.appearance_section_title" = "Application"; @@ -80,7 +80,7 @@ "keyboard_visualizer.theme_section_title" = "Thème"; "keyboard_visualizer.theme_section_subtitle" = "Attribuez une palette de couleurs différente à chaque type de touche."; "keyboard_visualizer.layout_section_title" = "Disposition"; -"keyboard_visualizer.layout_section_subtitle" = "Réglez l’ancrage à l’écran et l’espace occupé par la superposition."; +"keyboard_visualizer.layout_section_subtitle" = "Réglez l’espace occupé par la superposition."; "keyboard_visualizer.history_section_title" = "Historique"; "keyboard_visualizer.history_section_subtitle" = "Réglez le nombre de touches qui restent visibles et l’empilement des groupes successifs."; "keyboard_visualizer.timing_section_title" = "Durée"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index 3694a34..0ad91c8 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -49,8 +49,8 @@ "displays.margin_subtitle" = "オーバーレイと選択した画面端との間隔です。"; "displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Start Setting…"; -"displays.custom_position.stop_setting_button" = "Stop Setting"; +"displays.custom_position.start_setting_button" = "Arrange..."; +"displays.custom_position.stop_setting_button" = "Apply"; /* General settings pane */ "general.appearance_section_title" = "アプリ"; @@ -80,7 +80,7 @@ "keyboard_visualizer.theme_section_title" = "テーマ"; "keyboard_visualizer.theme_section_subtitle" = "キーの種類ごとにカラーパレットを設定します。"; "keyboard_visualizer.layout_section_title" = "レイアウト"; -"keyboard_visualizer.layout_section_subtitle" = "画面上の基準位置とオーバーレイの大きさを設定します。"; +"keyboard_visualizer.layout_section_subtitle" = "オーバーレイの大きさを設定します。"; "keyboard_visualizer.history_section_title" = "履歴"; "keyboard_visualizer.history_section_subtitle" = "表示するキーの数と、連続するグループの並び方を設定します。"; "keyboard_visualizer.timing_section_title" = "表示時間"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 6d1c516..86cdc5e 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -49,8 +49,8 @@ "displays.margin_subtitle" = "Відстань між накладкою та вибраним краєм екрана."; "displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Start Setting…"; -"displays.custom_position.stop_setting_button" = "Stop Setting"; +"displays.custom_position.start_setting_button" = "Arrange..."; +"displays.custom_position.stop_setting_button" = "Apply"; /* General settings pane */ "general.appearance_section_title" = "Програма"; @@ -80,7 +80,7 @@ "keyboard_visualizer.theme_section_title" = "Тема"; "keyboard_visualizer.theme_section_subtitle" = "Призначте власну палітру кольорів кожному типу клавіш."; "keyboard_visualizer.layout_section_title" = "Розташування"; -"keyboard_visualizer.layout_section_subtitle" = "Налаштуйте прив’язку до екрана та розмір накладки."; +"keyboard_visualizer.layout_section_subtitle" = "Налаштуйте розмір накладки."; "keyboard_visualizer.history_section_title" = "Історія"; "keyboard_visualizer.history_section_subtitle" = "Налаштуйте кількість видимих клавіш і розміщення послідовних груп."; "keyboard_visualizer.timing_section_title" = "Тривалість"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 4e707dc..5fd1f0d 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -49,8 +49,8 @@ "displays.margin_subtitle" = "叠加层与所选屏幕边缘之间的距离。"; "displays.custom_position.label" = "Position"; "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Start Setting…"; -"displays.custom_position.stop_setting_button" = "Stop Setting"; +"displays.custom_position.start_setting_button" = "Arrange..."; +"displays.custom_position.stop_setting_button" = "Apply"; /* General settings pane */ "general.appearance_section_title" = "应用"; @@ -80,7 +80,7 @@ "keyboard_visualizer.theme_section_title" = "主题"; "keyboard_visualizer.theme_section_subtitle" = "为每种按键类型设置专属的调色板。"; "keyboard_visualizer.layout_section_title" = "布局"; -"keyboard_visualizer.layout_section_subtitle" = "设置屏幕锚点以及叠加层占用的空间。"; +"keyboard_visualizer.layout_section_subtitle" = "设置叠加层占用的空间。"; "keyboard_visualizer.history_section_title" = "历史记录"; "keyboard_visualizer.history_section_subtitle" = "设置可见按键的数量以及连续按键组的堆叠方式。"; "keyboard_visualizer.timing_section_title" = "时间"; From a446b1fbc321247bc340cc37712ece49248c7849 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 15:25:59 +0200 Subject: [PATCH 10/21] chore(l10n): localize custom position strings --- .../Resources/de.lproj/Localizable.strings | 16 ++++++++-------- .../Resources/es.lproj/Localizable.strings | 18 +++++++++--------- .../Resources/fr.lproj/Localizable.strings | 14 +++++++------- .../Resources/ja.lproj/Localizable.strings | 18 +++++++++--------- .../Resources/uk.lproj/Localizable.strings | 18 +++++++++--------- .../zh-Hans.lproj/Localizable.strings | 18 +++++++++--------- 6 files changed, 51 insertions(+), 51 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index 31b7914..894a0dc 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -39,18 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Bildschirm"; "displays.display_subtitle" = "Bildschirm für die Tastatureinblendung"; -"displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; -"displays.placement.anchored" = "Anchored"; -"displays.placement.custom" = "Custom"; +"displays.placement_label" = "Platzierung"; +"displays.placement_subtitle" = "Wähle, ob die Einblendung Bildschirmanker oder eine benutzerdefinierte Position verwendet."; +"displays.placement.anchored" = "Verankert"; +"displays.placement.custom" = "Benutzerdefiniert"; "displays.anchor_label" = "Verankerung"; -"displays.anchor_subtitle" = "Overlay alignment and growth point."; +"displays.anchor_subtitle" = "Ausrichtung und Wachstumspunkt der Einblendung."; "displays.margin_label" = "Bildschirmabstand"; "displays.margin_subtitle" = "Abstand zwischen der Einblendung und dem ausgewählten Bildschirmrand."; "displays.custom_position.label" = "Position"; -"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Arrange..."; -"displays.custom_position.stop_setting_button" = "Apply"; +"displays.custom_position.set_subtitle" = "Wähle, wo die Einblendung auf dem ausgewählten Display erscheint."; +"displays.custom_position.start_setting_button" = "Anordnen..."; +"displays.custom_position.stop_setting_button" = "Anwenden"; /* General settings pane */ "general.appearance_section_title" = "App"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index 59e3fdf..297a489 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -39,18 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Pantalla"; "displays.display_subtitle" = "Pantalla en la que se muestra la superposición del teclado"; -"displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; -"displays.placement.anchored" = "Anchored"; -"displays.placement.custom" = "Custom"; +"displays.placement_label" = "Ubicación"; +"displays.placement_subtitle" = "Elige si la superposición usa anclajes de pantalla o una posición personalizada."; +"displays.placement.anchored" = "Anclada"; +"displays.placement.custom" = "Personalizada"; "displays.anchor_label" = "Anclaje"; -"displays.anchor_subtitle" = "Overlay alignment and growth point."; +"displays.anchor_subtitle" = "Alineación y punto de crecimiento de la superposición."; "displays.margin_label" = "Margen de pantalla"; "displays.margin_subtitle" = "Distancia entre la superposición y el borde seleccionado de la pantalla."; -"displays.custom_position.label" = "Position"; -"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Arrange..."; -"displays.custom_position.stop_setting_button" = "Apply"; +"displays.custom_position.label" = "Posición"; +"displays.custom_position.set_subtitle" = "Elige dónde aparece la superposición en la pantalla seleccionada."; +"displays.custom_position.start_setting_button" = "Organizar..."; +"displays.custom_position.stop_setting_button" = "Aplicar"; /* General settings pane */ "general.appearance_section_title" = "Aplicación"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index fa8e57c..7a8d3be 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -40,17 +40,17 @@ "displays.display_label" = "Écran"; "displays.display_subtitle" = "Écran sur lequel afficher la superposition du clavier"; "displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; -"displays.placement.anchored" = "Anchored"; -"displays.placement.custom" = "Custom"; +"displays.placement_subtitle" = "Choisissez si la superposition utilise des ancrages d’écran ou une position personnalisée."; +"displays.placement.anchored" = "Ancrée"; +"displays.placement.custom" = "Personnalisée"; "displays.anchor_label" = "Ancrage"; -"displays.anchor_subtitle" = "Overlay alignment and growth point."; +"displays.anchor_subtitle" = "Alignement et point d’expansion de la superposition."; "displays.margin_label" = "Marge de l’écran"; "displays.margin_subtitle" = "Distance entre la superposition et le bord d’écran sélectionné."; "displays.custom_position.label" = "Position"; -"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Arrange..."; -"displays.custom_position.stop_setting_button" = "Apply"; +"displays.custom_position.set_subtitle" = "Choisissez l’emplacement de la superposition sur l’écran sélectionné."; +"displays.custom_position.start_setting_button" = "Organiser..."; +"displays.custom_position.stop_setting_button" = "Appliquer"; /* General settings pane */ "general.appearance_section_title" = "Application"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index 0ad91c8..1289cfb 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -39,18 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "ディスプレイ"; "displays.display_subtitle" = "キーボードオーバーレイを表示するディスプレイ"; -"displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; -"displays.placement.anchored" = "Anchored"; -"displays.placement.custom" = "Custom"; +"displays.placement_label" = "配置"; +"displays.placement_subtitle" = "オーバーレイを画面の基準位置に固定するか、カスタム位置に配置するかを選択します。"; +"displays.placement.anchored" = "固定"; +"displays.placement.custom" = "カスタム"; "displays.anchor_label" = "基準位置"; -"displays.anchor_subtitle" = "Overlay alignment and growth point."; +"displays.anchor_subtitle" = "オーバーレイの配置と広がりの基準点。"; "displays.margin_label" = "画面端からの余白"; "displays.margin_subtitle" = "オーバーレイと選択した画面端との間隔です。"; -"displays.custom_position.label" = "Position"; -"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Arrange..."; -"displays.custom_position.stop_setting_button" = "Apply"; +"displays.custom_position.label" = "位置"; +"displays.custom_position.set_subtitle" = "選択したディスプレイ上でオーバーレイを表示する位置を選択します。"; +"displays.custom_position.start_setting_button" = "配置..."; +"displays.custom_position.stop_setting_button" = "適用"; /* General settings pane */ "general.appearance_section_title" = "アプリ"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 86cdc5e..16b2e49 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -39,18 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "Дисплей"; "displays.display_subtitle" = "Дисплей, на якому показуватиметься накладка клавіатури"; -"displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; -"displays.placement.anchored" = "Anchored"; -"displays.placement.custom" = "Custom"; +"displays.placement_label" = "Розміщення"; +"displays.placement_subtitle" = "Виберіть, чи використовувати прив’язки екрана або власну позицію для накладки."; +"displays.placement.anchored" = "Прив’язано"; +"displays.placement.custom" = "Власна"; "displays.anchor_label" = "Прив’язка"; -"displays.anchor_subtitle" = "Overlay alignment and growth point."; +"displays.anchor_subtitle" = "Вирівнювання накладки та точка розгортання."; "displays.margin_label" = "Відступ від краю"; "displays.margin_subtitle" = "Відстань між накладкою та вибраним краєм екрана."; -"displays.custom_position.label" = "Position"; -"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Arrange..."; -"displays.custom_position.stop_setting_button" = "Apply"; +"displays.custom_position.label" = "Положення"; +"displays.custom_position.set_subtitle" = "Виберіть, де накладка з’являтиметься на вибраному дисплеї."; +"displays.custom_position.start_setting_button" = "Розмістити..."; +"displays.custom_position.stop_setting_button" = "Застосувати"; /* General settings pane */ "general.appearance_section_title" = "Програма"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 5fd1f0d..8296f72 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -39,18 +39,18 @@ /* Displays settings pane */ "displays.display_label" = "显示器"; "displays.display_subtitle" = "用于显示键盘叠加层的显示器"; -"displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; -"displays.placement.anchored" = "Anchored"; -"displays.placement.custom" = "Custom"; +"displays.placement_label" = "放置方式"; +"displays.placement_subtitle" = "选择叠加层使用屏幕锚点还是自定义位置。"; +"displays.placement.anchored" = "锚定"; +"displays.placement.custom" = "自定义"; "displays.anchor_label" = "锚点"; -"displays.anchor_subtitle" = "Overlay alignment and growth point."; +"displays.anchor_subtitle" = "叠加层的对齐方式和展开基点。"; "displays.margin_label" = "屏幕边距"; "displays.margin_subtitle" = "叠加层与所选屏幕边缘之间的距离。"; -"displays.custom_position.label" = "Position"; -"displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; -"displays.custom_position.start_setting_button" = "Arrange..."; -"displays.custom_position.stop_setting_button" = "Apply"; +"displays.custom_position.label" = "位置"; +"displays.custom_position.set_subtitle" = "选择叠加层在所选显示器上的显示位置。"; +"displays.custom_position.start_setting_button" = "调整..."; +"displays.custom_position.stop_setting_button" = "应用"; /* General settings pane */ "general.appearance_section_title" = "应用"; From 4c9834a5266f1dc1b2345e1101736ccebdd7d62c Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:02:20 +0200 Subject: [PATCH 11/21] refactor(displays): remove unused placement labels --- .../KeyboardVisualizerSettings+PlacementMode.swift | 11 +---------- .../Keyty/Resources/de.lproj/Localizable.strings | 3 --- .../Keyty/Resources/en.lproj/Localizable.strings | 3 --- .../Keyty/Resources/es.lproj/Localizable.strings | 3 --- .../Keyty/Resources/fr.lproj/Localizable.strings | 3 --- .../Keyty/Resources/ja.lproj/Localizable.strings | 3 --- .../Keyty/Resources/uk.lproj/Localizable.strings | 3 --- .../Keyty/Resources/zh-Hans.lproj/Localizable.strings | 3 --- 8 files changed, 1 insertion(+), 31 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift index 529b09a..3587783 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift @@ -7,20 +7,11 @@ // extension KeyboardVisualizerSettings { - enum PlacementMode: Int, CaseIterable { + enum PlacementMode: Int { /// Positions the overlay from a screen anchor plus edge inset. case anchored /// Positions the overlay from a normalized point on the selected display. case custom - - var label: String { - switch self { - case .anchored: - return L10n.Displays.Placement.anchored - case .custom: - return L10n.Displays.Placement.custom - } - } } } diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index 894a0dc..ba168a3 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -39,9 +39,6 @@ /* Displays settings pane */ "displays.display_label" = "Bildschirm"; "displays.display_subtitle" = "Bildschirm für die Tastatureinblendung"; -"displays.placement_label" = "Platzierung"; -"displays.placement_subtitle" = "Wähle, ob die Einblendung Bildschirmanker oder eine benutzerdefinierte Position verwendet."; -"displays.placement.anchored" = "Verankert"; "displays.placement.custom" = "Benutzerdefiniert"; "displays.anchor_label" = "Verankerung"; "displays.anchor_subtitle" = "Ausrichtung und Wachstumspunkt der Einblendung."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index a27fe73..24cd68e 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -39,9 +39,6 @@ /* Displays settings pane */ "displays.display_label" = "Display"; "displays.display_subtitle" = "Display to host the keyboard overlay"; -"displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choose whether the overlay uses screen anchors or a custom position."; -"displays.placement.anchored" = "Anchored"; "displays.placement.custom" = "Custom"; "displays.anchor_label" = "Anchor"; "displays.anchor_subtitle" = "Overlay alignment and growth point."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index 297a489..8593c6e 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -39,9 +39,6 @@ /* Displays settings pane */ "displays.display_label" = "Pantalla"; "displays.display_subtitle" = "Pantalla en la que se muestra la superposición del teclado"; -"displays.placement_label" = "Ubicación"; -"displays.placement_subtitle" = "Elige si la superposición usa anclajes de pantalla o una posición personalizada."; -"displays.placement.anchored" = "Anclada"; "displays.placement.custom" = "Personalizada"; "displays.anchor_label" = "Anclaje"; "displays.anchor_subtitle" = "Alineación y punto de crecimiento de la superposición."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 7a8d3be..826e5d0 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -39,9 +39,6 @@ /* Displays settings pane */ "displays.display_label" = "Écran"; "displays.display_subtitle" = "Écran sur lequel afficher la superposition du clavier"; -"displays.placement_label" = "Placement"; -"displays.placement_subtitle" = "Choisissez si la superposition utilise des ancrages d’écran ou une position personnalisée."; -"displays.placement.anchored" = "Ancrée"; "displays.placement.custom" = "Personnalisée"; "displays.anchor_label" = "Ancrage"; "displays.anchor_subtitle" = "Alignement et point d’expansion de la superposition."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index 1289cfb..7443fbd 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -39,9 +39,6 @@ /* Displays settings pane */ "displays.display_label" = "ディスプレイ"; "displays.display_subtitle" = "キーボードオーバーレイを表示するディスプレイ"; -"displays.placement_label" = "配置"; -"displays.placement_subtitle" = "オーバーレイを画面の基準位置に固定するか、カスタム位置に配置するかを選択します。"; -"displays.placement.anchored" = "固定"; "displays.placement.custom" = "カスタム"; "displays.anchor_label" = "基準位置"; "displays.anchor_subtitle" = "オーバーレイの配置と広がりの基準点。"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index 16b2e49..eb62ab5 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -39,9 +39,6 @@ /* Displays settings pane */ "displays.display_label" = "Дисплей"; "displays.display_subtitle" = "Дисплей, на якому показуватиметься накладка клавіатури"; -"displays.placement_label" = "Розміщення"; -"displays.placement_subtitle" = "Виберіть, чи використовувати прив’язки екрана або власну позицію для накладки."; -"displays.placement.anchored" = "Прив’язано"; "displays.placement.custom" = "Власна"; "displays.anchor_label" = "Прив’язка"; "displays.anchor_subtitle" = "Вирівнювання накладки та точка розгортання."; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index 8296f72..f5a082a 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -39,9 +39,6 @@ /* Displays settings pane */ "displays.display_label" = "显示器"; "displays.display_subtitle" = "用于显示键盘叠加层的显示器"; -"displays.placement_label" = "放置方式"; -"displays.placement_subtitle" = "选择叠加层使用屏幕锚点还是自定义位置。"; -"displays.placement.anchored" = "锚定"; "displays.placement.custom" = "自定义"; "displays.anchor_label" = "锚点"; "displays.anchor_subtitle" = "叠加层的对齐方式和展开基点。"; From c4303fd58c1466c861f44478854528bbda4de6b7 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:12:34 +0200 Subject: [PATCH 12/21] refactor(keyboard): move size into general section --- .../Keyboard/KeyboardSettingsPane.swift | 27 +++++++++---------- .../Resources/de.lproj/Localizable.strings | 2 -- .../Resources/en.lproj/Localizable.strings | 2 -- .../Resources/es.lproj/Localizable.strings | 2 -- .../Resources/fr.lproj/Localizable.strings | 2 -- .../Resources/ja.lproj/Localizable.strings | 2 -- .../Resources/uk.lproj/Localizable.strings | 2 -- .../zh-Hans.lproj/Localizable.strings | 2 -- 8 files changed, 12 insertions(+), 29 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift index 354ef3c..93788c8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift @@ -45,6 +45,18 @@ struct KeyboardSettingsPane: View { self.baseThemePicker .disabled(!self.model.isEnabled) + + Divider() + + SettingsControlRow(title: L10n.KeyboardVisualizer.sizeLabel, subtitle: L10n.KeyboardVisualizer.sizeSubtitle) { + SettingsSliderControl( + value: self.$model.scale, + range: self.model.scaleRange, + step: self.model.scaleStep, + accessibilityLabel: L10n.KeyboardVisualizer.sizeLabel + ) + } + .disabled(!self.model.isEnabled) } if self.model.usesCustomThemePalette { @@ -165,21 +177,6 @@ struct KeyboardSettingsPane: View { } .disabled(!self.model.isEnabled) - SettingsSectionView( - title: L10n.KeyboardVisualizer.layoutSectionTitle, - subtitle: L10n.KeyboardVisualizer.layoutSectionSubtitle - ) { - SettingsControlRow(title: L10n.KeyboardVisualizer.sizeLabel, subtitle: L10n.KeyboardVisualizer.sizeSubtitle) { - SettingsSliderControl( - value: self.$model.scale, - range: self.model.scaleRange, - step: self.model.scaleStep, - accessibilityLabel: L10n.KeyboardVisualizer.sizeLabel - ) - } - } - .disabled(!self.model.isEnabled) - SettingsSectionView( title: L10n.KeyboardVisualizer.historySectionTitle, subtitle: L10n.KeyboardVisualizer.historySectionSubtitle diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index ba168a3..f375d23 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -76,8 +76,6 @@ "keyboard_visualizer.general_section_subtitle" = "Steuere Sichtbarkeit und Darstellung der Tastatureinblendung."; "keyboard_visualizer.theme_section_title" = "Thema"; "keyboard_visualizer.theme_section_subtitle" = "Weise jedem Tastentyp eine eigene Farbpalette zu."; -"keyboard_visualizer.layout_section_title" = "Layout"; -"keyboard_visualizer.layout_section_subtitle" = "Lege fest, wie viel Platz die Einblendung einnimmt."; "keyboard_visualizer.history_section_title" = "Verlauf"; "keyboard_visualizer.history_section_subtitle" = "Lege fest, wie viele Tastenkappen sichtbar bleiben und wie aufeinanderfolgende Gruppen gestapelt werden."; "keyboard_visualizer.timing_section_title" = "Zeitverhalten"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index 24cd68e..0621be8 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -76,8 +76,6 @@ "keyboard_visualizer.general_section_subtitle" = "Control keyboard overlay visibility and appearance."; "keyboard_visualizer.theme_section_title" = "Theme"; "keyboard_visualizer.theme_section_subtitle" = "Give each key type its own color palette."; -"keyboard_visualizer.layout_section_title" = "Layout"; -"keyboard_visualizer.layout_section_subtitle" = "Control how much space the overlay occupies."; "keyboard_visualizer.history_section_title" = "History"; "keyboard_visualizer.history_section_subtitle" = "Control how many keycaps remain visible and how consecutive groups stack."; "keyboard_visualizer.timing_section_title" = "Timing"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index 8593c6e..c8c1873 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -76,8 +76,6 @@ "keyboard_visualizer.general_section_subtitle" = "Controla la visibilidad y la apariencia de la superposición del teclado."; "keyboard_visualizer.theme_section_title" = "Tema"; "keyboard_visualizer.theme_section_subtitle" = "Asigna una paleta de colores distinta a cada tipo de tecla."; -"keyboard_visualizer.layout_section_title" = "Disposición"; -"keyboard_visualizer.layout_section_subtitle" = "Controla el espacio que ocupa la superposición."; "keyboard_visualizer.history_section_title" = "Historial"; "keyboard_visualizer.history_section_subtitle" = "Controla cuántas teclas permanecen visibles y cómo se apilan los grupos consecutivos."; "keyboard_visualizer.timing_section_title" = "Duración"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 826e5d0..a5d729b 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -76,8 +76,6 @@ "keyboard_visualizer.general_section_subtitle" = "Contrôlez la visibilité et l’apparence de la superposition du clavier."; "keyboard_visualizer.theme_section_title" = "Thème"; "keyboard_visualizer.theme_section_subtitle" = "Attribuez une palette de couleurs différente à chaque type de touche."; -"keyboard_visualizer.layout_section_title" = "Disposition"; -"keyboard_visualizer.layout_section_subtitle" = "Réglez l’espace occupé par la superposition."; "keyboard_visualizer.history_section_title" = "Historique"; "keyboard_visualizer.history_section_subtitle" = "Réglez le nombre de touches qui restent visibles et l’empilement des groupes successifs."; "keyboard_visualizer.timing_section_title" = "Durée"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index 7443fbd..13d293e 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -76,8 +76,6 @@ "keyboard_visualizer.general_section_subtitle" = "キーボードオーバーレイの表示と外観を設定します。"; "keyboard_visualizer.theme_section_title" = "テーマ"; "keyboard_visualizer.theme_section_subtitle" = "キーの種類ごとにカラーパレットを設定します。"; -"keyboard_visualizer.layout_section_title" = "レイアウト"; -"keyboard_visualizer.layout_section_subtitle" = "オーバーレイの大きさを設定します。"; "keyboard_visualizer.history_section_title" = "履歴"; "keyboard_visualizer.history_section_subtitle" = "表示するキーの数と、連続するグループの並び方を設定します。"; "keyboard_visualizer.timing_section_title" = "表示時間"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index eb62ab5..063f484 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -76,8 +76,6 @@ "keyboard_visualizer.general_section_subtitle" = "Керуйте видимістю та виглядом накладки клавіатури."; "keyboard_visualizer.theme_section_title" = "Тема"; "keyboard_visualizer.theme_section_subtitle" = "Призначте власну палітру кольорів кожному типу клавіш."; -"keyboard_visualizer.layout_section_title" = "Розташування"; -"keyboard_visualizer.layout_section_subtitle" = "Налаштуйте розмір накладки."; "keyboard_visualizer.history_section_title" = "Історія"; "keyboard_visualizer.history_section_subtitle" = "Налаштуйте кількість видимих клавіш і розміщення послідовних груп."; "keyboard_visualizer.timing_section_title" = "Тривалість"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index f5a082a..a868cc9 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -76,8 +76,6 @@ "keyboard_visualizer.general_section_subtitle" = "控制键盘叠加层的可见性和外观。"; "keyboard_visualizer.theme_section_title" = "主题"; "keyboard_visualizer.theme_section_subtitle" = "为每种按键类型设置专属的调色板。"; -"keyboard_visualizer.layout_section_title" = "布局"; -"keyboard_visualizer.layout_section_subtitle" = "设置叠加层占用的空间。"; "keyboard_visualizer.history_section_title" = "历史记录"; "keyboard_visualizer.history_section_subtitle" = "设置可见按键的数量以及连续按键组的堆叠方式。"; "keyboard_visualizer.timing_section_title" = "时间"; From c8a1f8f3e96987917567b53e975ff65be07db46c Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:27:57 +0200 Subject: [PATCH 13/21] refactor(foundation): add CGFloat clamp helper --- ...dVisualizerPlacementWindowController.swift | 12 ++++------ .../Keyboard/KeyboardVisualizerWindow.swift | 8 ++----- .../Extensions/Foundation/CGFloat+Clamp.swift | 23 +++++++++++++++++++ 3 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/CGFloat+Clamp.swift diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 90f8d39..c1d0552 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -86,8 +86,8 @@ extension KeyboardVisualizerPlacementWindowController { y: area.minY + area.height * position.y ) let origin = CGPoint( - x: Self.clamped(center.x - size.width / 2, minimum: area.minX, maximum: area.maxX - size.width), - y: Self.clamped(center.y - size.height / 2, minimum: area.minY, maximum: area.maxY - size.height) + x: (center.x - size.width / 2).clamped(minimum: area.minX, maximum: area.maxX - size.width), + y: (center.y - size.height / 2).clamped(minimum: area.minY, maximum: area.maxY - size.height) ) return CGRect(origin: origin, size: size) @@ -95,8 +95,8 @@ extension KeyboardVisualizerPlacementWindowController { static func normalizedPosition(for point: CGPoint, in area: CGRect) -> CGPoint { CGPoint( - x: Self.clamped((point.x - area.minX) / max(area.width, 1), minimum: 0, maximum: 1), - y: Self.clamped((point.y - area.minY) / max(area.height, 1), minimum: 0, maximum: 1) + x: ((point.x - area.minX) / max(area.width, 1)).clamped(to: 0...1), + y: ((point.y - area.minY) / max(area.height, 1)).clamped(to: 0...1) ) } @@ -202,10 +202,6 @@ private extension KeyboardVisualizerPlacementWindowController { return dx * dx + dy * dy } - static func clamped(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat { - guard maximum >= minimum else { return minimum } - return min(max(value, minimum), maximum) - } } extension KeyboardVisualizerPlacementWindowController { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index 113e4aa..7603858 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -202,17 +202,13 @@ final class KeyboardVisualizerWindow: NSWindow { y: center.y - size.height / 2 ) let clampedOrigin = CGPoint( - x: self.clampedOriginValue(origin.x, minimum: area.minX, maximum: area.maxX - size.width), - y: self.clampedOriginValue(origin.y, minimum: area.minY, maximum: area.maxY - size.height) + x: origin.x.clamped(minimum: area.minX, maximum: area.maxX - size.width), + y: origin.y.clamped(minimum: area.minY, maximum: area.maxY - size.height) ) return NSRect(origin: clampedOrigin, size: size) } - private func clampedOriginValue(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat { - guard maximum >= minimum else { return minimum } - return min(max(value, minimum), maximum) - } private func resolvedVisibleFrame() -> CGRect? { self.screensService.visibleFrame(for: self.settings.screenID) ?? self.screensService.mainVisibleFrame() diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/CGFloat+Clamp.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/CGFloat+Clamp.swift new file mode 100644 index 0000000..47ec50d --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/CGFloat+Clamp.swift @@ -0,0 +1,23 @@ +// +// CGFloat+Clamp.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics + +public extension CGFloat { + /// Returns this value constrained to the provided closed range. + func clamped(to range: ClosedRange) -> CGFloat { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } + + /// Returns this value constrained to `minimum...maximum`, or `minimum` when + /// `maximum` is below `minimum`. + func clamped(minimum: CGFloat, maximum: CGFloat) -> CGFloat { + guard maximum >= minimum else { return minimum } + return self.clamped(to: minimum...maximum) + } +} From e1f7c289e116edcb055c0428c281074d2fc97e61 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:30:03 +0200 Subject: [PATCH 14/21] refactor(coregraphics): add CGRect distance helper --- ...dVisualizerPlacementWindowController.swift | 25 +------------ .../CoreGraphics/CGRect+Distance.swift | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 24 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+Distance.swift diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index c1d0552..4c81072 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -176,32 +176,9 @@ private extension KeyboardVisualizerPlacementWindowController { in visibleFrames: [(screenID: CGDirectDisplayID, frame: CGRect)] ) -> (screenID: CGDirectDisplayID, frame: CGRect)? { visibleFrames.min { lhs, rhs in - Self.squaredDistance(from: point, to: lhs.frame) < Self.squaredDistance(from: point, to: rhs.frame) + lhs.frame.squaredDistance(to: point) < rhs.frame.squaredDistance(to: point) } } - - static func squaredDistance(from point: CGPoint, to frame: CGRect) -> CGFloat { - let dx: CGFloat - if point.x < frame.minX { - dx = frame.minX - point.x - } else if point.x > frame.maxX { - dx = point.x - frame.maxX - } else { - dx = 0 - } - - let dy: CGFloat - if point.y < frame.minY { - dy = frame.minY - point.y - } else if point.y > frame.maxY { - dy = point.y - frame.maxY - } else { - dy = 0 - } - - return dx * dx + dy * dy - } - } extension KeyboardVisualizerPlacementWindowController { diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+Distance.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+Distance.swift new file mode 100644 index 0000000..304ad3d --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+Distance.swift @@ -0,0 +1,36 @@ +// +// CGRect+Distance.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics + +public extension CGRect { + /// Returns the squared distance from this rectangle to `point`. + /// + /// Points inside the rectangle have a distance of zero. + func squaredDistance(to point: CGPoint) -> CGFloat { + let dx: CGFloat + if point.x < self.minX { + dx = self.minX - point.x + } else if point.x > self.maxX { + dx = point.x - self.maxX + } else { + dx = 0 + } + + let dy: CGFloat + if point.y < self.minY { + dy = self.minY - point.y + } else if point.y > self.maxY { + dy = point.y - self.maxY + } else { + dy = 0 + } + + return dx * dx + dy * dy + } +} From f6a1abb88f3b08de2035952a36681e0242fab507 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:33:08 +0200 Subject: [PATCH 15/21] refactor(coregraphics): add normalized point helper --- ...dVisualizerPlacementWindowController.swift | 5 +---- .../CoreGraphics/CGRect+NormalizedPoint.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 4c81072..564a9ce 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -94,10 +94,7 @@ extension KeyboardVisualizerPlacementWindowController { } static func normalizedPosition(for point: CGPoint, in area: CGRect) -> CGPoint { - CGPoint( - x: ((point.x - area.minX) / max(area.width, 1)).clamped(to: 0...1), - y: ((point.y - area.minY) / max(area.height, 1)).clamped(to: 0...1) - ) + area.normalizedPoint(for: point) } static func placement( diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift new file mode 100644 index 0000000..45a5ea7 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift @@ -0,0 +1,22 @@ +// +// CGRect+NormalizedPoint.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics + +public extension CGRect { + /// Returns `point` as a normalized coordinate within this rectangle. + /// + /// The returned point is clamped to `0...1` on both axes. Empty dimensions are + /// treated as `1` to avoid division by zero. + func normalizedPoint(for point: CGPoint) -> CGPoint { + CGPoint( + x: ((point.x - self.minX) / Swift.max(self.width, 1)).clamped(to: 0...1), + y: ((point.y - self.minY) / Swift.max(self.height, 1)).clamped(to: 0...1) + ) + } +} From c2c3cb00c57010bd1c07a9fe8efd253200c00938 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:35:53 +0200 Subject: [PATCH 16/21] test(extensions): cover geometry helpers --- .../CoreGraphics/CGRectDistanceTests.swift | 37 +++++++++++++++ .../CGRectNormalizedPointTests.swift | 45 +++++++++++++++++++ .../Foundation/CGFloatClampTests.swift | 29 ++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectDistanceTests.swift create mode 100644 Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift create mode 100644 Apps/Keyty/Tests/KeytyTests/Support/Extensions/Foundation/CGFloatClampTests.swift diff --git a/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectDistanceTests.swift b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectDistanceTests.swift new file mode 100644 index 0000000..8ad443b --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectDistanceTests.swift @@ -0,0 +1,37 @@ +// +// CGRectDistanceTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics +import XCTest +@testable import Keyty + +final class CGRectDistanceTests: XCTestCase { + func test_squaredDistanceToPoint_returnsZeroForPointInsideRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + + XCTAssertEqual(rect.squaredDistance(to: CGPoint(x: 40, y: 30)), 0) + } + + func test_squaredDistanceToPoint_usesHorizontalDistanceOutsideRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + + XCTAssertEqual(rect.squaredDistance(to: CGPoint(x: 4, y: 30)), 36) + } + + func test_squaredDistanceToPoint_usesVerticalDistanceOutsideRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + + XCTAssertEqual(rect.squaredDistance(to: CGPoint(x: 40, y: 75)), 25) + } + + func test_squaredDistanceToPoint_usesDiagonalDistanceOutsideRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + + XCTAssertEqual(rect.squaredDistance(to: CGPoint(x: 4, y: 75)), 61) + } +} diff --git a/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift new file mode 100644 index 0000000..32eaa7b --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift @@ -0,0 +1,45 @@ +// +// CGRectNormalizedPointTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics +import XCTest +@testable import Keyty + +final class CGRectNormalizedPointTests: XCTestCase { + func test_normalizedPoint_returnsPointRelativeToRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + let point = rect.normalizedPoint(for: CGPoint(x: 35, y: 45)) + + XCTAssertEqual(point.x, 0.25, accuracy: 0.0001) + XCTAssertEqual(point.y, 0.5, accuracy: 0.0001) + } + + func test_normalizedPoint_clampsPointBelowRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + let point = rect.normalizedPoint(for: CGPoint(x: 0, y: 10)) + + XCTAssertEqual(point.x, 0, accuracy: 0.0001) + XCTAssertEqual(point.y, 0, accuracy: 0.0001) + } + + func test_normalizedPoint_clampsPointAboveRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + let point = rect.normalizedPoint(for: CGPoint(x: 120, y: 80)) + + XCTAssertEqual(point.x, 1, accuracy: 0.0001) + XCTAssertEqual(point.y, 1, accuracy: 0.0001) + } + + func test_normalizedPoint_usesOneForEmptyDimensions() { + let rect = CGRect(x: 10, y: 20, width: 0, height: 0) + let point = rect.normalizedPoint(for: CGPoint(x: 10.5, y: 20.25)) + + XCTAssertEqual(point.x, 0.5, accuracy: 0.0001) + XCTAssertEqual(point.y, 0.25, accuracy: 0.0001) + } +} diff --git a/Apps/Keyty/Tests/KeytyTests/Support/Extensions/Foundation/CGFloatClampTests.swift b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/Foundation/CGFloatClampTests.swift new file mode 100644 index 0000000..8803136 --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/Foundation/CGFloatClampTests.swift @@ -0,0 +1,29 @@ +// +// CGFloatClampTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics +import XCTest +@testable import Keyty + +final class CGFloatClampTests: XCTestCase { + func test_clampedToRange_keepsValueInsideRange() { + XCTAssertEqual(CGFloat(4).clamped(to: 1...8), 4) + } + + func test_clampedToRange_limitsValueBelowRange() { + XCTAssertEqual(CGFloat(-2).clamped(to: 1...8), 1) + } + + func test_clampedToRange_limitsValueAboveRange() { + XCTAssertEqual(CGFloat(10).clamped(to: 1...8), 8) + } + + func test_clampedMinimumMaximum_returnsMinimumWhenMaximumIsBelowMinimum() { + XCTAssertEqual(CGFloat(4).clamped(minimum: 8, maximum: 1), 8) + } +} From bb7a2826d9ee1dba56914bf2731a54ad3d9ef71d Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:41:23 +0200 Subject: [PATCH 17/21] refactor(keyboard): nest placement type in controller --- .../Displays/DisplaysSettingsPane.swift | 4 +-- .../DisplaysSettingsPaneViewModel.swift | 10 +++---- .../Settings/SettingsPaneRegistry.swift | 4 +-- .../Settings/SettingsWindowController.swift | 4 +-- ...dVisualizerPlacementWindowController.swift | 28 +++++++++---------- .../DisplaysSettingsPaneViewModelTests.swift | 10 +++---- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 8ee82cc..3536188 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -13,8 +13,8 @@ struct DisplaysSettingsPane: View { init( keyboardVisualizerSettings: KeyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void = { _ in }, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement? = { nil } + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void = { _ in }, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement? = { nil } ) { _model = StateObject( wrappedValue: DisplaysSettingsPaneViewModel( diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 8c30cb4..93baae7 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -29,8 +29,8 @@ enum DisplaysAnchorSelection: Hashable { final class DisplaysSettingsPaneViewModel: ObservableObject { private let screensService: any ScreenServiceProvider private let keyboardVisualizerSettings: KeyboardVisualizerSettings - private let startSettingKeyboardVisualizerPosition: @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void - private let stopSettingKeyboardVisualizerPosition: @MainActor () -> KeyboardVisualizerPlacement? + private let startSettingKeyboardVisualizerPosition: @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void + private let stopSettingKeyboardVisualizerPosition: @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement? private var cancellables = Set() let paddingRange: ClosedRange = Double(KeyboardVisualizerSettings.minWindowPadding)...Double(KeyboardVisualizerSettings.maxWindowPadding) @@ -104,8 +104,8 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { init( screensService: any ScreenServiceProvider = ScreensService.shared, keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), - startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void = { _ in }, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement? = { nil } + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void = { _ in }, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement? = { nil } ) { guard let selectedScreen = Self.initialSelectedScreen( screensService: screensService, @@ -161,7 +161,7 @@ private extension DisplaysSettingsPaneViewModel { self.isSettingCustomPosition = false } - func applyPlacement(_ placement: KeyboardVisualizerPlacement) { + func applyPlacement(_ placement: KeyboardVisualizerPlacementWindowController.Placement) { self.selectedScreenID = placement.screenID self.customPositionX = Double(placement.positionX) self.customPositionY = Double(placement.positionY) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index c0033b0..330ea8f 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift @@ -27,8 +27,8 @@ struct SettingsPaneRegistry { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement?, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement?, permissionsService: any PermissionsService, updater: SPUUpdater ) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 1d12dd4..a00d891 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -25,8 +25,8 @@ final class SettingsWindowController: NSWindowController { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementChangeHandler) -> Void, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacement?, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement?, permissionsService: any PermissionsService, updater: SPUUpdater ) { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 564a9ce..986077d 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -8,19 +8,19 @@ import AppKit -struct KeyboardVisualizerPlacement { - let screenID: CGDirectDisplayID - let positionX: CGFloat - let positionY: CGFloat -} - -typealias KeyboardVisualizerPlacementChangeHandler = @MainActor (KeyboardVisualizerPlacement) -> Void - @MainActor final class KeyboardVisualizerPlacementWindowController: NSWindowController { + struct Placement { + let screenID: CGDirectDisplayID + let positionX: CGFloat + let positionY: CGFloat + } + + typealias PlacementChangeHandler = @MainActor (Placement) -> Void + private let settings: KeyboardVisualizerSettings private let screensService: any ScreenServiceProvider - private var onPlacementChanged: KeyboardVisualizerPlacementChangeHandler? + private var onPlacementChanged: PlacementChangeHandler? init( settings: KeyboardVisualizerSettings, @@ -39,7 +39,7 @@ final class KeyboardVisualizerPlacementWindowController: NSWindowController { // MARK: - Public API extension KeyboardVisualizerPlacementWindowController { - func startSettingPosition(onPlacementChanged: @escaping KeyboardVisualizerPlacementChangeHandler) { + func startSettingPosition(onPlacementChanged: @escaping PlacementChangeHandler) { self.onPlacementChanged = onPlacementChanged if let window = self.window { @@ -64,7 +64,7 @@ extension KeyboardVisualizerPlacementWindowController { window.orderFrontRegardless() } - func stopSettingPosition() -> KeyboardVisualizerPlacement? { + func stopSettingPosition() -> Placement? { guard let window = self.window else { return nil } let placement = self.placement(for: CGPoint(x: window.frame.midX, y: window.frame.midY)) @@ -100,7 +100,7 @@ extension KeyboardVisualizerPlacementWindowController { static func placement( for point: CGPoint, in visibleFrames: [(screenID: CGDirectDisplayID, frame: CGRect)] - ) -> KeyboardVisualizerPlacement? { + ) -> Placement? { guard let visibleFrame = visibleFrames.first(where: { $0.frame.contains(point) }) ?? Self.nearestVisibleFrame(to: point, in: visibleFrames) else { @@ -109,7 +109,7 @@ extension KeyboardVisualizerPlacementWindowController { let position = Self.normalizedPosition(for: point, in: visibleFrame.frame) - return KeyboardVisualizerPlacement( + return Placement( screenID: visibleFrame.screenID, positionX: position.x, positionY: position.y @@ -147,7 +147,7 @@ private extension KeyboardVisualizerPlacementWindowController { ?? self.screensService.mainVisibleFrame() } - func placement(for point: CGPoint) -> KeyboardVisualizerPlacement? { + func placement(for point: CGPoint) -> Placement? { Self.placement(for: point, in: self.visibleFrames()) } diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index a748c65..4f67dba 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -17,8 +17,8 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { private var screensService: TestScreenService! private var startSettingCallCount = 0 private var stopSettingCallCount = 0 - private var placementToReturn: KeyboardVisualizerPlacement? - private var placementChangeHandler: KeyboardVisualizerPlacementChangeHandler? + private var placementToReturn: KeyboardVisualizerPlacementWindowController.Placement? + private var placementChangeHandler: KeyboardVisualizerPlacementWindowController.PlacementChangeHandler? private var model: DisplaysSettingsPaneViewModel! override func setUp() { @@ -128,7 +128,7 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { } func testFinishingCustomPositionSettingStopsAndAppliesReturnedPlacement() { - self.placementToReturn = KeyboardVisualizerPlacement( + self.placementToReturn = KeyboardVisualizerPlacementWindowController.Placement( screenID: 2, positionX: 0.3, positionY: 0.7 @@ -155,7 +155,7 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { } func testStoppingCustomPositionSettingAppliesReturnedPlacement() { - self.placementToReturn = KeyboardVisualizerPlacement( + self.placementToReturn = KeyboardVisualizerPlacementWindowController.Placement( screenID: 2, positionX: 0.25, positionY: 0.75 @@ -176,7 +176,7 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { self.model.toggleCustomPositionSetting() self.placementChangeHandler?( - KeyboardVisualizerPlacement( + KeyboardVisualizerPlacementWindowController.Placement( screenID: 2, positionX: 0.4, positionY: 0.6 From d0cac054120e56ec7c5b9c5d53dc3643381aeda0 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:46:51 +0200 Subject: [PATCH 18/21] refactor(keyboard): clarify custom position naming --- .../DisplaysSettingsPane+PreviewCard.swift | 8 ++--- .../Displays/DisplaysSettingsPane.swift | 4 +-- .../DisplaysSettingsPaneViewModel.swift | 16 ++++----- ...dVisualizerPlacementWindowController.swift | 2 +- .../Keyboard/KeyboardVisualizerSettings.swift | 15 ++++---- .../KeyboardVisualizerSettingsKeys.swift | 4 +-- .../Keyboard/KeyboardVisualizerWindow.swift | 4 +-- .../DisplaysSettingsPaneViewModelTests.swift | 36 +++++++++---------- .../KeyboardVisualizerSettingsTests.swift | 20 +++++------ 9 files changed, 56 insertions(+), 53 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift index 7d59a5a..e261f7c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift @@ -16,8 +16,8 @@ extension DisplaysSettingsPane { let placementMode: KeyboardVisualizerSettings.PlacementMode let stackAxis: KeyboardVisualizerStackAxis let windowPadding: Double - let customPositionX: Double - let customPositionY: Double + let customPositionNormalizedX: Double + let customPositionNormalizedY: Double let onSelectScreen: (Screen) -> Void private let previewHeight = Spacing.grid(48) @@ -174,8 +174,8 @@ private extension DisplaysSettingsPane.PreviewCard { func customPositionOffset(in size: CGSize, markerSize: CGSize) -> CGSize { CGSize( - width: size.width * CGFloat(self.customPositionX) - size.width / 2, - height: size.height / 2 - size.height * CGFloat(self.customPositionY) + width: size.width * CGFloat(self.customPositionNormalizedX) - size.width / 2, + height: size.height / 2 - size.height * CGFloat(self.customPositionNormalizedY) ) } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 3536188..99b58a1 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -34,8 +34,8 @@ struct DisplaysSettingsPane: View { placementMode: self.model.placementMode, stackAxis: self.model.stackAxis, windowPadding: self.model.windowPadding, - customPositionX: self.model.customPositionX, - customPositionY: self.model.customPositionY, + customPositionNormalizedX: self.model.customPositionNormalizedX, + customPositionNormalizedY: self.model.customPositionNormalizedY, onSelectScreen: { screen in self.model.selectedScreenID = screen.id } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 93baae7..6c1c452 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -65,12 +65,12 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { didSet { self.keyboardVisualizerSettings.windowPadding = CGFloat(self.windowPadding) } } - @Published var customPositionX: Double { - didSet { self.keyboardVisualizerSettings.customPositionX = CGFloat(self.customPositionX) } + @Published var customPositionNormalizedX: Double { + didSet { self.keyboardVisualizerSettings.customPositionNormalizedX = CGFloat(self.customPositionNormalizedX) } } - @Published var customPositionY: Double { - didSet { self.keyboardVisualizerSettings.customPositionY = CGFloat(self.customPositionY) } + @Published var customPositionNormalizedY: Double { + didSet { self.keyboardVisualizerSettings.customPositionNormalizedY = CGFloat(self.customPositionNormalizedY) } } var stackAxis: KeyboardVisualizerStackAxis { @@ -123,8 +123,8 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.selectedAnchor = keyboardVisualizerSettings.anchor self.placementMode = keyboardVisualizerSettings.placementMode self.windowPadding = Double(keyboardVisualizerSettings.windowPadding) - self.customPositionX = Double(keyboardVisualizerSettings.customPositionX) - self.customPositionY = Double(keyboardVisualizerSettings.customPositionY) + self.customPositionNormalizedX = Double(keyboardVisualizerSettings.customPositionNormalizedX) + self.customPositionNormalizedY = Double(keyboardVisualizerSettings.customPositionNormalizedY) self.screensService.screensDidChange .receive(on: RunLoop.main) @@ -163,8 +163,8 @@ private extension DisplaysSettingsPaneViewModel { func applyPlacement(_ placement: KeyboardVisualizerPlacementWindowController.Placement) { self.selectedScreenID = placement.screenID - self.customPositionX = Double(placement.positionX) - self.customPositionY = Double(placement.positionY) + self.customPositionNormalizedX = Double(placement.positionX) + self.customPositionNormalizedY = Double(placement.positionY) } static func initialSelectedScreen( diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 986077d..2858897 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -52,7 +52,7 @@ extension KeyboardVisualizerPlacementWindowController { let contentView = KeyboardVisualizerGroupView(items: Self.previewItems(settings: self.settings), settings: self.settings) let size = contentView.preferredSize let frame = Self.frame( - forNormalizedPosition: CGPoint(x: self.settings.customPositionX, y: self.settings.customPositionY), + forNormalizedPosition: CGPoint(x: self.settings.customPositionNormalizedX, y: self.settings.customPositionNormalizedY), in: visibleFrame, size: size ) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index b5c16e0..d77db31 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -30,9 +30,12 @@ protocol KeyboardVisualizerSettingsProtocol: AnyObject { var groupBackgroundTheme: KeyboardVisualizerTheme { get set } var anchor: KeyboardVisualizerAnchor { get set } + /// Selects whether the keyboard overlay uses a predefined anchor or a custom normalized position. var placementMode: KeyboardVisualizerSettings.PlacementMode { get set } - var customPositionX: CGFloat { get set } - var customPositionY: CGFloat { get set } + /// Horizontal custom overlay position normalized to the selected display visible frame. + var customPositionNormalizedX: CGFloat { get set } + /// Vertical custom overlay position normalized to the selected display visible frame. + var customPositionNormalizedY: CGFloat { get set } var screenID: CGDirectDisplayID { get set } var scale: CGFloat { get set } @@ -186,15 +189,15 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS } } - @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionX, default: 0.5, clamp: 0...1)) - var customPositionX: CGFloat { + @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionNormalizedX, default: 0.5, clamp: 0...1)) + var customPositionNormalizedX: CGFloat { didSet { self.placementChangesSubject.send(()) } } - @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionY, default: 0.5, clamp: 0...1)) - var customPositionY: CGFloat { + @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionNormalizedY, default: 0.5, clamp: 0...1)) + var customPositionNormalizedY: CGFloat { didSet { self.placementChangesSubject.send(()) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift index a08efdf..303100c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift @@ -28,8 +28,8 @@ enum KeyboardVisualizerSettingsKeys { static let groupBackgroundTheme = "keyboard_visualizer.groupBackgroundTheme" static let anchor = "keyboard_visualizer.anchor" static let placementMode = "keyboard_visualizer.placementMode" - static let customPositionX = "keyboard_visualizer.customPositionX" - static let customPositionY = "keyboard_visualizer.customPositionY" + static let customPositionNormalizedX = "keyboard_visualizer.customPositionNormalizedX" + static let customPositionNormalizedY = "keyboard_visualizer.customPositionNormalizedY" static let screenID = "keyboard_visualizer.screenID" static let scale = "keyboard_visualizer.scale" static let windowPadding = "keyboard_visualizer.windowPadding" diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index 7603858..bad9a7b 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -194,8 +194,8 @@ final class KeyboardVisualizerWindow: NSWindow { private func customFrame(size: NSSize, in area: CGRect) -> NSRect { let center = CGPoint( - x: area.minX + area.width * self.settings.customPositionX, - y: area.minY + area.height * self.settings.customPositionY + x: area.minX + area.width * self.settings.customPositionNormalizedX, + y: area.minY + area.height * self.settings.customPositionNormalizedY ) let origin = CGPoint( x: center.x - size.width / 2, diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index 4f67dba..5141470 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -57,20 +57,20 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { } func testToggleCustomPositionSettingFlipsLocalFlagOnly() { - self.keyboardVisualizerSettings.customPositionX = 0.25 - self.keyboardVisualizerSettings.customPositionY = 0.75 + self.keyboardVisualizerSettings.customPositionNormalizedX = 0.25 + self.keyboardVisualizerSettings.customPositionNormalizedY = 0.75 self.model.toggleCustomPositionSetting() XCTAssertTrue(self.model.isSettingCustomPosition) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedY, 0.75, accuracy: 0.0001) self.model.toggleCustomPositionSetting() XCTAssertFalse(self.model.isSettingCustomPosition) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedY, 0.75, accuracy: 0.0001) } func testToggleCustomPositionSettingStartsAndStopsPlacementController() { @@ -141,10 +141,10 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertEqual(self.stopSettingCallCount, 1) XCTAssertEqual(self.model.selectedScreenID, 2) XCTAssertEqual(self.keyboardVisualizerSettings.screenID, 2) - XCTAssertEqual(self.model.customPositionX, 0.3, accuracy: 0.0001) - XCTAssertEqual(self.model.customPositionY, 0.7, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.3, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.7, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionNormalizedX, 0.3, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionNormalizedY, 0.7, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedX, 0.3, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedY, 0.7, accuracy: 0.0001) } func testFinishingCustomPositionSettingDoesNothingWhenNotSetting() { @@ -166,10 +166,10 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertEqual(self.model.selectedScreenID, 2) XCTAssertEqual(self.keyboardVisualizerSettings.screenID, 2) - XCTAssertEqual(self.model.customPositionX, 0.25, accuracy: 0.0001) - XCTAssertEqual(self.model.customPositionY, 0.75, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.25, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.75, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionNormalizedX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionNormalizedY, 0.75, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedY, 0.75, accuracy: 0.0001) } func testPlacementChangeWhileSettingAppliesPlacement() { @@ -186,10 +186,10 @@ final class DisplaysSettingsPaneViewModelTests: XCTestCase { XCTAssertTrue(self.model.isSettingCustomPosition) XCTAssertEqual(self.model.selectedScreenID, 2) XCTAssertEqual(self.keyboardVisualizerSettings.screenID, 2) - XCTAssertEqual(self.model.customPositionX, 0.4, accuracy: 0.0001) - XCTAssertEqual(self.model.customPositionY, 0.6, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionX, 0.4, accuracy: 0.0001) - XCTAssertEqual(self.keyboardVisualizerSettings.customPositionY, 0.6, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionNormalizedX, 0.4, accuracy: 0.0001) + XCTAssertEqual(self.model.customPositionNormalizedY, 0.6, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedX, 0.4, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedY, 0.6, accuracy: 0.0001) } } diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift index 765b9b0..9f1dcea 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift @@ -39,8 +39,8 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(store.integer(forKey: KeyboardVisualizerSettingsKeys.style), KeycapStyle.apple.rawValue) XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.scale), 1.0, accuracy: 0.0001) XCTAssertEqual(store.integer(forKey: KeyboardVisualizerSettingsKeys.placementMode), KeyboardVisualizerSettings.PlacementMode.anchored.rawValue) - XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionX), 0.5, accuracy: 0.0001) - XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionY), 0.5, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedX), 0.5, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedY), 0.5, accuracy: 0.0001) XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.windowPadding), Double(Size.KeyboardVisualizer.windowPadding), accuracy: 0.0001) XCTAssertEqual(store.bool(forKey: KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes), false) XCTAssertEqual(store.bool(forKey: KeyboardVisualizerSettingsKeys.showSpecialKeys), true) @@ -135,13 +135,13 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { } func testCustomPositionClamps() { - settings.customPositionX = 1.5 - settings.customPositionY = -0.5 + settings.customPositionNormalizedX = 1.5 + settings.customPositionNormalizedY = -0.5 - XCTAssertEqual(settings.customPositionX, 1, accuracy: 0.0001) - XCTAssertEqual(settings.customPositionY, 0, accuracy: 0.0001) - XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionX), 1, accuracy: 0.0001) - XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionY), 0, accuracy: 0.0001) + XCTAssertEqual(settings.customPositionNormalizedX, 1, accuracy: 0.0001) + XCTAssertEqual(settings.customPositionNormalizedY, 0, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedX), 1, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedY), 0, accuracy: 0.0001) } func testPublishesPlacementChangesForCustomPlacementSettings() { @@ -151,8 +151,8 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { } settings.placementMode = .custom - settings.customPositionX = 0.25 - settings.customPositionY = 0.75 + settings.customPositionNormalizedX = 0.25 + settings.customPositionNormalizedY = 0.75 XCTAssertEqual(receivedCount, 3) cancellable.cancel() From 690234366261b66ec01738668e9a379bac9c8e28 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:55:37 +0200 Subject: [PATCH 19/21] refactor(keyboard): clarify custom position naming --- .../Sources/Keyty/App/Composition/AppUIContainer.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift index 333a7f2..0c7c056 100644 --- a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift +++ b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift @@ -20,15 +20,15 @@ final class AppUIContainer { services: AppServiceContainer, updater: SPUUpdater ) { - aboutWindowController = AboutWindowController() + self.aboutWindowController = AboutWindowController() let keyboardVisualizerPlacementWindowController = KeyboardVisualizerPlacementWindowController( settings: settings.keyboardVisualizerSettings ) self.keyboardVisualizerPlacementWindowController = keyboardVisualizerPlacementWindowController - permissionsOnboardingWindowController = PermissionsOnboardingWindowController( + self.permissionsOnboardingWindowController = PermissionsOnboardingWindowController( permissionsService: services.permissionsService ) - settingsWindowController = SettingsWindowController( + self.settingsWindowController = SettingsWindowController( shortcutManager: services.shortcutManager, appSettings: settings.appSettings, pointerRingVisualizer: services.pointerVisualizersManager.ring, From 1d4aede95aa68cc41f4120d3c3717a2bcd25429c Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 17:59:03 +0200 Subject: [PATCH 20/21] refactor(displays): nest placement selection in view model --- .../Displays/DisplaysSettingsPane.swift | 4 +-- .../DisplaysSettingsPaneViewModel.swift | 36 ++++++++++--------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index 99b58a1..dbede05 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -64,7 +64,7 @@ struct DisplaysSettingsPane: View { subtitle: L10n.Displays.anchorSubtitle ) { Picker("", selection: self.anchorSelection) { - ForEach(DisplaysAnchorSelection.allCases, id: \.self) { selection in + ForEach(DisplaysSettingsPaneViewModel.PlacementSelection.allCases, id: \.self) { selection in Text(selection.label).tag(selection) } } @@ -115,7 +115,7 @@ private extension DisplaysSettingsPane { } } - var anchorSelection: Binding { + var anchorSelection: Binding { Binding( get: { self.model.anchorSelection }, set: { self.model.anchorSelection = $0 } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 6c1c452..c31a045 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -9,22 +9,6 @@ import Combine import SwiftUI -enum DisplaysAnchorSelection: Hashable { - case anchor(KeyboardVisualizerAnchor) - case custom - - static let allCases: [DisplaysAnchorSelection] = KeyboardVisualizerAnchor.allCases.map(Self.anchor) + [.custom] - - var label: String { - switch self { - case .anchor(let anchor): - return "\(anchor.symbol) \(anchor.label)" - case .custom: - return L10n.Displays.Placement.custom - } - } -} - @MainActor final class DisplaysSettingsPaneViewModel: ObservableObject { private let screensService: any ScreenServiceProvider @@ -81,7 +65,7 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.placementMode == .custom } - var anchorSelection: DisplaysAnchorSelection { + var anchorSelection: PlacementSelection { get { switch self.placementMode { case .anchored: @@ -152,6 +136,24 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { } } +extension DisplaysSettingsPaneViewModel { + enum PlacementSelection: Hashable { + case anchor(KeyboardVisualizerAnchor) + case custom + + static let allCases: [PlacementSelection] = KeyboardVisualizerAnchor.allCases.map(Self.anchor) + [.custom] + + var label: String { + switch self { + case .anchor(let anchor): + return "\(anchor.symbol) \(anchor.label)" + case .custom: + return L10n.Displays.Placement.custom + } + } + } +} + private extension DisplaysSettingsPaneViewModel { func stopCustomPositionSetting() { guard self.isSettingCustomPosition else { return } From b6bd97954c24321f9e862a669d8250bf08e97e10 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Sat, 1 Aug 2026 18:18:13 +0200 Subject: [PATCH 21/21] refactor(keyboard): add custom placement settings helper --- .../Keyty/App/Composition/AppUIContainer.swift | 11 ++++++++++- .../Settings/Displays/DisplaysSettingsPane.swift | 14 ++------------ .../Features/Settings/SettingsPaneRegistry.swift | 16 +++++++++++++--- .../Settings/SettingsWindowController.swift | 11 +++++++++++ .../Keyboard/KeyboardVisualizerSettings.swift | 7 +++++++ .../KeyboardVisualizerSettingsTests.swift | 15 +++++++++++++++ 6 files changed, 58 insertions(+), 16 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift index 0c7c056..c30a60f 100644 --- a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift +++ b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift @@ -28,7 +28,7 @@ final class AppUIContainer { self.permissionsOnboardingWindowController = PermissionsOnboardingWindowController( permissionsService: services.permissionsService ) - self.settingsWindowController = SettingsWindowController( + let settingsWindowController = SettingsWindowController( shortcutManager: services.shortcutManager, appSettings: settings.appSettings, pointerRingVisualizer: services.pointerVisualizersManager.ring, @@ -44,5 +44,14 @@ final class AppUIContainer { permissionsService: services.permissionsService, updater: updater ) + settingsWindowController.onClose = { [weak keyboardVisualizerPlacementWindowController, keyboardVisualizerSettings = settings.keyboardVisualizerSettings] in + guard let placement = keyboardVisualizerPlacementWindowController?.stopSettingPosition() else { return } + keyboardVisualizerSettings.applyCustomPlacement( + screenID: placement.screenID, + normalizedX: placement.positionX, + normalizedY: placement.positionY + ) + } + self.settingsWindowController = settingsWindowController } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift index dbede05..7d544c8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -11,18 +11,8 @@ import SwiftUI struct DisplaysSettingsPane: View { @StateObject private var model: DisplaysSettingsPaneViewModel - init( - keyboardVisualizerSettings: KeyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void = { _ in }, - stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement? = { nil } - ) { - _model = StateObject( - wrappedValue: DisplaysSettingsPaneViewModel( - keyboardVisualizerSettings: keyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, - stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition - ) - ) + init(model: DisplaysSettingsPaneViewModel) { + _model = StateObject(wrappedValue: model) } var body: some View { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index 330ea8f..d8e94cb 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift @@ -19,6 +19,7 @@ struct SettingsPaneEntry: Identifiable { @MainActor struct SettingsPaneRegistry { let entries: [SettingsPaneEntry] + private let displaysViewModel: DisplaysSettingsPaneViewModel init( shortcutManager: ShortcutManager, @@ -32,6 +33,13 @@ struct SettingsPaneRegistry { permissionsService: any PermissionsService, updater: SPUUpdater ) { + let displaysViewModel = DisplaysSettingsPaneViewModel( + keyboardVisualizerSettings: keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, + stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition + ) + self.displaysViewModel = displaysViewModel + self.entries = [ SettingsPaneEntry( id: .general, @@ -75,9 +83,7 @@ struct SettingsPaneRegistry { makeView: { AnyView( DisplaysSettingsPane( - keyboardVisualizerSettings: keyboardVisualizerSettings, - startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, - stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition + model: displaysViewModel ) ) } @@ -112,4 +118,8 @@ struct SettingsPaneRegistry { func entry(for identifier: SettingsPaneIdentifier) -> SettingsPaneEntry? { self.entries.first { $0.id == identifier } } + + func finishTransientWork() { + self.displaysViewModel.finishCustomPositionSetting() + } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index a00d891..a623cea 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -14,6 +14,8 @@ import SwiftUI @MainActor final class SettingsWindowController: NSWindowController { let sidebarViewModel = SettingsSidebarViewModel() + var onClose: (@MainActor () -> Void)? + private let registry: SettingsPaneRegistry private var cancellables = Set() private var permissionObservationToken: PermissionObservationToken? @@ -48,6 +50,7 @@ final class SettingsWindowController: NSWindowController { window.contentViewController = NSHostingController(rootView: rootView) super.init(window: window) + window.delegate = self self.bindWindowTitle() self.bindSidebarBadges(permissionsService: permissionsService) self.updateWindowTitle(for: self.sidebarViewModel.selectedPaneID) @@ -99,6 +102,14 @@ final class SettingsWindowController: NSWindowController { } } +// MARK: - NSWindowDelegate +extension SettingsWindowController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { + self.registry.finishTransientWork() + self.onClose?() + } +} + extension SettingsWindowController { final class Window: NSWindow { private let titlebarToolbar = NSToolbar(identifier: "KeytySettingsToolbar") diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index d77db31..8405be8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -47,6 +47,7 @@ protocol KeyboardVisualizerSettingsProtocol: AnyObject { var showMouseEvents: Bool { get set } func registerDefaults() + func applyCustomPlacement(screenID: CGDirectDisplayID, normalizedX: CGFloat, normalizedY: CGFloat) } final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasSettingsStore, PlacementReactiveSettings { @@ -73,6 +74,12 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS self.registerStoredDefaults() } + func applyCustomPlacement(screenID: CGDirectDisplayID, normalizedX: CGFloat, normalizedY: CGFloat) { + self.screenID = screenID + self.customPositionNormalizedX = normalizedX + self.customPositionNormalizedY = normalizedY + } + /// Whether the keyboard overlay window should render input events. @Stored(.bool(KeyboardVisualizerSettingsKeys.isEnabled, default: true)) private var storedIsEnabled: Bool diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift index 9f1dcea..5fd9540 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift @@ -144,6 +144,21 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedY), 0, accuracy: 0.0001) } + func testApplyCustomPlacementStoresDisplayAndClampedPosition() { + settings.applyCustomPlacement( + screenID: 42, + normalizedX: 1.5, + normalizedY: -0.5 + ) + + XCTAssertEqual(settings.screenID, 42) + XCTAssertEqual(settings.customPositionNormalizedX, 1, accuracy: 0.0001) + XCTAssertEqual(settings.customPositionNormalizedY, 0, accuracy: 0.0001) + XCTAssertEqual(store.integer(forKey: KeyboardVisualizerSettingsKeys.screenID), 42) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedX), 1, accuracy: 0.0001) + XCTAssertEqual(store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedY), 0, accuracy: 0.0001) + } + func testPublishesPlacementChangesForCustomPlacementSettings() { var receivedCount = 0 let cancellable = settings.placementChanges.sink { _ in