aboutsummaryrefslogtreecommitdiff
path: root/src/layout/scrolling.rs
diff options
context:
space:
mode:
authorIvan Molodetskikh <yalterz@gmail.com>2024-11-30 09:18:33 +0300
committerIvan Molodetskikh <yalterz@gmail.com>2024-12-01 22:24:21 -0800
commit8665003269d1fbe4efe3c477a71400392930cac9 (patch)
treec8c7bc727032c51259e4bdbd6e1541c859cc6e0f /src/layout/scrolling.rs
parent1e76716819ecda33dca0e612d62a8f6c2892890d (diff)
downloadniri-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.rs3985
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_