Add configurable inactive dimming and viewport-paged scrolling

This commit is contained in:
Samuel Onoja committed 2026-09-26 09:41:55 +01:00
1 parent a0fd760737
commit 48ac963ffe
8 files changed
+601 -8

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
/target/
*.log
ChatGPT Image*.png
+20 -1
View File
@@ -29,7 +29,10 @@ cargo run -p ferese -- foot
Set `RUST_LOG=ferese=debug` for detailed compositor logging.
`Super+F` toggles decorated maximization inside the workspace (bar and outer
gaps retained). `Super+Shift+F` toggles true fullscreen (no bar or window
gaps remain visible). In scrolling mode this expands the column to full width,
keeps it in the strip, and restores its previous width when toggled off. New
windows open to its right and scroll into focus.
`Super+Shift+F` toggles true fullscreen (no bar or window
decorations). Leaving fullscreen restores the previous maximized/tiled state.
Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under
@@ -108,8 +111,17 @@ opacity = 0.20
[theme.material]
style = "solid" # solid or translucent
blur_radius = 12.0 # logical pixels; translucent only, 0 disables blur
[appearance.inactive_dim]
enabled = true # opt-in; disabled by default
amount = 0.15 # dark tint over unfocused windows, 0–1; 0 disables
duration_ms = 150 # 0 snaps immediately; reduced motion also snaps
```
Inactive dimming follows window focus with a smooth, interruption-safe fade.
It keeps window opacity intact, respects animated geometry and rounded corners,
and leaves shell bars/popovers untouched. Overview temporarily removes dimming.
Material roles use the configured surface color and role-specific shadows.
Solid is the default. Translucent adds backdrop blur behind the same surface
color, without grain, glossy lighting, saturation boosts, or inset highlights.
@@ -139,6 +151,13 @@ viewport only when focus must be revealed. Set
`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused
columns instead.
Use `focus_strategy = "paged"` to pack consecutive columns into viewport-sized
pages. Half-width columns form pairs, thirds form triples, and full-width
columns occupy their own page. Mixed widths and client minimum sizes use their
actual allocated widths, including gaps. Focusing within a page keeps it still;
crossing a page boundary scrolls to that page. Explicit centering remains in
effect until focus changes or the output resizes.
Scrolling controls use `Super+R` to cycle configured width presets, `Super+C`
to center the focused column, `Super+[` to consume the focused window into an
adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the
+80
View File
@@ -114,6 +114,7 @@ pub struct ThemeSettings {
pub shadow_color: RgbaColor,
pub surface_base_color: RgbaColor,
pub panel_opacity: f64,
pub inactive_dim: InactiveDimSettings,
pub window_radius: f64,
pub shadow_offset_y: f64,
pub shadow_blur: f64,
@@ -135,6 +136,33 @@ pub enum MaterialStyle {
#[derive(Debug, Default, Deserialize)]
struct AppearanceConfig {
corner_radius: Option<f64>,
#[serde(default)]
inactive_dim: InactiveDimConfig,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InactiveDimSettings {
pub enabled: bool,
pub amount: f64,
pub duration_ms: f64,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
struct InactiveDimConfig {
enabled: bool,
amount: f64,
duration_ms: f64,
}
impl Default for InactiveDimConfig {
fn default() -> Self {
Self {
enabled: false,
amount: 0.15,
duration_ms: 150.0,
}
}
}
#[derive(Debug, Default, Deserialize)]
@@ -518,6 +546,7 @@ struct ScrollingConfig {
enum FocusStrategyValue {
Minimal,
CenterOnFocus,
Paged,
}
#[derive(Clone, Debug, Deserialize)]
@@ -680,6 +709,7 @@ impl Config {
{
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
FocusStrategyValue::Paged => ViewportFocusStrategy::Paged,
}
}
@@ -862,6 +892,17 @@ impl Config {
self.theme.surface.bar.opacity.unwrap_or(0.78),
"surface.bar.opacity",
)?,
inactive_dim: InactiveDimSettings {
enabled: self.appearance.inactive_dim.enabled,
amount: unit_theme_value(
self.appearance.inactive_dim.amount,
"appearance.inactive_dim.amount",
)?,
duration_ms: nonnegative_theme_value(
self.appearance.inactive_dim.duration_ms,
"appearance.inactive_dim.duration_ms",
)?,
},
window_radius,
shadow_offset_y,
shadow_blur,
@@ -1576,6 +1617,11 @@ mod tests {
fn parses_scrolling_focus_strategy() {
let minimal = parse("");
let centered = parse("[scrolling]\nfocus_strategy = \"center_on_focus\"");
let paged = parse("[scrolling]\nfocus_strategy = \"paged\"");
assert_eq!(
paged.scrolling_focus_strategy(),
ViewportFocusStrategy::Paged
);
assert_eq!(
minimal.scrolling_focus_strategy(),
@@ -1659,6 +1705,11 @@ mod tests {
shadow_color: RgbaColor([1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 128.0 / 255.0]),
surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
panel_opacity: 0.78,
inactive_dim: InactiveDimSettings {
enabled: false,
amount: 0.15,
duration_ms: 150.0
},
window_radius: 12.0,
shadow_offset_y: -2.0,
shadow_blur: 24.0,
@@ -1739,6 +1790,35 @@ mod tests {
}
}
#[test]
fn inactive_dimming_is_opt_in_and_configurable() {
let defaults = parse("").theme_settings().unwrap().inactive_dim;
assert!(!defaults.enabled);
assert_eq!(defaults.amount, 0.15);
assert_eq!(defaults.duration_ms, 150.0);
let settings =
parse("[appearance.inactive_dim]\nenabled = true\namount = 0.25\nduration_ms = 100")
.theme_settings()
.unwrap()
.inactive_dim;
assert!(settings.enabled);
assert_eq!(settings.amount, 0.25);
assert_eq!(settings.duration_ms, 100.0);
for (key, value) in [
("amount", "-0.1"),
("amount", "1.1"),
("amount", "nan"),
("duration_ms", "-1"),
("duration_ms", "inf"),
] {
assert!(
parse(&format!("[appearance.inactive_dim]\n{key} = {value}"))
.theme_settings()
.is_err()
);
}
}
#[test]
fn parses_input_and_touchpad_settings() {
let config = parse(
+129
View File
@@ -0,0 +1,129 @@
use crate::config::InactiveDimSettings;
use ferese_layout::WindowId;
use std::time::Duration;
pub(crate) fn target(
settings: InactiveDimSettings,
focused: Option<WindowId>,
window: WindowId,
overview: bool,
) -> f64 {
if settings.enabled && !overview && focused.is_some() && focused != Some(window) {
settings.amount
} else {
0.0
}
}
/// A bounded, interruption-safe opacity transition; it never overshoots.
#[derive(Debug)]
pub(crate) struct DimAnimation {
pub current: f64,
start: f64,
target: f64,
elapsed: f64,
}
impl DimAnimation {
pub fn new(value: f64) -> Self {
Self {
current: value,
start: value,
target: value,
elapsed: 0.0,
}
}
pub fn advance(&mut self, target: f64, delta: Duration, duration_ms: f64) -> bool {
if self.target != target {
self.start = self.current;
self.target = target;
self.elapsed = 0.0;
}
if duration_ms == 0.0 {
self.current = target;
return false;
}
self.elapsed += delta.as_secs_f64() * 1000.0;
let progress = (self.elapsed / duration_ms).min(1.0);
if progress == 1.0 {
self.current = target;
return false;
}
let eased = progress * progress * (3.0 - 2.0 * progress);
self.current = self.start + (target - self.start) * eased;
self.current != target
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_unfocused_windows_dim_and_overview_disables_it() {
let settings = InactiveDimSettings {
enabled: true,
amount: 0.15,
duration_ms: 150.0,
};
let left = WindowId(1);
let right = WindowId(2);
assert_eq!(target(settings, Some(right), left, false), 0.15);
assert_eq!(target(settings, Some(right), right, false), 0.0);
assert_eq!(target(settings, Some(right), left, true), 0.0);
assert_eq!(target(settings, None, left, false), 0.0);
assert_eq!(
target(
InactiveDimSettings {
enabled: false,
..settings
},
Some(right),
left,
false
),
0.0
);
assert_eq!(
target(
InactiveDimSettings {
amount: 0.0,
..settings
},
Some(right),
left,
false
),
0.0
);
}
#[test]
fn focus_transition_is_smooth_bounded_and_settles() {
let mut dim = DimAnimation::new(0.0);
assert!(dim.advance(0.15, Duration::from_millis(75), 150.0));
assert!((dim.current - 0.075).abs() < 1e-9);
assert!(!dim.advance(0.15, Duration::from_millis(75), 150.0));
assert_eq!(dim.current, 0.15);
assert!(!dim.advance(0.15, Duration::from_secs(10), 150.0));
}
#[test]
fn reversing_focus_starts_from_current_opacity() {
let mut dim = DimAnimation::new(0.0);
dim.advance(0.15, Duration::from_millis(75), 150.0);
let before = dim.current;
assert!(dim.advance(0.0, Duration::ZERO, 150.0));
assert_eq!(dim.current, before);
assert!(!dim.advance(0.0, Duration::from_millis(150), 150.0));
assert_eq!(dim.current, 0.0);
}
#[test]
fn zero_duration_snaps_for_reduced_motion() {
let mut dim = DimAnimation::new(0.15);
assert!(!dim.advance(0.0, Duration::ZERO, 0.0));
assert_eq!(dim.current, 0.0);
}
}
+1
View File
@@ -3,6 +3,7 @@
mod backends;
mod config;
mod cursor;
mod dimming;
mod effects;
mod grabs;
mod handlers;
+59 -4
View File
@@ -9,7 +9,7 @@ use std::{
use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
use ferese_core::{
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId,
WorkspaceSet,
WorkspaceLayout, WorkspaceSet,
};
use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints,
@@ -119,8 +119,11 @@ pub struct Ferese {
pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>,
maximized_windows: HashSet<WindowId>,
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
window_stack: crate::stacking::WindowStack,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dims: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
pub(crate) window_dimming: HashMap<WindowId, crate::dimming::DimAnimation>,
pub(crate) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
@@ -451,8 +454,11 @@ impl Ferese {
window_ids: HashMap::new(),
window_geometry: HashMap::new(),
maximized_windows: HashSet::new(),
maximized_column_widths: HashMap::new(),
window_stack: crate::stacking::WindowStack::default(),
window_borders: HashMap::new(),
window_dims: HashMap::new(),
window_dimming: HashMap::new(),
window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(),
overview_scrims: HashMap::new(),
@@ -911,8 +917,11 @@ impl Ferese {
self.space.unmap_elem(window);
self.window_geometry.remove(&id);
self.maximized_windows.remove(&id);
self.maximized_column_widths.remove(&id);
self.window_stack.remove(id);
self.window_borders.remove(&id);
self.window_dims.remove(&id);
self.window_dimming.remove(&id);
self.window_shadows.remove(&id);
self.closing_windows.remove(&id);
self.window_rules_applied.remove(&id);
@@ -954,6 +963,7 @@ impl Ferese {
continue;
};
let workspace_fullscreen = workspace.fullscreen;
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
let workspace_focus = workspace.last_focused;
let focused = self
.focused_window
@@ -1011,7 +1021,7 @@ impl Ferese {
self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id);
let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds
} else if is_maximized {
} else if is_maximized && !is_scrolling_layout {
maximized_rect(bounds, self.gap_config.outer)
} else {
match self.workspaces.placement(*id) {
@@ -1032,7 +1042,8 @@ impl Ferese {
);
visible.insert(*id);
let scrolling = if !is_fullscreen && !is_maximized && !is_floating {
let scrolling =
if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) {
viewport_target
.zip(viewport_current)
.map(|(target, current)| (workspace_id, rect.x + target, current))
@@ -1241,6 +1252,29 @@ impl Ferese {
.collect::<Vec<_>>();
let mut active_animation = false;
let dim_settings = self.theme_settings.inactive_dim;
let duration = if self.animations_enabled {
dim_settings.duration_ms
} else {
0.0
};
let mut dim_changed = false;
for (_, id) in &windows {
let target = crate::dimming::target(
dim_settings,
self.focused_window,
*id,
self.overview.is_presenting(),
);
let dim = self
.window_dimming
.entry(*id)
.or_insert_with(|| crate::dimming::DimAnimation::new(target));
let previous = dim.current;
active_animation |= dim.advance(target, delta, duration);
dim_changed |= previous != dim.current;
}
let mut ready_to_close = Vec::new();
for (id, animation) in &mut self.closing_windows {
if animation.advance(delta) {
@@ -1346,7 +1380,7 @@ impl Ferese {
.advance(delta, self.spring_config, self.animations_enabled);
self.sync_window_stacking();
if active_animation {
if active_animation || dim_changed {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
}
active_animation
@@ -1645,6 +1679,27 @@ impl Ferese {
} else {
self.maximized_windows.remove(&window);
}
if self.workspaces.placement(window) == Some(WindowPlacement::Tiled) {
let workspace_id = self.workspaces.workspace_for_window(window).unwrap();
if let Some(workspace) = self.workspaces.workspace_mut(workspace_id) {
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
if enabled {
if let Some(column) = layout
.columns()
.iter()
.find(|column| column.windows.contains(&window))
{
self.maximized_column_widths
.entry(window)
.or_insert(column.width);
}
let _ = layout.set_column_width(window, ColumnWidth::Proportion(1.0));
} else if let Some(width) = self.maximized_column_widths.remove(&window) {
let _ = layout.set_column_width(window, width);
}
}
}
}
self.relayout();
}
+82
View File
@@ -913,6 +913,22 @@ fn output_elements(
for (window, id, visual, decoration_progress, close_alpha) in windows {
let constrain = rounded_visual_rect(visual, output_geometry.loc);
let dim = state.window_dimming.get(&id).map_or(0.0, |dim| dim.current);
if let Some(overlay) = window_dim_element(
state,
renderer,
id,
constrain,
scale,
state.theme_settings.window_radius * decoration_progress,
dim as f32 * close_alpha,
output,
) {
// Front-to-back: dim the application and its border, not its shadow
// or other windows/layer surfaces. The overlay is input-transparent.
elements.push(overlay.into());
}
if let Some(programs) = rounded_clip_program.clone() {
let window_radius = state.theme_settings.window_radius * decoration_progress;
let shadow_offset_y = state.theme_settings.shadow_offset_y;
@@ -1163,6 +1179,72 @@ fn border_uniforms(parameters: &BorderParameters) -> Vec<Uniform<'static>> {
]
}
#[allow(clippy::too_many_arguments)]
fn window_dim_element(
state: &mut Ferese,
renderer: &mut GlesRenderer,
id: ferese_layout::WindowId,
geometry: Rectangle<i32, Logical>,
scale: f64,
radius: f64,
amount: f32,
output: &Output,
) -> Option<PixelShaderElement> {
if amount <= 0.0 {
state.window_dims.remove(&id);
return None;
}
let mode = output.current_mode()?;
let program = material_program(state, renderer)?;
let parameters = BorderParameters {
geometry,
clip_rect: framebuffer_clip_rect(
geometry.to_physical_precise_round(scale),
mode.size,
output.current_transform().invert(),
),
radius: scaled_effect_value(radius, geometry, scale),
width: 0.0,
color: [0.0, 0.0, 0.0, amount.clamp(0.0, 1.0)],
};
let uniforms = |p: &BorderParameters| {
vec![
Uniform::new("visible_rect", p.clip_rect).into_owned(),
Uniform::new("material_radius", p.radius).into_owned(),
Uniform::new("tint", p.color).into_owned(),
Uniform::new("paint_mode", 0.0_f32).into_owned(),
Uniform::new("shadow_rect", p.clip_rect).into_owned(),
Uniform::new("shadow_values", [0.0_f32; 2]).into_owned(),
]
};
let context = renderer.context_id().erased();
let buffers = state.window_dims.entry(id).or_default();
let cached = buffers
.contexts
.entry(context)
.or_insert_with(|| CachedBorder {
element: PixelShaderElement::new(
program.0,
geometry,
None,
1.0,
uniforms(&parameters),
RenderElementKind::Unspecified,
),
parameters: parameters.clone(),
});
if cached.parameters != parameters {
if cached.parameters.geometry != geometry {
cached.element.resize(geometry, None);
}
// Updating uniforms advances the element's commit so unchanged client
// buffers still repaint while focus dimming animates.
cached.element.update_uniforms(uniforms(&parameters));
cached.parameters = parameters;
}
Some(cached.element.clone())
}
#[allow(clippy::too_many_arguments)]
fn window_shadow_element(
state: &mut Ferese,
+229 -2
View File
@@ -24,6 +24,7 @@ pub enum ViewportFocusStrategy {
#[default]
Minimal,
Center,
Paged,
}
impl Default for ColumnWidth {
@@ -79,6 +80,7 @@ pub struct ScrollingLayout {
focus_strategy: ViewportFocusStrategy,
reveal_pending: bool,
width_cycle_pending: bool,
last_viewport_width: Option<f64>,
}
impl Default for ScrollingLayout {
@@ -92,6 +94,7 @@ impl Default for ScrollingLayout {
focus_strategy: ViewportFocusStrategy::Minimal,
reveal_pending: false,
width_cycle_pending: false,
last_viewport_width: None,
}
}
}
@@ -106,6 +109,7 @@ impl ScrollingLayout {
pub fn set_focus_strategy(&mut self, strategy: ViewportFocusStrategy) {
self.focus_strategy = strategy;
self.reveal_pending = true;
}
pub fn default_width(&self) -> ColumnWidth {
@@ -183,7 +187,9 @@ impl ScrollingLayout {
column.active = column.active.min(column.windows.len() - 1);
column.normalize_heights();
}
self.reveal_pending |= removed_active_column && !self.columns.is_empty();
self.reveal_pending |= (removed_active_column
|| self.focus_strategy == ViewportFocusStrategy::Paged)
&& !self.columns.is_empty();
debug_assert!(self.validate().is_ok());
Ok(())
@@ -256,6 +262,7 @@ impl ScrollingLayout {
.window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?;
self.columns[column].width = normalized_width(width);
self.reveal_pending = true;
Ok(())
}
@@ -464,11 +471,15 @@ impl ScrollingLayout {
column_positions.push((next_column_x, width));
next_column_x += width + inner;
}
let viewport_resized = self.last_viewport_width != Some(viewport_width);
self.last_viewport_width = Some(viewport_width);
if self.width_cycle_pending {
self.retarget_after_width_cycle(&column_positions, viewport_width);
self.width_cycle_pending = false;
self.reveal_pending = false;
} else if self.reveal_pending {
} else if self.reveal_pending
|| (viewport_resized && self.focus_strategy == ViewportFocusStrategy::Paged)
{
self.reveal_active_column(&column_positions, viewport_width);
self.reveal_pending = false;
}
@@ -504,6 +515,10 @@ impl ScrollingLayout {
}
fn reveal_active_column(&mut self, positions: &[(f64, f64)], viewport_width: f64) {
if let Some(start) = self.paged_viewport(positions, viewport_width) {
self.viewport_x = start;
return;
}
let Some(active) = self.active_column else {
return;
};
@@ -536,7 +551,37 @@ impl ScrollingLayout {
}
}
fn paged_viewport(&self, positions: &[(f64, f64)], viewport_width: f64) -> Option<f64> {
if self.focus_strategy != ViewportFocusStrategy::Paged {
return None;
}
let active = self.active_column?;
// Pack actual allocated widths, including gaps and client constraints.
// Page boundaries are independent of focus direction. An oversized
// column occupies its own page rather than overlapping its neighbors.
let mut first = 0;
while first < positions.len() {
let (start, _) = positions[first];
let mut last = first;
while let Some(&(next_start, next_width)) = positions.get(last + 1) {
if next_start + next_width - start > viewport_width + 1e-6 {
break;
}
last += 1;
}
if active <= last {
return Some(start);
}
first = last + 1;
}
None
}
fn retarget_after_width_cycle(&mut self, positions: &[(f64, f64)], viewport_width: f64) {
if let Some(start) = self.paged_viewport(positions, viewport_width) {
self.viewport_x = start;
return;
}
let Some(active) = self.active_column else {
return;
};
@@ -810,6 +855,188 @@ mod tests {
);
}
#[test]
fn full_width_column_stays_in_strip_when_new_half_window_opens() {
let (mut layout, bounds, gaps) = two_half_width_layout();
layout.set_focus_strategy(ViewportFocusStrategy::Paged);
layout
.set_column_width(window(2), ColumnWidth::Proportion(1.0))
.unwrap();
let zoomed = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
assert_eq!(zoomed.geometry[&window(2)].width, 980.0);
layout.insert(window(3), Some(window(2))).unwrap();
let opened = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3)))
.unwrap();
let previous = opened.geometry[&window(2)];
let new = opened.geometry[&window(3)];
assert_eq!(previous.width, 980.0);
assert_eq!(new, Rect::new(10.0, 10.0, 485.0, 780.0));
assert_eq!(previous.x + previous.width + gaps.inner, new.x);
assert_eq!(layout.active_window(), Some(window(3)));
layout
.set_column_width(window(2), ColumnWidth::Proportion(0.5))
.unwrap();
let restored = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
assert_eq!(restored.geometry[&window(2)].width, 485.0);
}
fn paged_layout(count: u64) -> (ScrollingLayout, Rect, GapConfig) {
let (mut layout, bounds, gaps) = two_half_width_layout();
layout.set_focus_strategy(ViewportFocusStrategy::Paged);
for id in 3..=count {
layout.insert(window(id), Some(window(id - 1))).unwrap();
}
(layout, bounds, gaps)
}
#[test]
fn paged_focus_snaps_six_half_columns_in_both_directions() {
let (mut layout, bounds, gaps) = paged_layout(6);
for focused in [1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1] {
let geometry = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(focused)))
.unwrap()
.geometry;
let first = (focused - 1) / 2 * 2 + 1;
assert_eq!(layout.viewport_x(), ((first - 1) / 2) as f64 * 990.0);
assert_eq!(geometry[&window(first)].x, 10.0);
assert_eq!(geometry[&window(first + 1)].x, 505.0);
assert_eq!(
geometry[&window(first + 1)].x + geometry[&window(first + 1)].width,
990.0
);
}
}
#[test]
fn paged_focus_realigns_after_output_resize() {
let (mut layout, bounds, gaps) = paged_layout(6);
layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(5)))
.unwrap();
let bounds = Rect::new(20.0, 48.0, 1400.0, 800.0);
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(5)))
.unwrap();
assert_eq!(layout.viewport_x(), 2780.0);
assert_eq!(result.geometry[&window(5)].x, 30.0);
assert_eq!(result.geometry[&window(6)].x, 725.0);
}
#[test]
fn paged_focus_preserves_explicit_center_until_focus_changes() {
let (mut layout, bounds, gaps) = paged_layout(4);
layout
.center_window(window(2), bounds, gaps, &HashMap::new())
.unwrap();
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
let rect = result.geometry[&window(2)];
assert_eq!(rect.x + rect.width / 2.0, 500.0);
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(1)))
.unwrap();
assert_eq!(result.geometry[&window(1)].x, 10.0);
}
#[test]
fn full_width_column_occupies_its_own_page() {
let (mut layout, bounds, gaps) = paged_layout(4);
layout.columns[0].width = ColumnWidth::Full;
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(3)))
.unwrap();
assert_eq!(result.geometry[&window(2)].x, 10.0);
assert_eq!(result.geometry[&window(3)].x, 505.0);
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
.unwrap();
assert_eq!(result.geometry[&window(4)].x, 10.0);
}
#[test]
fn client_minimums_split_columns_across_pages() {
let (mut layout, bounds, gaps) = paged_layout(2);
let constraints = HashMap::from([(
window(1),
SizeConstraints {
min_width: 700.0,
..Default::default()
},
)]);
let result = layout
.geometry_with_constraints(bounds, gaps, &constraints, Some(window(2)))
.unwrap();
assert!(layout.viewport_x() > 0.0);
let rect = result.geometry[&window(2)];
assert!(rect.x >= 10.0 && rect.x + rect.width <= 990.0);
}
#[test]
fn paged_focus_groups_thirds_in_both_directions_at_fractional_sizes() {
let (mut layout, _, gaps) = paged_layout(6);
for column in &mut layout.columns {
column.width = ColumnWidth::Proportion(1.0 / 3.0);
}
let bounds = Rect::new(13.25, 48.5, 1000.5, 800.25);
for focused in [1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1] {
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(focused)))
.unwrap();
let first = (focused - 1) / 3 * 3 + 1;
assert!((result.geometry[&window(first)].x - 23.25).abs() < 1e-6);
let last = result.geometry[&window(first + 2)];
assert!((last.x + last.width - 1003.75).abs() < 1e-6);
}
}
#[test]
fn paged_focus_packs_mixed_widths_by_actual_fit() {
let (mut layout, bounds, gaps) = paged_layout(5);
let widths = [0.6, 0.4, 0.25, 0.5, 0.25];
for (column, width) in layout.columns.iter_mut().zip(widths) {
column.width = ColumnWidth::Proportion(width);
}
for (focused, first) in [(1, 1), (2, 1), (3, 3), (4, 3), (5, 3), (2, 1)] {
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(focused)))
.unwrap();
assert_eq!(result.geometry[&window(first)].x, 10.0);
let rect = result.geometry[&window(focused)];
assert!(rect.x >= 10.0 && rect.x + rect.width <= 990.0 + 1e-6);
}
}
#[test]
fn oversized_page_does_not_absorb_neighbor_and_removal_repacks() {
let (mut layout, bounds, gaps) = paged_layout(4);
let constraints = HashMap::from([(
window(1),
SizeConstraints {
min_width: 1200.0,
..Default::default()
},
)]);
let result = layout
.geometry_with_constraints(bounds, gaps, &constraints, Some(window(2)))
.unwrap();
assert_eq!(result.geometry[&window(2)].x, 10.0);
assert_eq!(result.geometry[&window(3)].x, 505.0);
layout.remove(window(1)).unwrap();
let result = layout
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
.unwrap();
assert_eq!(layout.viewport_x(), 0.0);
assert_eq!(result.geometry[&window(2)].x, 10.0);
assert_eq!(result.geometry[&window(3)].x, 505.0);
}
#[test]
fn manual_resize_does_not_retarget_the_viewport() {
let mut layout = ScrollingLayout::default();