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
+607 -14

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
/target/ /target/
*.log *.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. Set `RUST_LOG=ferese=debug` for detailed compositor logging.
`Super+F` toggles decorated maximization inside the workspace (bar and outer `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. decorations). Leaving fullscreen restores the previous maximized/tiled state.
Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under Set `FERESE_TRACE_PERFORMANCE=1` to emit five-second per-output summaries under
@@ -108,8 +111,17 @@ opacity = 0.20
[theme.material] [theme.material]
style = "solid" # solid or translucent style = "solid" # solid or translucent
blur_radius = 12.0 # logical pixels; translucent only, 0 disables blur 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. Material roles use the configured surface color and role-specific shadows.
Solid is the default. Translucent adds backdrop blur behind the same surface Solid is the default. Translucent adds backdrop blur behind the same surface
color, without grain, glossy lighting, saturation boosts, or inset highlights. 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 `focus_strategy = "center_on_focus"` under `[scrolling]` to center focused
columns instead. 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` 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 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 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 shadow_color: RgbaColor,
pub surface_base_color: RgbaColor, pub surface_base_color: RgbaColor,
pub panel_opacity: f64, pub panel_opacity: f64,
pub inactive_dim: InactiveDimSettings,
pub window_radius: f64, pub window_radius: f64,
pub shadow_offset_y: f64, pub shadow_offset_y: f64,
pub shadow_blur: f64, pub shadow_blur: f64,
@@ -135,6 +136,33 @@ pub enum MaterialStyle {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct AppearanceConfig { struct AppearanceConfig {
corner_radius: Option<f64>, 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)] #[derive(Debug, Default, Deserialize)]
@@ -518,6 +546,7 @@ struct ScrollingConfig {
enum FocusStrategyValue { enum FocusStrategyValue {
Minimal, Minimal,
CenterOnFocus, CenterOnFocus,
Paged,
} }
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
@@ -680,6 +709,7 @@ impl Config {
{ {
FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal, FocusStrategyValue::Minimal => ViewportFocusStrategy::Minimal,
FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center, FocusStrategyValue::CenterOnFocus => ViewportFocusStrategy::Center,
FocusStrategyValue::Paged => ViewportFocusStrategy::Paged,
} }
} }
@@ -862,6 +892,17 @@ impl Config {
self.theme.surface.bar.opacity.unwrap_or(0.78), self.theme.surface.bar.opacity.unwrap_or(0.78),
"surface.bar.opacity", "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, window_radius,
shadow_offset_y, shadow_offset_y,
shadow_blur, shadow_blur,
@@ -1576,6 +1617,11 @@ mod tests {
fn parses_scrolling_focus_strategy() { fn parses_scrolling_focus_strategy() {
let minimal = parse(""); let minimal = parse("");
let centered = parse("[scrolling]\nfocus_strategy = \"center_on_focus\""); 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!( assert_eq!(
minimal.scrolling_focus_strategy(), 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]), 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,]), surface_base_color: RgbaColor([17.0 / 255.0, 24.0 / 255.0, 33.0 / 255.0, 1.0,]),
panel_opacity: 0.78, panel_opacity: 0.78,
inactive_dim: InactiveDimSettings {
enabled: false,
amount: 0.15,
duration_ms: 150.0
},
window_radius: 12.0, window_radius: 12.0,
shadow_offset_y: -2.0, shadow_offset_y: -2.0,
shadow_blur: 24.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] #[test]
fn parses_input_and_touchpad_settings() { fn parses_input_and_touchpad_settings() {
let config = parse( 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 backends;
mod config; mod config;
mod cursor; mod cursor;
mod dimming;
mod effects; mod effects;
mod grabs; mod grabs;
mod handlers; mod handlers;
+65 -10
View File
@@ -9,7 +9,7 @@ use std::{
use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry}; use ferese_animation::{AnimatedValue, ClientSize, PresentationMode, SpringConfig, WindowGeometry};
use ferese_core::{ use ferese_core::{
LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId, LayoutMode, OutputGeometry, OutputId, OutputWorkspaceMap, WindowPlacement, WorkspaceId,
WorkspaceSet, WorkspaceLayout, WorkspaceSet,
}; };
use ferese_layout::{ use ferese_layout::{
Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints, Axis, ColumnWidth, Direction, GapConfig, LayoutResult, Rect, SizeConstraints,
@@ -119,8 +119,11 @@ pub struct Ferese {
pub window_ids: HashMap<Window, WindowId>, pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub window_geometry: HashMap<WindowId, WindowGeometry>,
maximized_windows: HashSet<WindowId>, maximized_windows: HashSet<WindowId>,
maximized_column_widths: HashMap<WindowId, ColumnWidth>,
window_stack: crate::stacking::WindowStack, window_stack: crate::stacking::WindowStack,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>, 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) window_shadows: HashMap<WindowId, crate::winit::WindowShadowBuffers>,
pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>, pub(crate) rounded_clip_programs: HashMap<ErasedContextId, crate::winit::RoundedClipPrograms>,
pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>, pub(crate) overview_scrims: HashMap<OutputId, crate::winit::OverviewScrim>,
@@ -451,8 +454,11 @@ impl Ferese {
window_ids: HashMap::new(), window_ids: HashMap::new(),
window_geometry: HashMap::new(), window_geometry: HashMap::new(),
maximized_windows: HashSet::new(), maximized_windows: HashSet::new(),
maximized_column_widths: HashMap::new(),
window_stack: crate::stacking::WindowStack::default(), window_stack: crate::stacking::WindowStack::default(),
window_borders: HashMap::new(), window_borders: HashMap::new(),
window_dims: HashMap::new(),
window_dimming: HashMap::new(),
window_shadows: HashMap::new(), window_shadows: HashMap::new(),
rounded_clip_programs: HashMap::new(), rounded_clip_programs: HashMap::new(),
overview_scrims: HashMap::new(), overview_scrims: HashMap::new(),
@@ -911,8 +917,11 @@ impl Ferese {
self.space.unmap_elem(window); self.space.unmap_elem(window);
self.window_geometry.remove(&id); self.window_geometry.remove(&id);
self.maximized_windows.remove(&id); self.maximized_windows.remove(&id);
self.maximized_column_widths.remove(&id);
self.window_stack.remove(id); self.window_stack.remove(id);
self.window_borders.remove(&id); self.window_borders.remove(&id);
self.window_dims.remove(&id);
self.window_dimming.remove(&id);
self.window_shadows.remove(&id); self.window_shadows.remove(&id);
self.closing_windows.remove(&id); self.closing_windows.remove(&id);
self.window_rules_applied.remove(&id); self.window_rules_applied.remove(&id);
@@ -954,6 +963,7 @@ impl Ferese {
continue; continue;
}; };
let workspace_fullscreen = workspace.fullscreen; let workspace_fullscreen = workspace.fullscreen;
let is_scrolling_layout = matches!(workspace.layout, WorkspaceLayout::Scrolling(_));
let workspace_focus = workspace.last_focused; let workspace_focus = workspace.last_focused;
let focused = self let focused = self
.focused_window .focused_window
@@ -1011,7 +1021,7 @@ impl Ferese {
self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id); self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id);
let rect = if workspace_fullscreen == Some(*id) { let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds fullscreen_bounds
} else if is_maximized { } else if is_maximized && !is_scrolling_layout {
maximized_rect(bounds, self.gap_config.outer) maximized_rect(bounds, self.gap_config.outer)
} else { } else {
match self.workspaces.placement(*id) { match self.workspaces.placement(*id) {
@@ -1032,13 +1042,14 @@ impl Ferese {
); );
visible.insert(*id); visible.insert(*id);
let scrolling = if !is_fullscreen && !is_maximized && !is_floating { let scrolling =
viewport_target if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) {
.zip(viewport_current) viewport_target
.map(|(target, current)| (workspace_id, rect.x + target, current)) .zip(viewport_current)
} else { .map(|(target, current)| (workspace_id, rect.x + target, current))
None } else {
}; None
};
placements.push(( placements.push((
window.clone(), window.clone(),
@@ -1241,6 +1252,29 @@ impl Ferese {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut active_animation = false; 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(); let mut ready_to_close = Vec::new();
for (id, animation) in &mut self.closing_windows { for (id, animation) in &mut self.closing_windows {
if animation.advance(delta) { if animation.advance(delta) {
@@ -1346,7 +1380,7 @@ impl Ferese {
.advance(delta, self.spring_config, self.animations_enabled); .advance(delta, self.spring_config, self.animations_enabled);
self.sync_window_stacking(); self.sync_window_stacking();
if active_animation { if active_animation || dim_changed {
self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
} }
active_animation active_animation
@@ -1645,6 +1679,27 @@ impl Ferese {
} else { } else {
self.maximized_windows.remove(&window); 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(); self.relayout();
} }
+82
View File
@@ -913,6 +913,22 @@ fn output_elements(
for (window, id, visual, decoration_progress, close_alpha) in windows { for (window, id, visual, decoration_progress, close_alpha) in windows {
let constrain = rounded_visual_rect(visual, output_geometry.loc); 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() { if let Some(programs) = rounded_clip_program.clone() {
let window_radius = state.theme_settings.window_radius * decoration_progress; let window_radius = state.theme_settings.window_radius * decoration_progress;
let shadow_offset_y = state.theme_settings.shadow_offset_y; 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)] #[allow(clippy::too_many_arguments)]
fn window_shadow_element( fn window_shadow_element(
state: &mut Ferese, state: &mut Ferese,
+229 -2
View File
@@ -24,6 +24,7 @@ pub enum ViewportFocusStrategy {
#[default] #[default]
Minimal, Minimal,
Center, Center,
Paged,
} }
impl Default for ColumnWidth { impl Default for ColumnWidth {
@@ -79,6 +80,7 @@ pub struct ScrollingLayout {
focus_strategy: ViewportFocusStrategy, focus_strategy: ViewportFocusStrategy,
reveal_pending: bool, reveal_pending: bool,
width_cycle_pending: bool, width_cycle_pending: bool,
last_viewport_width: Option<f64>,
} }
impl Default for ScrollingLayout { impl Default for ScrollingLayout {
@@ -92,6 +94,7 @@ impl Default for ScrollingLayout {
focus_strategy: ViewportFocusStrategy::Minimal, focus_strategy: ViewportFocusStrategy::Minimal,
reveal_pending: false, reveal_pending: false,
width_cycle_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) { pub fn set_focus_strategy(&mut self, strategy: ViewportFocusStrategy) {
self.focus_strategy = strategy; self.focus_strategy = strategy;
self.reveal_pending = true;
} }
pub fn default_width(&self) -> ColumnWidth { pub fn default_width(&self) -> ColumnWidth {
@@ -183,7 +187,9 @@ impl ScrollingLayout {
column.active = column.active.min(column.windows.len() - 1); column.active = column.active.min(column.windows.len() - 1);
column.normalize_heights(); 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()); debug_assert!(self.validate().is_ok());
Ok(()) Ok(())
@@ -256,6 +262,7 @@ impl ScrollingLayout {
.window_location(window) .window_location(window)
.ok_or(LayoutError::UnknownWindow(window))?; .ok_or(LayoutError::UnknownWindow(window))?;
self.columns[column].width = normalized_width(width); self.columns[column].width = normalized_width(width);
self.reveal_pending = true;
Ok(()) Ok(())
} }
@@ -464,11 +471,15 @@ impl ScrollingLayout {
column_positions.push((next_column_x, width)); column_positions.push((next_column_x, width));
next_column_x += width + inner; 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 { if self.width_cycle_pending {
self.retarget_after_width_cycle(&column_positions, viewport_width); self.retarget_after_width_cycle(&column_positions, viewport_width);
self.width_cycle_pending = false; self.width_cycle_pending = false;
self.reveal_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_active_column(&column_positions, viewport_width);
self.reveal_pending = false; self.reveal_pending = false;
} }
@@ -504,6 +515,10 @@ impl ScrollingLayout {
} }
fn reveal_active_column(&mut self, positions: &[(f64, f64)], viewport_width: f64) { 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 { let Some(active) = self.active_column else {
return; 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) { 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 { let Some(active) = self.active_column else {
return; 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] #[test]
fn manual_resize_does_not_retarget_the_viewport() { fn manual_resize_does_not_retarget_the_viewport() {
let mut layout = ScrollingLayout::default(); let mut layout = ScrollingLayout::default();