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 00000000..ab6f380c --- /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 80877a8e..36195237 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, @@ -50,18 +53,21 @@ 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 self?.notifyPlacementChanged() } self.window = window + self.appliedHorizontalAlignment = self.previewHorizontalAlignment + self.observePlacementChanges() window.orderFrontRegardless() } @@ -71,38 +77,14 @@ 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 } } 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)] @@ -113,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, @@ -131,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 { @@ -160,17 +132,28 @@ 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() { + 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? { @@ -189,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 29634bf4..039a7b4b 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 7044e9ba..f8ee2727 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 @@ -209,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 { @@ -249,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 ) } @@ -267,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 00000000..309e9f50 --- /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 45a5ea7d..bded1d57 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 ef6184ba..5489c56f 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Settings/Displays/DisplaysSettingsPaneViewModelTests.swift @@ -211,95 +211,62 @@ 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) + 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 frame = KeyboardVisualizerPlacementWindowController.frame( - forNormalizedPosition: CGPoint(x: 0.25, y: 0.75), - in: area, - size: size, - horizontalAlignment: .trailing + 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 + ) + } - XCTAssertEqual(frame.maxX, 300, accuracy: 0.0001) - XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) - } + 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) - func testAnchorXUsesAlignmentSpecificAnchorPoint() { - let frame = CGRect(x: 180, y: 200, width: 120, height: 80) + settings.customHorizontalAlignment = .leading + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) + XCTAssertEqual(window.frame, previewFrame) XCTAssertEqual( - KeyboardVisualizerPlacementWindowController.anchorX(in: frame, alignment: .leading), - 180, - accuracy: 0.0001 - ) - XCTAssertEqual( - KeyboardVisualizerPlacementWindowController.anchorX(in: frame, alignment: .center), - 240, + settings.customPositionNormalizedX, + previewFrame.minX / screenWidth, accuracy: 0.0001 ) - XCTAssertEqual( - KeyboardVisualizerPlacementWindowController.anchorX(in: frame, alignment: .trailing), - 300, - accuracy: 0.0001 - ) - } - func testFrameClampsHandleInsideVisibleFrame() { - let area = CGRect(x: 100, y: 200, width: 800, height: 600) - let size = CGSize(width: 120, height: 80) + settings.customHorizontalAlignment = .trailing + RunLoop.main.run(until: Date().addingTimeInterval(0.1)) - let frame = KeyboardVisualizerPlacementWindowController.frame( - forNormalizedPosition: CGPoint(x: 0, y: 1), - in: area, - size: size + XCTAssertEqual(window.frame, previewFrame) + XCTAssertEqual( + settings.customPositionNormalizedX, + previewFrame.maxX / screenWidth, + accuracy: 0.0001 ) - XCTAssertEqual(frame.minX, area.minX, accuracy: 0.0001) - XCTAssertEqual(frame.maxY, area.maxY, accuracy: 0.0001) + _ = controller.stopSettingPosition() } - 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 00000000..ab8d9a4a --- /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 3f89353d..3e8a2568 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) + } }