Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -186,16 +188,27 @@ 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(
width: x,
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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()
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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()
}
Expand All @@ -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)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading