diff options
| author | Ivan Molodetskikh <yalterz@gmail.com> | 2024-11-30 09:18:33 +0300 |
|---|---|---|
| committer | Ivan Molodetskikh <yalterz@gmail.com> | 2024-12-01 22:24:21 -0800 |
| commit | 8665003269d1fbe4efe3c477a71400392930cac9 (patch) | |
| tree | c8c7bc727032c51259e4bdbd6e1541c859cc6e0f /src/layout/scrolling.rs | |
| parent | 1e76716819ecda33dca0e612d62a8f6c2892890d (diff) | |
| download | niri-8665003269d1fbe4efe3c477a71400392930cac9.tar.gz niri-8665003269d1fbe4efe3c477a71400392930cac9.tar.bz2 niri-8665003269d1fbe4efe3c477a71400392930cac9.zip | |
layout: Extract ScrollingSpace
Leave the Workspace to do the workspace parts, and extract the scrolling parts
into a new file. This is a pre-requisite for things like the floating layer
(which will live in a workspace alongside the scrolling layer).
As part of this huge refactor, I found and fixed at least these issues:
- Wrong horizontal popup unconstraining for a smaller window in an
always-centered column.
- Wrong workspace switch in focus_up_or_right().
Diffstat (limited to 'src/layout/scrolling.rs')
| -rw-r--r-- | src/layout/scrolling.rs | 3985 |
1 files changed, 3985 insertions, 0 deletions
diff --git a/src/layout/scrolling.rs b/src/layout/scrolling.rs new file mode 100644 index 00000000..cd75fb06 --- /dev/null +++ b/src/layout/scrolling.rs @@ -0,0 +1,3985 @@ +use std::cmp::{max, min}; +use std::iter::{self, zip}; +use std::rc::Rc; +use std::time::Duration; + +use niri_config::{CenterFocusedColumn, CornerRadius, PresetSize, Struts}; +use niri_ipc::SizeChange; +use ordered_float::NotNan; +use smithay::backend::renderer::gles::GlesRenderer; +use smithay::utils::{Logical, Point, Rectangle, Scale, Serial, Size}; + +use super::closing_window::{ClosingWindow, ClosingWindowRenderElement}; +use super::insert_hint_element::{InsertHintElement, InsertHintRenderElement}; +use super::tile::{Tile, TileRenderElement, TileRenderSnapshot}; +use super::workspace::{InteractiveResize, ResolvedSize}; +use super::{ConfigureIntent, InteractiveResizeData, LayoutElement, Options, RemovedTile}; +use crate::animation::{Animation, Clock}; +use crate::input::swipe_tracker::SwipeTracker; +use crate::niri_render_elements; +use crate::render_helpers::renderer::NiriRenderer; +use crate::render_helpers::RenderTarget; +use crate::utils::transaction::{Transaction, TransactionBlocker}; +use crate::utils::ResizeEdge; +use crate::window::ResolvedWindowRules; + +/// Amount of touchpad movement to scroll the view for the width of one working area. +const VIEW_GESTURE_WORKING_AREA_MOVEMENT: f64 = 1200.; + +/// A scrollable-tiling space for windows. +#[derive(Debug)] +pub struct ScrollingSpace<W: LayoutElement> { + /// Columns of windows on this space. + columns: Vec<Column<W>>, + + /// Extra per-column data. + data: Vec<ColumnData>, + + /// Index of the currently active column, if any. + active_column_idx: usize, + + /// Ongoing interactive resize. + interactive_resize: Option<InteractiveResize<W>>, + + /// Offset of the view computed from the active column. + /// + /// Any gaps, including left padding from work area left exclusive zone, is handled + /// with this view offset (rather than added as a constant elsewhere in the code). This allows + /// for natural handling of fullscreen windows, which must ignore work area padding. + view_offset: ViewOffset, + + /// Whether to activate the previous, rather than the next, column upon column removal. + /// + /// When a new column is created and removed with no focus changes in-between, it is more + /// natural to activate the previously-focused column. This variable tracks that. + /// + /// Since we only create-and-activate columns immediately to the right of the active column (in + /// contrast to tabs in Firefox, for example), we can track this as a bool, rather than an + /// index of the previous column to activate. + /// + /// The value is the view offset that the previous column had before, to restore it. + activate_prev_column_on_removal: Option<f64>, + + /// View offset to restore after unfullscreening. + view_offset_before_fullscreen: Option<f64>, + + /// Windows in the closing animation. + closing_windows: Vec<ClosingWindow>, + + /// Indication where an interactively-moved window is about to be placed. + insert_hint: Option<InsertHint>, + + /// Insert hint element for rendering. + insert_hint_element: InsertHintElement, + + /// View size for this space. + view_size: Size<f64, Logical>, + + /// Working area for this space. + /// + /// Takes into account layer-shell exclusive zones and niri struts. + working_area: Rectangle<f64, Logical>, + + /// Scale of the output the space is on (and rounds its sizes to). + scale: f64, + + /// Clock for driving animations. + clock: Clock, + + /// Configurable properties of the layout. + options: Rc<Options>, +} + +niri_render_elements! { + ScrollingSpaceRenderElement<R> => { + Tile = TileRenderElement<R>, + ClosingWindow = ClosingWindowRenderElement, + InsertHint = InsertHintRenderElement, + } +} + +#[derive(Debug, PartialEq)] +pub enum InsertPosition { + NewColumn(usize), + InColumn(usize, usize), +} + +#[derive(Debug)] +pub struct InsertHint { + pub position: InsertPosition, + pub width: ColumnWidth, + pub is_full_width: bool, + pub corner_radius: CornerRadius, +} + +/// Extra per-column data. +#[derive(Debug, Clone, Copy, PartialEq)] +struct ColumnData { + /// Cached actual column width. + width: f64, +} + +#[derive(Debug)] +enum ViewOffset { + /// The view offset is static. + Static(f64), + /// The view offset is animating. + Animation(Animation), + /// The view offset is controlled by the ongoing gesture. + Gesture(ViewGesture), +} + +#[derive(Debug)] +struct ViewGesture { + current_view_offset: f64, + tracker: SwipeTracker, + delta_from_tracker: f64, + // The view offset we'll use if needed for activate_prev_column_on_removal. + stationary_view_offset: f64, + /// Whether the gesture is controlled by the touchpad. + is_touchpad: bool, +} + +#[derive(Debug)] +pub struct Column<W: LayoutElement> { + /// Tiles in this column. + /// + /// Must be non-empty. + tiles: Vec<Tile<W>>, + + /// Extra per-tile data. + /// + /// Must have the same number of elements as `tiles`. + data: Vec<TileData>, + + /// Index of the currently active tile. + active_tile_idx: usize, + + /// Desired width of this column. + /// + /// If the column is full-width or full-screened, this is the width that should be restored + /// upon unfullscreening and untoggling full-width. + width: ColumnWidth, + + /// Whether this column is full-width. + is_full_width: bool, + + /// Whether this column contains a single full-screened window. + is_fullscreen: bool, + + /// Animation of the render offset during window swapping. + move_animation: Option<Animation>, + + /// Latest known view size for this column's workspace. + view_size: Size<f64, Logical>, + + /// Latest known working area for this column's workspace. + working_area: Rectangle<f64, Logical>, + + /// Scale of the output the column is on (and rounds its sizes to). + scale: f64, + + /// Clock for driving animations. + clock: Clock, + + /// Configurable properties of the layout. + options: Rc<Options>, +} + +/// Extra per-tile data. +#[derive(Debug, Clone, Copy, PartialEq)] +struct TileData { + /// Requested height of the window. + /// + /// This is window height, not tile height, so it excludes tile decorations. + height: WindowHeight, + + /// Cached actual size of the tile. + size: Size<f64, Logical>, + + /// Cached whether the tile is being interactively resized by its left edge. + interactively_resizing_by_left_edge: bool, +} + +/// Width of a column. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ColumnWidth { + /// Proportion of the current view width. + Proportion(f64), + /// Fixed width in logical pixels. + Fixed(f64), + /// One of the preset widths. + Preset(usize), +} + +/// Height of a window in a column. +/// +/// Every window but one in a column must be `Auto`-sized so that the total height can add up to +/// the workspace height. Resizing a window converts all other windows to `Auto`, weighted to +/// preserve their visual heights at the moment of the conversion. +/// +/// In contrast to column widths, proportional height changes are converted to, and stored as, +/// fixed height right away. With column widths you frequently want e.g. two columns side-by-side +/// with 50% width each, and you want them to remain this way when moving to a differently sized +/// monitor. Windows in a column, however, already auto-size to fill the available height, giving +/// you this behavior. The main reason to set a different window height, then, is when you want +/// something in the window to fit exactly, e.g. to fit 30 lines in a terminal, which corresponds +/// to the `Fixed` variant. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum WindowHeight { + /// Automatically computed *tile* height, distributed across the column according to weights. + /// + /// This controls the tile height rather than the window height because it's easier in the auto + /// height distribution algorithm. + Auto { weight: f64 }, + /// Fixed *window* height in logical pixels. + Fixed(f64), + /// One of the preset heights (tile or window). + Preset(usize), +} + +impl<W: LayoutElement> ScrollingSpace<W> { + pub fn new( + view_size: Size<f64, Logical>, + working_area: Rectangle<f64, Logical>, + scale: f64, + clock: Clock, + options: Rc<Options>, + ) -> Self { + let working_area = compute_working_area(working_area, scale, options.struts); + + Self { + columns: Vec::new(), + data: Vec::new(), + active_column_idx: 0, + interactive_resize: None, + view_offset: ViewOffset::Static(0.), + activate_prev_column_on_removal: None, + view_offset_before_fullscreen: None, + closing_windows: Vec::new(), + insert_hint: None, + insert_hint_element: InsertHintElement::new(options.insert_hint), + view_size, + working_area, + scale, + clock, + options, + } + } + + pub fn update_config( + &mut self, + view_size: Size<f64, Logical>, + working_area: Rectangle<f64, Logical>, + scale: f64, + options: Rc<Options>, + ) { + let working_area = compute_working_area(working_area, scale, options.struts); + + for (column, data) in zip(&mut self.columns, &mut self.data) { + column.update_config(view_size, working_area, scale, options.clone()); + data.update(column); + } + + self.insert_hint_element.update_config(options.insert_hint); + + self.view_size = view_size; + self.working_area = working_area; + self.scale = scale; + self.options = options; + } + + pub fn update_shaders(&mut self) { + for tile in self.tiles_mut() { + tile.update_shaders(); + } + + self.insert_hint_element.update_shaders(); + } + + pub fn advance_animations(&mut self) { + if let ViewOffset::Animation(anim) = &self.view_offset { + if anim.is_done() { + self.view_offset = ViewOffset::Static(anim.to()); + } + } + + for col in &mut self.columns { + col.advance_animations(); + } + + self.closing_windows.retain_mut(|closing| { + closing.advance_animations(); + closing.are_animations_ongoing() + }); + } + + pub fn are_animations_ongoing(&self) -> bool { + self.view_offset.is_animation() + || self.columns.iter().any(Column::are_animations_ongoing) + || !self.closing_windows.is_empty() + } + + pub fn are_transitions_ongoing(&self) -> bool { + !self.view_offset.is_static() + || self.columns.iter().any(Column::are_animations_ongoing) + || !self.closing_windows.is_empty() + } + + pub fn update_render_elements(&mut self, is_active: bool) { + let view_pos = Point::from((self.view_pos(), 0.)); + let view_size = self.view_size(); + let active_idx = self.active_column_idx; + for (col_idx, (col, col_x)) in self.columns_mut().enumerate() { + let is_active = is_active && col_idx == active_idx; + let col_off = Point::from((col_x, 0.)); + let col_pos = view_pos - col_off - col.render_offset(); + let view_rect = Rectangle::from_loc_and_size(col_pos, view_size); + col.update_render_elements(is_active, view_rect); + } + + if let Some(insert_hint) = &self.insert_hint { + if let Some(area) = self.insert_hint_area(insert_hint) { + let view_rect = Rectangle::from_loc_and_size(area.loc.upscale(-1.), view_size); + self.insert_hint_element.update_render_elements( + area.size, + view_rect, + insert_hint.corner_radius, + self.scale, + ); + } + } + } + + pub fn tiles(&self) -> impl Iterator<Item = &Tile<W>> + '_ { + self.columns.iter().flat_map(|col| col.tiles.iter()) + } + + pub fn tiles_mut(&mut self) -> impl Iterator<Item = &mut Tile<W>> + '_ { + self.columns.iter_mut().flat_map(|col| col.tiles.iter_mut()) + } + + pub fn active_window(&self) -> Option<&W> { + if self.columns.is_empty() { + return None; + } + + let col = &self.columns[self.active_column_idx]; + Some(col.tiles[col.active_tile_idx].window()) + } + + pub fn is_active_fullscreen(&self) -> bool { + if self.columns.is_empty() { + return false; + } + + let col = &self.columns[self.active_column_idx]; + col.is_fullscreen + } + + pub fn toplevel_bounds(&self, rules: &ResolvedWindowRules) -> Size<i32, Logical> { + let border_config = rules.border.resolve_against(self.options.border); + compute_toplevel_bounds(border_config, self.working_area.size, self.options.gaps) + } + + pub fn new_window_size( + &self, + width: Option<ColumnWidth>, + rules: &ResolvedWindowRules, + ) -> Size<i32, Logical> { + let border = rules.border.resolve_against(self.options.border); + + let width = if let Some(width) = width { + let is_fixed = matches!(width, ColumnWidth::Fixed(_)); + + let mut width = width.resolve(&self.options, self.working_area.size.w); + + if !is_fixed && !border.off { + width -= border.width.0 * 2.; + } + + max(1, width.floor() as i32) + } else { + 0 + }; + + let mut height = self.working_area.size.h - self.options.gaps * 2.; + if !border.off { + height -= border.width.0 * 2.; + } + + Size::from((width, max(height.floor() as i32, 1))) + } + + pub fn is_centering_focused_column(&self) -> bool { + self.options.center_focused_column == CenterFocusedColumn::Always + || (self.options.always_center_single_column && self.columns.len() <= 1) + } + + fn compute_new_view_offset_fit( + &self, + target_x: Option<f64>, + col_x: f64, + width: f64, + is_fullscreen: bool, + ) -> f64 { + if is_fullscreen { + return 0.; + } + + let target_x = target_x.unwrap_or_else(|| self.target_view_pos()); + + let new_offset = compute_new_view_offset( + target_x + self.working_area.loc.x, + self.working_area.size.w, + col_x, + width, + self.options.gaps, + ); + + // Non-fullscreen windows are always offset at least by the working area position. + new_offset - self.working_area.loc.x + } + + fn compute_new_view_offset_centered( + &self, + target_x: Option<f64>, + col_x: f64, + width: f64, + is_fullscreen: bool, + ) -> f64 { + if is_fullscreen { + return self.compute_new_view_offset_fit(target_x, col_x, width, is_fullscreen); + } + + // Columns wider than the view are left-aligned (the fit code can deal with that). + if self.working_area.size.w <= width { + return self.compute_new_view_offset_fit(target_x, col_x, width, is_fullscreen); + } + + -(self.working_area.size.w - width) / 2. - self.working_area.loc.x + } + + fn compute_new_view_offset_for_column_fit(&self, target_x: Option<f64>, idx: usize) -> f64 { + let col = &self.columns[idx]; + self.compute_new_view_offset_fit( + target_x, + self.column_x(idx), + col.width(), + col.is_fullscreen, + ) + } + + fn compute_new_view_offset_for_column_centered( + &self, + target_x: Option<f64>, + idx: usize, + ) -> f64 { + let col = &self.columns[idx]; + self.compute_new_view_offset_centered( + target_x, + self.column_x(idx), + col.width(), + col.is_fullscreen, + ) + } + + fn compute_new_view_offset_for_column( + &self, + target_x: Option<f64>, + idx: usize, + prev_idx: Option<usize>, + ) -> f64 { + if self.is_centering_focused_column() { + return self.compute_new_view_offset_for_column_centered(target_x, idx); + } + + match self.options.center_focused_column { + CenterFocusedColumn::Always => { + self.compute_new_view_offset_for_column_centered(target_x, idx) + } + CenterFocusedColumn::OnOverflow => { + let Some(prev_idx) = prev_idx else { + return self.compute_new_view_offset_for_column_fit(target_x, idx); + }; + + // Always take the left or right neighbor of the target as the source. + let source_idx = if prev_idx > idx { + min(idx + 1, self.columns.len() - 1) + } else { + idx.saturating_sub(1) + }; + + let source_col_x = self.column_x(source_idx); + let source_col_width = self.columns[source_idx].width(); + + let target_col_x = self.column_x(idx); + let target_col_width = self.columns[idx].width(); + + let total_width = if source_col_x < target_col_x { + // Source is left from target. + target_col_x - source_col_x + target_col_width + } else { + // Source is right from target. + source_col_x - target_col_x + source_col_width + } + self.options.gaps * 2.; + + // If it fits together, do a normal animation, otherwise center the new column. + if total_width <= self.working_area.size.w { + self.compute_new_view_offset_for_column_fit(target_x, idx) + } else { + self.compute_new_view_offset_for_column_centered(target_x, idx) + } + } + CenterFocusedColumn::Never => { + self.compute_new_view_offset_for_column_fit(target_x, idx) + } + } + } + + fn animate_view_offset(&mut self, idx: usize, new_view_offset: f64) { + self.animate_view_offset_with_config( + idx, + new_view_offset, + self.options.animations.horizontal_view_movement.0, + ); + } + + fn animate_view_offset_with_config( + &mut self, + idx: usize, + new_view_offset: f64, + config: niri_config::Animation, + ) { + self.view_offset.cancel_gesture(); + + let new_col_x = self.column_x(idx); + let old_col_x = self.column_x(self.active_column_idx); + let offset_delta = old_col_x - new_col_x; + self.view_offset.offset(offset_delta); + + let pixel = 1. / self.scale; + + // If our view offset is already this or animating towards this, we don't need to do + // anything. + let to_diff = new_view_offset - self.view_offset.target(); + if to_diff.abs() < pixel { + // Correct for any inaccuracy. + self.view_offset.offset(to_diff); + return; + } + + // FIXME: also compute and use current velocity. + self.view_offset = ViewOffset::Animation(Animation::new( + self.clock.clone(), + self.view_offset.current(), + new_view_offset, + 0., + config, + )); + } + + fn animate_view_offset_to_column_centered( + &mut self, + target_x: Option<f64>, + idx: usize, + config: niri_config::Animation, + ) { + let new_view_offset = self.compute_new_view_offset_for_column_centered(target_x, idx); + self.animate_view_offset_with_config(idx, new_view_offset, config); + } + + fn animate_view_offset_to_column_with_config( + &mut self, + target_x: Option<f64>, + idx: usize, + prev_idx: Option<usize>, + config: niri_config::Animation, + ) { + let new_view_offset = self.compute_new_view_offset_for_column(target_x, idx, prev_idx); + self.animate_view_offset_with_config(idx, new_view_offset, config); + } + + fn animate_view_offset_to_column( + &mut self, + target_x: Option<f64>, + idx: usize, + prev_idx: Option<usize>, + ) { + self.animate_view_offset_to_column_with_config( + target_x, + idx, + prev_idx, + self.options.animations.horizontal_view_movement.0, + ) + } + + fn activate_column(&mut self, idx: usize) { + self.activate_column_with_anim_config( + idx, + self.options.animations.horizontal_view_movement.0, + ); + } + + fn activate_column_with_anim_config(&mut self, idx: usize, config: niri_config::Animation) { + if self.active_column_idx == idx { + return; + } + + self.animate_view_offset_to_column_with_config( + None, + idx, + Some(self.active_column_idx), + config, + ); + + self.active_column_idx = idx; + + // A different column was activated; reset the flag. + self.activate_prev_column_on_removal = None; + self.view_offset_before_fullscreen = None; + self.interactive_resize = None; + } + + pub fn set_insert_hint(&mut self, insert_hint: InsertHint) { + if self.options.insert_hint.off { + return; + } + self.insert_hint = Some(insert_hint); + } + + pub fn clear_insert_hint(&mut self) { + self.insert_hint = None; + } + + pub fn get_insert_position(&self, pos: Point<f64, Logical>) -> InsertPosition { + if self.columns.is_empty() { + return InsertPosition::NewColumn(0); + } + + let x = pos.x + self.view_pos(); + + // Aim for the center of the gap. + let x = x + self.options.gaps / 2.; + let y = pos.y + self.options.gaps / 2.; + + // Insert position is before the first column. + if x < 0. { + return InsertPosition::NewColumn(0); + } + + // Find the closest gap between columns. + let (closest_col_idx, col_x) = self + .column_xs(self.data.iter().copied()) + .enumerate() + .min_by_key(|(_, col_x)| NotNan::new((col_x - x).abs()).unwrap()) + .unwrap(); + + // Find the column containing the position. + let (col_idx, _) = self + .column_xs(self.data.iter().copied()) + .enumerate() + .take_while(|(_, col_x)| *col_x <= x) + .last() + .unwrap_or((0, 0.)); + + // Insert position is past the last column. + if col_idx == self.columns.len() { + return InsertPosition::NewColumn(closest_col_idx); + } + + // Find the closest gap between tiles. + let col = &self.columns[col_idx]; + let (closest_tile_idx, tile_off) = col + .tile_offsets() + .enumerate() + .min_by_key(|(_, tile_off)| NotNan::new((tile_off.y - y).abs()).unwrap()) + .unwrap(); + + // Return the closest among the vertical and the horizontal gap. + let vert_dist = (col_x - x).abs(); + let hor_dist = (tile_off.y - y).abs(); + if vert_dist <= hor_dist { + InsertPosition::NewColumn(closest_col_idx) + } else { + InsertPosition::InColumn(col_idx, closest_tile_idx) + } + } + + pub fn add_tile( + &mut self, + col_idx: Option<usize>, + tile: Tile<W>, + activate: bool, + width: ColumnWidth, + is_full_width: bool, + anim_config: Option<niri_config::Animation>, + ) { + let column = Column::new_with_tile( + tile, + self.view_size, + self.working_area, + self.scale, + width, + is_full_width, + true, + ); + + self.add_column(col_idx, column, activate, anim_config); + } + + pub fn add_tile_to_column( + &mut self, + col_idx: usize, + tile_idx: Option<usize>, + tile: Tile<W>, + activate: bool, + ) { + let prev_next_x = self.column_x(col_idx + 1); + + let target_column = &mut self.columns[col_idx]; + let tile_idx = tile_idx.unwrap_or(target_column.tiles.len()); + let was_fullscreen = target_column.tiles[target_column.active_tile_idx].is_fullscreen(); + + target_column.add_tile_at(tile_idx, tile, true); + self.data[col_idx].update(target_column); + + // If the target column is the active column and its window was requested to, but hasn't + // gone into fullscreen yet, then clear the stored view offset, since we just asked it to + // stop going into fullscreen. + if col_idx == self.active_column_idx && !was_fullscreen { + self.view_offset_before_fullscreen = None; + } + + if activate { + target_column.active_tile_idx = tile_idx; + if self.active_column_idx != col_idx { + self.activate_column(col_idx); + } + } else if tile_idx <= target_column.active_tile_idx { + target_column.active_tile_idx += 1; + } + + // Adding a wider window into a column increases its width now (even if the window will + // shrink later). Move the columns to account for this. + let offset = self.column_x(col_idx + 1) - prev_next_x; + if self.active_column_idx <= col_idx { + for col in &mut self.columns[col_idx + 1..] { + col.animate_move_from(-offset); + } + } else { + for col in &mut self.columns[..=col_idx] { + col.animate_move_from(offset); + } + } + } + + pub fn add_tile_right_of( + &mut self, + right_of: &W::Id, + tile: Tile<W>, + width: ColumnWidth, + is_full_width: bool, + ) { + let right_of_idx = self + .columns + .iter() + .position(|col| col.contains(right_of)) + .unwrap(); + let col_idx = right_of_idx + 1; + + // Activate the new window if right_of was active. + let activate = self.active_column_idx == right_of_idx; + + self.add_tile(Some(col_idx), tile, activate, width, is_full_width, None); + } + + pub fn add_column( + &mut self, + idx: Option<usize>, + mut column: Column<W>, + activate: bool, + anim_config: Option<niri_config::Animation>, + ) { + let was_empty = self.columns.is_empty(); + + let idx = idx.unwrap_or_else(|| { + if was_empty { + 0 + } else { + self.active_column_idx + 1 + } + }); + + column.update_config( + self.view_size, + self.working_area, + self.scale, + self.options.clone(), + ); + self.data.insert(idx, ColumnData::new(&column)); + self.columns.insert(idx, column); + + if activate { + // If this is the first window on an empty workspace, remove the effect of whatever + // view_offset was left over and skip the animation. + if was_empty { + self.view_offset = ViewOffset::Static(0.); + self.view_offset = + ViewOffset::Static(self.compute_new_view_offset_for_column(None, idx, None)); + } + + let prev_offset = (!was_empty && idx == self.active_column_idx + 1) + .then(|| self.view_offset.stationary()); + + let anim_config = + anim_config.unwrap_or(self.options.animations.horizontal_view_movement.0); + self.activate_column_with_anim_config(idx, anim_config); + self.activate_prev_column_on_removal = prev_offset; + } else if !was_empty && idx <= self.active_column_idx { + self.active_column_idx += 1; + } + + // Animate movement of other columns. + let offset = self.column_x(idx + 1) - self.column_x(idx); + let config = anim_config.unwrap_or(self.options.animations.window_movement.0); + if self.active_column_idx <= idx { + for col in &mut self.columns[idx + 1..] { + col.animate_move_from_with_config(-offset, config); + } + } else { + for col in &mut self.columns[..idx] { + col.animate_move_from_with_config(offset, config); + } + } + } + + pub fn remove_active_tile(&mut self, transaction: Transaction) -> Option<RemovedTile<W>> { + if self.columns.is_empty() { + return None; + } + + let column = &self.columns[self.active_column_idx]; + Some(self.remove_tile_by_idx( + self.active_column_idx, + column.active_tile_idx, + transaction, + None, + )) + } + + pub fn remove_tile(&mut self, window: &W::Id, transaction: Transaction) -> RemovedTile<W> { + let column_idx = self + .columns + .iter() + .position(|col| col.contains(window)) + .unwrap(); + let column = &self.columns[column_idx]; + + let tile_idx = column.position(window).unwrap(); + self.remove_tile_by_idx(column_idx, tile_idx, transaction, None) + } + + pub fn remove_tile_by_idx( + &mut self, + column_idx: usize, + tile_idx: usize, + transaction: Transaction, + anim_config: Option<niri_config::Animation>, + ) -> RemovedTile<W> { + // If this is the only tile in the column, remove the whole column. + if self.columns[column_idx].tiles.len() == 1 { + let mut column = self.remove_column_by_idx(column_idx, anim_config); + return RemovedTile { + tile: column.tiles.remove(tile_idx), + width: column.width, + is_full_width: column.is_full_width, + }; + } + + let column = &mut self.columns[column_idx]; + let prev_width = self.data[column_idx].width; + + // Animate movement of other tiles. + // FIXME: tiles can move by X too, in a centered or resizing layout with one window smaller + // than the others. + let offset_y = column.tile_offset(tile_idx + 1).y - column.tile_offset(tile_idx).y; + for tile in &mut column.tiles[tile_idx + 1..] { + tile.animate_move_y_from(offset_y); + } + + let tile = column.tiles.remove(tile_idx); + column.data.remove(tile_idx); + + // If one window is left, reset its weight to 1. + if column.data.len() == 1 { + if let WindowHeight::Auto { weight } = &mut column.data[0].height { + *weight = 1.; + } + } + + // Stop interactive resize. + if let Some(resize) = &self.interactive_resize { + if tile.window().id() == &resize.window { + self.interactive_resize = None; + } + } + + let tile = RemovedTile { + tile, + width: column.width, + is_full_width: column.is_full_width, + }; + + column.active_tile_idx = min(column.active_tile_idx, column.tiles.len() - 1); + column.update_tile_sizes_with_transaction(true, transaction); + self.data[column_idx].update(column); + let offset = prev_width - column.width(); + + // Animate movement of the other columns. + let movement_config = anim_config.unwrap_or(self.options.animations.window_movement.0); + if self.active_column_idx <= column_idx { + for col in &mut self.columns[column_idx + 1..] { + col.animate_move_from_with_config(offset, movement_config); + } + } else { + for col in &mut self.columns[..=column_idx] { + col.animate_move_from_with_config(-offset, movement_config); + } + } + + tile + } + + pub fn remove_active_column(&mut self) -> Option<Column<W>> { + if self.columns.is_empty() { + return None; + } + + Some(self.remove_column_by_idx(self.active_column_idx, None)) + } + + pub fn remove_column_by_idx( + &mut self, + column_idx: usize, + anim_config: Option<niri_config::Animation>, + ) -> Column<W> { + // Animate movement of the other columns. + let movement_config = anim_config.unwrap_or(self.options.animations.window_movement.0); + let offset = self.column_x(column_idx + 1) - self.column_x(column_idx); + if self.active_column_idx <= column_idx { + for col in &mut self.columns[column_idx + 1..] { + col.animate_move_from_with_config(offset, movement_config); + } + } else { + for col in &mut self.columns[..column_idx] { + col.animate_move_from_with_config(-offset, movement_config); + } + } + + let column = self.columns.remove(column_idx); + self.data.remove(column_idx); + + // Stop interactive resize. + if let Some(resize) = &self.interactive_resize { + if column + .tiles + .iter() + .any(|tile| tile.window().id() == &resize.window) + { + self.interactive_resize = None; + } + } + + if column_idx + 1 == self.active_column_idx { + // The previous column, that we were going to activate upon removal of the active + // column, has just been itself removed. + self.activate_prev_column_on_removal = None; + } + + if column_idx == self.active_column_idx { + self.view_offset_before_fullscreen = None; + } + + if self.columns.is_empty() { + return column; + } + + let view_config = anim_config.unwrap_or(self.options.animations.horizontal_view_movement.0); + + if column_ |
