Stabilize minimal reveal at viewport edges

This commit is contained in:
Samuel Onoja committed 2026-10-01 10:38:29 +01:00
1 parent 8d695da04f
commit 9c99b13c31
2 files changed
+106 -7

No files matched your search

+102 -5
View File
@@ -9,6 +9,7 @@ const DEFAULT_WIDTH: f64 = 0.5;
const MIN_COLUMN_WIDTH: f64 = 0.1;
const MAX_COLUMN_WIDTH: f64 = 2.0;
const MIN_ROW_HEIGHT: f64 = 0.05;
const REVEAL_EPSILON: f64 = 0.5;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ColumnWidth {
@@ -416,6 +417,9 @@ impl ScrollingLayout {
};
let viewport_width = (bounds.width - outer * 2.0).max(1.0);
let viewport_height = (bounds.height - outer * 2.0).max(1.0);
// Strip coordinates start at the viewport's inset left edge. Inner
// gaps belong to column positions; outer margins inset the viewport
// and are added back exactly once when placing windows on the output.
let mut column_positions = Vec::with_capacity(self.columns.len());
let mut next_column_x = 0.0;
@@ -510,14 +514,24 @@ impl ScrollingLayout {
return;
}
if start >= self.viewport_x && end <= self.viewport_x + viewport_width {
if start >= self.viewport_x - REVEAL_EPSILON && end <= self.viewport_x + viewport_width + REVEAL_EPSILON {
return;
}
if width >= viewport_width || start < self.viewport_x {
self.viewport_x = start;
let target = if width >= viewport_width {
// Approaching from the left reveals the left edge; approaching
// from the right reveals the right edge. Keep an existing view
// inside an oversized column stable on repeated focus.
self.viewport_x.clamp(start, end - viewport_width)
} else if start < self.viewport_x {
start
} else if end > self.viewport_x + viewport_width {
self.viewport_x = end - viewport_width;
end - viewport_width
} else {
self.viewport_x
};
if (target - self.viewport_x).abs() > REVEAL_EPSILON {
self.viewport_x = target;
}
}
@@ -805,7 +819,8 @@ mod tests {
(500.0, 1100.0, 500.0, 600.0),
(500.0, 400.0, 1000.0, 400.0),
(500.0, 1100.0, 1000.0, 1100.0),
(500.0, 400.0, 1500.0, 400.0),
(500.0, 400.0, 1500.0, 500.0),
(500.0, -1000.0, 1500.0, -500.0),
(500.0, 1100.0, 1500.0, 1100.0),
] {
let mut layout = ScrollingLayout {
@@ -823,6 +838,52 @@ mod tests {
}
}
#[test]
fn minimal_reveal_ignores_subpixel_edge_drift() {
for (start, width, expected) in [
(499.5, 500.0, 500.0),
(1000.5, 500.0, 500.0),
(499.4, 500.0, 499.4),
(1000.6, 500.0, 500.6),
(499.5, 1000.0, 500.0),
(500.5, 1000.0, 500.0),
(500.5, 1500.0, 500.0),
(-0.5, 1500.0, 500.0),
] {
let mut layout = ScrollingLayout {
viewport_x: 500.0,
..ScrollingLayout::default()
};
layout.reveal_column(&[(start, width)], 1000.0, 0);
assert!(
(layout.viewport_x() - expected).abs() < 1e-9,
"start={start}, width={width}"
);
}
}
#[test]
fn minimal_reveal_enters_oversized_columns_from_either_side() {
let mut layout = ScrollingLayout::default();
for id in 1..=3 {
layout.insert(window(id), Some(window(id.saturating_sub(1)))).unwrap();
}
layout.columns[1].width = ColumnWidth::Fixed(1500.0);
let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0);
let gaps = GapConfig {
inner: 0.0,
outer: 0.0,
smart: false,
};
for (focused, expected) in [(1, 0.0), (2, 500.0), (3, 1500.0), (2, 1000.0), (2, 1000.0)] {
layout.focus_and_reveal(window(focused)).unwrap();
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(focused)))
.unwrap();
assert_eq!(layout.viewport_x(), expected);
}
}
#[test]
fn minimal_reveal_aligns_full_width_columns_without_neighbor_context() {
let mut layout = ScrollingLayout::with_default_width(ColumnWidth::Full);
@@ -850,6 +911,42 @@ mod tests {
assert!(result.geometry[&window(2)].x >= bounds.width);
}
#[test]
fn minimal_reveal_accounts_for_fractional_gaps_and_outer_margins() {
let bounds = Rect::new(100.25, 30.5, 1000.5, 800.0);
let gaps = GapConfig {
inner: 9.5,
outer: 13.25,
smart: false,
};
for (width, offsets) in [
(ColumnWidth::Fixed(400.0), [0.0, 245.0, 245.0, 0.0]),
(ColumnWidth::Full, [0.0, 1967.0, 983.5, 0.0]),
] {
let mut layout = ScrollingLayout::with_default_width(width);
for id in 1..=3 {
layout.insert(window(id), Some(window(id.saturating_sub(1)))).unwrap();
}
for (focused, offset) in [1, 3, 2, 1].into_iter().zip(offsets) {
layout.focus_and_reveal(window(focused)).unwrap();
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(focused)))
.unwrap();
let rect = result.geometry[&window(focused)];
assert_eq!(layout.viewport_x(), offset, "width={width:?}, focused={focused}");
if focused == 1 || width == ColumnWidth::Full {
assert_eq!(rect.x, 113.5, "left edge includes the outer margin");
}
if focused == 3 || width == ColumnWidth::Full {
assert_eq!(rect.x + rect.width, 1087.5, "right edge includes the outer margin");
}
let next = result.geometry[&window(2)];
let first = result.geometry[&window(1)];
assert_eq!(next.x - (first.x + first.width), 9.5, "inner gap stays intact");
}
}
}
#[test]
fn two_half_width_columns_fit_without_focus_scrolling() {
let mut layout = ScrollingLayout::default();
+4 -2
View File
@@ -88,8 +88,10 @@ Live config is limited to 60 KiB.
| `width-presets` | array of widths | `[0.3333333333333333, 0.5, 0.6666666666666666, "full"]` | Super+R cycle; empty uses defaults |
`minimal` leaves fully visible columns in place and scrolls only enough to reveal
a hidden edge. Columns as wide as the viewport or wider align to its left edge,
without keeping a sliver of the neighboring window. `center_on_focus` centers
a hidden edge, ignoring differences of up to 0.5 logical pixels. Full-width
columns align with the viewport. Wider columns reveal their left edge when
approached from the left and their right edge when approached from the right;
an existing view inside a wider column stays in place. `center_on_focus` centers
the focused column. `paged` packs columns into viewport-sized pages: halves form pairs, thirds
form triples; mixed widths and client minimum sizes determine actual boundaries.
Changing default width preserves manually resized columns.