Add configurable inactive dimming and viewport-paged scrolling
This commit is contained in:
1 parent
a0fd760737
commit
48ac963ffe
8 files changed
+601
-8
No files matched your search
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
/target/
|
/target/
|
||||||
*.log
|
*.log
|
||||||
|
ChatGPT Image*.png
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
+59
-4
@@ -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,7 +1042,8 @@ impl Ferese {
|
|||||||
);
|
);
|
||||||
|
|
||||||
visible.insert(*id);
|
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
|
viewport_target
|
||||||
.zip(viewport_current)
|
.zip(viewport_current)
|
||||||
.map(|(target, current)| (workspace_id, rect.x + target, current))
|
.map(|(target, current)| (workspace_id, rect.x + target, current))
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(¶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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn window_shadow_element(
|
fn window_shadow_element(
|
||||||
state: &mut Ferese,
|
state: &mut Ferese,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user