diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift index 2a04115..c30a60f 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 @@ -19,19 +20,38 @@ final class AppUIContainer { services: AppServiceContainer, updater: SPUUpdater ) { - aboutWindowController = AboutWindowController() - permissionsOnboardingWindowController = PermissionsOnboardingWindowController( + self.aboutWindowController = AboutWindowController() + let keyboardVisualizerPlacementWindowController = KeyboardVisualizerPlacementWindowController( + settings: settings.keyboardVisualizerSettings + ) + self.keyboardVisualizerPlacementWindowController = keyboardVisualizerPlacementWindowController + self.permissionsOnboardingWindowController = PermissionsOnboardingWindowController( permissionsService: services.permissionsService ) - settingsWindowController = SettingsWindowController( + let settingsWindowController = SettingsWindowController( shortcutManager: services.shortcutManager, appSettings: settings.appSettings, pointerRingVisualizer: services.pointerVisualizersManager.ring, pointerRingSettings: settings.pointerRingSettings, pointerIconSettings: settings.pointerIconSettings, keyboardVisualizerSettings: settings.keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: { [weak keyboardVisualizerPlacementWindowController] onPlacementChanged in + keyboardVisualizerPlacementWindowController?.startSettingPosition(onPlacementChanged: onPlacementChanged) + }, + stopSettingKeyboardVisualizerPosition: { [weak keyboardVisualizerPlacementWindowController] in + keyboardVisualizerPlacementWindowController?.stopSettingPosition() + }, 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/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 2131ba1..e261f7c 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 customPositionNormalizedX: Double + let customPositionNormalizedY: 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,13 @@ private extension DisplaysSettingsPane.PreviewCard { return CGSize(width: x, height: y) } + + func customPositionOffset(in size: CGSize, markerSize: CGSize) -> CGSize { + CGSize( + width: size.width * CGFloat(self.customPositionNormalizedX) - size.width / 2, + height: size.height / 2 - size.height * CGFloat(self.customPositionNormalizedY) + ) + } } // 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..7d544c8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -11,64 +11,104 @@ import SwiftUI struct DisplaysSettingsPane: View { @StateObject private var model: DisplaysSettingsPaneViewModel - init(keyboardVisualizerSettings: KeyboardVisualizerSettings) { - _model = StateObject(wrappedValue: DisplaysSettingsPaneViewModel(keyboardVisualizerSettings: keyboardVisualizerSettings)) + init(model: DisplaysSettingsPaneViewModel) { + _model = StateObject(wrappedValue: model) } var body: some View { - DisplaysSettingsPane.PreviewCard( - screens: self.model.screens, - selectedScreen: self.model.selectedScreen, - anchor: self.model.selectedAnchor, - stackAxis: self.model.stackAxis, - windowPadding: self.model.windowPadding, - 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) + 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, + customPositionNormalizedX: self.model.customPositionNormalizedX, + customPositionNormalizedY: self.model.customPositionNormalizedY, + 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) } - .labelsHidden() - .accessibilityLabel(L10n.Displays.displayLabel) - .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) - } - Divider() + Divider() - SettingsControlRow( - title: L10n.Displays.anchorLabel, - subtitle: L10n.Displays.anchorSubtitle - ) { - KeyboardVisualizerAnchorPicker( - selection: $model.selectedAnchor, - accessibilityLabel: L10n.Displays.anchorLabel - ) - } + SettingsControlRow( + title: L10n.Displays.anchorLabel, + subtitle: L10n.Displays.anchorSubtitle + ) { + Picker("", selection: self.anchorSelection) { + ForEach(DisplaysSettingsPaneViewModel.PlacementSelection.allCases, id: \.self) { selection in + Text(selection.label).tag(selection) + } + } + .labelsHidden() + .accessibilityLabel(L10n.Displays.anchorLabel) + .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) + } - Divider() + if self.model.isCustomPlacement { + Divider() - SettingsControlRow( - title: L10n.Displays.marginLabel, - subtitle: L10n.Displays.marginSubtitle - ) { - SettingsSliderControl( - value: $model.windowPadding, - range: model.paddingRange, - step: model.paddingStep, - accessibilityLabel: L10n.Displays.marginLabel - ) + self.customPlacementControls + } else { + 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() + } + } +} + +private extension DisplaysSettingsPane { + var customPlacementControls: some View { + SettingsControlRow( + title: L10n.Displays.CustomPosition.label, + subtitle: L10n.Displays.CustomPosition.setSubtitle + ) { + Button(self.model.isSettingCustomPosition ? L10n.Displays.CustomPosition.stopSettingButton : L10n.Displays.CustomPosition.startSettingButton) { + self.model.toggleCustomPositionSetting() + } + .controlSize(.regular) + .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 d27f946..c31a045 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -9,15 +9,19 @@ import Combine import SwiftUI +@MainActor final class DisplaysSettingsPaneViewModel: ObservableObject { private let screensService: any ScreenServiceProvider private let keyboardVisualizerSettings: KeyboardVisualizerSettings + 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) 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,17 +36,60 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { didSet { self.keyboardVisualizerSettings.anchor = self.selectedAnchor } } + @Published var placementMode: KeyboardVisualizerSettings.PlacementMode { + didSet { + self.keyboardVisualizerSettings.placementMode = self.placementMode + if self.placementMode != .custom { + self.stopCustomPositionSetting() + } + } + } + @Published var windowPadding: Double { didSet { self.keyboardVisualizerSettings.windowPadding = CGFloat(self.windowPadding) } } + @Published var customPositionNormalizedX: Double { + didSet { self.keyboardVisualizerSettings.customPositionNormalizedX = CGFloat(self.customPositionNormalizedX) } + } + + @Published var customPositionNormalizedY: Double { + didSet { self.keyboardVisualizerSettings.customPositionNormalizedY = CGFloat(self.customPositionNormalizedY) } + } + var stackAxis: KeyboardVisualizerStackAxis { self.keyboardVisualizerSettings.stackAxis } + var isCustomPlacement: Bool { + self.placementMode == .custom + } + + var anchorSelection: PlacementSelection { + 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() + keyboardVisualizerSettings: KeyboardVisualizerSettings = KeyboardVisualizerSettings(), + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void = { _ in }, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement? = { nil } ) { guard let selectedScreen = Self.initialSelectedScreen( screensService: screensService, @@ -53,10 +100,15 @@ 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 + self.placementMode = keyboardVisualizerSettings.placementMode self.windowPadding = Double(keyboardVisualizerSettings.windowPadding) + self.customPositionNormalizedX = Double(keyboardVisualizerSettings.customPositionNormalizedX) + self.customPositionNormalizedY = Double(keyboardVisualizerSettings.customPositionNormalizedY) self.screensService.screensDidChange .receive(on: RunLoop.main) @@ -67,9 +119,56 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { } .store(in: &self.cancellables) } + + func toggleCustomPositionSetting() { + if self.isSettingCustomPosition { + self.stopCustomPositionSetting() + } else { + self.startSettingKeyboardVisualizerPosition { [weak self] placement in + self?.applyPlacement(placement) + } + self.isSettingCustomPosition = true + } + } + + func finishCustomPositionSetting() { + self.stopCustomPositionSetting() + } +} + +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 } + if let placement = self.stopSettingKeyboardVisualizerPosition() { + self.applyPlacement(placement) + } + self.isSettingCustomPosition = false + } + + func applyPlacement(_ placement: KeyboardVisualizerPlacementWindowController.Placement) { + self.selectedScreenID = placement.screenID + self.customPositionNormalizedX = Double(placement.positionX) + self.customPositionNormalizedY = Double(placement.positionY) + } + static func initialSelectedScreen( screensService: any ScreenServiceProvider, keyboardVisualizerSettings: KeyboardVisualizerSettings diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Keyboard/KeyboardSettingsPane.swift index 304dd77..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,30 +177,6 @@ struct KeyboardSettingsPane: View { } .disabled(!self.model.isEnabled) - SettingsSectionView( - 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, - 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/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index aa07c7f..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, @@ -27,9 +28,18 @@ struct SettingsPaneRegistry { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement?, permissionsService: any PermissionsService, updater: SPUUpdater ) { + let displaysViewModel = DisplaysSettingsPaneViewModel( + keyboardVisualizerSettings: keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, + stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition + ) + self.displaysViewModel = displaysViewModel + self.entries = [ SettingsPaneEntry( id: .general, @@ -71,7 +81,11 @@ struct SettingsPaneRegistry { title: SettingsPaneIdentifier.displays.label, systemImageName: SettingsPaneIdentifier.displays.sfSymbolName, makeView: { - AnyView(DisplaysSettingsPane(keyboardVisualizerSettings: keyboardVisualizerSettings)) + AnyView( + DisplaysSettingsPane( + model: displaysViewModel + ) + ) } ), SettingsPaneEntry( @@ -104,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 1b1c618..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? @@ -25,6 +27,8 @@ final class SettingsWindowController: NSWindowController { pointerRingSettings: any PointerRingSettingsProtocol, pointerIconSettings: any PointerIconSettingsProtocol, keyboardVisualizerSettings: KeyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: @escaping @MainActor (@escaping KeyboardVisualizerPlacementWindowController.PlacementChangeHandler) -> Void, + stopSettingKeyboardVisualizerPosition: @escaping @MainActor () -> KeyboardVisualizerPlacementWindowController.Placement?, permissionsService: any PermissionsService, updater: SPUUpdater ) { @@ -35,6 +39,8 @@ final class SettingsWindowController: NSWindowController { pointerRingSettings: pointerRingSettings, pointerIconSettings: pointerIconSettings, keyboardVisualizerSettings: keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: startSettingKeyboardVisualizerPosition, + stopSettingKeyboardVisualizerPosition: stopSettingKeyboardVisualizerPosition, permissionsService: permissionsService, updater: updater ) @@ -44,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) @@ -52,7 +59,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?) { @@ -95,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/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..2858897 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -0,0 +1,209 @@ +// +// KeyboardVisualizerPlacementWindowController.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +@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: PlacementChangeHandler? + + 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(onPlacementChanged: @escaping PlacementChangeHandler) { + self.onPlacementChanged = onPlacementChanged + + 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.customPositionNormalizedX, y: self.settings.customPositionNormalizedY), + in: visibleFrame, + size: size + ) + let window = Window(frame: frame, contentView: contentView) + window.onMoveEnded = { [weak self] in + self?.notifyPlacementChanged() + } + self.window = window + window.orderFrontRegardless() + } + + 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)) + window.close() + self.onPlacementChanged = nil + self.window = nil + return placement + } +} + +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: (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) + } + + static func normalizedPosition(for point: CGPoint, in area: CGRect) -> CGPoint { + area.normalizedPoint(for: point) + } + + static func placement( + for point: CGPoint, + in visibleFrames: [(screenID: CGDirectDisplayID, frame: CGRect)] + ) -> Placement? { + 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 Placement( + screenID: visibleFrame.screenID, + positionX: position.x, + positionY: position.y + ) + } + + 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() + } + + func placement(for point: CGPoint) -> Placement? { + 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 } + 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 + lhs.frame.squaredDistance(to: point) < rhs.frame.squaredDistance(to: point) + } + } +} + +extension KeyboardVisualizerPlacementWindowController { + final class Window: NSPanel { + var onMoveEnded: (() -> Void)? + + 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 + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + self.onMoveEnded?() + } + } +} 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..3587783 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings+PlacementMode.swift @@ -0,0 +1,17 @@ +// +// KeyboardVisualizerSettings+PlacementMode.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +extension KeyboardVisualizerSettings { + 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 + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index 657d5cf..8405be8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -11,20 +11,32 @@ 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 } var mouseTheme: KeyboardVisualizerTheme { get set } 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 } + /// 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 } var windowPadding: CGFloat { get set } @@ -35,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 { @@ -61,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 @@ -170,6 +189,27 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS } } + @Stored(.enum(KeyboardVisualizerSettingsKeys.placementMode, default: .anchored)) + var placementMode: PlacementMode { + didSet { + self.placementChangesSubject.send(()) + } + } + + @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionNormalizedX, default: 0.5, clamp: 0...1)) + var customPositionNormalizedX: CGFloat { + didSet { + self.placementChangesSubject.send(()) + } + } + + @Stored(.cgFloat(KeyboardVisualizerSettingsKeys.customPositionNormalizedY, default: 0.5, clamp: 0...1)) + var customPositionNormalizedY: 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..303100c 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 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 bc00d48..bad9a7b 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,23 @@ 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 center = CGPoint( + 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, + y: center.y - size.height / 2 + ) + let clampedOrigin = CGPoint( + 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 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..f375d23 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -39,10 +39,15 @@ /* Displays settings pane */ "displays.display_label" = "Bildschirm"; "displays.display_subtitle" = "Bildschirm für die Tastatureinblendung"; +"displays.placement.custom" = "Benutzerdefiniert"; "displays.anchor_label" = "Verankerung"; -"displays.anchor_subtitle" = "Position der Tastatureinblendung"; +"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" = "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"; @@ -71,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 die Bildschirmposition und Größe der Einblendung fest."; "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 4f762d3..0621be8 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -39,10 +39,15 @@ /* Displays settings pane */ "displays.display_label" = "Display"; "displays.display_subtitle" = "Display to host the keyboard overlay"; +"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.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"; /* General settings pane */ "general.appearance_section_title" = "App"; @@ -71,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 screen anchor and 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 ff00119..c8c1873 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -39,10 +39,15 @@ /* Displays settings pane */ "displays.display_label" = "Pantalla"; "displays.display_subtitle" = "Pantalla en la que se muestra la superposición del teclado"; +"displays.placement.custom" = "Personalizada"; "displays.anchor_label" = "Anclaje"; -"displays.anchor_subtitle" = "Posición de anclaje de la superposición del teclado"; +"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" = "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"; @@ -71,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 anclaje en pantalla y 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 c8e8a47..a5d729b 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -39,10 +39,15 @@ /* Displays settings pane */ "displays.display_label" = "Écran"; "displays.display_subtitle" = "Écran sur lequel afficher la superposition du clavier"; +"displays.placement.custom" = "Personnalisée"; "displays.anchor_label" = "Ancrage"; -"displays.anchor_subtitle" = "Position d’ancrage de la superposition du clavier"; +"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" = "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"; @@ -71,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’ancrage à l’écran et 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 ac6e740..13d293e 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -39,10 +39,15 @@ /* Displays settings pane */ "displays.display_label" = "ディスプレイ"; "displays.display_subtitle" = "キーボードオーバーレイを表示するディスプレイ"; +"displays.placement.custom" = "カスタム"; "displays.anchor_label" = "基準位置"; -"displays.anchor_subtitle" = "キーボードオーバーレイの基準位置"; +"displays.anchor_subtitle" = "オーバーレイの配置と広がりの基準点。"; "displays.margin_label" = "画面端からの余白"; "displays.margin_subtitle" = "オーバーレイと選択した画面端との間隔です。"; +"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" = "アプリ"; @@ -71,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 6b92bb0..063f484 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -39,10 +39,15 @@ /* Displays settings pane */ "displays.display_label" = "Дисплей"; "displays.display_subtitle" = "Дисплей, на якому показуватиметься накладка клавіатури"; +"displays.placement.custom" = "Власна"; "displays.anchor_label" = "Прив’язка"; -"displays.anchor_subtitle" = "Положення накладки клавіатури"; +"displays.anchor_subtitle" = "Вирівнювання накладки та точка розгортання."; "displays.margin_label" = "Відступ від краю"; "displays.margin_subtitle" = "Відстань між накладкою та вибраним краєм екрана."; +"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" = "Програма"; @@ -71,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 787b623..a868cc9 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,15 @@ /* Displays settings pane */ "displays.display_label" = "显示器"; "displays.display_subtitle" = "用于显示键盘叠加层的显示器"; +"displays.placement.custom" = "自定义"; "displays.anchor_label" = "锚点"; -"displays.anchor_subtitle" = "键盘叠加层的锚点位置"; +"displays.anchor_subtitle" = "叠加层的对齐方式和展开基点。"; "displays.margin_label" = "屏幕边距"; "displays.margin_subtitle" = "叠加层与所选屏幕边缘之间的距离。"; +"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" = "应用"; @@ -71,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/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 + } +} 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) + ) + } +} 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) + } +} 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..5141470 --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -0,0 +1,308 @@ +// +// DisplaysSettingsPaneViewModelTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +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 placementToReturn: KeyboardVisualizerPlacementWindowController.Placement? + private var placementChangeHandler: KeyboardVisualizerPlacementWindowController.PlacementChangeHandler? + private var model: DisplaysSettingsPaneViewModel! + + override func setUp() { + super.setUp() + self.store = InMemoryKeyValueStore() + self.keyboardVisualizerSettings = KeyboardVisualizerSettings(store: self.store) + self.screensService = TestScreenService() + self.startSettingCallCount = 0 + self.stopSettingCallCount = 0 + self.placementToReturn = nil + self.placementChangeHandler = nil + self.model = DisplaysSettingsPaneViewModel( + screensService: self.screensService, + keyboardVisualizerSettings: self.keyboardVisualizerSettings, + startSettingKeyboardVisualizerPosition: { [weak self] onPlacementChanged in + self?.startSettingCallCount += 1 + self?.placementChangeHandler = onPlacementChanged + }, + stopSettingKeyboardVisualizerPosition: { [weak self] in + self?.stopSettingCallCount += 1 + return self?.placementToReturn + } + ) + } + + override func tearDown() { + self.model = nil + self.placementChangeHandler = nil + self.placementToReturn = nil + self.stopSettingCallCount = 0 + self.startSettingCallCount = 0 + self.screensService = nil + self.keyboardVisualizerSettings = nil + self.store = nil + super.tearDown() + } + + func testToggleCustomPositionSettingFlipsLocalFlagOnly() { + self.keyboardVisualizerSettings.customPositionNormalizedX = 0.25 + self.keyboardVisualizerSettings.customPositionNormalizedY = 0.75 + + self.model.toggleCustomPositionSetting() + + XCTAssertTrue(self.model.isSettingCustomPosition) + 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.customPositionNormalizedX, 0.25, accuracy: 0.0001) + XCTAssertEqual(self.keyboardVisualizerSettings.customPositionNormalizedY, 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) + } + + 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 = KeyboardVisualizerPlacementWindowController.Placement( + 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.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() { + self.model.finishCustomPositionSetting() + + XCTAssertFalse(self.model.isSettingCustomPosition) + XCTAssertEqual(self.stopSettingCallCount, 0) + } + + func testStoppingCustomPositionSettingAppliesReturnedPlacement() { + self.placementToReturn = KeyboardVisualizerPlacementWindowController.Placement( + 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.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() { + self.model.toggleCustomPositionSetting() + + self.placementChangeHandler?( + KeyboardVisualizerPlacementWindowController.Placement( + 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.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) + } +} + +@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) + } + + 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 { + 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) + ), + Screen( + id: 2, + displayName: "Display 2", + wallpaperImageURL: nil, + frame: CGRect(x: 1920, 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..5fd9540 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.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) @@ -124,6 +127,52 @@ 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.customPositionNormalizedX = 1.5 + settings.customPositionNormalizedY = -0.5 + + 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 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 + receivedCount += 1 + } + + settings.placementMode = .custom + settings.customPositionNormalizedX = 0.25 + settings.customPositionNormalizedY = 0.75 + + XCTAssertEqual(receivedCount, 3) + cancellable.cancel() + } + func testPersistsSharedTimingSettings() { settings.fadeDelay = 3.5 settings.fadeDuration = 0.45 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) + } +}