diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0829abc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "chat.tools.terminal.autoApprove": { + "git add": true, + "git commit": true + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 99a81ce..7c7699a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -159,3 +159,28 @@ When configuration is hot-reloaded, only default or unmodified values should be Even after validation, code that iterates based on external values should have caps. This is defense-in-depth: validation catches expected bad input, caps catch unexpected arithmetic. When computing resize edges, drag thresholds, or hit testing, consider degenerate geometry. A window narrower than twice the resize threshold can have overlapping left and right edges. + +## Scroll layout + +The scroll layout is an alternative to the default tree layout, inspired by niri and PaperWM. Instead of subdividing the screen into tiles, columns extend in a horizontal strip and the user scrolls a viewport across them. + +The tree supports two layout modes, selected per-space: + +- **Tree** – traditional tiling with horizontal/vertical splitting (i3-style) +- **Scroll** – scrollable column layout + +Both modes share the same tree structure, weight-based sizing, and selection model. The scroll mode adds a `ViewportState` per layout and routes additional events (scroll wheel, interactive resize/move) through the LayoutManager. + +### Viewport and animation + +`ViewportState` manages the horizontal scroll offset. It is either static or animating via a `SpringAnimation`. The spring uses a classical damped model with configurable response time and damping fraction (default: critically damped). `retarget()` preserves continuity of position and velocity when the target changes mid-animation, so rapid focus changes feel fluid rather than jerky. + +Three centering modes control when the viewport scrolls to keep the focused column visible: `Always`, `OnOverflow`, and `Never`. + +After layout calculation, `apply_viewport_to_frames` offsets window positions by the scroll offset and hides off-screen windows by moving them out of view. This function is generic over the window identifier type to keep the model layer free of actor-layer dependencies. + +The Reactor drives animation with a timer that fires only when a scroll animation is active. + +### Interactive resize and move + +The LayoutManager handles interactive resize and move via mouse drag. `detect_edges` determines which edges of the focused window are near the cursor and sets the drag mode. Interactive resize works by converting pixel deltas into weight adjustments on the layout tree. diff --git a/examples/devtool.rs b/examples/devtool.rs index e78b1ff..38d3514 100644 --- a/examples/devtool.rs +++ b/examples/devtool.rs @@ -329,7 +329,7 @@ fn inspect(mtm: MainThreadMarker) { async fn inspect_inner(mut rx: UnboundedReceiver<()>, mtm: MainThreadMarker) { let mut screen_cache = ScreenCache::new(mtm); - let Some((_, _, converter)) = screen_cache.update_screen_config() else { + let Some((_, converter)) = screen_cache.update_screen_config() else { return; }; while let Some(()) = rx.recv().await { diff --git a/glide.default.toml b/glide.default.toml index 79c0255..43a922a 100644 --- a/glide.default.toml +++ b/glide.default.toml @@ -26,6 +26,10 @@ outer_gap = 0 # Gap between adjacent windows (in pixels). inner_gap = 0 +# The default layout kind for new spaces: "tree" or "scroll". +# Note: "scroll" requires settings.experimental.scroll.enable = true. +default_layout_kind = "tree" + # Visual bars for window groups (tabbed/stacked containers). group_bars.enable = true group_bars.thickness = 6 @@ -139,6 +143,15 @@ default_keys = false # Print the current layout in the logs. "Alt + Shift + D" = "debug" +# Scroll layout commands are experimental and intentionally not bound by +# default. If you enable settings.experimental.scroll.enable, add explicit +# keybindings in your personal config under [keys], for example: +# "Alt + Shift + S" = "change_layout_kind" +# "Alt + Shift + T" = "toggle_column_tabbed" +# "Alt + Shift + W" = "cycle_column_width" +# "Alt + Shift + BracketLeft" = { consume_or_expel_window = "left" } +# "Alt + Shift + BracketRight" = { consume_or_expel_window = "right" } + # WARNING: # This section contains experimental features that might break or be removed in # the future. Use at your own risk! @@ -152,3 +165,32 @@ status_icon.color = false # Ignored; kept for compatibility. status_icon.enable = true + +# Scroll layout settings. + +# Enable the experimental scroll/niri layout and related commands. +scroll.enable = false + +# When to center the focused column: "never", "always", or "on_overflow". +scroll.center_focused_column = "never" + +# Number of columns visible at once (1-5). +scroll.visible_columns = 2 + +# Preset column width proportions to cycle through with cycle_column_width. +scroll.column_width_presets = [0.333, 0.5, 0.667, 1.0] + +# Where to place new windows: "new_column" or "same_column". +scroll.new_window_in_column = "new_column" + +# Scroll sensitivity multiplier for trackpad/mouse wheel. +scroll.scroll_sensitivity = 20.0 + +# Invert the scroll direction (natural scrolling). +scroll.invert_scroll_direction = false + +# Allow focus to wrap around from last column to first and vice versa. +scroll.infinite_loop = false + +# Aspect ratio for single-column mode (e.g. "16:9"). Empty string disables. +scroll.single_column_aspect_ratio = "" diff --git a/src/actor/layout.rs b/src/actor/layout.rs index 06edd3d..49ae7da 100644 --- a/src/actor/layout.rs +++ b/src/actor/layout.rs @@ -6,18 +6,21 @@ use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::PathBuf; +use std::time::Instant; -use objc2_core_foundation::{CGRect, CGSize}; +use objc2_core_foundation::{CGPoint, CGRect, CGSize}; use serde::{Deserialize, Serialize}; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; use crate::actor::app::{WindowId, pid_t}; use crate::collections::{BTreeExt, BTreeSet, HashMap, HashSet}; -use crate::config::Config; +use crate::config::{Config, NewWindowPlacement, ScrollConfig}; +use crate::model::scroll_viewport::ViewportState; use crate::model::{ - ContainerKind, Direction, LayoutId, LayoutTree, Orientation, SpaceLayoutMapping, + ContainerKind, Direction, LayoutId, LayoutKind, LayoutTree, NodeId, Orientation, + SpaceLayoutMapping, }; -use crate::sys::geometry::CGSizeExt; +use crate::sys::geometry::{CGRectExt, CGSizeExt}; use crate::sys::screen::SpaceId; #[allow(dead_code)] @@ -42,6 +45,10 @@ pub enum LayoutCommand { #[serde(default = "default_resize_percent")] percent: f64, }, + CycleColumnWidth, + ChangeLayoutKind, + ToggleColumnTabbed, + ConsumeOrExpelWindow(Direction), } fn default_resize_percent() -> f64 { @@ -95,14 +102,62 @@ impl LayoutCommand { fn modifies_layout(&self) -> bool { use LayoutCommand::*; match self { - MoveNode(_) | Group(_) | Ungroup | Resize { .. } => true, + MoveNode(_) + | Group(_) + | Ungroup + | Resize { .. } + | CycleColumnWidth + | ToggleColumnTabbed + | ConsumeOrExpelWindow(_) => true, NextLayout | PrevLayout | MoveFocus(_) | Ascend | Descend | Split(_) - | ToggleFocusFloating | ToggleWindowFloating | ToggleFullscreen => false, + | ToggleFocusFloating | ToggleWindowFloating | ToggleFullscreen | ChangeLayoutKind => { + false + } } } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct ResizeEdge(u8); + +impl ResizeEdge { + const LEFT: u8 = 0b0001; + const RIGHT: u8 = 0b0010; + const TOP: u8 = 0b0100; + const BOTTOM: u8 = 0b1000; + + fn has_horizontal(self) -> bool { + self.0 & (Self::LEFT | Self::RIGHT) != 0 + } + + fn has_vertical(self) -> bool { + self.0 & (Self::TOP | Self::BOTTOM) != 0 + } + + fn is_empty(self) -> bool { + self.0 == 0 + } +} + +struct InteractiveScrollResize { + column_node: NodeId, + window_node: NodeId, + edges: ResizeEdge, + last_mouse: CGPoint, +} + +struct InteractiveScrollMove { + layout_id: LayoutId, + window_id: WindowId, + window_node: NodeId, + start_mouse: CGPoint, + drag_active: bool, +} + +const RESIZE_EDGE_THRESHOLD: f64 = 8.0; +const MOVE_DRAG_THRESHOLD: f64 = 10.0; + /// Actor that manages the layouts for each space. /// /// The LayoutManager is the event-driven layer that sits between the Reactor @@ -135,8 +190,21 @@ pub struct LayoutManager { #[serde(skip)] focused_window: Option, /// Last window focused in floating mode. + #[serde(skip)] // TODO: We should keep a stack for each space. last_floating_focus: Option, + #[serde(skip)] + viewports: HashMap, + #[serde(skip)] + default_layout_kind: LayoutKind, + #[serde(skip)] + scroll_cfg: ScrollConfig, + #[serde(skip)] + scroll_enabled: bool, + #[serde(skip)] + interactive_resize: Option, + #[serde(skip)] + interactive_move: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -183,7 +251,135 @@ impl LayoutManager { active_floating_windows: Default::default(), focused_window: None, last_floating_focus: None, + viewports: Default::default(), + default_layout_kind: LayoutKind::default(), + scroll_cfg: Config::default().settings.experimental.scroll.validated(), + scroll_enabled: false, + interactive_resize: None, + interactive_move: None, + } + } + + pub fn set_config(&mut self, config: &Config) { + self.scroll_cfg = config.settings.experimental.scroll.clone().validated(); + self.scroll_enabled = self.scroll_cfg.enable; + self.default_layout_kind = match (self.scroll_enabled, config.settings.default_layout_kind) + { + (false, LayoutKind::Scroll) => { + warn!( + "Ignoring default_layout_kind=scroll because experimental.scroll.enable=false" + ); + LayoutKind::Tree + } + (_, kind) => kind, + }; + if !self.scroll_enabled { + self.convert_active_scroll_layouts_to_tree(); + } + } + + fn convert_active_scroll_layouts_to_tree(&mut self) { + for space in self.layout_mapping.keys().copied().collect::>() { + self.ensure_layout_kind_allowed_for_space(space); + } + } + + fn ensure_layout_kind_allowed_for_space(&mut self, space: SpaceId) { + if self.scroll_enabled { + return; + } + let Some(layout) = self.try_layout(space) else { return }; + if !self.tree.is_scroll_layout(layout) { + return; + } + debug!( + ?space, + "Converting scroll layout to tree because scroll gate is disabled" + ); + let new_layout = Self::convert_layout_kind( + &mut self.tree, + &self.scroll_cfg, + self.focused_window, + layout, + LayoutKind::Tree, + ); + if let Some(mapping) = self.layout_mapping.get_mut(&space) + && mapping.active_layout() == layout + { + mapping.replace_active_layout(new_layout); } + self.viewports.remove(&layout); + } + + fn convert_layout_kind( + tree: &mut LayoutTree, + scroll_cfg: &ScrollConfig, + focused_window: Option, + layout: LayoutId, + new_kind: LayoutKind, + ) -> LayoutId { + if tree.layout_kind(layout) == new_kind { + return layout; + } + + let selected_window = tree.window_at(tree.selection(layout)); + let windows: Vec = tree + .root(layout) + .traverse_postorder(tree.map()) + .filter_map(|n| tree.window_at(n)) + .collect(); + + let new_layout = match new_kind { + LayoutKind::Tree => tree.create_layout(), + LayoutKind::Scroll => tree.create_scroll_layout(), + }; + + let visible_columns = scroll_cfg.visible_columns; + for wid in windows { + tree.remove_window(wid); + if new_kind == LayoutKind::Scroll { + tree.add_window_to_scroll_column_with_visible( + new_layout, + wid, + true, + visible_columns, + ); + } else { + let sel = tree.selection(new_layout); + tree.add_window_after(new_layout, sel, wid); + } + } + + if let Some(wid) = focused_window.or(selected_window) + && let Some(node) = tree.window_node(new_layout, wid) + { + tree.select(node); + } + + new_layout + } + + fn change_layout_index_filtered( + mapping: &mut SpaceLayoutMapping, + tree: &LayoutTree, + offset: i16, + allow_scroll: bool, + ) -> LayoutId { + let layouts: Vec<_> = mapping.layouts().collect(); + let len = layouts.len(); + if len <= 1 { + return mapping.active_layout(); + } + let cur_idx = mapping.active_layout_index() as i16; + for step in 1..=len { + let idx = (cur_idx + offset * step as i16).rem_euclid(len as i16) as usize; + let candidate = layouts[idx]; + if allow_scroll || !tree.is_scroll_layout(candidate) { + mapping.select_layout(candidate); + return candidate; + } + } + mapping.active_layout() } pub fn debug_tree(&self, space: SpaceId) { @@ -216,10 +412,15 @@ impl LayoutManager { match event { LayoutEvent::SpaceExposed(space, size) => { self.debug_tree(space); - self.layout_mapping - .entry(space) - .or_insert_with(|| SpaceLayoutMapping::new(size, &mut self.tree)) - .activate_size(size, &mut self.tree); + let kind = self.default_layout_kind; + { + let mapping = self + .layout_mapping + .entry(space) + .or_insert_with(|| SpaceLayoutMapping::new(size, &mut self.tree, kind)); + mapping.activate_size(size, &mut self.tree); + } + self.ensure_layout_kind_allowed_for_space(space); } LayoutEvent::WindowsOnScreenUpdated(space, pid, windows) => { self.debug_tree(space); @@ -233,6 +434,7 @@ impl LayoutManager { self.active_floating_windows.entry(space).or_default().entry(pid).or_default(); floating_active.clear(); let mut add_floating = Vec::new(); + let mut new_windows = Vec::new(); let tree_windows = windows .iter() .map(|(wid, _info)| *wid) @@ -251,11 +453,28 @@ impl LayoutManager { add_floating.push(*wid); false } - WindowClass::Regular => true, + WindowClass::Regular => { + if self.tree.is_scroll_layout(layout) { + new_windows.push(*wid); + false + } else { + true + } + } } }) .collect(); self.tree.set_windows_for_app(self.layout(space), pid, tree_windows); + for wid in new_windows { + let new_column = + self.scroll_config().new_window_in_column == NewWindowPlacement::NewColumn; + self.tree.add_window_to_scroll_column_with_visible( + layout, + wid, + new_column, + self.scroll_config().visible_columns, + ); + } for wid in add_floating { self.add_floating_window(wid, Some(space)); } @@ -273,7 +492,18 @@ impl LayoutManager { WindowClass::FloatByDefault => self.add_floating_window(wid, Some(space)), WindowClass::Regular => { let layout = self.layout(space); - self.tree.add_window_after(layout, self.tree.selection(layout), wid); + if self.tree.is_scroll_layout(layout) { + let new_column = self.scroll_config().new_window_in_column + == NewWindowPlacement::NewColumn; + self.tree.add_window_to_scroll_column_with_visible( + layout, + wid, + new_column, + self.scroll_config().visible_columns, + ); + } else { + self.tree.add_window_after(layout, self.tree.selection(layout), wid); + } } WindowClass::Untracked => (), } @@ -287,6 +517,9 @@ impl LayoutManager { if self.floating_windows.contains(&wid) { self.last_floating_focus = Some(wid); } else { + for space in &spaces { + self.clear_user_scrolling(*space); + } for space in spaces { let layout = self.layout(space); if let Some(node) = self.tree.window_node(layout, wid) { @@ -413,7 +646,14 @@ impl LayoutManager { "Could not find layout mapping for current space"); return EventResponse::default(); }; - if command.modifies_layout() { + let scroll_command_disabled = !self.scroll_enabled + && matches!( + command, + LayoutCommand::CycleColumnWidth + | LayoutCommand::ToggleColumnTabbed + | LayoutCommand::ConsumeOrExpelWindow(_) + ); + if command.modifies_layout() && !scroll_command_disabled { mapping.prepare_modify(&mut self.tree); } let layout = mapping.active_layout(); @@ -469,7 +709,8 @@ impl LayoutManager { LayoutCommand::NextLayout => { // FIXME: Update windows in the new layout. - let layout = mapping.change_layout_index(1); + let layout = + Self::change_layout_index_filtered(mapping, &self.tree, 1, self.scroll_enabled); if let Some(wid) = self.focused_window && let Some(node) = self.tree.window_node(layout, wid) { @@ -479,7 +720,12 @@ impl LayoutManager { } LayoutCommand::PrevLayout => { // FIXME: Update windows in the new layout. - let layout = mapping.change_layout_index(-1); + let layout = Self::change_layout_index_filtered( + mapping, + &self.tree, + -1, + self.scroll_enabled, + ); if let Some(wid) = self.focused_window && let Some(node) = self.tree.window_node(layout, wid) { @@ -488,11 +734,27 @@ impl LayoutManager { EventResponse::default() } LayoutCommand::MoveFocus(direction) => { - let new_focus = - self.tree.traverse(self.tree.selection(layout), direction).or_else(|| { - let layout = self.layout(next_space(direction)?); - Some(self.tree.selection(layout)) - }); + let is_scroll = self.tree.is_scroll_layout(layout); + let use_wrapping = self.scroll_enabled + && is_scroll + && self.scroll_config().infinite_loop + && matches!(direction, Direction::Left | Direction::Right); + let new_focus = if use_wrapping { + self.tree.traverse_scroll_wrapping( + layout, + self.tree.selection(layout), + direction, + ) + } else { + self.tree.traverse(self.tree.selection(layout), direction) + } + .or_else(|| { + let layout = self.layout(next_space(direction)?); + Some(self.tree.selection(layout)) + }); + if new_focus.is_some() && is_scroll { + self.clear_user_scrolling(space); + } let focus_window = new_focus.and_then(|new| self.tree.window_at(new)); let raise_windows = new_focus .map(|new| self.tree.select_returning_surfaced_windows(new)) @@ -509,7 +771,15 @@ impl LayoutManager { } LayoutCommand::MoveNode(direction) => { let selection = self.tree.selection(layout); - if !self.tree.move_node(layout, selection, direction) { + let moved = if self.scroll_enabled + && self.tree.is_scroll_layout(layout) + && matches!(direction, Direction::Left | Direction::Right) + { + self.tree.move_column_in_scroll(layout, direction) + } else { + self.tree.move_node(layout, selection, direction) + }; + if !moved { if let Some(new_space) = next_space(direction) { let new_layout = self.layout(new_space); self.tree.move_node_after(self.tree.selection(new_layout), selection); @@ -520,6 +790,13 @@ impl LayoutManager { LayoutCommand::Split(orientation) => { // Don't mark as written yet, since merely splitting doesn't // usually have a visible effect. + if self.tree.is_scroll_layout(layout) && orientation == Orientation::Horizontal { + let selection = self.tree.selection(layout); + let root = self.tree.root(layout); + if selection == root || selection.parent(self.tree.map()) == Some(root) { + return EventResponse::default(); + } + } let selection = self.tree.selection(layout); self.tree.nest_in_container(layout, selection, ContainerKind::from(orientation)); EventResponse::default() @@ -566,6 +843,88 @@ impl LayoutManager { self.tree.resize(node, percent / 100.0, direction); EventResponse::default() } + LayoutCommand::CycleColumnWidth => { + if !self.scroll_enabled { + debug!("Ignoring cycle_column_width because scroll gate is disabled"); + return EventResponse::default(); + } + if !self.tree.is_scroll_layout(layout) { + return EventResponse::default(); + } + let presets = &self.scroll_config().column_width_presets; + if presets.is_empty() { + return EventResponse::default(); + } + let selection = self.tree.selection(layout); + if let Some(col) = self.tree.column_of(layout, selection) { + let current_proportion = self.tree.proportion(col).unwrap_or(1.0); + let next = presets + .iter() + .find(|&&p| p > current_proportion + 0.01) + .or(presets.first()) + .copied() + .unwrap_or(current_proportion); + let delta = next - current_proportion; + if delta.abs() > 0.001 { + self.tree.resize(col, delta, Direction::Right); + } + } + EventResponse::default() + } + LayoutCommand::ToggleColumnTabbed => { + if !self.scroll_enabled { + debug!("Ignoring toggle_column_tabbed because scroll gate is disabled"); + return EventResponse::default(); + } + if !self.tree.is_scroll_layout(layout) { + return EventResponse::default(); + } + let selection = self.tree.selection(layout); + if let Some(col) = self.tree.column_of(layout, selection) { + let new_kind = match self.tree.container_kind(col) { + ContainerKind::Vertical => ContainerKind::Tabbed, + ContainerKind::Tabbed => ContainerKind::Vertical, + other => other, + }; + self.tree.set_container_kind(col, new_kind); + } + EventResponse::default() + } + LayoutCommand::ConsumeOrExpelWindow(direction) => { + if !self.scroll_enabled { + debug!("Ignoring consume_or_expel_window because scroll gate is disabled"); + return EventResponse::default(); + } + if self.tree.is_scroll_layout(layout) { + self.tree.consume_or_expel_in_scroll( + layout, + direction, + self.scroll_config().visible_columns, + ); + } + EventResponse::default() + } + LayoutCommand::ChangeLayoutKind => { + if !self.scroll_enabled { + debug!("Ignoring change_layout_kind because scroll gate is disabled"); + return EventResponse::default(); + } + let old_kind = self.tree.layout_kind(layout); + let new_kind = match old_kind { + LayoutKind::Tree => LayoutKind::Scroll, + LayoutKind::Scroll => LayoutKind::Tree, + }; + let new_layout = Self::convert_layout_kind( + &mut self.tree, + &self.scroll_cfg, + self.focused_window, + layout, + new_kind, + ); + mapping.replace_active_layout(new_layout); + self.viewports.remove(&layout); + EventResponse::default() + } } } @@ -613,7 +972,13 @@ impl LayoutManager { ) -> Vec<(WindowId, CGRect)> { let layout = self.layout(space); //debug!("{}", self.tree.draw_tree(space)); - self.tree.calculate_layout(layout, screen, config) + let frames = self.tree.calculate_layout(layout, screen, config); + if self.scroll_enabled && self.tree.is_scroll_layout(layout) { + if let Some(vp) = self.viewports.get(&layout) { + return vp.apply_viewport_to_frames(screen, frames, Instant::now()); + } + } + frames } pub fn calculate_layout_and_groups( @@ -630,9 +995,367 @@ impl LayoutManager { group.is_on_top = false; } } + if self.scroll_enabled && self.tree.is_scroll_layout(layout) { + if let Some(vp) = self.viewports.get(&layout) { + let transformed = vp.apply_viewport_to_frames(screen, sizes, Instant::now()); + for group in &mut groups { + group.indicator_frame = vp.offset_rect(group.indicator_frame, Instant::now()); + } + return (transformed, groups); + } + } (sizes, groups) } + fn scroll_config(&self) -> &ScrollConfig { + &self.scroll_cfg + } + + pub fn viewport(&self, layout: LayoutId) -> Option<&ViewportState> { + self.viewports.get(&layout) + } + + pub fn viewport_mut(&mut self, layout: LayoutId) -> &mut ViewportState { + self.viewports.entry(layout).or_insert_with(|| ViewportState::new(1920.0)) + } + + pub fn clear_user_scrolling(&mut self, space: SpaceId) { + let layout = self.layout(space); + if let Some(vp) = self.viewports.get_mut(&layout) { + vp.user_scrolling = false; + } + } + + pub fn update_viewport_for_focus(&mut self, space: SpaceId, screen: CGRect, config: &Config) { + if !self.scroll_enabled { + return; + } + let layout = self.layout(space); + if !self.tree.is_scroll_layout(layout) { + return; + } + + if self.viewport(layout).map_or(false, |vp| vp.user_scrolling) { + return; + } + + let frames = self.tree.calculate_layout(layout, screen, config); + let selection = self.tree.selection(layout); + let sel_wid = self.tree.window_at(selection); + let columns = self.tree.columns(layout); + let col = self.tree.column_of(layout, selection); + let center_mode = config.settings.experimental.scroll.center_focused_column; + let gap = config.settings.inner_gap; + + let vp = self.viewport_mut(layout); + vp.set_screen_width(screen.size.width); + + if let Some(wid) = sel_wid { + if let Some((_, frame)) = frames.iter().find(|(w, _)| *w == wid) { + if let Some(c) = col { + let col_idx = columns.iter().position(|&n| n == c).unwrap_or(0); + vp.ensure_column_visible( + col_idx, + frame.origin.x, + frame.size.width, + center_mode, + gap, + Instant::now(), + ); + } + } + } + } + + pub fn has_active_scroll_animation(&self) -> bool { + if !self.scroll_enabled { + return false; + } + self.viewports.values().any(|vp| vp.is_animating(Instant::now())) + } + + pub fn tick_viewports(&mut self) { + for vp in self.viewports.values_mut() { + vp.tick(Instant::now()); + } + } + + pub fn handle_scroll_wheel( + &mut self, + space: SpaceId, + delta_x: f64, + screen: &CGRect, + config: &crate::config::ScrollConfig, + ) -> EventResponse { + if !self.scroll_enabled { + return EventResponse::default(); + } + let layout = self.layout(space); + if !self.tree.is_scroll_layout(layout) { + return EventResponse::default(); + } + + let columns = self.tree.columns(layout); + let col_count = columns.len(); + if col_count == 0 { + return EventResponse::default(); + } + + let step_threshold = screen.size.width / col_count.min(3) as f64; + + let delta = if config.invert_scroll_direction { + -delta_x + } else { + delta_x + }; + let scaled_delta = delta * config.scroll_sensitivity; + + let is_discrete = delta_x.abs() < 10.0 && delta_x.fract() == 0.0; + let (effective_delta, effective_threshold) = if is_discrete { + (scaled_delta.signum() * step_threshold, step_threshold) + } else { + (scaled_delta, step_threshold) + }; + + let vp = self.viewport_mut(layout); + vp.set_screen_width(screen.size.width); + + let steps = match vp.accumulate_scroll(effective_delta, effective_threshold) { + Some(s) => s, + None => return EventResponse::default(), + }; + + let selection = self.tree.selection(layout); + let direction = if steps < 0 { + Direction::Right + } else { + Direction::Left + }; + let abs_steps = steps.unsigned_abs().min(16) as usize; + + let mut current = selection; + for _ in 0..abs_steps { + let next = if self.scroll_config().infinite_loop { + self.tree.traverse_scroll_wrapping(layout, current, direction) + } else { + self.tree.traverse(current, direction) + }; + match next { + Some(n) => current = n, + None => break, + } + } + + if current == selection { + return EventResponse::default(); + } + + self.clear_user_scrolling(space); + let focus_window = self.tree.window_at(current); + let raise_windows = self.tree.select_returning_surfaced_windows(current); + EventResponse { focus_window, raise_windows } + } + + pub(crate) fn hit_test_scroll_edges( + &self, + space: SpaceId, + point: CGPoint, + screen: CGRect, + config: &Config, + ) -> Option<(NodeId, NodeId, ResizeEdge)> { + if !self.scroll_enabled { + return None; + } + let layout = self.try_layout(space)?; + if !self.tree.is_scroll_layout(layout) { + return None; + } + let frames = self.calculate_layout(space, screen, config); + for (wid, frame) in &frames { + let edges = detect_edges(point, *frame); + if !edges.is_empty() { + let window_node = self.tree.window_node(layout, *wid)?; + let column_node = self.tree.column_of(layout, window_node)?; + return Some((column_node, window_node, edges)); + } + } + None + } + + pub fn hit_test_scroll_window( + &self, + space: SpaceId, + point: CGPoint, + screen: CGRect, + config: &Config, + ) -> Option<(WindowId, NodeId)> { + if !self.scroll_enabled { + return None; + } + let layout = self.try_layout(space)?; + if !self.tree.is_scroll_layout(layout) { + return None; + } + let frames = self.calculate_layout(space, screen, config); + for (wid, frame) in &frames { + if frame.contains(point) { + let node = self.tree.window_node(layout, *wid)?; + return Some((*wid, node)); + } + } + None + } + + pub(crate) fn begin_interactive_resize( + &mut self, + column: NodeId, + window: NodeId, + edges: ResizeEdge, + mouse: CGPoint, + ) -> bool { + if self.interactive_resize.is_some() { + return false; + } + self.interactive_resize = Some(InteractiveScrollResize { + column_node: column, + window_node: window, + edges, + last_mouse: mouse, + }); + true + } + + pub fn update_interactive_resize(&mut self, mouse: CGPoint, screen: CGRect) -> bool { + let Some(state) = self.interactive_resize.as_mut() else { + return false; + }; + let dx = mouse.x - state.last_mouse.x; + let dy = mouse.y - state.last_mouse.y; + state.last_mouse = mouse; + + let mut changed = false; + if state.edges.has_horizontal() { + let ratio = dx / screen.size.width; + let direction = if state.edges.0 & ResizeEdge::LEFT != 0 { + Direction::Left + } else { + Direction::Right + }; + let col = state.column_node; + if self.tree.resize(col, ratio, direction) { + changed = true; + } + } + if state.edges.has_vertical() { + let ratio = dy / screen.size.height; + let direction = if state.edges.0 & ResizeEdge::TOP != 0 { + Direction::Up + } else { + Direction::Down + }; + let win = state.window_node; + if self.tree.resize(win, ratio, direction) { + changed = true; + } + } + changed + } + + pub fn end_interactive_resize(&mut self, space: SpaceId, screen: CGRect, config: &Config) { + if self.interactive_resize.take().is_some() { + self.clear_user_scrolling(space); + self.update_viewport_for_focus(space, screen, config); + } + } + + pub fn begin_interactive_move( + &mut self, + space: SpaceId, + wid: WindowId, + node: NodeId, + mouse: CGPoint, + ) -> bool { + if self.interactive_resize.is_some() || self.interactive_move.is_some() { + return false; + } + let layout_id = self.layout(space); + self.interactive_move = Some(InteractiveScrollMove { + layout_id, + window_id: wid, + window_node: node, + start_mouse: mouse, + drag_active: false, + }); + true + } + + pub fn update_interactive_move( + &mut self, + mouse: CGPoint, + screen: CGRect, + config: &Config, + ) -> bool { + let Some(state) = self.interactive_move.as_mut() else { + return false; + }; + if !state.drag_active { + let dx = mouse.x - state.start_mouse.x; + let dy = mouse.y - state.start_mouse.y; + if (dx * dx + dy * dy).sqrt() < MOVE_DRAG_THRESHOLD { + return false; + } + state.drag_active = true; + } + let source_node = state.window_node; + let source_wid = state.window_id; + let layout = state.layout_id; + let frames = self.tree.calculate_layout(layout, screen, config); + let vp_opt = self.viewports.get(&layout); + + for (wid, frame) in &frames { + if *wid == source_wid { + continue; + } + + let target_frame; + if let Some(vp) = vp_opt { + if !vp.is_visible(*frame, Instant::now()) { + continue; + } + target_frame = vp.offset_rect(*frame, Instant::now()); + } else { + target_frame = *frame; + } + + if target_frame.contains(mouse) + && let Some(target_node) = self.tree.window_node(layout, *wid) + { + self.tree.swap_windows(source_node, target_node); + if let Some(state) = self.interactive_move.as_mut() { + state.window_node = target_node; + } + return true; + } + } + false + } + + pub fn end_interactive_move(&mut self, space: SpaceId, screen: CGRect, config: &Config) { + if self.interactive_move.take().is_some() { + self.clear_user_scrolling(space); + self.update_viewport_for_focus(space, screen, config); + } + } + + pub fn cancel_interactive_state(&mut self) { + self.interactive_resize = None; + self.interactive_move = None; + } + + pub fn has_interactive_state(&self) -> bool { + self.interactive_resize.is_some() || self.interactive_move.is_some() + } + fn try_layout(&self, space: SpaceId) -> Option { self.layout_mapping.get(&space)?.active_layout().into() } @@ -644,7 +1367,9 @@ impl LayoutManager { pub fn load(path: PathBuf) -> anyhow::Result { let mut buf = String::new(); File::open(path)?.read_to_string(&mut buf)?; - Ok(ron::from_str(&buf)?) + let mut manager: Self = ron::from_str(&buf)?; + manager.tree.rebuild_scroll_roots_from_layout_kinds(); + Ok(manager) } pub fn save(&self, path: PathBuf) -> std::io::Result<()> { @@ -666,6 +1391,58 @@ impl LayoutManager { let layout = self.layout(space); self.tree.window_at(self.tree.selection(layout)) } + + #[cfg(test)] + pub(super) fn active_layout_kind(&self, space: SpaceId) -> LayoutKind { + self.tree.layout_kind(self.layout(space)) + } +} + +fn detect_edges(point: CGPoint, frame: CGRect) -> ResizeEdge { + use objc2_core_foundation::CGRect as R; + let threshold = RESIZE_EDGE_THRESHOLD; + let expanded = R::new( + CGPoint::new(frame.origin.x - threshold, frame.origin.y - threshold), + objc2_core_foundation::CGSize::new( + frame.size.width + threshold * 2.0, + frame.size.height + threshold * 2.0, + ), + ); + if !expanded.contains(point) { + return ResizeEdge(0); + } + let inner = R::new( + CGPoint::new(frame.origin.x + threshold, frame.origin.y + threshold), + objc2_core_foundation::CGSize::new( + (frame.size.width - threshold * 2.0).max(0.0), + (frame.size.height - threshold * 2.0).max(0.0), + ), + ); + if inner.contains(point) { + return ResizeEdge(0); + } + let mut edges = 0u8; + if point.x < frame.origin.x + threshold { + edges |= ResizeEdge::LEFT; + } + if point.x > frame.origin.x + frame.size.width - threshold { + edges |= ResizeEdge::RIGHT; + } + if point.y < frame.origin.y + threshold { + edges |= ResizeEdge::TOP; + } + if point.y > frame.origin.y + frame.size.height - threshold { + edges |= ResizeEdge::BOTTOM; + } + // If both opposing edges are set (window too small for edge detection), + // disable that axis to avoid conflicting resize directions. + if edges & ResizeEdge::LEFT != 0 && edges & ResizeEdge::RIGHT != 0 { + edges &= !(ResizeEdge::LEFT | ResizeEdge::RIGHT); + } + if edges & ResizeEdge::TOP != 0 && edges & ResizeEdge::BOTTOM != 0 { + edges &= !(ResizeEdge::TOP | ResizeEdge::BOTTOM); + } + ResizeEdge(edges) } #[cfg(test)] @@ -693,6 +1470,13 @@ mod tests { } } + fn config_with_scroll(enable: bool, default_layout_kind: LayoutKind) -> Config { + let mut config = Config::default(); + config.settings.experimental.scroll.enable = enable; + config.settings.default_layout_kind = default_layout_kind; + config + } + impl LayoutManager { fn layout_sorted(&self, space: SpaceId, screen: CGRect) -> Vec<(WindowId, CGRect)> { let mut layout = self.calculate_layout( @@ -1387,4 +2171,132 @@ mod tests { mgr.layout_sorted(space, screen), ); } + + #[test] + fn space_exposed_forces_tree_when_scroll_gate_disabled() { + use LayoutEvent::*; + let mut mgr = LayoutManager::new(); + let config = config_with_scroll(false, LayoutKind::Scroll); + mgr.set_config(&config); + + let space = SpaceId::new(1); + _ = mgr.handle_event(SpaceExposed(space, rect(0, 0, 300, 200).size)); + + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Tree); + } + + #[test] + fn change_layout_kind_noops_when_scroll_gate_disabled() { + use LayoutCommand::*; + use LayoutEvent::*; + + let mut mgr = LayoutManager::new(); + let config = config_with_scroll(false, LayoutKind::Tree); + mgr.set_config(&config); + + let space = SpaceId::new(1); + let pid = 1; + _ = mgr.handle_event(SpaceExposed(space, rect(0, 0, 400, 200).size)); + _ = mgr.handle_event(WindowsOnScreenUpdated(space, pid, make_windows(pid, 2))); + _ = mgr.handle_command(Some(space), &[space], ChangeLayoutKind); + + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Tree); + } + + #[test] + fn active_scroll_layout_converts_to_tree_when_gate_disabled() { + use LayoutEvent::*; + + let mut mgr = LayoutManager::new(); + let config_on = config_with_scroll(true, LayoutKind::Scroll); + mgr.set_config(&config_on); + + let space = SpaceId::new(1); + let pid = 1; + _ = mgr.handle_event(SpaceExposed(space, rect(0, 0, 500, 300).size)); + _ = mgr.handle_event(WindowsOnScreenUpdated(space, pid, make_windows(pid, 3))); + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Scroll); + + let config_off = config_with_scroll(false, LayoutKind::Tree); + mgr.set_config(&config_off); + + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Tree); + } + + #[test] + fn next_layout_skips_scroll_when_gate_disabled() { + use LayoutCommand::*; + use LayoutEvent::*; + + let mut mgr = LayoutManager::new(); + let config_on = config_with_scroll(true, LayoutKind::Scroll); + mgr.set_config(&config_on); + + let space = SpaceId::new(1); + let pid = 1; + _ = mgr.handle_event(SpaceExposed(space, rect(0, 0, 500, 300).size)); + _ = mgr.handle_event(WindowsOnScreenUpdated(space, pid, make_windows(pid, 3))); + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Scroll); + + _ = mgr.handle_command(Some(space), &[space], ChangeLayoutKind); + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Tree); + + let config_off = config_with_scroll(false, LayoutKind::Tree); + mgr.set_config(&config_off); + _ = mgr.handle_command(Some(space), &[space], NextLayout); + + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Tree); + } + + #[test] + fn scroll_wheel_is_ignored_when_scroll_gate_disabled() { + use LayoutEvent::*; + + let mut mgr = LayoutManager::new(); + let config_on = config_with_scroll(true, LayoutKind::Scroll); + mgr.set_config(&config_on); + + let space = SpaceId::new(1); + let screen = rect(0, 0, 900, 600); + let pid = 1; + _ = mgr.handle_event(SpaceExposed(space, screen.size)); + _ = mgr.handle_event(WindowsOnScreenUpdated(space, pid, make_windows(pid, 3))); + _ = mgr.handle_event(WindowFocused(vec![space], WindowId::new(pid, 1))); + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Scroll); + + let config_off = config_with_scroll(false, LayoutKind::Tree); + mgr.set_config(&config_off); + let response = + mgr.handle_scroll_wheel(space, -1.0, &screen, &config_off.settings.experimental.scroll); + assert!(response.raise_windows.is_empty()); + assert!(response.focus_window.is_none()); + } + + #[test] + fn scroll_only_commands_noop_when_gate_disabled() { + use LayoutCommand::*; + use LayoutEvent::*; + + let mut mgr = LayoutManager::new(); + let config_on = config_with_scroll(true, LayoutKind::Scroll); + mgr.set_config(&config_on); + + let space = SpaceId::new(1); + let screen = rect(0, 0, 1000, 600); + let pid = 1; + _ = mgr.handle_event(SpaceExposed(space, screen.size)); + _ = mgr.handle_event(WindowsOnScreenUpdated(space, pid, make_windows(pid, 3))); + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Scroll); + + let config_off = config_with_scroll(false, LayoutKind::Tree); + mgr.set_config(&config_off); + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Tree); + + let before = mgr.layout_sorted(space, screen); + _ = mgr.handle_command(Some(space), &[space], CycleColumnWidth); + _ = mgr.handle_command(Some(space), &[space], ToggleColumnTabbed); + _ = mgr.handle_command(Some(space), &[space], ConsumeOrExpelWindow(Direction::Right)); + assert_eq!(mgr.active_layout_kind(space), LayoutKind::Tree); + assert_eq!(mgr.layout_sorted(space, screen), before); + } } diff --git a/src/actor/mouse.rs b/src/actor/mouse.rs index 34639ca..065f725 100644 --- a/src/actor/mouse.rs +++ b/src/actor/mouse.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use core_foundation::runloop::{CFRunLoop, kCFRunLoopCommonModes}; use core_graphics::event::{ - CGEvent, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, CGEventType, - CallbackResult, + CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, + CGEventType, CallbackResult, EventField, }; use objc2_core_foundation::{CGPoint, CGRect}; use objc2_foundation::{MainThreadMarker, NSInteger}; @@ -68,8 +68,7 @@ impl Mouse { let this = Rc::new(self); let events = vec![ - // Any event we want the mouse to be shown for. - // Note that this does not include scroll events. + // Any event we want the mouse to be shown for, plus scroll events. CGEventType::LeftMouseDown, CGEventType::LeftMouseUp, CGEventType::RightMouseDown, @@ -77,6 +76,7 @@ impl Mouse { CGEventType::MouseMoved, CGEventType::LeftMouseDragged, CGEventType::RightMouseDragged, + CGEventType::ScrollWheel, ]; let this_ = Rc::clone(&this); let current = CFRunLoop::get_current(); @@ -157,14 +157,23 @@ impl Mouse { fn on_event(self: &Rc, event_type: CGEventType, event: &CGEvent, mtm: MainThreadMarker) { let mut state = self.state.borrow_mut(); - if state.hide_count > 0 { + let is_scroll = matches!(event_type, CGEventType::ScrollWheel); + if !is_scroll && state.hide_count > 0 { debug!("Showing mouse"); state.show_mouse(); } match event_type { + CGEventType::LeftMouseDown => { + let loc = event.location(); + self.events_tx.send(Event::LeftMouseDown(loc.to_icrate())); + } CGEventType::LeftMouseUp => { self.events_tx.send(Event::MouseUp); } + CGEventType::LeftMouseDragged => { + let loc = event.location(); + self.events_tx.send(Event::LeftMouseDragged(loc.to_icrate())); + } CGEventType::MouseMoved if self.config.borrow().settings.focus_follows_mouse => { let loc = event.location(); #[cfg(false)] @@ -173,6 +182,19 @@ impl Mouse { self.events_tx.send(Event::MouseMovedOverWindow(wsid)); } } + CGEventType::ScrollWheel => { + let delta_y = event + .get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1) + as f64; + let delta_x = event + .get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2) + as f64; + + if delta_x != 0.0 || delta_y != 0.0 { + let alt_held = event.get_flags().contains(CGEventFlags::CGEventFlagAlternate); + self.events_tx.send(Event::ScrollWheel { delta_x, delta_y, alt_held }); + } + } _ => (), } } diff --git a/src/actor/notification_center.rs b/src/actor/notification_center.rs index 3010929..5d03a9b 100644 --- a/src/actor/notification_center.rs +++ b/src/actor/notification_center.rs @@ -85,11 +85,20 @@ impl NotificationCenterInner { fn send_screen_parameters(&self) { let mut screen_cache = self.ivars().screen_cache.borrow_mut(); - let Some((frames, ids, converter)) = screen_cache.update_screen_config() else { + let Some((screens, converter)) = screen_cache.update_screen_config() else { return; }; + let frames = screens.iter().map(|s| s.visible_frame).collect(); + let ids = screens.iter().map(|s| s.id).collect(); + let scale_factors = screens.iter().map(|s| s.scale_factor).collect(); let spaces = screen_cache.get_screen_spaces(); - self.send_event(WmEvent::ScreenParametersChanged(frames, ids, converter, spaces)); + self.send_event(WmEvent::ScreenParametersChanged( + frames, + ids, + converter, + spaces, + scale_factors, + )); } fn send_current_space(&self) { diff --git a/src/actor/reactor.rs b/src/actor/reactor.rs index c1eca0a..17f6477 100644 --- a/src/actor/reactor.rs +++ b/src/actor/reactor.rs @@ -16,6 +16,7 @@ mod testing; use std::collections::BTreeMap; use std::sync::Arc; +use std::time::Duration; use std::{mem, thread}; use animation::Animation; @@ -38,8 +39,9 @@ use crate::config::Config; use crate::log::{self, MetricsCommand}; use crate::sys::event::MouseState; use crate::sys::executor::Executor; -use crate::sys::geometry::{CGRectDef, CGRectExt, Round, SameAs}; +use crate::sys::geometry::{CGRectDef, CGRectExt, SameAs, round_to_physical}; use crate::sys::screen::{CoordinateConverter, SpaceId}; +use crate::sys::timer::Timer; use crate::sys::window_server::{WindowServerId, WindowServerInfo}; pub type Sender = crate::actor::Sender; @@ -60,6 +62,7 @@ pub enum Event { Vec>, Vec, CoordinateConverter, + Vec, ), /// The current space changed. @@ -144,6 +147,19 @@ pub enum Event { sequence_id: u64, }, + LeftMouseDown( + #[serde(with = "crate::sys::geometry::CGPointDef")] objc2_core_foundation::CGPoint, + ), + LeftMouseDragged( + #[serde(with = "crate::sys::geometry::CGPointDef")] objc2_core_foundation::CGPoint, + ), + + ScrollWheel { + delta_x: f64, + delta_y: f64, + alt_held: bool, + }, + Command(Command), ConfigChanged(Arc), } @@ -199,6 +215,7 @@ struct AppState { struct Screen { frame: CGRect, space: Option, + scale_factor: f64, } /// A per-window counter that tracks the last time the reactor sent a request to @@ -242,6 +259,14 @@ impl From for WindowState { } } +enum LayoutMode<'m, 'a> { + Animate { + new_wid: Option, + anim: &'m mut Animation<'a>, + }, + Immediate, +} + impl Reactor { pub fn spawn( config: Arc, @@ -267,12 +292,13 @@ impl Reactor { pub fn new( config: Arc, - layout: LayoutManager, + mut layout: LayoutManager, mut record: Record, group_indicators_tx: group_bars::Sender, ) -> Reactor { // FIXME: Remove apps that are no longer running from restored state. record.start(&config, &layout); + layout.set_config(&config); let (raise_manager_tx, _rx) = mpsc::unbounded_channel(); Reactor { config, @@ -306,16 +332,40 @@ impl Reactor { } async fn run_reactor_loop(mut self, mut events: Receiver) { - while let Some((span, event)) = events.recv().await { - let _guard = span.enter(); - self.handle_event(event); + // TODO: Accessibility APIs may be too slow for 120Hz; consider screen-capture animation approach. + let tick_interval = Duration::from_secs_f64(1.0 / 120.0); + let mut tick_timer = Timer::manual(); + + loop { + let animating = self.layout.has_active_scroll_animation(); + tokio::select! { + event = events.recv() => { + let Some((span, event)) = event else { break }; + let _guard = span.enter(); + let was_animating = self.layout.has_active_scroll_animation(); + self.handle_event(event); + if !was_animating && self.layout.has_active_scroll_animation() { + tick_timer.set_next_fire(Duration::ZERO); + } + } + _ = tick_timer.next(), if animating => { + self.layout.tick_viewports(); + self.update_layout_no_anim(); + if self.layout.has_active_scroll_animation() { + tick_timer.set_next_fire(tick_interval); + } + } + } } } fn log_event(&self, event: &Event) { match event { // Record more noisy events as trace logs instead of debug. - Event::WindowFrameChanged(..) | Event::MouseUp => trace!(?event, "Event"), + Event::WindowFrameChanged(..) + | Event::MouseUp + | Event::LeftMouseDown(_) + | Event::LeftMouseDragged(_) => trace!(?event, "Event"), _ => debug!(?event, "Event"), } } @@ -395,6 +445,8 @@ impl Reactor { } } Event::WindowDestroyed(wid) => { + self.layout.cancel_interactive_state(); + self.in_drag = false; if self.windows.remove(&wid).is_none() { warn!("Got destroyed event for unknown window {wid:?}"); } @@ -438,12 +490,13 @@ impl Reactor { self.in_drag = true; } } - Event::ScreenParametersChanged(frames, spaces, ws_info, converter) => { + Event::ScreenParametersChanged(frames, spaces, ws_info, converter, scale_factors) => { info!("screen parameters changed"); self.screens = frames .into_iter() .zip(spaces.clone()) - .map(|(frame, space)| Screen { frame, space }) + .zip(scale_factors) + .map(|((frame, space), scale_factor)| Screen { frame, space, scale_factor }) .collect(); let screens = self.screens.clone(); for screen in screens { @@ -469,6 +522,8 @@ impl Reactor { ); return; } + self.layout.cancel_interactive_state(); + self.in_drag = false; info!("space changed"); for (space, screen) in spaces.iter().zip(&mut self.screens) { screen.space = *space; @@ -488,7 +543,47 @@ impl Reactor { self.update_active_screen(); self.update_visible_windows(); } + Event::LeftMouseDown(point) => { + if let Some(screen) = self.active_screen() + && let Some(space) = screen.space + { + if let Some((col, win, edges)) = + self.layout.hit_test_scroll_edges(space, point, screen.frame, &self.config) + { + self.layout.begin_interactive_resize(col, win, edges, point); + self.in_drag = true; + } else if let Some((wid, node)) = + self.layout.hit_test_scroll_window(space, point, screen.frame, &self.config) + { + self.layout.begin_interactive_move(space, wid, node, point); + self.in_drag = true; + } + } + } + Event::LeftMouseDragged(point) => { + if let Some(&screen) = self.active_screen() { + if screen.space.is_some() { + if self.layout.update_interactive_resize(point, screen.frame) { + self.update_layout(None, true); + } else if self.layout.update_interactive_move( + point, + screen.frame, + &self.config, + ) { + self.update_layout(None, false); + } + } + } + } Event::MouseUp => { + if self.layout.has_interactive_state() { + if let Some(&screen) = self.active_screen() { + if let Some(space) = screen.space { + self.layout.end_interactive_resize(space, screen.frame, &self.config); + self.layout.end_interactive_move(space, screen.frame, &self.config); + } + } + } self.in_drag = false; // Now re-check the layout. } @@ -519,6 +614,29 @@ impl Reactor { let msg = raise::Event::RaiseTimeout { sequence_id }; _ = self.raise_manager_tx.send((Span::current(), msg)); } + Event::ScrollWheel { delta_x, delta_y, alt_held } => { + if !self.config.settings.experimental.scroll.enable { + return; + } + // TODO: Make the modifier key configurable. + if !alt_held { + return; + } + if let Some(screen) = self.screens.get(self.active_screen_idx.unwrap_or(0) as usize) + { + if let Some(space) = screen.space { + let scroll_config = &self.config.settings.experimental.scroll; + let delta = if delta_x != 0.0 { delta_x } else { delta_y }; + let response = self.layout.handle_scroll_wheel( + space, + delta, + &screen.frame, + scroll_config, + ); + self.handle_layout_response(response); + } + } + } Event::Command(Command::Layout(cmd)) => { info!(?cmd); let visible_spaces = @@ -549,6 +667,7 @@ impl Reactor { } } Event::ConfigChanged(config) => { + self.layout.set_config(&config); self.config = config; } } @@ -687,6 +806,10 @@ impl Reactor { } } + fn active_screen(&self) -> Option<&Screen> { + self.screens.get(self.active_screen_idx.unwrap_or(0) as usize) + } + fn window_is_tracked(&self, _id: WindowId) -> bool { // For now we track all windows in the reactor and let the LayoutManager // decide what to keep. @@ -767,47 +890,113 @@ impl Reactor { #[instrument(skip(self), fields())] pub fn update_layout(&mut self, new_wid: Option, is_resize: bool) { - let screens = self.screens.clone(); - let mut anim = Animation::new(); let main_window = self.main_window(); trace!(?main_window); - for screen in screens { - let Some(space) = screen.space else { continue }; - trace!(?screen); + let mut anim = Animation::new(); + let Self { + screens, + layout, + config, + group_indicators_tx, + windows, + apps, + .. + } = self; + Self::apply_layout( + screens, + layout, + config, + group_indicators_tx, + windows, + apps, + LayoutMode::Animate { new_wid, anim: &mut anim }, + true, + ); + // If the user is doing something with the mouse we don't want to + // animate on top of that. + if is_resize || !config.settings.animate || layout.has_active_scroll_animation() { + anim.skip_to_end(); + } else { + anim.run(); + } + } + + fn update_layout_no_anim(&mut self) { + let Self { + screens, + layout, + config, + group_indicators_tx, + windows, + apps, + .. + } = self; + Self::apply_layout( + screens, + layout, + config, + group_indicators_tx, + windows, + apps, + LayoutMode::Immediate, + false, + ); + } - let (layout, groups) = - self.layout - .calculate_layout_and_groups(space, screen.frame.clone(), &self.config); - trace!(?layout, "Layout"); + #[allow(clippy::too_many_arguments)] + fn apply_layout<'a>( + screens: &[Screen], + layout: &mut LayoutManager, + config: &Config, + group_indicators_tx: &group_bars::Sender, + windows: &mut HashMap, + apps: &'a HashMap, + mut mode: LayoutMode<'_, 'a>, + update_viewport: bool, + ) { + for &screen in screens { + let Some(space) = screen.space else { continue }; + if update_viewport { + layout.update_viewport_for_focus(space, screen.frame, config); + } + let (result, groups) = layout.calculate_layout_and_groups(space, screen.frame, config); - self.group_indicators_tx - .send(group_bars::Event::GroupsUpdated { space_id: space, groups }); + group_indicators_tx.send(group_bars::Event::GroupsUpdated { space_id: space, groups }); - for &(wid, target_frame) in &layout { - let Some(window) = self.windows.get_mut(&wid) else { + for &(wid, target_frame) in &result { + let Some(window) = windows.get_mut(&wid) else { // If we restored a saved state the window may not be available yet. continue; }; - let target_frame = target_frame.round(); + let target_frame = round_to_physical(target_frame, screen.scale_factor); let current_frame = window.frame_monotonic; if target_frame.same_as(current_frame) { continue; } - trace!(?wid, ?current_frame, ?target_frame); - let handle = &self.apps.get(&wid.pid).unwrap().handle; - let is_new = Some(wid) == new_wid; + let Some(app) = apps.get(&wid.pid) else { + continue; + }; let txid = window.next_txid(); - anim.add_window(handle, wid, current_frame, target_frame, is_new, txid); + match &mut mode { + LayoutMode::Animate { new_wid, anim } => { + trace!(?wid, ?current_frame, ?target_frame); + let is_new = Some(wid) == *new_wid; + anim.add_window( + &app.handle, + wid, + current_frame, + target_frame, + is_new, + txid, + ); + } + LayoutMode::Immediate => { + let _ = app.handle.send(Request::SetWindowFrame(wid, target_frame, txid)); + } + } window.frame_monotonic = target_frame; } } - if is_resize || !self.config.settings.animate { - // If the user is doing something with the mouse we don't want to - // animate on top of that. - anim.skip_to_end(); - } else { - anim.run(); - } } } @@ -833,6 +1022,7 @@ pub mod tests { vec![Some(SpaceId::new(1))], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app(1, make_windows(2))); @@ -863,6 +1053,7 @@ pub mod tests { vec![Some(SpaceId::new(1))], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app(1, make_windows(2))); @@ -901,6 +1092,7 @@ pub mod tests { vec![Some(SpaceId::new(1))], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app(1, make_windows(2))); @@ -943,6 +1135,7 @@ pub mod tests { vec![Some(SpaceId::new(1))], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app(1, make_windows(3))); @@ -992,6 +1185,7 @@ pub mod tests { vec![Some(SpaceId::new(1))], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app(1, make_windows(1))); @@ -1022,6 +1216,7 @@ pub mod tests { vec![None], ws_info.clone(), CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app_with_opts( @@ -1053,6 +1248,7 @@ pub mod tests { vec![None], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app(1, make_windows(1))); @@ -1083,6 +1279,7 @@ pub mod tests { vec![Some(SpaceId::new(1)), Some(SpaceId::new(2))], vec![], CoordinateConverter::default(), + vec![2.0, 2.0], )); let mut windows = make_windows(2); @@ -1115,6 +1312,7 @@ pub mod tests { frame: CGRect::ZERO, }], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app_with_opts(1, make_windows(1), None, true, false)); @@ -1148,6 +1346,7 @@ pub mod tests { vec![Some(SpaceId::new(1)), Some(SpaceId::new(2))], vec![], CoordinateConverter::default(), + vec![2.0, 2.0], )); reactor.handle_events(apps.make_app(1, make_windows(2))); @@ -1228,6 +1427,7 @@ pub mod tests { vec![Some(space)], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app_with_opts( @@ -1255,6 +1455,7 @@ pub mod tests { vec![None], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_event(Event::ScreenParametersChanged( vec![full_screen], @@ -1268,6 +1469,7 @@ pub mod tests { }) .collect(), CoordinateConverter::default(), + vec![2.0], )); let requests = apps.requests(); for request in requests { @@ -1308,6 +1510,7 @@ pub mod tests { vec![Some(SpaceId::new(1))], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app(1, make_windows(1))); @@ -1334,6 +1537,7 @@ pub mod tests { frame: CGRect::new(CGPoint::new(500., 0.), CGSize::new(500., 500.)), }], CoordinateConverter::default(), + vec![2.0, 2.0], )); let _events = apps.simulate_events(); @@ -1359,6 +1563,7 @@ pub mod tests { vec![Some(space)], vec![], CoordinateConverter::default(), + vec![2.0], )); assert_eq!(None, reactor.main_window()); @@ -1390,6 +1595,7 @@ pub mod tests { vec![Some(space)], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor1.handle_events(apps.make_app(1, make_windows(2))); reactor1.handle_events(apps.make_app(2, make_windows(2))); @@ -1413,6 +1619,7 @@ pub mod tests { vec![Some(space)], vec![], CoordinateConverter::default(), + vec![2.0], )); // Only apps 1 and 3 launch during restore (app 2 was terminated between save and restore) reactor2.handle_events(apps2.make_app(1, make_windows(2))); @@ -1446,4 +1653,28 @@ pub mod tests { ] ); } + + #[test] + fn no_scroll_animation_when_idle() { + let mut reactor = Reactor::new_for_test(LayoutManager::new()); + let space = SpaceId::new(1); + let screen = CGRect::new(CGPoint::new(0., 0.), CGSize::new(1000., 1000.)); + reactor.handle_event(Event::ScreenParametersChanged( + vec![screen], + vec![Some(space)], + vec![], + CoordinateConverter::default(), + vec![2.0], + )); + + let mut apps = Apps::new(); + reactor.handle_events(apps.make_app(1, make_windows(2))); + reactor.handle_event(Event::StartupComplete); + apps.simulate_until_quiet(&mut reactor); + + assert!( + !reactor.layout.has_active_scroll_animation(), + "timer should be dormant when no scroll animation is active" + ); + } } diff --git a/src/actor/reactor/main_window.rs b/src/actor/reactor/main_window.rs index 2781da5..ae34af3 100644 --- a/src/actor/reactor/main_window.rs +++ b/src/actor/reactor/main_window.rs @@ -88,6 +88,9 @@ impl MainWindowTracker { | Event::MouseMovedOverWindow(..) | Event::RaiseCompleted { .. } | Event::RaiseTimeout { .. } + | Event::ScrollWheel { .. } + | Event::LeftMouseDown(_) + | Event::LeftMouseDragged(_) | Event::Command(..) | Event::ConfigChanged(_) => return None, }; @@ -149,6 +152,7 @@ mod tests { vec![Some(space)], vec![], CoordinateConverter::default(), + vec![2.0], )); assert_eq!(None, reactor.main_window()); @@ -212,6 +216,7 @@ mod tests { vec![Some(space)], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_event(ApplicationGloballyActivated(1)); @@ -277,6 +282,7 @@ mod tests { vec![Some(space)], vec![], CoordinateConverter::default(), + vec![2.0], )); reactor.handle_events(apps.make_app_with_opts( diff --git a/src/actor/wm_controller.rs b/src/actor/wm_controller.rs index ab8e649..01bdd6f 100644 --- a/src/actor/wm_controller.rs +++ b/src/actor/wm_controller.rs @@ -47,6 +47,7 @@ pub enum WmEvent { Vec, CoordinateConverter, Vec>, + Vec, ), ExposeEntered, ExposeExited, @@ -212,7 +213,7 @@ impl WmController { AppTerminated(pid) => { self.send_event(Event::ApplicationTerminated(pid)); } - ScreenParametersChanged(frames, ids, converter, spaces) => { + ScreenParametersChanged(frames, ids, converter, spaces, scale_factors) => { self.cur_screen_id = ids; self.handle_space_changed(spaces.clone()); self.send_event(Event::ScreenParametersChanged( @@ -220,6 +221,7 @@ impl WmController { self.active_spaces(), self.get_windows(), converter, + scale_factors, )); self.status_tx.send(status::Event::SpaceChanged(spaces)); self.status_tx.send(status::Event::SpaceEnabledChanged( diff --git a/src/config.rs b/src/config.rs index 6a1b999..8acb50d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,7 @@ use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use crate::actor::wm_controller::WmCommand; +use crate::model::LayoutKind; pub fn data_dir() -> PathBuf { dirs::home_dir().unwrap().join(".glide") @@ -87,6 +88,7 @@ pub struct Settings { pub outer_gap: f64, pub inner_gap: f64, pub default_keys: bool, + pub default_layout_kind: LayoutKind, #[derive_args(GroupBarsPartial)] pub group_bars: GroupBars, #[derive_args(StatusIconPartial)] @@ -102,6 +104,103 @@ pub struct Settings { pub struct Experimental { #[derive_args(StatusIconExperimentalPartial)] pub status_icon: StatusIconExperimental, + #[derive_args(ScrollConfigPartial)] + pub scroll: ScrollConfig, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub enum NewWindowPlacement { + NewColumn, + SameColumn, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, Default)] +#[serde(rename_all = "snake_case")] +pub enum CenterMode { + #[default] + Never, + Always, + OnOverflow, +} + +#[derive(PartialConfig!)] +#[derive_args(ScrollConfigPartial)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(deny_unknown_fields)] +pub struct ScrollConfig { + pub enable: bool, + pub center_focused_column: CenterMode, + pub visible_columns: u32, + pub column_width_presets: Vec, + pub new_window_in_column: NewWindowPlacement, + pub scroll_sensitivity: f64, + pub invert_scroll_direction: bool, + pub infinite_loop: bool, + pub single_column_aspect_ratio: String, +} + +impl Default for ScrollConfig { + fn default() -> Self { + Self { + enable: false, + center_focused_column: CenterMode::Always, + visible_columns: 2, + column_width_presets: vec![0.333, 0.5, 0.667, 1.0], + new_window_in_column: NewWindowPlacement::NewColumn, + scroll_sensitivity: 20.0, + invert_scroll_direction: false, + infinite_loop: false, + single_column_aspect_ratio: String::new(), + } + } +} + +impl ScrollConfig { + pub fn validated(mut self) -> Self { + self.visible_columns = self.visible_columns.clamp(1, 5); + self.scroll_sensitivity = self.scroll_sensitivity.clamp(0.0, 100.0); + self.column_width_presets.retain(|&p| p > 0.0 && p <= 1.0); + self + } + + pub fn aspect_ratio(&self) -> Option { + if self.single_column_aspect_ratio.is_empty() { + return None; + } + AspectRatio::from_str(&self.single_column_aspect_ratio).ok() + } +} + +#[derive(Debug, PartialEq, Clone, Copy, Serialize)] +pub struct AspectRatio { + pub width: f64, + pub height: f64, +} + +impl FromStr for AspectRatio { + type Err = String; + + fn from_str(s: &str) -> Result { + let (w, h) = + s.split_once(':').ok_or_else(|| format!("expected 'W:H' format, got {s:?}"))?; + let width: f64 = w.trim().parse().map_err(|_| format!("invalid width: {w:?}"))?; + let height: f64 = h.trim().parse().map_err(|_| format!("invalid height: {h:?}"))?; + if width <= 0.0 || height <= 0.0 { + return Err("aspect ratio values must be positive".into()); + } + Ok(AspectRatio { width, height }) + } +} + +impl<'de> Deserialize<'de> for AspectRatio { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + AspectRatio::from_str(&s).map_err(serde::de::Error::custom) + } } #[derive(PartialConfig!)] @@ -280,6 +379,9 @@ impl From for SpannedError { #[cfg(test)] mod tests { use super::*; + use crate::actor::layout::LayoutCommand; + use crate::actor::reactor::Command as ReactorCommand; + use crate::model::Direction; #[test] fn default_config_is_valid() { @@ -291,6 +393,50 @@ mod tests { assert_eq!(Config::default().settings, Config::parse("").unwrap().settings); } + #[test] + fn scroll_gate_is_disabled_by_default() { + assert!(!Config::default().settings.experimental.scroll.enable); + } + + #[test] + fn default_keys_exclude_scroll_experimental_commands() { + let config = Config::default(); + assert!(!config.keys.iter().any(|(_, cmd)| { + matches!( + cmd, + WmCommand::ReactorCommand(ReactorCommand::Layout( + LayoutCommand::ChangeLayoutKind + | LayoutCommand::ToggleColumnTabbed + | LayoutCommand::CycleColumnWidth + | LayoutCommand::ConsumeOrExpelWindow(_) + )) + ) + })); + } + + #[test] + fn consume_or_expel_window_key_parses() { + let config = Config::parse( + r#" + [settings] + default_keys = false + + [keys] + "Alt + Shift + BracketLeft" = { consume_or_expel_window = "left" } + "#, + ) + .unwrap(); + + assert!(config.keys.iter().any(|(_, cmd)| { + matches!( + cmd, + WmCommand::ReactorCommand(ReactorCommand::Layout( + LayoutCommand::ConsumeOrExpelWindow(Direction::Left) + )) + ) + })); + } + #[test] fn default_keys_false_excludes_default_bindings() { let config = Config::parse( @@ -377,6 +523,28 @@ mod tests { assert!(config.keys.iter().any(|(hk, _)| hk.to_string() == "Alt + KeyJ")); } + #[test] + fn aspect_ratio_from_str_valid() { + let ar = AspectRatio::from_str("16:9").unwrap(); + assert_eq!(ar.width, 16.0); + assert_eq!(ar.height, 9.0); + } + + #[test] + fn aspect_ratio_from_str_with_spaces() { + let ar = AspectRatio::from_str(" 4 : 3 ").unwrap(); + assert_eq!(ar.width, 4.0); + assert_eq!(ar.height, 3.0); + } + + #[test] + fn aspect_ratio_from_str_invalid() { + assert!(AspectRatio::from_str("16x9").is_err()); + assert!(AspectRatio::from_str("0:9").is_err()); + assert!(AspectRatio::from_str("16:-1").is_err()); + assert!(AspectRatio::from_str("abc:def").is_err()); + } + #[test] fn arrow_keys_parse_correctly() { let config = Config::parse( diff --git a/src/model.rs b/src/model.rs index 8923a50..d23931c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -6,12 +6,15 @@ mod layout_mapping; mod layout_tree; +mod scroll_constraints; +pub mod scroll_viewport; mod selection; mod size; +pub mod spring; mod tree; mod window; pub use layout_mapping::SpaceLayoutMapping; -pub use layout_tree::{LayoutId, LayoutTree}; +pub use layout_tree::{LayoutId, LayoutKind, LayoutTree}; pub use size::{ContainerKind, Direction, GroupBarInfo, Orientation}; pub use tree::NodeId; diff --git a/src/model/layout_mapping.rs b/src/model/layout_mapping.rs index 325112d..5987422 100644 --- a/src/model/layout_mapping.rs +++ b/src/model/layout_mapping.rs @@ -31,8 +31,11 @@ enum SaveState { } impl SpaceLayoutMapping { - pub fn new(size: CGSize, tree: &mut LayoutTree) -> Self { - let layout = tree.create_layout(); + pub fn new(size: CGSize, tree: &mut LayoutTree, kind: LayoutKind) -> Self { + let layout = match kind { + LayoutKind::Tree => tree.create_layout(), + LayoutKind::Scroll => tree.create_scroll_layout(), + }; SpaceLayoutMapping { active_size: size.into(), active_save_state: SaveState::Unretained, @@ -138,6 +141,14 @@ impl SpaceLayoutMapping { self.active_layout } + pub fn replace_active_layout(&mut self, new_layout: LayoutId) { + let old = self.active_layout; + self.layouts.insert(new_layout, 1); + self.decrement_ref(old); + self.active_layout = new_layout; + self.active_save_state = SaveState::Retained; + } + fn increment_ref(&mut self, layout: LayoutId) { *self.layouts.entry(layout).or_insert(0) += 1; } @@ -169,7 +180,7 @@ use serde::{Deserialize, Serialize}; use tracing::debug; use crate::collections::HashMap; -use crate::model::{LayoutId, LayoutTree}; +use crate::model::{LayoutId, LayoutKind, LayoutTree}; #[cfg(test)] mod tests { @@ -182,7 +193,7 @@ mod tests { #[test] fn unmodified_layout_is_reused() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); let layout1 = mapping.active_layout(); assert_eq!(tree.layouts().len(), 1); @@ -202,7 +213,7 @@ mod tests { #[test] fn prepare_modify_reuses_unique_layouts() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); let original_layout = mapping.active_layout(); let modified_layout = mapping.prepare_modify(&mut tree); @@ -213,7 +224,7 @@ mod tests { #[test] fn prepare_modify_clones_shared_layouts() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); let original_layout = mapping.active_layout(); assert_eq!(tree.layouts().len(), 1); @@ -232,7 +243,7 @@ mod tests { #[test] fn state_is_not_saved_without_retention() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); // Switch without retention - should not save to memory mapping.activate_size(SIZE_2, &mut tree); @@ -255,7 +266,7 @@ mod tests { #[test] fn state_is_saved_with_retention() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); // Switch with retention - should save to memory mapping.retain_layout(); @@ -277,7 +288,7 @@ mod tests { #[test] fn correct_refcount_with_retain() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); let layout1 = mapping.active_layout(); // Initially refcount should be 1 @@ -309,7 +320,7 @@ mod tests { #[test] fn garbage_collection_removes_layouts_when_unused() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); let layout1 = mapping.active_layout(); assert_eq!(mapping.layouts().len(), 1); assert_eq!(tree.layouts().len(), 1); @@ -339,7 +350,7 @@ mod tests { #[test] fn change_index() { let mut tree = LayoutTree::new(); - let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree); + let mut mapping = SpaceLayoutMapping::new(SIZE_1, &mut tree, LayoutKind::Tree); // Make three layouts. let layout1 = mapping.active_layout(); diff --git a/src/model/layout_tree.rs b/src/model/layout_tree.rs index 847857c..344a464 100644 --- a/src/model/layout_tree.rs +++ b/src/model/layout_tree.rs @@ -1,6 +1,7 @@ // Copyright The Glide Authors // SPDX-License-Identifier: MIT OR Apache-2.0 +use std::collections::HashSet; use std::{iter, mem}; use objc2_core_foundation::CGRect; @@ -19,10 +20,20 @@ use crate::model::tree::{NodeId, NodeMap, OwnedNode}; /// /// All interactions with the data model happen through the public APIs on this /// type. +#[derive(Serialize, Deserialize, Default, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LayoutKind { + #[default] + Tree, + Scroll, +} + #[derive(Serialize, Deserialize)] pub struct LayoutTree { tree: Tree, layout_roots: slotmap::SlotMap, + #[serde(default)] + layout_kinds: slotmap::SecondaryMap, } slotmap::new_key_type! { @@ -34,16 +45,52 @@ impl LayoutTree { LayoutTree { tree: Tree::with_observer(Components::default()), layout_roots: Default::default(), + layout_kinds: Default::default(), } } pub fn create_layout(&mut self) -> LayoutId { + self.create_layout_with_kind(LayoutKind::Tree) + } + + pub fn create_scroll_layout(&mut self) -> LayoutId { + self.create_layout_with_kind(LayoutKind::Scroll) + } + + fn create_layout_with_kind(&mut self, kind: LayoutKind) -> LayoutId { let root = OwnedNode::new_root_in(&mut self.tree, "layout_root"); - self.layout_roots.insert(root) + let id = self.layout_roots.insert(root); + self.layout_kinds.insert(id, kind); + if kind == LayoutKind::Scroll { + let root_id = self.layout_roots[id].id(); + self.tree + .data + .size + .set_kind(root_id, ContainerKind::Horizontal); + self.tree.data.scroll_roots.insert(root_id); + } + id } pub fn remove_layout(&mut self, layout: LayoutId) { - self.layout_roots.remove(layout).unwrap().remove(&mut self.tree) + let mut root = self.layout_roots.remove(layout).unwrap(); + self.tree.data.scroll_roots.remove(&root.id()); + root.remove(&mut self.tree); + self.layout_kinds.remove(layout); + } + + pub fn rebuild_scroll_roots_from_layout_kinds(&mut self) { + let mut scroll_roots = Vec::new(); + for (layout_id, root) in &self.layout_roots { + if self.layout_kind(layout_id) == LayoutKind::Scroll { + scroll_roots.push(root.id()); + } + } + self.tree.data.scroll_roots.clear(); + for root_id in scroll_roots { + self.tree.data.scroll_roots.insert(root_id); + self.tree.data.size.set_kind(root_id, ContainerKind::Horizontal); + } } pub fn layouts(&self) -> impl ExactSizeIterator { @@ -54,11 +101,96 @@ impl LayoutTree { self.layout_roots[layout].id() } + pub fn layout_kind(&self, layout: LayoutId) -> LayoutKind { + self.layout_kinds.get(layout).copied().unwrap_or_default() + } + + pub fn is_scroll_layout(&self, layout: LayoutId) -> bool { + self.layout_kind(layout) == LayoutKind::Scroll + } + + /// Returns true if `node` is the root node of a scroll layout. + fn is_scroll_root(&self, node: NodeId) -> bool { + self.tree.data.scroll_roots.contains(&node) + } + + pub fn add_window_to_scroll_column( + &mut self, + layout: LayoutId, + wid: WindowId, + new_column: bool, + ) -> NodeId { + self.add_window_to_scroll_column_with_visible(layout, wid, new_column, 2) + } + + pub fn add_window_to_scroll_column_with_visible( + &mut self, + layout: LayoutId, + wid: WindowId, + new_column: bool, + visible_columns: u32, + ) -> NodeId { + let root = self.root(layout); + let selection = self.selection(layout); + let weight = 1.0 / visible_columns.max(1) as f32; + if new_column { + let column = if selection == root { + self.tree.mk_node().push_back(root) + } else { + let parent = selection + .ancestors(&self.tree.map) + .find(|&n| n.parent(&self.tree.map) == Some(root)) + .unwrap_or(selection); + self.tree.mk_node().insert_after(parent) + }; + self.tree.data.size.set_kind(column, ContainerKind::Vertical); + self.tree.data.size.set_weight(column, weight, &self.tree.map); + let node = self.tree.mk_node().push_back(column); + self.tree.data.window.set_window(layout, node, wid); + node + } else { + let column = selection + .ancestors(&self.tree.map) + .find(|&n| n.parent(&self.tree.map) == Some(root)) + .unwrap_or(root); + if column == root { + let col = self.tree.mk_node().push_back(root); + self.tree.data.size.set_kind(col, ContainerKind::Vertical); + self.tree.data.size.set_weight(col, weight, &self.tree.map); + let node = self.tree.mk_node().push_back(col); + self.tree.data.window.set_window(layout, node, wid); + node + } else { + let node = self.tree.mk_node().insert_after(selection); + self.tree.data.window.set_window(layout, node, wid); + node + } + } + } + + pub fn column_of(&self, layout: LayoutId, node: NodeId) -> Option { + let root = self.root(layout); + node.ancestors(&self.tree.map).find(|&n| n.parent(&self.tree.map) == Some(root)) + } + + pub fn columns(&self, layout: LayoutId) -> Vec { + let root = self.root(layout); + root.children(&self.tree.map).collect() + } + + pub fn set_column_weight(&mut self, node: NodeId, weight: f32) { + self.tree.data.size.set_weight(node, weight, &self.tree.map); + } + pub fn clone_layout(&mut self, layout: LayoutId) -> LayoutId { let source_root = self.layout_roots[layout].id(); let cloned = source_root.deep_copy(&mut self.tree).make_root("layout_root"); let cloned_root = cloned.id(); let dest_layout = self.layout_roots.insert(cloned); + self.layout_kinds.insert(dest_layout, self.layout_kind(layout)); + if self.is_scroll_layout(layout) { + self.tree.data.scroll_roots.insert(cloned_root); + } self.print_tree(layout); for (src, dest) in iter::zip( source_root.traverse_preorder(&self.tree.map), @@ -255,6 +387,7 @@ impl LayoutTree { config, self.root(layout), frame, + self.is_scroll_layout(layout), ) } @@ -271,6 +404,7 @@ impl LayoutTree { config, self.root(layout), frame, + self.is_scroll_layout(layout), ) } @@ -296,6 +430,45 @@ impl LayoutTree { .last() } + pub fn traverse_scroll_wrapping( + &self, + layout: LayoutId, + from: NodeId, + direction: Direction, + ) -> Option { + let root = self.root(layout); + let columns: Vec = root.children(&self.tree.map).collect(); + let len = columns.len(); + if len == 0 { + return None; + } + let current_col = from + .ancestors(&self.tree.map) + .find(|&n| n.parent(&self.tree.map) == Some(root))?; + let idx = columns.iter().position(|&c| c == current_col)?; + let step: isize = match direction { + Direction::Left => -1, + Direction::Right => 1, + _ => return self.traverse(from, direction), + }; + let new_idx = (idx as isize + step).rem_euclid(len as isize) as usize; + if new_idx == idx { + return None; + } + let target_col = columns[new_idx]; + let mut node = target_col; + while let Some(child) = self + .tree + .data + .selection + .local_selection(&self.tree.map, node) + .or(node.first_child(&self.tree.map)) + { + node = child; + } + Some(node) + } + pub fn select_returning_surfaced_windows(&mut self, selection: NodeId) -> Vec { let map = &self.tree.map; let mut highest_revealed = selection; @@ -445,6 +618,88 @@ impl LayoutTree { true } + pub fn move_column_in_scroll(&mut self, layout: LayoutId, direction: Direction) -> bool { + let selection = self.selection(layout); + let Some(column) = self.column_of(layout, selection) else { + return false; + }; + let neighbor = match direction { + Direction::Left => column.prev_sibling(&self.tree.map), + Direction::Right => column.next_sibling(&self.tree.map), + _ => return false, + }; + let Some(neighbor) = neighbor else { + return false; + }; + let saved_weight = self.tree.data.size.weight(column); + match direction { + Direction::Left => column.detach(&mut self.tree).insert_before(neighbor), + Direction::Right => column.detach(&mut self.tree).insert_after(neighbor), + _ => unreachable!(), + }; + self.tree.data.size.set_weight(column, saved_weight, &self.tree.map); + for node in selection.ancestors(&self.tree.map) { + self.tree.data.selection.select_locally(&self.tree.map, node); + } + true + } + + pub fn consume_or_expel_in_scroll( + &mut self, + layout: LayoutId, + direction: Direction, + visible_columns: u32, + ) -> bool { + if !matches!(direction, Direction::Left | Direction::Right) { + return false; + } + let root = self.root(layout); + let selection = self.selection(layout); + let Some(column) = self.column_of(layout, selection) else { + return false; + }; + let has_siblings = column.first_child(&self.tree.map) != column.last_child(&self.tree.map); + if has_siblings { + // Expel: move the selected window into a new column. + let window = selection; + if window == column { + return false; + } + let weight = 1.0 / visible_columns.max(1) as f32; + let new_column = match direction { + Direction::Left => self.tree.mk_node().insert_before(column), + Direction::Right => self.tree.mk_node().insert_after(column), + _ => unreachable!(), + }; + self.tree.data.size.set_kind(new_column, ContainerKind::Vertical); + self.tree.data.size.set_weight(new_column, weight, &self.tree.map); + window.detach(&mut self.tree).push_back(new_column); + for node in window.ancestors(&self.tree.map) { + self.tree.data.selection.select_locally(&self.tree.map, node); + } + true + } else { + // Consume: move the single window into the adjacent column. + let neighbor = match direction { + Direction::Left => column.prev_sibling(&self.tree.map), + Direction::Right => column.next_sibling(&self.tree.map), + _ => unreachable!(), + }; + let Some(target_column) = neighbor else { + return false; + }; + let window = selection; + if window == root || window == column { + return false; + } + window.detach(&mut self.tree).push_back(target_column); + for node in window.ancestors(&self.tree.map) { + self.tree.data.selection.select_locally(&self.tree.map, node); + } + true + } + } + pub fn map(&self) -> &NodeMap { &self.tree.map } @@ -453,6 +708,10 @@ impl LayoutTree { self.tree.data.size.kind(node) } + pub fn proportion(&self, node: NodeId) -> Option { + self.tree.data.size.proportion(&self.tree.map, node) + } + pub fn last_ungrouped_container_kind(&self, node: NodeId) -> ContainerKind { self.tree.data.size.last_ungrouped_kind(node) } @@ -497,6 +756,10 @@ impl LayoutTree { parent } + pub fn swap_windows(&mut self, node_a: NodeId, node_b: NodeId) { + self.tree.data.window.swap_windows(node_a, node_b); + } + pub fn resize(&mut self, node: NodeId, screen_ratio: f64, direction: Direction) -> bool { // Pick an ancestor to resize that has a sibling in the given direction. let can_resize = |&node: &NodeId| -> bool { @@ -525,13 +788,29 @@ impl LayoutTree { _ => r, } }); - let local_ratio = f64::from(screen_ratio) - * self.tree.data.size.total(resizing_node.parent(&self.tree.map).unwrap()) - / exchange_rate; - self.tree - .data - .size - .take_share(&self.tree.map, resizing_node, sibling, local_ratio as f32); + let parent = resizing_node.parent(&self.tree.map).unwrap(); + let parent_total = self.tree.data.size.total(parent); + let is_scroll_column = self.is_scroll_root(parent); + let local_ratio = if is_scroll_column { + f64::from(screen_ratio) / exchange_rate + } else { + f64::from(screen_ratio) * parent_total / exchange_rate + }; + if is_scroll_column { + let current_weight = self.tree.data.size.weight(resizing_node); + self.tree.data.size.set_weight( + resizing_node, + current_weight + local_ratio as f32, + &self.tree.map, + ); + } else { + self.tree.data.size.take_share( + &self.tree.map, + resizing_node, + sibling, + local_ratio as f32, + ); + } true } @@ -655,6 +934,8 @@ struct Components { #[serde(alias = "layout")] size: Size, window: Window, + #[serde(default)] + scroll_roots: HashSet, } #[derive(Copy, Clone)] @@ -710,6 +991,11 @@ impl tree::Observer for Components { if parent.is_empty(&tree.map) { parent.detach(tree).remove(); } else if parent.first_child(&tree.map) == parent.last_child(&tree.map) { + if let Some(grandparent) = parent.parent(&tree.map) { + if tree.data.scroll_roots.contains(&grandparent) { + return; + } + } // Promote the only remaining child of the parent node. let child = parent.first_child(&tree.map).unwrap(); child @@ -1464,4 +1750,88 @@ mod tests { windows.sort(); assert_eq!(windows, vec![w(1, 2), w(2, 1)]); } + + #[test] + fn traverse_scroll_wrapping_wraps_right() { + let mut tree = LayoutTree::new(); + let layout = tree.create_scroll_layout(); + let w1 = tree.add_window_to_scroll_column(layout, w(1, 1), true); + tree.select(w1); + let w2 = tree.add_window_to_scroll_column(layout, w(1, 2), true); + tree.select(w2); + let w3 = tree.add_window_to_scroll_column(layout, w(1, 3), true); + tree.select(w3); + + let result = tree.traverse_scroll_wrapping(layout, w3, Direction::Right); + assert_eq!(result, Some(w1)); + } + + #[test] + fn traverse_scroll_wrapping_wraps_left() { + let mut tree = LayoutTree::new(); + let layout = tree.create_scroll_layout(); + let w1 = tree.add_window_to_scroll_column(layout, w(1, 1), true); + tree.select(w1); + let w2 = tree.add_window_to_scroll_column(layout, w(1, 2), true); + tree.select(w2); + let _w3 = tree.add_window_to_scroll_column(layout, w(1, 3), true); + + let result = tree.traverse_scroll_wrapping(layout, w1, Direction::Left); + assert!(result.is_some()); + } + + #[test] + fn traverse_scroll_wrapping_single_column_returns_none() { + let mut tree = LayoutTree::new(); + let layout = tree.create_scroll_layout(); + let w1 = tree.add_window_to_scroll_column(layout, w(1, 1), true); + + let result = tree.traverse_scroll_wrapping(layout, w1, Direction::Right); + assert_eq!(result, None); + } + + #[test] + fn traverse_scroll_wrapping_empty_returns_none() { + let mut tree = LayoutTree::new(); + let layout = tree.create_scroll_layout(); + let root = tree.root(layout); + + let result = tree.traverse_scroll_wrapping(layout, root, Direction::Right); + assert_eq!(result, None); + } + + #[test] + fn rebuild_scroll_roots_backfills_legacy_scroll_layouts() { + let mut tree = LayoutTree::new(); + let layout = tree.create_scroll_layout(); + let root = tree.root(layout); + + tree.tree.data.scroll_roots.clear(); + assert!(!tree.tree.data.scroll_roots.contains(&root)); + + tree.rebuild_scroll_roots_from_layout_kinds(); + assert!(tree.tree.data.scroll_roots.contains(&root)); + assert_eq!(tree.container_kind(root), ContainerKind::Horizontal); + } + + #[test] + fn rebuild_scroll_roots_preserves_single_window_scroll_columns_on_expel() { + let mut tree = LayoutTree::new(); + let layout = tree.create_scroll_layout(); + + let w1 = tree.add_window_to_scroll_column(layout, w(1, 1), true); + tree.select(w1); + let w2 = tree.add_window_to_scroll_column(layout, w(1, 2), false); + tree.select(w2); + let _w3 = tree.add_window_to_scroll_column(layout, w(1, 3), true); + let original_col = tree.column_of(layout, w1).unwrap(); + + // Simulate loading a legacy save where scroll_roots was absent. + tree.tree.data.scroll_roots.clear(); + tree.rebuild_scroll_roots_from_layout_kinds(); + + tree.select(w2); + assert!(tree.consume_or_expel_in_scroll(layout, Direction::Right, 2)); + assert_eq!(Some(original_col), w1.parent(tree.map())); + } } diff --git a/src/model/scroll_constraints.rs b/src/model/scroll_constraints.rs new file mode 100644 index 0000000..89f40a6 --- /dev/null +++ b/src/model/scroll_constraints.rs @@ -0,0 +1,210 @@ +pub(crate) const MIN_WINDOW_SIZE: f64 = 50.0; + +pub(crate) struct WindowInput { + pub weight: f64, + pub min_size: f64, + pub max_size: Option, + pub fixed_size: Option, +} + +pub(crate) struct WindowOutput { + pub size: f64, + #[cfg_attr(not(test), allow(dead_code))] + pub was_constrained: bool, +} + +pub(crate) fn solve_sizes(windows: &[WindowInput], available: f64, gap: f64) -> Vec { + let count = windows.len(); + if count == 0 { + return vec![]; + } + + let usable = available - gap * (count as f64 - 1.0).max(0.0); + + let total_min: f64 = windows.iter().map(|w| w.min_size).sum(); + if usable <= 0.0 || usable < total_min { + let weights: Vec = windows.iter().map(|w| w.weight.max(0.1)).collect(); + let total_weight: f64 = weights.iter().sum(); + return windows + .iter() + .enumerate() + .map(|(i, _w)| { + let size = if total_weight > 0.0 { + (usable.max(0.0) * weights[i] / total_weight).max(1.0) + } else { + 1.0 + }; + WindowOutput { size, was_constrained: true } + }) + .collect(); + } + + let mut sizes: Vec = vec![0.0; count]; + let mut fixed = vec![false; count]; + + for (i, w) in windows.iter().enumerate() { + if let Some(fs) = w.fixed_size { + let max = w.max_size.unwrap_or(f64::MAX); + sizes[i] = fs.clamp(w.min_size, max); + fixed[i] = true; + } else if w.max_size.is_some_and(|m| m <= w.min_size) { + sizes[i] = w.min_size; + fixed[i] = true; + } + } + + let weights: Vec = windows.iter().map(|w| w.weight.max(0.1)).collect(); + + for _ in 0..count + 1 { + let used: f64 = (0..count).filter(|&i| fixed[i]).map(|i| sizes[i]).sum(); + let remaining = usable - used; + let total_weight: f64 = (0..count).filter(|&i| !fixed[i]).map(|i| weights[i]).sum(); + + if total_weight <= 0.0 { + break; + } + + let mut violated = false; + for i in 0..count { + if fixed[i] { + continue; + } + let proposed = remaining * (weights[i] / total_weight); + if proposed < windows[i].min_size { + sizes[i] = windows[i].min_size; + fixed[i] = true; + violated = true; + break; + } + } + + if !violated { + for i in 0..count { + if !fixed[i] { + sizes[i] = remaining * (weights[i] / total_weight); + } + } + break; + } + } + + let mut excess = 0.0; + let mut max_fixed = vec![false; count]; + for (i, w) in windows.iter().enumerate() { + if let Some(max) = w.max_size { + if sizes[i] > max { + excess += sizes[i] - max; + sizes[i] = max; + max_fixed[i] = true; + } + } + } + + if excess > 0.0 { + let redist_weight: f64 = + (0..count).filter(|&i| !max_fixed[i] && !fixed[i]).map(|i| weights[i]).sum(); + if redist_weight > 0.0 { + for i in 0..count { + if !max_fixed[i] && !fixed[i] { + sizes[i] += excess * (weights[i] / redist_weight); + } + } + } + } + + for s in sizes.iter_mut() { + *s = s.max(1.0); + } + + sizes + .iter() + .enumerate() + .map(|(i, &size)| { + let w = &windows[i]; + let was_constrained = fixed[i] + && (Some(size) == w.max_size.map(|m| size.min(m)) + || (size - w.min_size).abs() < f64::EPSILON); + WindowOutput { size, was_constrained } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input(weight: f64) -> WindowInput { + WindowInput { + weight, + min_size: MIN_WINDOW_SIZE, + max_size: None, + fixed_size: None, + } + } + + #[test] + fn empty_input() { + let result = solve_sizes(&[], 1000.0, 10.0); + assert!(result.is_empty()); + } + + #[test] + fn single_window() { + let result = solve_sizes(&[input(1.0)], 500.0, 10.0); + assert_eq!(result.len(), 1); + assert!((result[0].size - 500.0).abs() < 0.01); + } + + #[test] + fn equal_weights() { + let inputs = vec![input(1.0), input(1.0), input(1.0)]; + let result = solve_sizes(&inputs, 1000.0, 10.0); + let usable = 1000.0 - 20.0; + let expected = usable / 3.0; + for r in &result { + assert!((r.size - expected).abs() < 0.01); + } + } + + #[test] + fn unequal_weights() { + let inputs = vec![input(1.0), input(2.0)]; + let result = solve_sizes(&inputs, 310.0, 10.0); + let _usable = 300.0; + assert!((result[0].size - 100.0).abs() < 0.01); + assert!((result[1].size - 200.0).abs() < 0.01); + } + + #[test] + fn min_violation() { + let inputs = vec![input(1.0), input(100.0)]; + let result = solve_sizes(&inputs, 160.0, 10.0); + assert!(result[0].size >= MIN_WINDOW_SIZE); + } + + #[test] + fn max_clamping() { + let inputs = vec![ + WindowInput { + weight: 1.0, + min_size: MIN_WINDOW_SIZE, + max_size: Some(100.0), + fixed_size: None, + }, + input(1.0), + ]; + let result = solve_sizes(&inputs, 510.0, 10.0); + assert!(result[0].size <= 100.0); + assert!((result[0].size + result[1].size - 500.0).abs() < 0.01); + } + + #[test] + fn negative_available() { + let inputs = vec![input(1.0), input(1.0), input(1.0)]; + let result = solve_sizes(&inputs, 10.0, 100.0); + for r in &result { + assert!(r.size >= 1.0); + assert!(r.was_constrained); + } + } +} diff --git a/src/model/scroll_viewport.rs b/src/model/scroll_viewport.rs new file mode 100644 index 0000000..3fc2806 --- /dev/null +++ b/src/model/scroll_viewport.rs @@ -0,0 +1,303 @@ +// TODO: Consider moving animation state out of the model layer. + +use std::time::Instant; + +use objc2_core_foundation::CGRect; + +use super::spring::SpringAnimation; +use crate::config::CenterMode; + +#[derive(Debug, Clone)] +pub enum ScrollState { + Static(f64), + Animating(SpringAnimation), +} + +impl ScrollState { + pub fn current(&self, now: Instant) -> f64 { + match self { + ScrollState::Static(v) => *v, + ScrollState::Animating(spring) => spring.current(now), + } + } + + pub fn target(&self) -> f64 { + match self { + ScrollState::Static(v) => *v, + ScrollState::Animating(spring) => spring.target(), + } + } +} + +#[derive(Debug, Clone)] +pub struct ViewportState { + pub scroll: ScrollState, + pub active_column_index: usize, + pub screen_width: f64, + pub user_scrolling: bool, + pub scroll_progress: f64, +} + +impl ViewportState { + pub fn new(screen_width: f64) -> Self { + ViewportState { + scroll: ScrollState::Static(0.0), + active_column_index: 0, + screen_width, + user_scrolling: false, + scroll_progress: 0.0, + } + } + + pub fn scroll_offset(&self, now: Instant) -> f64 { + self.scroll.current(now) + } + + pub fn target_offset(&self) -> f64 { + self.scroll.target() + } + + pub fn set_screen_width(&mut self, width: f64) { + self.screen_width = width; + } + + pub fn ensure_column_visible( + &mut self, + column_index: usize, + column_x: f64, + column_width: f64, + center_mode: CenterMode, + gap: f64, + now: Instant, + ) { + self.active_column_index = column_index; + self.user_scrolling = false; + let current = self.target_offset(); + + let new_offset = match center_mode { + CenterMode::Always => column_x + column_width / 2.0 - self.screen_width / 2.0, + CenterMode::OnOverflow => { + if column_width > self.screen_width { + column_x + column_width / 2.0 - self.screen_width / 2.0 + } else { + self.compute_edge_fit(column_x, column_width, current, gap) + } + } + CenterMode::Never => self.compute_edge_fit(column_x, column_width, current, gap), + }; + + if (new_offset - current).abs() > 0.5 { + self.animate_to(new_offset, now); + } + } + + fn compute_edge_fit(&self, col_x: f64, col_w: f64, current: f64, gap: f64) -> f64 { + let view_left = current; + let view_right = current + self.screen_width; + + if col_x >= view_left && col_x + col_w <= view_right { + return current; + } + + let padding = ((self.screen_width - col_w) / 2.0).clamp(0.0, gap); + + if col_x < view_left { + col_x - padding + } else { + col_x + col_w + padding - self.screen_width + } + } + + pub fn snap_to_offset(&mut self, offset: f64) { + self.scroll = ScrollState::Static(offset); + } + + pub fn animate_to(&mut self, target: f64, now: Instant) { + match &mut self.scroll { + ScrollState::Animating(spring) => { + spring.retarget(target, now); + } + ScrollState::Static(current) => { + self.scroll = + ScrollState::Animating(SpringAnimation::with_defaults(*current, target, now)); + } + } + } + + pub fn accumulate_scroll(&mut self, delta: f64, avg_column_width: f64) -> Option { + if avg_column_width <= 0.0 { + return None; + } + self.scroll_progress += delta; + let steps = (self.scroll_progress / avg_column_width).trunc() as i32; + if steps != 0 { + self.scroll_progress -= steps as f64 * avg_column_width; + Some(steps) + } else { + None + } + } + + pub fn is_animating(&self, now: Instant) -> bool { + match &self.scroll { + ScrollState::Static(_) => false, + ScrollState::Animating(spring) => !spring.is_complete(now), + } + } + + pub fn tick(&mut self, now: Instant) { + if let ScrollState::Animating(spring) = &self.scroll { + if spring.is_complete(now) { + self.scroll = ScrollState::Static(spring.target()); + self.user_scrolling = false; + } + } + } + + pub fn offset_rect(&self, rect: CGRect, now: Instant) -> CGRect { + let offset = self.scroll_offset(now); + CGRect::new( + objc2_core_foundation::CGPoint::new(rect.origin.x - offset, rect.origin.y), + rect.size, + ) + } + + pub fn is_visible(&self, rect: CGRect, now: Instant) -> bool { + let offset = self.scroll_offset(now); + let view_left = offset; + let view_right = offset + self.screen_width; + let rect_left = rect.origin.x; + let rect_right = rect.origin.x + rect.size.width; + rect_right > view_left && rect_left < view_right + } + + pub fn apply_viewport_to_frames( + &self, + screen: CGRect, + frames: Vec<(T, CGRect)>, + now: Instant, + ) -> Vec<(T, CGRect)> { + let offset = self.scroll_offset(now); + let view_left = offset; + let view_right = offset + self.screen_width; + + frames + .into_iter() + .map(|(wid, rect)| { + let rect_right = rect.origin.x + rect.size.width; + let rect_left = rect.origin.x; + + if rect_right > view_left && rect_left < view_right { + (wid, self.offset_rect(rect, now)) + } else if rect_right <= view_left { + let hidden = CGRect::new( + objc2_core_foundation::CGPoint::new( + screen.origin.x - rect.size.width, + rect.origin.y, + ), + rect.size, + ); + (wid, hidden) + } else { + let hidden = CGRect::new( + objc2_core_foundation::CGPoint::new( + screen.origin.x + screen.size.width, + rect.origin.y, + ), + rect.size, + ); + (wid, hidden) + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use objc2_core_foundation::{CGPoint, CGSize}; + + use super::*; + + fn make_rect(x: f64, y: f64, w: f64, h: f64) -> CGRect { + CGRect::new(CGPoint::new(x, y), CGSize::new(w, h)) + } + + #[test] + fn ensure_column_visible_already_visible() { + let now = Instant::now(); + let mut vp = ViewportState::new(1920.0); + vp.snap_to_offset(0.0); + vp.ensure_column_visible(0, 100.0, 500.0, CenterMode::Never, 0.0, now); + assert_eq!(vp.target_offset(), 0.0); + } + + #[test] + fn ensure_column_visible_scrolls_left() { + let now = Instant::now(); + let mut vp = ViewportState::new(1920.0); + vp.snap_to_offset(500.0); + vp.ensure_column_visible(0, 100.0, 500.0, CenterMode::Never, 0.0, now); + assert_eq!(vp.target_offset(), 100.0); + } + + #[test] + fn apply_viewport_returns_all_windows_with_correct_positions() { + let mut vp = ViewportState::new(1920.0); + vp.snap_to_offset(960.0); + let screen = make_rect(0.0, 0.0, 1920.0, 1080.0); + + let frames: Vec<(usize, CGRect)> = (0..5) + .map(|i| { + let wid = i; + let rect = make_rect(i as f64 * 640.0, 0.0, 640.0, 1080.0); + (wid, rect) + }) + .collect(); + + let result = vp.apply_viewport_to_frames(screen, frames, Instant::now()); + assert_eq!(result.len(), 5); + + for (_, r) in &result { + assert_eq!(r.size.width, 640.0, "all windows should preserve original width"); + assert_eq!( + r.size.height, 1080.0, + "all windows should preserve original height" + ); + } + + let on_screen: Vec<_> = result + .iter() + .filter(|(_, r)| r.origin.x + r.size.width > 0.0 && r.origin.x < 1920.0) + .collect(); + let off_screen: Vec<_> = result + .iter() + .filter(|(_, r)| r.origin.x + r.size.width <= 0.0 || r.origin.x >= 1920.0) + .collect(); + assert!(!on_screen.is_empty()); + assert!(!off_screen.is_empty()); + } + + #[test] + fn is_visible_checks_correctly() { + let vp = ViewportState::new(1920.0); + assert!(vp.is_visible(make_rect(0.0, 0.0, 500.0, 1080.0), Instant::now())); + assert!(!vp.is_visible(make_rect(2000.0, 0.0, 500.0, 1080.0), Instant::now())); + } + + #[test] + fn static_viewport_is_not_animating() { + let vp = ViewportState::new(1000.0); + assert!(!vp.is_animating(Instant::now())); + } + + #[test] + fn completed_animation_settles_to_static() { + let now = Instant::now(); + let mut vp = ViewportState::new(1000.0); + vp.animate_to(10.0, now); + let later = now + std::time::Duration::from_secs(1); + vp.tick(later); + assert!(!vp.is_animating(later)); + } +} diff --git a/src/model/size.rs b/src/model/size.rs index df4e383..6bc6bd0 100644 --- a/src/model/size.rs +++ b/src/model/size.rs @@ -206,6 +206,10 @@ impl Size { f64::from(self.info[node].total) } + pub(super) fn weight(&self, node: NodeId) -> f32 { + self.info[node].size + } + pub(super) fn take_share(&mut self, map: &NodeMap, node: NodeId, from: NodeId, share: f32) { assert_eq!(node.parent(map), from.parent(map)); let share = share.min(self.info[from].size); @@ -214,6 +218,14 @@ impl Size { self.info[node].size += share; } + pub(super) fn set_weight(&mut self, node: NodeId, weight: f32, map: &NodeMap) { + let old = self.info[node].size; + self.info[node].size = weight; + if let Some(parent) = node.parent(map) { + self.info[parent].total += weight - old; + } + } + pub(super) fn set_fullscreen(&mut self, node: NodeId, is_fullscreen: bool) { self.info[node].is_fullscreen = is_fullscreen; } @@ -247,6 +259,7 @@ impl Size { config: &Config, root: NodeId, screen: CGRect, + is_scroll: bool, ) -> Vec<(WindowId, CGRect)> { let mut sizes = vec![]; Visitor { @@ -257,6 +270,7 @@ impl Size { fullscreen_nodes: &[], config, screen, + is_scroll, sizes: &mut sizes, groups: None, } @@ -272,6 +286,7 @@ impl Size { config: &Config, root: NodeId, screen: CGRect, + is_scroll: bool, ) -> (Vec<(WindowId, CGRect)>, Vec) { let mut sizes = vec![]; let mut groups = vec![]; @@ -287,6 +302,7 @@ impl Size { fullscreen_nodes, config, screen, + is_scroll, sizes: &mut sizes, groups: Some(&mut groups), } @@ -303,6 +319,7 @@ struct Visitor<'a, 'out> { fullscreen_nodes: &'a [NodeId], config: &'a Config, screen: CGRect, + is_scroll: bool, sizes: &'out mut Vec<(WindowId, CGRect)>, groups: Option<&'out mut Vec>, } @@ -390,19 +407,46 @@ impl<'a, 'out> Visitor<'a, 'out> { } } Horizontal => { - let mut x = rect.origin.x; - let total = self.size.info[node].total; - let local_selection = self.selection.local_selection(self.map, node); let inner_gap = self.config.settings.inner_gap; - let width = - rect.size.width - inner_gap * (node.children(self.map).count() as f64 - 1.0); + let local_selection = self.selection.local_selection(self.map, node); + let children: Vec = node.children(self.map).collect(); + + let aspect_max_width = if children.len() == 1 { + self.config + .settings + .experimental + .scroll + .aspect_ratio() + .map(|ar| rect.size.height * (ar.width / ar.height)) + } else { + None + }; - for child in node.children(self.map) { - let ratio = f64::from(self.size.info[child].size) / f64::from(total); + let inputs: Vec = children + .iter() + .map(|&child| super::scroll_constraints::WindowInput { + weight: f64::from(self.size.info[child].size), + min_size: if self.is_scroll { super::scroll_constraints::MIN_WINDOW_SIZE } else { 1.0 }, + max_size: aspect_max_width, + fixed_size: None, + }) + .collect(); + + let total_weight: f64 = inputs.iter().map(|i| i.weight).sum(); + let virtual_width = if self.is_scroll { + total_weight * rect.size.width + } else { + rect.size.width + }; + let outputs = + super::scroll_constraints::solve_sizes(&inputs, virtual_width, inner_gap); + + let mut x = rect.origin.x; + for (&child, output) in children.iter().zip(outputs.iter()) { let rect = CGRect { origin: CGPoint { x, y: rect.origin.y }, size: CGSize { - width: width * ratio, + width: output.size, height: rect.size.height, }, } @@ -418,20 +462,30 @@ impl<'a, 'out> Visitor<'a, 'out> { } } Vertical => { - let mut y = rect.origin.y; - let total = self.size.info[node].total; - let local_selection = self.selection.local_selection(self.map, node); let inner_gap = self.config.settings.inner_gap; - let height = - rect.size.height - inner_gap * (node.children(self.map).count() as f64 - 1.0); + let local_selection = self.selection.local_selection(self.map, node); + let children: Vec = node.children(self.map).collect(); + + let inputs: Vec = children + .iter() + .map(|&child| super::scroll_constraints::WindowInput { + weight: f64::from(self.size.info[child].size), + min_size: if self.is_scroll { super::scroll_constraints::MIN_WINDOW_SIZE } else { 1.0 }, + max_size: None, + fixed_size: None, + }) + .collect(); - for child in node.children(self.map) { - let ratio = f64::from(self.size.info[child].size) / f64::from(total); + let outputs = + super::scroll_constraints::solve_sizes(&inputs, rect.size.height, inner_gap); + + let mut y = rect.origin.y; + for (&child, output) in children.iter().zip(outputs.iter()) { let rect = CGRect { origin: CGPoint { x: rect.origin.x, y }, size: CGSize { width: rect.size.width, - height: height * ratio, + height: output.size, }, } .round(); diff --git a/src/model/spring.rs b/src/model/spring.rs new file mode 100644 index 0000000..108d224 --- /dev/null +++ b/src/model/spring.rs @@ -0,0 +1,148 @@ +use std::time::Instant; + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SpringAnimation { + initial_value: f64, + target_value: f64, + initial_velocity: f64, + start_time: Instant, + response: f64, + damping_fraction: f64, + omega_n: f64, + omega_d: f64, + zeta: f64, +} + +impl SpringAnimation { + pub fn new( + initial_value: f64, + target_value: f64, + initial_velocity: f64, + response: f64, + damping_fraction: f64, + now: Instant, + ) -> Self { + let omega_n = 2.0 * std::f64::consts::PI / response; + let zeta = damping_fraction; + let omega_d = omega_n * (1.0 - zeta * zeta).max(0.0).sqrt(); + SpringAnimation { + initial_value, + target_value, + initial_velocity, + start_time: now, + response, + damping_fraction, + omega_n, + omega_d, + zeta, + } + } + + pub fn with_defaults(initial_value: f64, target_value: f64, now: Instant) -> Self { + Self::new(initial_value, target_value, 0.0, 0.5, 1.0, now) + } + + pub fn retarget(&mut self, new_target: f64, now: Instant) { + let current = self.value_at(now); + let vel = self.velocity_at(now); + self.initial_value = current; + self.target_value = new_target; + self.initial_velocity = vel; + self.start_time = now; + } + + pub fn value_at(&self, time: Instant) -> f64 { + let t = time.duration_since(self.start_time).as_secs_f64(); + let x0 = self.initial_value - self.target_value; + let v0 = self.initial_velocity; + + let displacement = if self.zeta >= 1.0 { + let decay = (-self.omega_n * t).exp(); + decay * (x0 + (v0 + self.omega_n * x0) * t) + } else { + let decay = (-self.zeta * self.omega_n * t).exp(); + let cos_part = x0 * (self.omega_d * t).cos(); + let sin_part = + ((v0 + self.zeta * self.omega_n * x0) / self.omega_d) * (self.omega_d * t).sin(); + decay * (cos_part + sin_part) + }; + + self.target_value + displacement + } + + pub fn velocity_at(&self, time: Instant) -> f64 { + let t = time.duration_since(self.start_time).as_secs_f64(); + let x0 = self.initial_value - self.target_value; + let v0 = self.initial_velocity; + + if self.zeta >= 1.0 { + let decay = (-self.omega_n * t).exp(); + let a = v0 + self.omega_n * x0; + decay * (a - self.omega_n * (x0 + a * t)) + } else { + let decay = (-self.zeta * self.omega_n * t).exp(); + let b = (v0 + self.zeta * self.omega_n * x0) / self.omega_d; + let cos_t = (self.omega_d * t).cos(); + let sin_t = (self.omega_d * t).sin(); + decay + * ((-self.zeta * self.omega_n) * (x0 * cos_t + b * sin_t) + + (-x0 * self.omega_d * sin_t + b * self.omega_d * cos_t)) + } + } + + pub fn is_complete(&self, time: Instant) -> bool { + let t = time.duration_since(self.start_time).as_secs_f64(); + if t < 0.01 { + return false; + } + let val = self.value_at(time); + let vel = self.velocity_at(time); + (val - self.target_value).abs() < 0.5 && vel.abs() < 0.5 + } + + pub fn target(&self) -> f64 { + self.target_value + } + + pub fn current(&self, now: Instant) -> f64 { + self.value_at(now) + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn critically_damped_converges() { + let now = Instant::now(); + let spring = SpringAnimation::new(0.0, 100.0, 0.0, 0.5, 1.0, now); + let end = spring.start_time + Duration::from_secs(2); + let val = spring.value_at(end); + assert!((val - 100.0).abs() < 1.0); + assert!(spring.is_complete(end)); + } + + #[test] + fn underdamped_oscillates() { + let now = Instant::now(); + let spring = SpringAnimation::new(0.0, 100.0, 0.0, 0.5, 0.5, now); + let mid = spring.start_time + Duration::from_millis(200); + let val = spring.value_at(mid); + assert!(val > 50.0); + } + + #[test] + fn retarget_preserves_continuity() { + let now = Instant::now(); + let mut spring = SpringAnimation::new(0.0, 100.0, 0.0, 0.5, 1.0, now); + let mid = now; + let val_before = spring.value_at(mid); + spring.retarget(200.0, now); + let val_after = spring.value_at(now); + assert!((val_before - val_after).abs() < 5.0); + } +} diff --git a/src/model/window.rs b/src/model/window.rs index e5143ac..bc90775 100644 --- a/src/model/window.rs +++ b/src/model/window.rs @@ -49,6 +49,28 @@ impl Window { self.window_nodes.entry(wid).or_default().push(WindowNodeInfo { layout, node }); } + pub fn swap_windows(&mut self, node_a: NodeId, node_b: NodeId) { + let wid_a = self.windows.get(node_a).copied(); + let wid_b = self.windows.get(node_b).copied(); + match (wid_a, wid_b) { + (Some(a), Some(b)) => { + self.windows[node_a] = b; + self.windows[node_b] = a; + for info in self.window_nodes.get_mut(&a).into_iter().flatten() { + if info.node == node_a { + info.node = node_b; + } + } + for info in self.window_nodes.get_mut(&b).into_iter().flatten() { + if info.node == node_b { + info.node = node_a; + } + } + } + _ => {} + } + } + pub fn set_capacity(&mut self, capacity: usize) { self.windows.set_capacity(capacity); // There's not currently a stable way to do this for BTreeMap. diff --git a/src/sys/geometry.rs b/src/sys/geometry.rs index ccc6401..b30d642 100644 --- a/src/sys/geometry.rs +++ b/src/sys/geometry.rs @@ -81,6 +81,20 @@ impl Round for ic::CGRect { } } +pub fn round_to_physical(rect: ic::CGRect, scale: f64) -> ic::CGRect { + let min_x = (rect.min().x * scale).round() / scale; + let min_y = (rect.min().y * scale).round() / scale; + let max_x = (rect.max().x * scale).round() / scale; + let max_y = (rect.max().y * scale).round() / scale; + ic::CGRect { + origin: ic::CGPoint { x: min_x, y: min_y }, + size: ic::CGSize { + width: max_x - min_x, + height: max_y - min_y, + }, + } +} + impl Round for ic::CGPoint { fn round(&self) -> Self { ic::CGPoint { @@ -229,3 +243,47 @@ impl<'de> DeserializeAs<'de, ic::CGRect> for CGRectDef { CGRectDef::deserialize(deserializer) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_to_physical_retina() { + let rect = ic::CGRect { + origin: ic::CGPoint { x: 10.3, y: 20.7 }, + size: ic::CGSize { width: 100.3, height: 200.7 }, + }; + let result = round_to_physical(rect, 2.0); + assert_eq!(result.origin.x, 10.5); + assert_eq!(result.origin.y, 20.5); + assert_eq!(result.size.width, 100.0); + assert_eq!(result.size.height, 201.0); + } + + #[test] + fn round_to_physical_1x() { + let rect = ic::CGRect { + origin: ic::CGPoint { x: 10.3, y: 20.7 }, + size: ic::CGSize { width: 100.3, height: 200.7 }, + }; + let result = round_to_physical(rect, 1.0); + assert_eq!(result.origin.x, 10.0); + assert_eq!(result.origin.y, 21.0); + assert_eq!(result.size.width, 101.0); + assert_eq!(result.size.height, 200.0); + } + + #[test] + fn round_to_physical_preserves_pixel_aligned() { + let rect = ic::CGRect { + origin: ic::CGPoint { x: 10.0, y: 20.0 }, + size: ic::CGSize { width: 100.0, height: 200.0 }, + }; + let result = round_to_physical(rect, 2.0); + assert_eq!(result.origin.x, 10.0); + assert_eq!(result.origin.y, 20.0); + assert_eq!(result.size.width, 100.0); + assert_eq!(result.size.height, 200.0); + } +} diff --git a/src/sys/screen.rs b/src/sys/screen.rs index dddf6da..c6f057a 100644 --- a/src/sys/screen.rs +++ b/src/sys/screen.rs @@ -56,9 +56,7 @@ impl ScreenCache { /// The main screen (if any) is always first. Note that there may be no /// screens. #[forbid(unsafe_code)] // called from test - pub fn update_screen_config( - &mut self, - ) -> Option<(Vec, Vec, CoordinateConverter)> { + pub fn update_screen_config(&mut self) -> Option<(Vec, CoordinateConverter)> { let ns_screens = self.system.ns_screens(); debug!("ns_screens={ns_screens:?}"); @@ -77,7 +75,7 @@ impl ScreenCache { } if cg_screens.is_empty() { - return Some((vec![], vec![], CoordinateConverter::default())); + return Some((vec![], CoordinateConverter::default())); }; // Ensure that the main screen is always first. @@ -101,7 +99,7 @@ impl ScreenCache { screen_height: cg_screens[0].bounds.max().y, }; - let (visible_frames, ids) = cg_screens + let screens: Vec = cg_screens .iter() .flat_map(|&CGScreenInfo { cg_id, .. }| { let Some(ns_screen) = ns_screens.iter().find(|s| s.cg_id == cg_id) else { @@ -109,10 +107,14 @@ impl ScreenCache { return None; }; let converted = converter.convert_rect(ns_screen.visible_frame).unwrap(); - Some((converted, cg_id)) + Some(ScreenInfo { + visible_frame: converted, + id: cg_id, + scale_factor: ns_screen.backing_scale_factor, + }) }) - .unzip(); - Some((visible_frames, ids, converter)) + .collect(); + Some((screens, converter)) } /// Returns a list of the active spaces on each screen. The order @@ -185,6 +187,7 @@ struct NSScreenInfo { frame: CGRect, visible_frame: CGRect, cg_id: ScreenId, + backing_scale_factor: f64, } pub struct Actual { @@ -233,6 +236,7 @@ impl System for Actual { frame: s.frame(), visible_frame: s.visibleFrame(), cg_id: s.get_number().ok()?, + backing_scale_factor: s.backingScaleFactor(), }) }) .collect() @@ -241,6 +245,13 @@ impl System for Actual { type CGDirectDisplayID = u32; +#[derive(Debug, Clone)] +pub struct ScreenInfo { + pub visible_frame: CGRect, + pub id: ScreenId, + pub scale_factor: f64, +} + #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)] pub struct ScreenId(CGDirectDisplayID); @@ -414,6 +425,7 @@ mod test { CGPoint::new(0.0, 76.0), CGSize::new(3840.0, 2059.0), ), + backing_scale_factor: 2.0, }, NSScreenInfo { cg_id: ScreenId(1), @@ -422,6 +434,7 @@ mod test { CGPoint::new(3840.0, 98.0), CGSize::new(1512.0, 950.0), ), + backing_scale_factor: 2.0, }, ], }; @@ -431,7 +444,12 @@ mod test { CGRect::new(CGPoint::new(0.0, 25.0), CGSize::new(3840.0, 2059.0)), CGRect::new(CGPoint::new(3840.0, 1112.0), CGSize::new(1512.0, 950.0)), ], - sc.update_screen_config().unwrap().0 + sc.update_screen_config() + .unwrap() + .0 + .iter() + .map(|s| s.visible_frame) + .collect::>() ); } }