From 437848036a94be4b0d0bdc53f328b22250fb0bf4 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 14 Aug 2026 17:01:16 +0200 Subject: [PATCH 1/2] fix(displays): keep the overlay aligned to the preview anchor --- ...dVisualizerPlacementWindowController.swift | 24 +++++++++ .../Keyboard/KeyboardVisualizerWindow.swift | 7 +-- .../DisplaysSettingsPaneViewModelTests.swift | 51 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 80877a8..44774d1 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -7,6 +7,7 @@ // import AppKit +import Combine @MainActor final class KeyboardVisualizerPlacementWindowController: NSWindowController, KeyboardVisualizerPlacementCoordinating { @@ -21,6 +22,8 @@ final class KeyboardVisualizerPlacementWindowController: NSWindowController, Key private let settings: KeyboardVisualizerSettings private let screensService: any ScreenServiceProvider private var onPlacementChanged: PlacementChangeHandler? + private var placementCancellable: AnyCancellable? + private var appliedHorizontalAlignment: KeyboardVisualizerAlignment? init( settings: KeyboardVisualizerSettings, @@ -62,6 +65,8 @@ extension KeyboardVisualizerPlacementWindowController { self?.notifyPlacementChanged() } self.window = window + self.appliedHorizontalAlignment = self.previewHorizontalAlignment + self.observePlacementChanges() window.orderFrontRegardless() } @@ -71,6 +76,8 @@ extension KeyboardVisualizerPlacementWindowController { let placement = self.placement(for: self.previewAnchorPoint(in: window.frame)) window.close() self.onPlacementChanged = nil + self.placementCancellable = nil + self.appliedHorizontalAlignment = nil self.window = nil return placement } @@ -173,6 +180,23 @@ private extension KeyboardVisualizerPlacementWindowController { } } + func observePlacementChanges() { + self.placementCancellable = self.settings.placementChanges + .sink { [weak self] _ in + MainActor.assumeIsolated { + self?.republishAnchorOnAlignmentChange() + } + } + } + + func republishAnchorOnAlignmentChange() { + let alignment = self.previewHorizontalAlignment + guard alignment != self.appliedHorizontalAlignment else { return } + self.appliedHorizontalAlignment = alignment + + self.notifyPlacementChanged() + } + func placement(for point: CGPoint) -> Placement? { Self.placement(for: point, in: self.visibleFrames()) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index 7044e9b..1ea0a31 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -161,10 +161,11 @@ final class KeyboardVisualizerWindow: NSWindow { self.setFrame(targetFrame, display: true, animate: false) } - /// Re-pins the window to its anchor using the current content size, without adding - /// or removing any groups. Driven by anchor-setting and screen-layout changes. + /// Re-pins the window to its anchor without adding or removing any groups. Driven by + /// anchor-setting and screen-layout changes. Group origins are recomputed too: in custom + /// placement they are resolved against the anchor point, so alignment changes move them. @objc private func repositionToAnchor() { - self.setFrame(self.anchoredFrame(for: self.rootView.frame.size), display: true, animate: false) + self.layoutGroups() } /// Frame that places `contentSize` against the main screen's visible area for the diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index ef6184b..36533fb 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -275,6 +275,57 @@ final class KeyboardVisualizerPlacementWindowControllerTests: XCTestCase { ) } + func testAlignmentChangeKeepsPreviewInPlaceAndMovesAnchorToNewEdge() { + let settings = KeyboardVisualizerSettings(store: InMemoryKeyValueStore()) + settings.placementMode = .custom + settings.stackAxis = .vertical + settings.customHorizontalAlignment = .center + settings.customPositionNormalizedX = 0.25 + settings.customPositionNormalizedY = 0.5 + + let screensService = TestScreenService() + let screenWidth = screensService.screens[0].frame.width + let controller = KeyboardVisualizerPlacementWindowController( + settings: settings, + screensService: screensService + ) + controller.startSettingPosition { placement in + settings.applyCustomPlacement( + screenID: placement.screenID, + normalizedX: placement.positionX, + normalizedY: placement.positionY + ) + } + + guard let window = controller.window else { + return XCTFail("Expected the preview window to be created") + } + let previewFrame = window.frame + XCTAssertEqual(previewFrame.midX, 480, accuracy: 0.0001) + + settings.customHorizontalAlignment = .leading + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + + XCTAssertEqual(window.frame, previewFrame) + XCTAssertEqual( + settings.customPositionNormalizedX, + previewFrame.minX / screenWidth, + accuracy: 0.0001 + ) + + settings.customHorizontalAlignment = .trailing + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + + XCTAssertEqual(window.frame, previewFrame) + XCTAssertEqual( + settings.customPositionNormalizedX, + previewFrame.maxX / screenWidth, + accuracy: 0.0001 + ) + + _ = controller.stopSettingPosition() + } + func testFrameClampsHandleInsideVisibleFrame() { let area = CGRect(x: 100, y: 200, width: 800, height: 600) let size = CGSize(width: 120, height: 80) From d4951d320bfa936e218a067314cd627d9efe64bd Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 14 Aug 2026 17:14:09 +0200 Subject: [PATCH 2/2] refactor(displays): move overlay placement geometry onto the alignment --- ...eyboardVisualizerAlignment+Placement.swift | 48 +++++++++ ...dVisualizerPlacementWindowController.swift | 63 ++--------- .../Keyboard/KeyboardVisualizerSettings.swift | 25 +++-- .../Keyboard/KeyboardVisualizerWindow.swift | 65 +++-------- .../ScreenServiceProvider+VisibleFrame.swift | 15 +++ .../CoreGraphics/CGRect+NormalizedPoint.swift | 14 ++- .../DisplaysSettingsPaneViewModelTests.swift | 84 --------------- ...ardVisualizerAlignmentPlacementTests.swift | 102 ++++++++++++++++++ .../CGRectNormalizedPointTests.swift | 17 +++ 9 files changed, 236 insertions(+), 197 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift create mode 100644 Apps/Keyty/Sources/Keyty/Platform/Screens/ScreenServiceProvider+VisibleFrame.swift create mode 100644 Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift new file mode 100644 index 0000000..ab6f380 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift @@ -0,0 +1,48 @@ +// +// KeyboardVisualizerAlignment+Placement.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics + +/// Geometry helpers for custom overlay placement, shared by: +/// - overlay window +/// - placement preview window +/// +/// To make sure all resolve the anchor the same way. +extension KeyboardVisualizerAlignment { + /// Leading edge of `width` when this alignment's edge sits on `x`. + func originX(for width: CGFloat, anchoredAt x: CGFloat) -> CGFloat { + switch self { + case .leading: return x + case .center: return x - width / 2 + case .trailing: return x - width + } + } + + /// This alignment's edge of `frame` — the point the stored normalized position names. + func anchorX(in frame: CGRect) -> CGFloat { + switch self { + case .leading: return frame.minX + case .center: return frame.midX + case .trailing: return frame.maxX + } + } + + /// Frame of `size` whose aligned edge sits on the normalized position and which is + /// vertically centered on it, clamped inside `visibleFrame`. + func frame(for size: CGSize, atNormalized position: CGPoint, in visibleFrame: CGRect) -> CGRect { + let anchor = visibleFrame.point(forNormalized: position) + let origin = CGPoint( + x: self.originX(for: size.width, anchoredAt: anchor.x) + .clamped(minimum: visibleFrame.minX, maximum: visibleFrame.maxX - size.width), + y: (anchor.y - size.height / 2) + .clamped(minimum: visibleFrame.minY, maximum: visibleFrame.maxY - size.height) + ) + + return CGRect(origin: origin, size: size) + } +} diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 44774d1..3619523 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -53,12 +53,13 @@ extension KeyboardVisualizerPlacementWindowController { 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, - horizontalAlignment: self.previewHorizontalAlignment + let frame = self.previewHorizontalAlignment.frame( + for: contentView.preferredSize, + atNormalized: CGPoint( + x: self.settings.customPositionNormalizedX, + y: self.settings.customPositionNormalizedY + ), + in: visibleFrame ) let window = Window(frame: frame, contentView: contentView) window.onMoveEnded = { [weak self] in @@ -84,32 +85,6 @@ extension KeyboardVisualizerPlacementWindowController { } extension KeyboardVisualizerPlacementWindowController { - static func frame( - forNormalizedPosition position: CGPoint, - in area: CGRect, - size: CGSize, - horizontalAlignment: KeyboardVisualizerAlignment = .center - ) -> CGRect { - let center = CGPoint( - x: area.minX + area.width * position.x, - y: area.minY + area.height * position.y - ) - let origin = CGPoint( - x: KeyboardVisualizerWindow.customHorizontalOriginX( - for: size.width, - anchoredAt: center.x, - alignment: horizontalAlignment - ).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)] @@ -120,7 +95,7 @@ extension KeyboardVisualizerPlacementWindowController { return nil } - let position = Self.normalizedPosition(for: point, in: visibleFrame.frame) + let position = visibleFrame.frame.normalizedPoint(for: point) return Placement( screenID: visibleFrame.screenID, @@ -138,16 +113,6 @@ extension KeyboardVisualizerPlacementWindowController { ] } - static func anchorX(in frame: CGRect, alignment: KeyboardVisualizerAlignment) -> CGFloat { - switch alignment { - case .leading: - return frame.minX - case .center: - return frame.midX - case .trailing: - return frame.maxX - } - } } private extension KeyboardVisualizerPlacementWindowController { @@ -167,17 +132,11 @@ private extension KeyboardVisualizerPlacementWindowController { } func resolvedVisibleFrame() -> CGRect? { - self.screensService.visibleFrame(for: self.settings.screenID) - ?? self.screensService.mainVisibleFrame() + self.screensService.visibleFrame(preferring: self.settings.screenID) } var previewHorizontalAlignment: KeyboardVisualizerAlignment { - switch self.settings.stackAxis { - case .vertical: - return self.settings.customHorizontalAlignment - case .horizontal: - return .center - } + self.settings.effectiveHorizontalAlignment } func observePlacementChanges() { @@ -213,7 +172,7 @@ private extension KeyboardVisualizerPlacementWindowController { func previewAnchorPoint(in frame: CGRect) -> CGPoint { CGPoint( - x: Self.anchorX(in: frame, alignment: self.previewHorizontalAlignment), + x: self.previewHorizontalAlignment.anchorX(in: frame), y: frame.midY ) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index 29634bf..039a7b4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -115,17 +115,24 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS )) var stackAxis: KeyboardVisualizerStackAxis - /// Cross-axis alignment used by group layout. Preset anchors derive this from the - /// pinned edge; custom placement uses an explicit horizontal alignment only for - /// vertical stacks, while horizontal stacks remain vertically centered. + /// Horizontal alignment applied to custom placement. + /// + /// Only vertical stacks can be aligned horizontally - horizontal stacks always grow from a centered anchor. + var effectiveHorizontalAlignment: KeyboardVisualizerAlignment { + switch self.stackAxis { + case .vertical: + return self.customHorizontalAlignment + case .horizontal: + return .center + } + } + + /// Cross-axis alignment used by group layout. + /// + /// Preset anchors derive this from the pinned edge - custom placement uses `effectiveHorizontalAlignment`. var alignment: KeyboardVisualizerAlignment { if self.placementMode == .custom { - switch self.stackAxis { - case .vertical: - return self.customHorizontalAlignment - case .horizontal: - return .center - } + return self.effectiveHorizontalAlignment } switch stackAxis { diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift index 1ea0a31..f8ee272 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -210,29 +210,22 @@ final class KeyboardVisualizerWindow: NSWindow { } 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: Self.customHorizontalOriginX( - for: size.width, - anchoredAt: center.x, - alignment: self.settings.customHorizontalAlignment - ), - 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) + self.settings.effectiveHorizontalAlignment.frame( + for: size, + atNormalized: self.customNormalizedPosition, + in: area ) + } - return NSRect(origin: clampedOrigin, size: size) + private var customNormalizedPosition: CGPoint { + CGPoint( + x: self.settings.customPositionNormalizedX, + y: self.settings.customPositionNormalizedY + ) } private func resolvedVisibleFrame() -> CGRect? { - self.screensService.visibleFrame(for: self.settings.screenID) - ?? self.screensService.mainVisibleFrame() + self.screensService.visibleFrame(preferring: self.settings.screenID) } private func alignmentOffset(free: CGFloat, alignment: KeyboardVisualizerAlignment) -> CGFloat { @@ -250,10 +243,9 @@ final class KeyboardVisualizerWindow: NSWindow { customAnchorX: CGFloat? ) -> CGFloat { if let customAnchorX { - return Self.customHorizontalOriginX( + return self.settings.effectiveHorizontalAlignment.originX( for: width, - anchoredAt: customAnchorX, - alignment: self.settings.customHorizontalAlignment + anchoredAt: customAnchorX ) } @@ -268,34 +260,7 @@ final class KeyboardVisualizerWindow: NSWindow { return nil } - return Self.customHorizontalAnchorX( - in: frame, - visibleFrame: area, - normalizedX: self.settings.customPositionNormalizedX - ) - } - - static func customHorizontalOriginX( - for width: CGFloat, - anchoredAt x: CGFloat, - alignment: KeyboardVisualizerAlignment - ) -> CGFloat { - switch alignment { - case .leading: - return x - case .center: - return x - width / 2 - case .trailing: - return x - width - } - } - - static func customHorizontalAnchorX( - in windowFrame: NSRect, - visibleFrame: CGRect, - normalizedX: CGFloat - ) -> CGFloat { - let anchorX = visibleFrame.minX + visibleFrame.width * normalizedX - return anchorX - windowFrame.minX + // Expressed in window coordinates, since it positions groups inside `rootView`. + return area.point(forNormalized: self.customNormalizedPosition).x - frame.minX } } diff --git a/Apps/Keyty/Sources/Keyty/Platform/Screens/ScreenServiceProvider+VisibleFrame.swift b/Apps/Keyty/Sources/Keyty/Platform/Screens/ScreenServiceProvider+VisibleFrame.swift new file mode 100644 index 0000000..309e9f5 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Platform/Screens/ScreenServiceProvider+VisibleFrame.swift @@ -0,0 +1,15 @@ +// +// ScreenServiceProvider+VisibleFrame.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import CoreGraphics + +extension ScreenServiceProvider { + func visibleFrame(preferring id: CGDirectDisplayID) -> CGRect? { + self.visibleFrame(for: id) ?? self.mainVisibleFrame() + } +} diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift index 45a5ea7..bded1d5 100644 --- a/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/CoreGraphics/CGRect+NormalizedPoint.swift @@ -11,12 +11,22 @@ 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. + /// 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) ) } + + /// Returns the point a normalized coordinate refers to within this rectangle. + /// + /// Inverse of `normalizedPoint(for:)`. + func point(forNormalized position: CGPoint) -> CGPoint { + CGPoint( + x: self.minX + self.width * position.x, + y: self.minY + self.height * position.y + ) + } } diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift index 36533fb..5489c56 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -211,69 +211,9 @@ private final class FakeKeyboardVisualizerPlacementCoordinator: KeyboardVisualiz @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 testFramePlacesLeadingAlignedHandleAtNormalizedPosition() { - 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, - horizontalAlignment: .leading - ) - - XCTAssertEqual(frame.minX, 300, accuracy: 0.0001) - XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) - } - func testFramePlacesTrailingAlignedHandleAtNormalizedPosition() { - 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, - horizontalAlignment: .trailing - ) - - XCTAssertEqual(frame.maxX, 300, accuracy: 0.0001) - XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) - } - - func testAnchorXUsesAlignmentSpecificAnchorPoint() { - let frame = CGRect(x: 180, y: 200, width: 120, height: 80) - - XCTAssertEqual( - KeyboardVisualizerPlacementWindowController.anchorX(in: frame, alignment: .leading), - 180, - accuracy: 0.0001 - ) - XCTAssertEqual( - KeyboardVisualizerPlacementWindowController.anchorX(in: frame, alignment: .center), - 240, - accuracy: 0.0001 - ) - XCTAssertEqual( - KeyboardVisualizerPlacementWindowController.anchorX(in: frame, alignment: .trailing), - 300, - accuracy: 0.0001 - ) - } func testAlignmentChangeKeepsPreviewInPlaceAndMovesAnchorToNewEdge() { let settings = KeyboardVisualizerSettings(store: InMemoryKeyValueStore()) @@ -326,31 +266,7 @@ final class KeyboardVisualizerPlacementWindowControllerTests: XCTestCase { _ = controller.stopSettingPosition() } - 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( diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift new file mode 100644 index 0000000..ab8d9a4 --- /dev/null +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift @@ -0,0 +1,102 @@ +// +// KeyboardVisualizerAlignmentPlacementTests.swift +// KeytyTests +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import XCTest +@testable import Keyty + +final class KeyboardVisualizerAlignmentPlacementTests: XCTestCase { + private let area = CGRect(x: 100, y: 200, width: 800, height: 600) + private let size = CGSize(width: 120, height: 80) + + func testFrameCentersContentOnNormalizedPosition() { + let frame = KeyboardVisualizerAlignment.center.frame( + for: self.size, + atNormalized: CGPoint(x: 0.25, y: 0.75), + in: self.area + ) + + XCTAssertEqual(frame.midX, 300, accuracy: 0.0001) + XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) + } + + func testFramePlacesLeadingEdgeOnNormalizedPosition() { + let frame = KeyboardVisualizerAlignment.leading.frame( + for: self.size, + atNormalized: CGPoint(x: 0.25, y: 0.75), + in: self.area + ) + + XCTAssertEqual(frame.minX, 300, accuracy: 0.0001) + XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) + } + + func testFramePlacesTrailingEdgeOnNormalizedPosition() { + let frame = KeyboardVisualizerAlignment.trailing.frame( + for: self.size, + atNormalized: CGPoint(x: 0.25, y: 0.75), + in: self.area + ) + + XCTAssertEqual(frame.maxX, 300, accuracy: 0.0001) + XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) + } + + func testFrameClampsContentInsideVisibleFrame() { + let frame = KeyboardVisualizerAlignment.center.frame( + for: self.size, + atNormalized: CGPoint(x: 0, y: 1), + in: self.area + ) + + XCTAssertEqual(frame.minX, self.area.minX, accuracy: 0.0001) + XCTAssertEqual(frame.maxY, self.area.maxY, accuracy: 0.0001) + } + + func testAnchorXUsesAlignmentSpecificEdge() { + let frame = CGRect(x: 180, y: 200, width: 120, height: 80) + + XCTAssertEqual(KeyboardVisualizerAlignment.leading.anchorX(in: frame), 180, accuracy: 0.0001) + XCTAssertEqual(KeyboardVisualizerAlignment.center.anchorX(in: frame), 240, accuracy: 0.0001) + XCTAssertEqual(KeyboardVisualizerAlignment.trailing.anchorX(in: frame), 300, accuracy: 0.0001) + } + + func testOriginXResolvesAlignedEdgeOntoAnchor() { + XCTAssertEqual( + KeyboardVisualizerAlignment.leading.originX(for: 120, anchoredAt: 300), + 300, + accuracy: 0.0001 + ) + XCTAssertEqual( + KeyboardVisualizerAlignment.center.originX(for: 120, anchoredAt: 300), + 240, + accuracy: 0.0001 + ) + XCTAssertEqual( + KeyboardVisualizerAlignment.trailing.originX(for: 120, anchoredAt: 300), + 180, + accuracy: 0.0001 + ) + } + + func testAnchoredFrameRoundTripsThroughItsOwnAnchor() { + for alignment in KeyboardVisualizerAlignment.allCases { + let frame = alignment.frame( + for: self.size, + atNormalized: CGPoint(x: 0.25, y: 0.75), + in: self.area + ) + + XCTAssertEqual( + self.area.normalizedPoint(for: CGPoint(x: alignment.anchorX(in: frame), y: frame.midY)).x, + 0.25, + accuracy: 0.0001, + "\(alignment) should place its own anchor edge back on the normalized position" + ) + } + } +} diff --git a/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift index 3f89353..3e8a256 100644 --- a/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Support/Extensions/CoreGraphics/CGRectNormalizedPointTests.swift @@ -42,4 +42,21 @@ final class CGRectNormalizedPointTests: XCTestCase { XCTAssertEqual(point.x, 0.5, accuracy: 0.0001) XCTAssertEqual(point.y, 0.25, accuracy: 0.0001) } + + func testPointForNormalizedReturnsPointInsideRect() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + let point = rect.point(forNormalized: CGPoint(x: 0.25, y: 0.5)) + + XCTAssertEqual(point.x, 35, accuracy: 0.0001) + XCTAssertEqual(point.y, 45, accuracy: 0.0001) + } + + func testPointForNormalizedInvertsNormalizedPoint() { + let rect = CGRect(x: 10, y: 20, width: 100, height: 50) + let original = CGPoint(x: 35, y: 45) + let point = rect.point(forNormalized: rect.normalizedPoint(for: original)) + + XCTAssertEqual(point.x, original.x, accuracy: 0.0001) + XCTAssertEqual(point.y, original.y, accuracy: 0.0001) + } }