diff --git a/.gitignore b/.gitignore index 288a0eb..37872fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /target/ *.log - +ChatGPT Image*.png diff --git a/README.md b/README.md index 9710dd4..4a3472f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 1dfafca..f05b7cd 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -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, + #[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( diff --git a/compositor/src/dimming.rs b/compositor/src/dimming.rs new file mode 100644 index 0000000..b8bf96c --- /dev/null +++ b/compositor/src/dimming.rs @@ -0,0 +1,129 @@ +use crate::config::InactiveDimSettings; +use ferese_layout::WindowId; +use std::time::Duration; + +pub(crate) fn target( + settings: InactiveDimSettings, + focused: Option, + 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); + } +} diff --git a/compositor/src/main.rs b/compositor/src/main.rs index ac42945..27835e8 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -3,6 +3,7 @@ mod backends; mod config; mod cursor; +mod dimming; mod effects; mod grabs; mod handlers; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 4863232..0ea1cd0 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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, pub window_geometry: HashMap, maximized_windows: HashSet, + maximized_column_widths: HashMap, window_stack: crate::stacking::WindowStack, pub(crate) window_borders: HashMap, + pub(crate) window_dims: HashMap, + pub(crate) window_dimming: HashMap, pub(crate) window_shadows: HashMap, pub(crate) rounded_clip_programs: HashMap, pub(crate) overview_scrims: HashMap, @@ -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,13 +1042,14 @@ impl Ferese { ); visible.insert(*id); - let scrolling = if !is_fullscreen && !is_maximized && !is_floating { - viewport_target - .zip(viewport_current) - .map(|(target, current)| (workspace_id, rect.x + target, current)) - } else { - None - }; + 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)) + } else { + None + }; placements.push(( window.clone(), @@ -1241,6 +1252,29 @@ impl Ferese { .collect::>(); 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(); } diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index ace4db2..ed2263d 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -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> { ] } +#[allow(clippy::too_many_arguments)] +fn window_dim_element( + state: &mut Ferese, + renderer: &mut GlesRenderer, + id: ferese_layout::WindowId, + geometry: Rectangle, + scale: f64, + radius: f64, + amount: f32, + output: &Output, +) -> Option { + 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(¶meters), + 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(¶meters)); + cached.parameters = parameters; + } + Some(cached.element.clone()) +} + #[allow(clippy::too_many_arguments)] fn window_shadow_element( state: &mut Ferese, diff --git a/crates/ferese-layout/src/scrolling.rs b/crates/ferese-layout/src/scrolling.rs index 5d07518..308f3cd 100644 --- a/crates/ferese-layout/src/scrolling.rs +++ b/crates/ferese-layout/src/scrolling.rs @@ -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, } 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 { + 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();