fix an issue with horizontal window margins not respected

This commit is contained in:
2026-07-12 15:23:22 +02:00
parent 6de06ac8e1
commit 19a8f09e5a
5 changed files with 112 additions and 52 deletions
@@ -17,44 +17,40 @@ enum PHThemeWindowAnchor: String, Decodable {
extension PHThemeWindowAnchor {
/// Compute the top-left origin (in screen coordinates) of a window of `size`
/// placed against this anchor within `screen`, offset by `margin` along the
/// anchored edges.
/// placed against this anchor within `rect`.
///
/// Edges the anchor does not reference are ignored, so margins only affect
/// the relevant sides.
func origin(in screen: CGRect, size: CGSize, margin: PHThemeWindowMargin) -> CGPoint {
let top = margin.top ?? 0
let bottom = margin.bottom ?? 0
let leading = margin.leading ?? 0
let trailing = margin.trailing ?? 0
// Default: centered on both axes.
/// `rect` is the content rectangle the window is placed within typically the
/// screen inset by the window's margin (see `PHThemeWindowMargin.inset(of:)`).
/// Single-edge anchors pin to that edge and center along the opposite axis;
/// corner anchors pin to two edges; `.center` centers on both axes.
func origin(in rect: CGRect, size: CGSize) -> CGPoint {
// Default: centered on both axes within the rect.
var point = CGPoint(
x: screen.midX - size.width / 2,
y: screen.midY - size.height / 2
x: rect.midX - size.width / 2,
y: rect.midY - size.height / 2
)
switch self {
case .top:
point.y = screen.maxY - CGFloat(top) - size.height
point.y = rect.maxY - size.height
case .bottom:
point.y = screen.minY + CGFloat(bottom)
point.y = rect.minY
case .leading:
point.x = screen.minX + CGFloat(leading)
point.x = rect.minX
case .trailing:
point.x = screen.maxX - CGFloat(trailing) - size.width
point.x = rect.maxX - size.width
case .topLeading:
point.x = screen.minX + CGFloat(leading)
point.y = screen.maxY - CGFloat(top) - size.height
point.x = rect.minX
point.y = rect.maxY - size.height
case .topTrailing:
point.x = screen.maxX - CGFloat(trailing) - size.width
point.y = screen.maxY - CGFloat(top) - size.height
point.x = rect.maxX - size.width
point.y = rect.maxY - size.height
case .bottomLeading:
point.x = screen.minX + CGFloat(leading)
point.y = screen.minY + CGFloat(bottom)
point.x = rect.minX
point.y = rect.minY
case .bottomTrailing:
point.x = screen.maxX - CGFloat(trailing) - size.width
point.y = screen.minY + CGFloat(bottom)
point.x = rect.maxX - size.width
point.y = rect.minY
case .center:
break
}
@@ -1,9 +1,12 @@
import CoreGraphics
import Foundation
/// Insets (in points) from the screen edges implied by a window's anchor.
/// Insets (in points) from the screen edges, defining the content rectangle a
/// window is sized and placed within.
///
/// Only the edges referenced by the anchor are consumed; the rest are ignored,
/// so a margin only affects the relevant sides of the placement.
/// Margins compose with `width`/`height`: a `width = "100%"` window spans the
/// space *between* the leading/trailing margins rather than the full screen.
/// Unspecified edges are left untouched (treated as 0).
struct PHThemeWindowMargin: Decodable, Equatable {
let top: Double?
let bottom: Double?
@@ -21,4 +24,20 @@ struct PHThemeWindowMargin: Decodable, Equatable {
self.leading = leading
self.trailing = trailing
}
/// Returns `rect` inset by the specified edges; unspecified edges are left
/// untouched (treated as 0). Uses AppKit's coordinate system (origin at the
/// bottom-left, y increasing upward): `top` reduces the max-Y edge.
func inset(of rect: CGRect) -> CGRect {
let l = leading ?? 0
let r = trailing ?? 0
let t = top ?? 0
let b = bottom ?? 0
return CGRect(
x: rect.minX + l,
y: rect.minY + b,
width: rect.width - l - r,
height: rect.height - t - b
)
}
}
+1 -4
View File
@@ -7,16 +7,13 @@ name = "default"
anchor = "top"
height = 30
width = "100%"
shadow = false
blur = 0.0
margin = { leading = 30, top = 30, trailing = 30, bottom = 30 }
[[window]]
name = "bottom"
anchor = "bottom"
height = 30
width = "100%"
shadow = false
blur = 0.0
[[text]]
name = "default"
+14 -5
View File
@@ -52,13 +52,14 @@ extension BarWindow {
let screenFrame = controller.screen.frame
let themeWindow = controller.window
// Absolute origin bypasses the anchor/margin system entirely: width
// resolves against the full screen and the window sits at the exact
// coordinates given.
if let origin = themeWindow.origin {
let size = CGSize(
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: screenFrame.width),
height: CGFloat(themeWindow.height ?? 30)
)
// Absolute origin wins over anchor/margin.
if let origin = themeWindow.origin {
return NSRect(
x: CGFloat(origin.x),
y: CGFloat(origin.y),
@@ -67,10 +68,18 @@ extension BarWindow {
)
}
let anchor = themeWindow.anchor ?? .top
// Margins define a content rectangle within the screen: the window is
// sized against it (so `width = "100%"` spans only between the margins,
// not the full screen) and placed by the anchor inside it.
let margin = themeWindow.margin ?? .init()
let contentRect = margin.inset(of: screenFrame)
let size = CGSize(
width: (themeWindow.width ?? .percentage(1.0)).resolve(against: contentRect.width),
height: CGFloat(themeWindow.height ?? 30)
)
let anchor = themeWindow.anchor ?? .top
return NSRect(
origin: anchor.origin(in: screenFrame, size: size, margin: margin),
origin: anchor.origin(in: contentRect, size: size),
size: size
)
}
+51 -12
View File
@@ -488,40 +488,43 @@ private struct DimensionWrapper: Decodable {
@Test func anchorPlacesAtTopEdge() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size, margin: .init())
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size)
#expect(origin == CGPoint(x: 0, y: 770))
}
@Test func anchorRespectsTopMargin() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 1000, height: 30)
let origin = PHThemeWindowAnchor.top.origin(in: screen, size: size, margin: .init(top: 10))
let rect = PHThemeWindowMargin(top: 10).inset(of: screen)
let origin = PHThemeWindowAnchor.top.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 0, y: 760))
}
@Test func anchorPlacesBottomLeadingCorner() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let origin = PHThemeWindowAnchor.bottomLeading.origin(
in: screen, size: size, margin: .init(bottom: 8, leading: 12)
)
let rect = PHThemeWindowMargin(bottom: 8, leading: 12).inset(of: screen)
let origin = PHThemeWindowAnchor.bottomLeading.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 12, y: 8))
}
@Test func anchorTrailingCentersVertically() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let origin = PHThemeWindowAnchor.trailing.origin(
in: screen, size: size, margin: .init(trailing: 20)
)
let rect = PHThemeWindowMargin(trailing: 20).inset(of: screen)
let origin = PHThemeWindowAnchor.trailing.origin(in: rect, size: size)
#expect(origin == CGPoint(x: 780, y: 380))
}
@Test func anchorCentersOnBothAxesIgnoringMargin() {
@Test func anchorCentersWithinContentRect() {
let screen = CGRect(x: 0, y: 0, width: 1000, height: 800)
let size = CGSize(width: 200, height: 40)
let origin = PHThemeWindowAnchor.center.origin(in: screen, size: size, margin: .init(top: 999))
#expect(origin == CGPoint(x: 400, y: 380))
// `.center` centers within the inset rect: a top margin lifts the center,
// it doesn't ignore the margin.
let rect = PHThemeWindowMargin(top: 100).inset(of: screen)
let origin = PHThemeWindowAnchor.center.origin(in: rect, size: size)
// rect: y [0, 700], midY = 350 350 20 = 330
#expect(origin == CGPoint(x: 400, y: 330))
}
// MARK: - Monitor window/blocks resolver
@@ -625,7 +628,15 @@ private func makeBlocksConfigDir() throws -> URL {
@MainActor
@Test func computeFrameDefaultsToFullScreenTopBar() throws {
let screen = try #require(NSScreen.main)
let theme = try PHTheme.load("voltage")
// A window that omits geometry exercises computeFrame's defaults:
// width 100%, top anchor, no margin a full-screen top bar. Built locally
// (rather than loading the bundled theme) so the test tracks the engine's
// defaults, not the theme file's editorial margins.
let theme = PHTheme(
windows: [PHThemeWindow(name: "default")],
texts: nil,
styles: nil
)
let config = PHConfig(window: "default", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
@@ -655,6 +666,34 @@ private func makeBlocksConfigDir() throws -> URL {
#expect(frame.height == 30)
}
@MainActor
@Test func computeFrameInsetsFullWidthBarByMargins() throws {
// Regression: `width = "100%"` previously ignored leading/trailing margins
// and spanned edge to edge. Margins now inset the content rectangle, so the
// bar spans only between them.
let screen = try #require(NSScreen.main)
let sf = screen.frame
let theme = PHTheme(
windows: [PHThemeWindow(
name: "inset",
width: .percentage(1.0),
anchor: .top,
margin: PHThemeWindowMargin(leading: 20, trailing: 20)
)],
texts: nil,
styles: nil
)
let config = PHConfig(window: "inset", blocks: nil, env: nil, monitors: nil)
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
let frame = BarWindow.computeFrame(from: controller)
#expect(frame.width == sf.width - 40)
#expect(frame.minX == sf.minX + 20)
#expect(frame.maxX == sf.maxX - 20)
}
// MARK: - PHPaths (config directory cascade)
@Test func pathsCandidatesIncludeAbsoluteXdgFirst() {