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 bddd6c90..791d3b8b 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane+PreviewCard.swift @@ -19,6 +19,8 @@ extension DisplaysSettingsPane { let windowPadding: Double let customPositionNormalizedX: Double let customPositionNormalizedY: Double + let customHorizontalAlignment: KeyboardVisualizerAlignment + let customVerticalAlignment: KeyboardVisualizerAlignment let onSelectScreen: (Screen) -> Void private let previewHeight = Spacing.grid(48) @@ -186,9 +188,12 @@ private extension DisplaysSettingsPane.PreviewCard { func customPositionOffset(in size: CGSize, markerSize: CGSize) -> CGSize { let halfMarkerWidth = markerSize.width / 2 let halfMarkerHeight = markerSize.height / 2 - let x = (size.width * CGFloat(self.customPositionNormalizedX) - size.width / 2) + let anchorX = size.width * CGFloat(self.customPositionNormalizedX) - size.width / 2 + let anchorY = size.height / 2 - size.height * CGFloat(self.customPositionNormalizedY) + + let x = (anchorX + self.markerShift(for: self.customHorizontalAlignment, half: halfMarkerWidth)) .clamped(minimum: -size.width / 2 + halfMarkerWidth, maximum: size.width / 2 - halfMarkerWidth) - let y = (size.height / 2 - size.height * CGFloat(self.customPositionNormalizedY)) + let y = (anchorY - self.markerShift(for: self.customVerticalAlignment, half: halfMarkerHeight)) .clamped(minimum: -size.height / 2 + halfMarkerHeight, maximum: size.height / 2 - halfMarkerHeight) return CGSize( @@ -196,6 +201,14 @@ private extension DisplaysSettingsPane.PreviewCard { height: y ) } + + func markerShift(for alignment: KeyboardVisualizerAlignment, half: CGFloat) -> CGFloat { + switch alignment { + case .leading: return half + case .center: return 0 + case .trailing: return -half + } + } } // 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 d5cd9d25..f63199e7 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPane.swift @@ -27,6 +27,8 @@ struct DisplaysSettingsPane: View { windowPadding: self.model.windowPadding, customPositionNormalizedX: self.model.customPositionNormalizedX, customPositionNormalizedY: self.model.customPositionNormalizedY, + customHorizontalAlignment: self.model.customHorizontalAlignment, + customVerticalAlignment: self.model.customVerticalAlignment, onSelectScreen: { screen in self.model.selectedScreenID = screen.id } @@ -109,16 +111,32 @@ private extension DisplaysSettingsPane { Divider() SettingsControlRow( - title: L10n.Displays.customContentAlignmentLabel, - subtitle: L10n.Displays.customContentAlignmentSubtitle + title: L10n.Displays.customHorizontalAlignmentLabel, + subtitle: L10n.Displays.customHorizontalAlignmentSubtitle ) { Picker("", selection: self.$model.customHorizontalAlignment) { ForEach(KeyboardVisualizerAlignment.allCases, id: \.rawValue) { alignment in - Text(alignment.label).tag(alignment) + Text(alignment.horizontalLabel).tag(alignment) } } .labelsHidden() - .accessibilityLabel(L10n.Displays.customContentAlignmentLabel) + .accessibilityLabel(L10n.Displays.customHorizontalAlignmentLabel) + .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) + } + + Divider() + + SettingsControlRow( + title: L10n.Displays.customVerticalAlignmentLabel, + subtitle: L10n.Displays.customVerticalAlignmentSubtitle + ) { + Picker("", selection: self.$model.customVerticalAlignment) { + ForEach(KeyboardVisualizerAlignment.allCases, id: \.rawValue) { alignment in + Text(alignment.verticalLabel).tag(alignment) + } + } + .labelsHidden() + .accessibilityLabel(L10n.Displays.customVerticalAlignmentLabel) .frame(width: Size.Control.settingsPickerWidth, alignment: .trailing) } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift index 7febf146..4708a495 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Displays/DisplaysSettingsPaneViewModel.swift @@ -63,6 +63,13 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { } } + @Published var customVerticalAlignment: KeyboardVisualizerAlignment { + didSet { + guard self.keyboardVisualizerSettings.customVerticalAlignment != self.customVerticalAlignment else { return } + self.keyboardVisualizerSettings.customVerticalAlignment = self.customVerticalAlignment + } + } + @Published private(set) var stackAxis: KeyboardVisualizerStackAxis @Published private(set) var scale: Double @@ -113,6 +120,7 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.customPositionNormalizedX = Double(keyboardVisualizerSettings.customPositionNormalizedX) self.customPositionNormalizedY = Double(keyboardVisualizerSettings.customPositionNormalizedY) self.customHorizontalAlignment = keyboardVisualizerSettings.customHorizontalAlignment + self.customVerticalAlignment = keyboardVisualizerSettings.customVerticalAlignment self.stackAxis = keyboardVisualizerSettings.stackAxis self.scale = Double(keyboardVisualizerSettings.scale) @@ -132,6 +140,7 @@ final class DisplaysSettingsPaneViewModel: ObservableObject { self.stackAxis = keyboardVisualizerSettings.stackAxis self.scale = Double(keyboardVisualizerSettings.scale) self.customHorizontalAlignment = keyboardVisualizerSettings.customHorizontalAlignment + self.customVerticalAlignment = keyboardVisualizerSettings.customVerticalAlignment } .store(in: &self.cancellables) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift index ab6f380c..5328c7a8 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment+Placement.swift @@ -23,6 +23,17 @@ extension KeyboardVisualizerAlignment { } } + /// Bottom edge of `height` when this alignment's edge sits on `y`. + /// + /// AppKit's y grows upward, so `leading` is the bottom edge and `trailing` the top one. + func originY(for height: CGFloat, anchoredAt y: CGFloat) -> CGFloat { + switch self { + case .leading: return y + case .center: return y - height / 2 + case .trailing: return y - height + } + } + /// This alignment's edge of `frame` — the point the stored normalized position names. func anchorX(in frame: CGRect) -> CGFloat { switch self { @@ -32,14 +43,29 @@ extension KeyboardVisualizerAlignment { } } - /// 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 { + /// This alignment's edge of `frame` on the vertical axis. + func anchorY(in frame: CGRect) -> CGFloat { + switch self { + case .leading: return frame.minY + case .center: return frame.midY + case .trailing: return frame.maxY + } + } + + /// Frame of `size` whose aligned edges sit on the normalized position, clamped inside + /// `visibleFrame`. + static func frame( + for size: CGSize, + atNormalized position: CGPoint, + in visibleFrame: CGRect, + horizontal: KeyboardVisualizerAlignment, + vertical: KeyboardVisualizerAlignment + ) -> CGRect { let anchor = visibleFrame.point(forNormalized: position) let origin = CGPoint( - x: self.originX(for: size.width, anchoredAt: anchor.x) + x: horizontal.originX(for: size.width, anchoredAt: anchor.x) .clamped(minimum: visibleFrame.minX, maximum: visibleFrame.maxX - size.width), - y: (anchor.y - size.height / 2) + y: vertical.originY(for: size.height, anchoredAt: anchor.y) .clamped(minimum: visibleFrame.minY, maximum: visibleFrame.maxY - size.height) ) diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment.swift index fcc5c0ac..c9e22eb4 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerAlignment.swift @@ -16,7 +16,7 @@ enum KeyboardVisualizerAlignment: Int, CaseIterable { /// Pinned to the cross-axis end (right for a vertical stack, top for a horizontal stack). case trailing = 2 - var label: String { + var horizontalLabel: String { switch self { case .leading: L10n.Anchor.left @@ -26,4 +26,15 @@ enum KeyboardVisualizerAlignment: Int, CaseIterable { L10n.Anchor.right } } + + var verticalLabel: String { + switch self { + case .leading: + L10n.Anchor.bottom + case .center: + L10n.Anchor.verticalCenter + case .trailing: + L10n.Anchor.top + } + } } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift index 36195237..9e5e76c0 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerPlacementWindowController.swift @@ -11,19 +11,13 @@ import Combine @MainActor final class KeyboardVisualizerPlacementWindowController: NSWindowController, KeyboardVisualizerPlacementCoordinating { - 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? private var placementCancellable: AnyCancellable? - private var appliedHorizontalAlignment: KeyboardVisualizerAlignment? + private var appliedAlignment: Alignment? init( settings: KeyboardVisualizerSettings, @@ -40,6 +34,26 @@ final class KeyboardVisualizerPlacementWindowController: NSWindowController, Key } } +// MARK: - Placement +extension KeyboardVisualizerPlacementWindowController { + /// Display and position the preview window resolved to, + /// normalized to that display's visible frame. + struct Placement { + let screenID: CGDirectDisplayID + let positionX: CGFloat + let positionY: CGFloat + } +} + +// MARK: - Alignment +extension KeyboardVisualizerPlacementWindowController { + /// Alignment pair the preview window is currently anchored with. + struct Alignment: Equatable { + let horizontal: KeyboardVisualizerAlignment + let vertical: KeyboardVisualizerAlignment + } +} + // MARK: - Public API extension KeyboardVisualizerPlacementWindowController { func startSettingPosition(onPlacementChanged: @escaping PlacementChangeHandler) { @@ -53,20 +67,23 @@ extension KeyboardVisualizerPlacementWindowController { guard let visibleFrame = self.resolvedVisibleFrame() else { return } let contentView = KeyboardVisualizerGroupView(items: Self.previewItems(settings: self.settings), settings: self.settings) - let frame = self.previewHorizontalAlignment.frame( + let previewAlignment = self.previewAlignment + let frame = KeyboardVisualizerAlignment.frame( for: contentView.preferredSize, atNormalized: CGPoint( x: self.settings.customPositionNormalizedX, y: self.settings.customPositionNormalizedY ), - in: visibleFrame + in: visibleFrame, + horizontal: previewAlignment.horizontal, + vertical: previewAlignment.vertical ) let window = Window(frame: frame, contentView: contentView) window.onMoveEnded = { [weak self] in self?.notifyPlacementChanged() } self.window = window - self.appliedHorizontalAlignment = self.previewHorizontalAlignment + self.appliedAlignment = previewAlignment self.observePlacementChanges() window.orderFrontRegardless() } @@ -78,7 +95,7 @@ extension KeyboardVisualizerPlacementWindowController { window.close() self.onPlacementChanged = nil self.placementCancellable = nil - self.appliedHorizontalAlignment = nil + self.appliedAlignment = nil self.window = nil return placement } @@ -135,8 +152,11 @@ private extension KeyboardVisualizerPlacementWindowController { self.screensService.visibleFrame(preferring: self.settings.screenID) } - var previewHorizontalAlignment: KeyboardVisualizerAlignment { - self.settings.effectiveHorizontalAlignment + var previewAlignment: Alignment { + Alignment( + horizontal: self.settings.effectiveHorizontalAlignment, + vertical: self.settings.customVerticalAlignment + ) } func observePlacementChanges() { @@ -149,9 +169,9 @@ private extension KeyboardVisualizerPlacementWindowController { } func republishAnchorOnAlignmentChange() { - let alignment = self.previewHorizontalAlignment - guard alignment != self.appliedHorizontalAlignment else { return } - self.appliedHorizontalAlignment = alignment + let alignment = self.previewAlignment + guard alignment != self.appliedAlignment else { return } + self.appliedAlignment = alignment self.notifyPlacementChanged() } @@ -171,9 +191,11 @@ private extension KeyboardVisualizerPlacementWindowController { } func previewAnchorPoint(in frame: CGRect) -> CGPoint { - CGPoint( - x: self.previewHorizontalAlignment.anchorX(in: frame), - y: frame.midY + let alignment = self.previewAlignment + + return CGPoint( + x: alignment.horizontal.anchorX(in: frame), + y: alignment.vertical.anchorY(in: frame) ) } diff --git a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift index 039a7b4b..c279a319 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettings.swift @@ -38,6 +38,8 @@ protocol KeyboardVisualizerSettingsProtocol: AnyObject { var customPositionNormalizedY: CGFloat { get set } /// Horizontal alignment used when custom placement is selected. var customHorizontalAlignment: KeyboardVisualizerAlignment { get set } + /// Vertical alignment used when custom placement is selected. + var customVerticalAlignment: KeyboardVisualizerAlignment { get set } var screenID: CGDirectDisplayID { get set } var scale: CGFloat { get set } @@ -129,10 +131,14 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS /// Cross-axis alignment used by group layout. /// - /// Preset anchors derive this from the pinned edge - custom placement uses `effectiveHorizontalAlignment`. + /// Preset anchors derive this from the pinned edge - custom placement uses whichever custom + /// alignment names the cross axis of the current stack. var alignment: KeyboardVisualizerAlignment { if self.placementMode == .custom { - return self.effectiveHorizontalAlignment + switch self.stackAxis { + case .vertical: return self.effectiveHorizontalAlignment + case .horizontal: return self.customVerticalAlignment + } } switch stackAxis { @@ -242,6 +248,15 @@ final class KeyboardVisualizerSettings: KeyboardVisualizerSettingsProtocol, HasS } } + /// Vertical alignment applied to custom placement. + @Stored(.enum(KeyboardVisualizerSettingsKeys.customVerticalAlignment, default: .center)) + var customVerticalAlignment: KeyboardVisualizerAlignment { + didSet { + guard oldValue != self.customVerticalAlignment else { return } + 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 8b78c519..4489fe97 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerSettingsKeys.swift @@ -31,6 +31,7 @@ enum KeyboardVisualizerSettingsKeys { static let customPositionNormalizedX = "keyboard_visualizer.customPositionNormalizedX" static let customPositionNormalizedY = "keyboard_visualizer.customPositionNormalizedY" static let customHorizontalAlignment = "keyboard_visualizer.customHorizontalAlignment" + static let customVerticalAlignment = "keyboard_visualizer.customVerticalAlignment" 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 f8ee2727..ff3ed376 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Visualizers/Keyboard/KeyboardVisualizerWindow.swift @@ -131,13 +131,22 @@ final class KeyboardVisualizerWindow: NSWindow { ) let targetFrame = self.anchoredFrame(for: layoutSize) let customVerticalAnchorX = self.customVerticalAnchorX(in: targetFrame) + let customHorizontalAnchorY = self.customHorizontalAnchorY(in: targetFrame) cursor = .zero for (view, size) in viewSizes { let origin: CGPoint switch stackAxis { case .horizontal: - origin = CGPoint(x: cursor.x, y: self.alignmentOffset(free: layoutSize.height - size.height, alignment: alignment)) + origin = CGPoint( + x: cursor.x, + y: self.verticalOriginY( + for: size.height, + layoutHeight: layoutSize.height, + alignment: alignment, + customAnchorY: customHorizontalAnchorY + ) + ) cursor.x += size.width + spacing case .vertical: origin = CGPoint( @@ -210,10 +219,12 @@ final class KeyboardVisualizerWindow: NSWindow { } private func customFrame(size: NSSize, in area: CGRect) -> NSRect { - self.settings.effectiveHorizontalAlignment.frame( + KeyboardVisualizerAlignment.frame( for: size, atNormalized: self.customNormalizedPosition, - in: area + in: area, + horizontal: self.settings.effectiveHorizontalAlignment, + vertical: self.settings.customVerticalAlignment ) } @@ -252,6 +263,22 @@ final class KeyboardVisualizerWindow: NSWindow { return self.alignmentOffset(free: layoutWidth - width, alignment: alignment) } + private func verticalOriginY( + for height: CGFloat, + layoutHeight: CGFloat, + alignment: KeyboardVisualizerAlignment, + customAnchorY: CGFloat? + ) -> CGFloat { + if let customAnchorY { + return self.settings.customVerticalAlignment.originY( + for: height, + anchoredAt: customAnchorY + ) + } + + return self.alignmentOffset(free: layoutHeight - height, alignment: alignment) + } + private func customVerticalAnchorX(in frame: NSRect) -> CGFloat? { guard self.settings.placementMode == .custom, self.settings.stackAxis == .vertical else { return nil @@ -263,4 +290,16 @@ final class KeyboardVisualizerWindow: NSWindow { // Expressed in window coordinates, since it positions groups inside `rootView`. return area.point(forNormalized: self.customNormalizedPosition).x - frame.minX } + + private func customHorizontalAnchorY(in frame: NSRect) -> CGFloat? { + guard self.settings.placementMode == .custom, self.settings.stackAxis == .horizontal else { + return nil + } + guard let area = self.resolvedVisibleFrame() else { + return nil + } + + // Expressed in window coordinates, since it positions groups inside `rootView`. + return area.point(forNormalized: self.customNormalizedPosition).y - frame.minY + } } diff --git a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings index 37655837..c4adcbc9 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/de.lproj/Localizable.strings @@ -50,8 +50,10 @@ "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"; -"displays.custom_content_alignment_label" = "Inhaltsausrichtung"; -"displays.custom_content_alignment_subtitle" = "Wähle, wie sich die Einblendung horizontal von der benutzerdefinierten Position aus ausdehnt."; +"displays.custom_horizontal_alignment_label" = "Horizontale Ausrichtung"; +"displays.custom_horizontal_alignment_subtitle" = "Wähle, wie sich die Einblendung horizontal von der benutzerdefinierten Position aus ausdehnt."; +"displays.custom_vertical_alignment_label" = "Vertikale Ausrichtung"; +"displays.custom_vertical_alignment_subtitle" = "Wähle, wie sich die Einblendung vertikal von der benutzerdefinierten Position aus ausdehnt."; /* General settings pane */ "general.appearance_section_title" = "App"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings index cf353cd3..c5ec5416 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/en.lproj/Localizable.strings @@ -50,8 +50,10 @@ "displays.custom_position.set_subtitle" = "Choose where the overlay appears on the selected display."; "displays.custom_position.start_setting_button" = "Arrange..."; "displays.custom_position.stop_setting_button" = "Apply"; -"displays.custom_content_alignment_label" = "Content Alignment"; -"displays.custom_content_alignment_subtitle" = "Choose how the overlay expands horizontally from the custom position."; +"displays.custom_horizontal_alignment_label" = "Horizontal Alignment"; +"displays.custom_horizontal_alignment_subtitle" = "Choose how the overlay expands horizontally from the custom position."; +"displays.custom_vertical_alignment_label" = "Vertical Alignment"; +"displays.custom_vertical_alignment_subtitle" = "Choose how the overlay expands vertically from the custom position."; /* General settings pane */ "general.appearance_section_title" = "App"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings index a3788cb9..bea80d08 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/es.lproj/Localizable.strings @@ -50,8 +50,10 @@ "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"; -"displays.custom_content_alignment_label" = "Alineación del contenido"; -"displays.custom_content_alignment_subtitle" = "Elige cómo se expande horizontalmente la superposición desde la posición personalizada."; +"displays.custom_horizontal_alignment_label" = "Alineación horizontal"; +"displays.custom_horizontal_alignment_subtitle" = "Elige cómo se expande horizontalmente la superposición desde la posición personalizada."; +"displays.custom_vertical_alignment_label" = "Alineación vertical"; +"displays.custom_vertical_alignment_subtitle" = "Elige cómo se expande verticalmente la superposición desde la posición personalizada."; /* General settings pane */ "general.appearance_section_title" = "Aplicación"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings index 851ec7d8..2b842a1f 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/fr.lproj/Localizable.strings @@ -50,8 +50,10 @@ "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"; -"displays.custom_content_alignment_label" = "Alignement du contenu"; -"displays.custom_content_alignment_subtitle" = "Choisissez comment la superposition s’étend horizontalement à partir de la position personnalisée."; +"displays.custom_horizontal_alignment_label" = "Alignement horizontal"; +"displays.custom_horizontal_alignment_subtitle" = "Choisissez comment la superposition s’étend horizontalement à partir de la position personnalisée."; +"displays.custom_vertical_alignment_label" = "Alignement vertical"; +"displays.custom_vertical_alignment_subtitle" = "Choisissez comment la superposition s’étend verticalement à partir de la position personnalisée."; /* General settings pane */ "general.appearance_section_title" = "Application"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings index 7f89e32c..812ef0cd 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/ja.lproj/Localizable.strings @@ -50,8 +50,10 @@ "displays.custom_position.set_subtitle" = "選択したディスプレイ上でオーバーレイを表示する位置を選択します。"; "displays.custom_position.start_setting_button" = "配置..."; "displays.custom_position.stop_setting_button" = "適用"; -"displays.custom_content_alignment_label" = "コンテンツの配置"; -"displays.custom_content_alignment_subtitle" = "カスタム位置を基準にオーバーレイが水平方向へどのように広がるかを選びます。"; +"displays.custom_horizontal_alignment_label" = "水平方向の配置"; +"displays.custom_horizontal_alignment_subtitle" = "カスタム位置を基準にオーバーレイが水平方向へどのように広がるかを選びます。"; +"displays.custom_vertical_alignment_label" = "垂直方向の配置"; +"displays.custom_vertical_alignment_subtitle" = "カスタム位置を基準にオーバーレイが垂直方向へどのように広がるかを選びます。"; /* General settings pane */ "general.appearance_section_title" = "アプリ"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings index 7cc6ae85..aca61f26 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/nl.lproj/Localizable.strings @@ -50,8 +50,10 @@ "displays.custom_position.set_subtitle" = "Kies waar de overlay op het geselecteerde beeldscherm verschijnt."; "displays.custom_position.start_setting_button" = "Plaatsen..."; "displays.custom_position.stop_setting_button" = "Toepassen"; -"displays.custom_content_alignment_label" = "Inhoudsuitlijning"; -"displays.custom_content_alignment_subtitle" = "Kies hoe de overlay zich horizontaal uitbreidt vanaf de aangepaste positie."; +"displays.custom_horizontal_alignment_label" = "Horizontale uitlijning"; +"displays.custom_horizontal_alignment_subtitle" = "Kies hoe de overlay zich horizontaal uitbreidt vanaf de aangepaste positie."; +"displays.custom_vertical_alignment_label" = "Verticale uitlijning"; +"displays.custom_vertical_alignment_subtitle" = "Kies hoe de overlay zich verticaal uitbreidt vanaf de aangepaste positie."; /* General settings pane */ "general.appearance_section_title" = "App"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings index aede4fbc..70611a25 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/pl.lproj/Localizable.strings @@ -50,8 +50,10 @@ "displays.custom_position.set_subtitle" = "Wybierz miejsce wyświetlania nakładki na wybranym ekranie."; "displays.custom_position.start_setting_button" = "Ustaw..."; "displays.custom_position.stop_setting_button" = "Zastosuj"; -"displays.custom_content_alignment_label" = "Wyrównanie zawartości"; -"displays.custom_content_alignment_subtitle" = "Wybierz, jak nakładka ma rozszerzać się poziomo od własnej pozycji."; +"displays.custom_horizontal_alignment_label" = "Wyrównanie w poziomie"; +"displays.custom_horizontal_alignment_subtitle" = "Wybierz, jak nakładka ma rozszerzać się poziomo od własnej pozycji."; +"displays.custom_vertical_alignment_label" = "Wyrównanie w pionie"; +"displays.custom_vertical_alignment_subtitle" = "Wybierz, jak nakładka ma rozszerzać się pionowo od własnej pozycji."; /* General settings pane */ "general.appearance_section_title" = "Aplikacja"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings index aacf857a..a86785f9 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/uk.lproj/Localizable.strings @@ -50,8 +50,10 @@ "displays.custom_position.set_subtitle" = "Виберіть, де накладка з’являтиметься на вибраному дисплеї."; "displays.custom_position.start_setting_button" = "Розмістити..."; "displays.custom_position.stop_setting_button" = "Застосувати"; -"displays.custom_content_alignment_label" = "Вирівнювання вмісту"; -"displays.custom_content_alignment_subtitle" = "Виберіть, як накладка розширюється горизонтально від користувацької позиції."; +"displays.custom_horizontal_alignment_label" = "Горизонтальне вирівнювання"; +"displays.custom_horizontal_alignment_subtitle" = "Виберіть, як накладка розширюється горизонтально від користувацької позиції."; +"displays.custom_vertical_alignment_label" = "Вертикальне вирівнювання"; +"displays.custom_vertical_alignment_subtitle" = "Виберіть, як накладка розширюється вертикально від користувацької позиції."; /* General settings pane */ "general.appearance_section_title" = "Програма"; diff --git a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings index d190d206..907a39fa 100644 --- a/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings +++ b/Apps/Keyty/Sources/Keyty/Resources/zh-Hans.lproj/Localizable.strings @@ -50,8 +50,10 @@ "displays.custom_position.set_subtitle" = "选择叠加层在所选显示器上的显示位置。"; "displays.custom_position.start_setting_button" = "调整..."; "displays.custom_position.stop_setting_button" = "应用"; -"displays.custom_content_alignment_label" = "内容对齐"; -"displays.custom_content_alignment_subtitle" = "选择叠加层如何从自定义位置向水平方向展开。"; +"displays.custom_horizontal_alignment_label" = "水平对齐"; +"displays.custom_horizontal_alignment_subtitle" = "选择叠加层如何从自定义位置向水平方向展开。"; +"displays.custom_vertical_alignment_label" = "垂直对齐"; +"displays.custom_vertical_alignment_subtitle" = "选择叠加层如何从自定义位置向垂直方向展开。"; /* General settings pane */ "general.appearance_section_title" = "应用"; diff --git a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift index ab8d9a4a..ca78efc7 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/KeyboardVisualizerAlignmentPlacementTests.swift @@ -14,10 +14,12 @@ final class KeyboardVisualizerAlignmentPlacementTests: XCTestCase { private let size = CGSize(width: 120, height: 80) func testFrameCentersContentOnNormalizedPosition() { - let frame = KeyboardVisualizerAlignment.center.frame( + let frame = KeyboardVisualizerAlignment.frame( for: self.size, atNormalized: CGPoint(x: 0.25, y: 0.75), - in: self.area + in: self.area, + horizontal: .center, + vertical: .center ) XCTAssertEqual(frame.midX, 300, accuracy: 0.0001) @@ -25,10 +27,12 @@ final class KeyboardVisualizerAlignmentPlacementTests: XCTestCase { } func testFramePlacesLeadingEdgeOnNormalizedPosition() { - let frame = KeyboardVisualizerAlignment.leading.frame( + let frame = KeyboardVisualizerAlignment.frame( for: self.size, atNormalized: CGPoint(x: 0.25, y: 0.75), - in: self.area + in: self.area, + horizontal: .leading, + vertical: .center ) XCTAssertEqual(frame.minX, 300, accuracy: 0.0001) @@ -36,27 +40,69 @@ final class KeyboardVisualizerAlignmentPlacementTests: XCTestCase { } func testFramePlacesTrailingEdgeOnNormalizedPosition() { - let frame = KeyboardVisualizerAlignment.trailing.frame( + let frame = KeyboardVisualizerAlignment.frame( for: self.size, atNormalized: CGPoint(x: 0.25, y: 0.75), - in: self.area + in: self.area, + horizontal: .trailing, + vertical: .center ) XCTAssertEqual(frame.maxX, 300, accuracy: 0.0001) XCTAssertEqual(frame.midY, 650, accuracy: 0.0001) } + func testFramePlacesBottomEdgeOnNormalizedPosition() { + let frame = KeyboardVisualizerAlignment.frame( + for: self.size, + atNormalized: CGPoint(x: 0.25, y: 0.5), + in: self.area, + horizontal: .center, + vertical: .leading + ) + + XCTAssertEqual(frame.midX, 300, accuracy: 0.0001) + XCTAssertEqual(frame.minY, 500, accuracy: 0.0001) + } + + func testFramePlacesTopEdgeOnNormalizedPosition() { + let frame = KeyboardVisualizerAlignment.frame( + for: self.size, + atNormalized: CGPoint(x: 0.25, y: 0.5), + in: self.area, + horizontal: .center, + vertical: .trailing + ) + + XCTAssertEqual(frame.midX, 300, accuracy: 0.0001) + XCTAssertEqual(frame.maxY, 500, accuracy: 0.0001) + } + func testFrameClampsContentInsideVisibleFrame() { - let frame = KeyboardVisualizerAlignment.center.frame( + let frame = KeyboardVisualizerAlignment.frame( for: self.size, atNormalized: CGPoint(x: 0, y: 1), - in: self.area + in: self.area, + horizontal: .center, + vertical: .center ) XCTAssertEqual(frame.minX, self.area.minX, accuracy: 0.0001) XCTAssertEqual(frame.maxY, self.area.maxY, accuracy: 0.0001) } + func testFrameClampsBottomAlignedContentInsideVisibleFrame() { + let frame = KeyboardVisualizerAlignment.frame( + for: self.size, + atNormalized: CGPoint(x: 0.5, y: 1), + in: self.area, + horizontal: .center, + vertical: .leading + ) + + XCTAssertEqual(frame.maxY, self.area.maxY, accuracy: 0.0001) + } + func testAnchorXUsesAlignmentSpecificEdge() { let frame = CGRect(x: 180, y: 200, width: 120, height: 80) @@ -65,6 +111,14 @@ final class KeyboardVisualizerAlignmentPlacementTests: XCTestCase { XCTAssertEqual(KeyboardVisualizerAlignment.trailing.anchorX(in: frame), 300, accuracy: 0.0001) } + func testAnchorYUsesAlignmentSpecificEdge() { + let frame = CGRect(x: 180, y: 200, width: 120, height: 80) + + XCTAssertEqual(KeyboardVisualizerAlignment.leading.anchorY(in: frame), 200, accuracy: 0.0001) + XCTAssertEqual(KeyboardVisualizerAlignment.center.anchorY(in: frame), 240, accuracy: 0.0001) + XCTAssertEqual(KeyboardVisualizerAlignment.trailing.anchorY(in: frame), 280, accuracy: 0.0001) + } + func testOriginXResolvesAlignedEdgeOntoAnchor() { XCTAssertEqual( KeyboardVisualizerAlignment.leading.originX(for: 120, anchoredAt: 300), @@ -83,20 +137,54 @@ final class KeyboardVisualizerAlignmentPlacementTests: XCTestCase { ) } + func testOriginYResolvesAlignedEdgeOntoAnchor() { + XCTAssertEqual( + KeyboardVisualizerAlignment.leading.originY(for: 80, anchoredAt: 300), + 300, + accuracy: 0.0001 + ) + XCTAssertEqual( + KeyboardVisualizerAlignment.center.originY(for: 80, anchoredAt: 300), + 260, + accuracy: 0.0001 + ) + XCTAssertEqual( + KeyboardVisualizerAlignment.trailing.originY(for: 80, anchoredAt: 300), + 220, + 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" - ) + for horizontal in KeyboardVisualizerAlignment.allCases { + for vertical in KeyboardVisualizerAlignment.allCases { + let frame = KeyboardVisualizerAlignment.frame( + for: self.size, + atNormalized: CGPoint(x: 0.25, y: 0.75), + in: self.area, + horizontal: horizontal, + vertical: vertical + ) + let anchor = self.area.normalizedPoint( + for: CGPoint( + x: horizontal.anchorX(in: frame), + y: vertical.anchorY(in: frame) + ) + ) + + XCTAssertEqual( + anchor.x, + 0.25, + accuracy: 0.0001, + "\(horizontal) should place its own anchor edge back on the normalized position" + ) + XCTAssertEqual( + anchor.y, + 0.75, + accuracy: 0.0001, + "\(vertical) should place its own anchor edge back on the normalized position" + ) + } } } } 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 c85d71f7..55d6216a 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/Visualizers/Keyboard/Keycaps/KeyboardVisualizerSettingsTests.swift @@ -42,6 +42,7 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(self.store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedX), 0.5, accuracy: 0.0001) XCTAssertEqual(self.store.double(forKey: KeyboardVisualizerSettingsKeys.customPositionNormalizedY), 0.5, accuracy: 0.0001) XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.customHorizontalAlignment), KeyboardVisualizerAlignment.center.rawValue) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.customVerticalAlignment), KeyboardVisualizerAlignment.center.rawValue) XCTAssertEqual(self.store.double(forKey: KeyboardVisualizerSettingsKeys.windowPadding), Double(Size.KeyboardVisualizer.windowPadding), accuracy: 0.0001) XCTAssertEqual(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.onlyShowModifiedKeystrokes), false) XCTAssertEqual(self.store.bool(forKey: KeyboardVisualizerSettingsKeys.showSpecialKeys), true) @@ -154,10 +155,22 @@ final class KeyboardVisualizerSettingsTests: XCTestCase { XCTAssertEqual(self.settings.alignment, .trailing) } - func testCustomPlacementKeepsHorizontalStacksVerticallyCentered() { + func testPersistsCustomVerticalAlignment() { + self.settings.customVerticalAlignment = .trailing + + XCTAssertEqual(self.settings.customVerticalAlignment, .trailing) + XCTAssertEqual(self.store.integer(forKey: KeyboardVisualizerSettingsKeys.customVerticalAlignment), KeyboardVisualizerAlignment.trailing.rawValue) + } + + func testCustomPlacementUsesCustomVerticalAlignmentForHorizontalStacks() { self.settings.placementMode = .custom self.settings.stackAxis = .horizontal self.settings.customHorizontalAlignment = .trailing + self.settings.customVerticalAlignment = .leading + + XCTAssertEqual(self.settings.alignment, .leading) + + self.settings.customVerticalAlignment = .center XCTAssertEqual(self.settings.alignment, .center) }