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
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import AppKit
import Combine

@MainActor
final class KeyboardVisualizerPlacementWindowController: NSWindowController, KeyboardVisualizerPlacementCoordinating {
Expand All @@ -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,
Expand Down Expand Up @@ -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()
}

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

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