window management library
git clone https://git.lucas.co/cce-window-manager.git
feat: snap window content to grid cells instead of border outer edges
Placement was border-inclusive: a snapped window's BORDER outer edge landed
on the cell edge and the content was inset by the border width, so the
surface never actually filled its cell. Snap the content edges directly
instead and let the border, which draws outside the content box, overhang
into the grid gap.
Applies to all four sites that compensated for the border: the Maximized
grid snap, interactive move/resize snapping, the fresh overlay slot, and
the popup dock slot. NormalParams::border_width and SnapParams::border_width
are gone rather than left as dead plumbing; OverlayParams::border_width
stays because it still floors the overlay decoration strip height.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
src/arrange.rs | 97 ++++++++++++++++++++++++++--------------------------------
src/snap.rs | 94 +++++++++++++++++++++++++-------------------------------
2 files changed, 84 insertions(+), 107 deletions(-)
diff --git a/src/arrange.rs b/src/arrange.rs
index e429c38..8c7f48a 100644
--- a/src/arrange.rs
+++ b/src/arrange.rs
@@ -203,7 +203,9 @@ pub struct OverlaySnapshot {
pub struct OverlayParams {
pub overlay_width: i32,
pub border_gap: i32,
- /// Server-side border width; the fresh overlay slot is border-inclusive.
+ /// Server-side border width. Placement no longer insets by it (the border
+ /// overhangs the content box); it only sets a floor on the decoration
+ /// strip height reserved above the overlay slot.
pub border_width: i32,
pub position_right: bool,
pub cloud_position_default: Option<[i32; 2]>,
@@ -221,10 +223,9 @@ pub struct OverlayPlacement {
/// slot (left or right edge, full usable height); cloud windows snap to their
/// configured default position; anything else keeps its stored geometry.
///
-/// The fresh slot is border-inclusive: since borders draw outside the content
-/// box, the content is inset by the border width so slot + border stays inside
-/// the configured gaps. Stored geometry is treated like a floating window (the
-/// border extends beyond it).
+/// The fresh slot is content-aligned: the content box sits directly on the
+/// configured gaps and the border, which draws outside it, overhangs them.
+/// Stored geometry behaves the same way.
pub fn place_overlay_window(
snap: &OverlaySnapshot,
p: &OverlayParams,
@@ -246,14 +247,14 @@ pub fn place_overlay_window(
} else {
p.overlay_width
};
- sp_h = (ctx.usable.height - dec_h - 2 * g - 2 * bw).max(1);
+ sp_h = (ctx.usable.height - dec_h - 2 * g).max(1);
sp_x = if p.position_right {
- ctx.usable.x + ctx.usable.width - sp_w - g - bw
+ ctx.usable.x + ctx.usable.width - sp_w - g
} else {
- ctx.usable.x + g + bw
+ ctx.usable.x + g
};
- sp_y = ctx.usable.y + dec_h + g + bw;
+ sp_y = ctx.usable.y + dec_h + g;
box_geom_write = Some(Rect { x: sp_x, y: sp_y, width: sp_w, height: sp_h });
} else if snap.is_cloud {
@@ -342,9 +343,6 @@ pub struct NormalSnapshot {
pub struct NormalParams {
pub gap_right: i32,
pub gap_top: i32,
- /// Server-side border width; docked and grid-snapped placements are
- /// border-inclusive.
- pub border_width: i32,
pub cloud_position_default: Option<[i32; 2]>,
pub desktop_grid_scale: f64,
/// Desktop grid gap between cells (the grid period is scale + gap).
@@ -393,15 +391,14 @@ pub fn place_normal_window(
100
};
- // The dock slot is border-inclusive: inset so the border stays
- // within the configured gaps. Explicit cloud positions are taken
- // as content positions verbatim.
- let bw = p.border_width.max(0);
+ // The dock slot is content-aligned: the content box sits on the
+ // configured gaps and the border overhangs them. Explicit cloud
+ // positions are taken as content positions verbatim.
let (fx, fy) = if snap.is_cloud && p.cloud_position_default.is_some() {
let pos = p.cloud_position_default.unwrap();
(ctx.usable.x + pos[0], ctx.usable.y + pos[1])
} else {
- (ctx.usable.x + ctx.usable.width - fw - p.gap_right - bw, ctx.usable.y + p.gap_top + bw)
+ (ctx.usable.x + ctx.usable.width - fw - p.gap_right, ctx.usable.y + p.gap_top)
};
NormalPlacement {
@@ -437,14 +434,13 @@ pub fn place_normal_window(
y1, y2, p.desktop_grid_scale, p.desktop_gap_width, p.desktop_cell_inset,
);
- // The span is border-inclusive: the content is inset so the
- // border stays inside the covered cells. Idempotent across
- // frames because it re-derives from the saved geometry.
- let bw = p.border_width.max(0) as f64;
- let content_x = low_x + bw;
- let content_y = low_y + bw;
- let fw = (high_x - low_x - 2.0 * bw).max(1.0);
- let fh = (high_y - low_y - 2.0 * bw).max(1.0);
+ // The content fills the covered cells edge to edge; the border
+ // draws outside it and overhangs into the grid gap. Idempotent
+ // across frames because it re-derives from the saved geometry.
+ let content_x = low_x;
+ let content_y = low_y;
+ let fw = (high_x - low_x).max(1.0);
+ let fh = (high_y - low_y).max(1.0);
let (final_x, final_y) = ctx.virtual_to_screen(content_x, content_y);
@@ -1288,7 +1284,7 @@ mod tests {
}
#[test]
- fn fresh_overlay_slot_insets_by_border_width() {
+ fn fresh_overlay_slot_ignores_border_width() {
let placement = place_overlay_window(
&OverlaySnapshot {
box_geom: Rect { x: 0, y: 0, width: 0, height: 0 },
@@ -1306,10 +1302,12 @@ mod tests {
},
&ctx(),
);
- // Content sits one border width inside the zero-width slot on every
- // side, so the border lands within the gaps.
- assert_eq!(placement.pos, (1512 - 4, 54 + 4));
- assert_eq!(placement.size, (400, 1018 - 8));
+ // Placement no longer insets by the border: the content box lands on
+ // the gaps exactly as it does with no border, and the border overhangs
+ // outward. (bw 4 < OVERLAY_DEC_H 16, so the decoration strip is
+ // unaffected too.)
+ assert_eq!(placement.pos, (1512, 54));
+ assert_eq!(placement.size, (400, 1018));
}
#[test]
@@ -1366,7 +1364,7 @@ mod tests {
saved_maximized_size: (100, 50),
saved_maximized_virtual: (150.0, 120.0),
};
- let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
+ let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
let placement = place_normal_window(&snap, &p, &ctx());
// Saved geometry spans grid columns 1-2 and row 1 → snapped to
// (100,100) with size 200x100.
@@ -1378,7 +1376,7 @@ mod tests {
}
#[test]
- fn maximized_insets_by_border_width() {
+ fn maximized_ignores_border_width() {
let snap = NormalSnapshot {
mode: TilingMode::Maximized,
box_geom: Rect { x: 0, y: 0, width: 100, height: 50 },
@@ -1389,13 +1387,13 @@ mod tests {
saved_maximized_size: (100, 50),
saved_maximized_virtual: (150.0, 120.0),
};
- let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 4, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
+ let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
let placement = place_normal_window(&snap, &p, &ctx());
- // Same cell span as without borders (100,100)+200x100, with the
- // content inset so the border stays inside the covered cells.
- assert_eq!(placement.virtual_write, Some((104.0, 104.0)));
- assert_eq!(placement.pos, (104, 104));
- assert_eq!(placement.size, (192, 92));
+ // The content fills the covered cells (100,100)+200x100 exactly; the
+ // border draws outside that and overhangs into the grid gap.
+ assert_eq!(placement.virtual_write, Some((100.0, 100.0)));
+ assert_eq!(placement.pos, (100, 100));
+ assert_eq!(placement.size, (200, 100));
}
#[test]
@@ -1410,21 +1408,20 @@ mod tests {
saved_maximized_size: (100, 50),
saved_maximized_virtual: (150.0, 120.0),
};
- // period 110 (gap 10), inset 5, border 4: cells x 1-2 visibly span
- // [115, 315], row y 1 spans [115, 205]; content insets by the border.
+ // period 110 (gap 10), inset 5: cells x 1-2 visibly span [115, 315],
+ // row y 1 spans [115, 205]; the content fills them edge to edge.
let p = NormalParams {
gap_right: 10,
gap_top: 6,
- border_width: 4,
cloud_position_default: None,
desktop_grid_scale: 100.0,
desktop_gap_width: 10.0,
desktop_cell_inset: 5.0,
};
let placement = place_normal_window(&snap, &p, &ctx());
- assert_eq!(placement.virtual_write, Some((119.0, 119.0)));
- assert_eq!(placement.pos, (119, 119));
- assert_eq!(placement.size, (192, 82));
+ assert_eq!(placement.virtual_write, Some((115.0, 115.0)));
+ assert_eq!(placement.pos, (115, 115));
+ assert_eq!(placement.size, (200, 90));
}
#[test]
@@ -1439,19 +1436,12 @@ mod tests {
saved_maximized_size: (0, 0),
saved_maximized_virtual: (0.0, 0.0),
};
- let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
+ let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
let placement = place_normal_window(&snap, &p, &ctx());
// Defaults to 360x100, docked inside the usable area (below the bar).
assert_eq!(placement.pos, (1920 - 360 - 10, 30 + 6));
assert_eq!(placement.size, (360, 100));
assert_eq!(placement.hidden, None);
-
- // With a border, the dock position insets so the border stays inside
- // the gaps; the popup keeps its size.
- let p = NormalParams { border_width: 4, ..p };
- let placement = place_normal_window(&snap, &p, &ctx());
- assert_eq!(placement.pos, (1920 - 360 - 10 - 4, 30 + 6 + 4));
- assert_eq!(placement.size, (360, 100));
}
#[test]
@@ -1466,7 +1456,7 @@ mod tests {
saved_maximized_size: (0, 0),
saved_maximized_virtual: (0.0, 0.0),
};
- let p = NormalParams { gap_right: 10, gap_top: 6, border_width: 0, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
+ let p = NormalParams { gap_right: 10, gap_top: 6, cloud_position_default: None, desktop_grid_scale: 100.0, desktop_gap_width: 0.0, desktop_cell_inset: 0.0 };
let mut c = ctx();
c.pan_x = 50.0;
c.pan_y = 100.0;
@@ -1545,7 +1535,6 @@ mod tests {
normal: NormalParams {
gap_right: 10,
gap_top: 6,
- border_width: 0,
cloud_position_default: None,
desktop_grid_scale: 100.0,
desktop_gap_width: 0.0,
diff --git a/src/snap.rs b/src/snap.rs
index 061937f..8be9fe9 100644
--- a/src/snap.rs
+++ b/src/snap.rs
@@ -1,9 +1,11 @@
// Magnetic grid snapping for interactive move/resize.
//
-// All coordinates are virtual-surface CONTENT coordinates. Snapping is
-// border-inclusive: the border's outer edge is what lands on the snap
-// target, matching the Maximized grid-snap convention where borders stay
-// inside the covered cells.
+// All coordinates are virtual-surface CONTENT coordinates, and snapping acts
+// directly on them: the window's own edge lands on the snap target. Borders
+// draw OUTSIDE the content box, so a snapped border overhangs its cell into
+// the gap rather than being inset to stay within it. This matches the
+// Maximized grid-snap convention, where the content fills the covered cells
+// edge to edge.
//
// Targets are the VISIBLE cell edges, not the raw grid lines. The desktop
// grid draws cells of `cell_size` every `cell_size + gap_width`, and each
@@ -21,8 +23,8 @@ fn grid_inset(cell_size: f64, cell_inset: f64) -> f64 {
}
/// Hard grid snap for Maximized windows: the visible outer edges of every
-/// cell the span [x1, x2) touches. Returns (outer_low, outer_high) — the
-/// border-inclusive footprint; content insets by the border width from it.
+/// cell the span [x1, x2) touches. Returns (low, high) — the content
+/// footprint, which fills the covered cells exactly.
pub fn maximized_span(x1: f64, x2: f64, cell_size: f64, gap_width: f64, cell_inset: f64) -> (f64, f64) {
let p = grid_period(cell_size, gap_width);
let inset = grid_inset(cell_size, cell_inset);
@@ -41,8 +43,6 @@ pub struct SnapParams {
pub cell_inset: f64,
/// Snap radius in virtual units; <= 0 disables snapping.
pub threshold: f64,
- /// Server-side border width (border-inclusive alignment).
- pub border_width: f64,
}
impl SnapParams {
@@ -50,10 +50,6 @@ impl SnapParams {
self.threshold > 0.0 && self.cell_size > 0.5
}
- fn bw(&self) -> f64 {
- self.border_width.max(0.0)
- }
-
fn period(&self) -> f64 {
grid_period(self.cell_size, self.gap_width)
}
@@ -82,9 +78,9 @@ fn within(delta: f64, p: &SnapParams) -> bool {
delta.abs() <= p.threshold
}
-/// Snap a window position during a move. On each axis the two outer border
-/// edges compete for their nearest visible cell edge; the closer candidate
-/// within the threshold wins. `w`/`h` are content sizes.
+/// Snap a window position during a move. On each axis the two content edges
+/// compete for their nearest visible cell edge; the closer candidate within
+/// the threshold wins. `w`/`h` are content sizes.
pub fn snap_move(x: f64, y: f64, w: f64, h: f64, p: &SnapParams) -> (f64, f64) {
(snap_move_axis(x, w, p), snap_move_axis(y, h, p))
}
@@ -93,8 +89,8 @@ fn snap_move_axis(pos: f64, len: f64, p: &SnapParams) -> f64 {
if !p.enabled() {
return pos;
}
- let lo = pos - p.bw();
- let hi = pos + len + p.bw();
+ let lo = pos;
+ let hi = pos + len;
let lo_delta = p.nearest_low_target(lo) - lo;
let hi_delta = p.nearest_high_target(hi) - hi;
if lo_delta.abs() <= hi_delta.abs() && within(lo_delta, p) {
@@ -106,15 +102,13 @@ fn snap_move_axis(pos: f64, len: f64, p: &SnapParams) -> f64 {
}
}
-/// Snap the dragged left/top CONTENT edge during a resize: the outer border
-/// edge (content - border width) is pulled onto the nearest visible left/top
-/// cell edge.
+/// Snap the dragged left/top CONTENT edge during a resize onto the nearest
+/// visible left/top cell edge.
pub fn snap_low_edge(pos: f64, p: &SnapParams) -> f64 {
if !p.enabled() {
return pos;
}
- let outer = pos - p.bw();
- let delta = p.nearest_low_target(outer) - outer;
+ let delta = p.nearest_low_target(pos) - pos;
if within(delta, p) {
pos + delta
} else {
@@ -122,15 +116,13 @@ pub fn snap_low_edge(pos: f64, p: &SnapParams) -> f64 {
}
}
-/// Snap the dragged right/bottom CONTENT edge during a resize: the outer
-/// border edge (content + border width) is pulled onto the nearest visible
-/// right/bottom cell edge.
+/// Snap the dragged right/bottom CONTENT edge during a resize onto the
+/// nearest visible right/bottom cell edge.
pub fn snap_high_edge(pos: f64, p: &SnapParams) -> f64 {
if !p.enabled() {
return pos;
}
- let outer = pos + p.bw();
- let delta = p.nearest_high_target(outer) - outer;
+ let delta = p.nearest_high_target(pos) - pos;
if within(delta, p) {
pos + delta
} else {
@@ -168,26 +160,25 @@ pub fn resize_axis(
mod tests {
use super::*;
- /// cell 512, no gap, fade inset 4, border 8: visible cell k spans
- /// [512k + 4, 512k + 508].
+ /// cell 512, no gap, fade inset 4: visible cell k spans
+ /// [512k + 4, 512k + 508]. Border width is irrelevant to snapping now —
+ /// content edges land on the targets and the border overhangs outward.
fn params() -> SnapParams {
- SnapParams { cell_size: 512.0, gap_width: 0.0, cell_inset: 4.0, threshold: 24.0, border_width: 8.0 }
+ SnapParams { cell_size: 512.0, gap_width: 0.0, cell_inset: 4.0, threshold: 24.0 }
}
#[test]
fn resize_low_edge_abuts_visible_cell_edge() {
- // Content left 510 → outer 502 → visible edge 516 (dist 14) →
- // content 524, border spans [516, 524].
- assert_eq!(snap_low_edge(510.0, ¶ms()), 524.0);
+ // Content left 510 → visible edge 516 (dist 6) → content 516.
+ assert_eq!(snap_low_edge(510.0, ¶ms()), 516.0);
// Far from an edge: unchanged.
assert_eq!(snap_low_edge(300.0, ¶ms()), 300.0);
}
#[test]
fn resize_high_edge_abuts_visible_cell_edge() {
- // Content right 1000 → outer 1008 → visible edge 1020
- // (2*512 - 4, dist 12) → content 1012, border spans [1012, 1020].
- assert_eq!(snap_high_edge(1000.0, ¶ms()), 1012.0);
+ // Content right 1000 → visible edge 1020 (2*512 - 4, dist 20) → 1020.
+ assert_eq!(snap_high_edge(1000.0, ¶ms()), 1020.0);
}
#[test]
@@ -195,23 +186,22 @@ mod tests {
// cell 500 + gap 12 → period 512; cell 1's rect spans [512, 1012],
// visibly [516, 1008].
let p = SnapParams { cell_size: 500.0, gap_width: 12.0, ..params() };
- assert_eq!(snap_low_edge(520.0, &p), 524.0); // outer 512 → 516
- assert_eq!(snap_high_edge(996.0, &p), 1000.0); // outer 1004 → 1008
+ assert_eq!(snap_low_edge(520.0, &p), 516.0);
+ assert_eq!(snap_high_edge(996.0, &p), 1008.0);
}
#[test]
fn move_snaps_the_closer_edge() {
- // Window content [500, 800]: left outer 492 → low target 516
- // (dist 24); right outer 808 → high target... 1020 (dist 212).
- // Left wins: x = 524.
+ // Window content [500, 800]: left 500 → low target 516 (dist 16);
+ // right 800 → high target 1020 (dist 220). Left wins: x = 516.
let (x, y) = snap_move(500.0, 300.0, 300.0, 100.0, ¶ms());
- assert_eq!(x, 524.0);
+ assert_eq!(x, 516.0);
assert_eq!(y, 300.0);
- // Right outer edge 4 away from the visible edge 508 beats left.
- // Content [196, 496]: right outer 504 → 508 (dist 4) → x = 200.
- let (x, _) = snap_move(196.0, 300.0, 300.0, 100.0, ¶ms());
- assert_eq!(x, 200.0);
+ // Right content edge 4 past the visible edge 508 beats left.
+ // Content [212, 512]: right 512 → 508 (dist 4) → x = 208.
+ let (x, _) = snap_move(212.0, 300.0, 300.0, 100.0, ¶ms());
+ assert_eq!(x, 208.0);
}
#[test]
@@ -222,13 +212,11 @@ mod tests {
#[test]
fn resize_axis_snaps_the_dragged_edge_only() {
- // Window [600, 900), dragging the left edge to 510: outer 502 →
- // visible edge 516 → content 524; anchored right edge 900 keeps
- // the width at 376.
- assert_eq!(resize_axis(600.0, 300.0, -90.0, true, false, 50.0, ¶ms()), 376.0);
- // Dragging the right edge to 1000: outer 1008 → visible edge 1020 →
- // content 1012 → width 412.
- assert_eq!(resize_axis(600.0, 300.0, 100.0, false, true, 50.0, ¶ms()), 412.0);
+ // Window [600, 900), dragging the left edge to 510: visible edge 516
+ // → content 516; anchored right edge 900 keeps the width at 384.
+ assert_eq!(resize_axis(600.0, 300.0, -90.0, true, false, 50.0, ¶ms()), 384.0);
+ // Dragging the right edge to 1000: visible edge 1020 → width 420.
+ assert_eq!(resize_axis(600.0, 300.0, 100.0, false, true, 50.0, ¶ms()), 420.0);
// Not dragging this axis: length unchanged.
assert_eq!(resize_axis(600.0, 300.0, 100.0, false, false, 50.0, ¶ms()), 300.0);
// Minimum clamps.