feat(scrolling): lay out the scrolling layout with the scrolling model

The Scrolling layout now keeps a ScrollingSpace per workspace, keyed by
container id, instead of recomputing equal-width columns from the previous
layout on every update. Column widths follow layout_options.scrolling.columns
and persist as windows come and go.
This commit is contained in:
LGUG2Z committed 2026-10-30 15:45:37 -07:00
1 parent 6c4eb621d3
commit 3b93035d48
4 files changed
+150 -13

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ pub mod process_command;
pub mod process_event;
pub mod process_movement;
pub mod reaper;
pub mod scrolling;
pub mod set_window_position;
pub mod splash;
pub mod stackbar_manager;
+122
View File
@@ -0,0 +1,122 @@
//! The glue between a [`Workspace`] and the platform-free model of a strip of columns in
//! `komorebi-scrolling`.
//!
//! A container is a tile in the strip, and is known to it by the id of the container.
use crate::core::DefaultLayout;
use crate::core::Layout;
use crate::core::LayoutOptions;
use crate::core::Rect;
use crate::workspace::Workspace;
use komorebi_scrolling::CenterFocusedColumn;
use komorebi_scrolling::ColumnWidth;
use komorebi_scrolling::Options;
use komorebi_scrolling::ScrollingSpace;
use komorebi_scrolling::Viewport;
use komorebi_scrolling::Visibility;
/// How many columns fit in the viewport when nothing says otherwise.
const DEFAULT_COLUMNS: usize = 3;
/// Where a container goes, and how much of it is in view.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ScrollingPlacement {
/// Where the container is in the strip, in the coordinates of the screen. A container that is
/// partly or wholly out of view has the rectangle that it really has, which reaches past the
/// work area.
pub rect: Rect,
#[allow(dead_code)]
pub visibility: Visibility,
}
/// What the layout options of a workspace come to for the model.
///
/// `columns` says how many columns fit in the viewport, which is to say that a new column is a
/// `columns`th of its width. It does not say how many columns there are, and it does not stretch
/// a lone column to fill the viewport.
pub(crate) fn scrolling_options(layout_options: Option<LayoutOptions>) -> Options {
let scrolling = layout_options.and_then(|options| options.scrolling);
let columns = scrolling
.map_or(DEFAULT_COLUMNS, |scrolling| scrolling.columns)
.max(1);
let centering = match scrolling.and_then(|scrolling| scrolling.center_focused_column) {
Some(true) => CenterFocusedColumn::Always,
_ => CenterFocusedColumn::Never,
};
Options {
default_width: ColumnWidth::Proportion(1.0 / columns as f64),
center_focused_column: centering,
..Options::default()
}
}
impl Workspace {
/// Whether the workspace is laid out as a strip of columns.
pub(crate) fn is_scrolling(&self) -> bool {
matches!(self.layout, Layout::Default(DefaultLayout::Scrolling))
}
/// Make the space match the containers, and the focus, as they are now.
fn sync_scrolling(&mut self, viewport: Viewport) {
let options = scrolling_options(self.effective_layout_options());
let ids: Vec<String> = self
.containers()
.iter()
.map(|container| container.id.clone())
.collect();
let focused = self
.focused_container()
.map(|container| container.id.clone());
let space = self
.scrolling
.get_or_insert_with(|| ScrollingSpace::new(options.clone(), viewport));
if *space.options() != options {
space.set_options(options);
}
if space.viewport() != viewport {
space.set_viewport(viewport);
}
space.reconcile(&ids, focused.as_ref());
}
/// Where every container goes in `work_area`, from left to right, with `container_padding`
/// taken off every side of each.
pub(crate) fn layout_scrolling(
&mut self,
work_area: &Rect,
container_padding: i32,
) -> Vec<ScrollingPlacement> {
self.sync_scrolling(Viewport {
width: work_area.right,
height: work_area.bottom,
});
let Some(space) = &self.scrolling else {
return vec![];
};
space
.layout()
.into_iter()
.map(|placement| {
let mut rect = Rect {
left: work_area.left + placement.rect.x,
top: work_area.top + placement.rect.y,
right: placement.rect.width,
bottom: placement.rect.height,
};
rect.add_padding(container_padding);
ScrollingPlacement {
rect,
visibility: placement.visibility,
}
})
.collect()
}
}
+1
View File
@@ -286,6 +286,7 @@ impl From<&WindowManager> for State {
initial_window_placement_rules: workspace
.initial_window_placement_rules
.clone(),
scrolling: None,
})
.collect::<VecDeque<_>>();
ws.focus(monitor.workspaces.focused_idx());
+26 -13
View File
@@ -46,6 +46,7 @@ use crate::window::WindowDetails;
use crate::windows_api::WindowsApi;
use color_eyre::eyre;
use color_eyre::eyre::OptionExt;
use komorebi_scrolling::ScrollingSpace;
use komorebi_themes::Base16ColourPalette;
use komorebi_themes::KomorebiThemeCustom as Custom;
use serde::Deserialize;
@@ -102,6 +103,10 @@ pub struct Workspace {
/// Initial window placement rules that determine where new tiled windows are placed
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_window_placement_rules: Option<InitialWindowPlacementRules>,
/// The strip of columns that the Scrolling layout lays containers out in
#[serde(skip)]
#[cfg_attr(feature = "schemars", schemars(skip))]
pub(crate) scrolling: Option<ScrollingSpace<String>>,
}
#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -159,6 +164,7 @@ impl Default for Workspace {
preselected_container_idx: None,
promotion_swap_container_idx: None,
initial_window_placement_rules: None,
scrolling: None,
}
}
}
@@ -364,7 +370,7 @@ impl Workspace {
/// 1. Try threshold match from rules (highest matching threshold wins)
/// 2. If a rule matches -> use it (full replacement of base)
/// 3. Else -> use the base `layout_options`
fn effective_layout_options(&self) -> Option<LayoutOptions> {
pub(crate) fn effective_layout_options(&self) -> Option<LayoutOptions> {
let container_count = self.containers().len();
let has_workspace_overrides =
@@ -715,18 +721,25 @@ impl Workspace {
self.layout_options,
self.layout_options_rules.len(),
);
let mut layouts = self.layout.as_boxed_arrangement().calculate(
&adjusted_work_area,
NonZeroUsize::new(self.containers().len()).ok_or_eyre(
"there must be at least one container to calculate a workspace layout",
)?,
Some(container_padding),
self.layout_flip,
&self.resize_dimensions,
self.focused_container_idx(),
effective_layout_options,
&self.latest_layout,
);
let mut layouts = if self.is_scrolling() {
self.layout_scrolling(&adjusted_work_area, container_padding)
.into_iter()
.map(|placement| placement.rect)
.collect()
} else {
self.layout.as_boxed_arrangement().calculate(
&adjusted_work_area,
NonZeroUsize::new(self.containers().len()).ok_or_eyre(
"there must be at least one container to calculate a workspace layout",
)?,
Some(container_padding),
self.layout_flip,
&self.resize_dimensions,
self.focused_container_idx(),
effective_layout_options,
&self.latest_layout,
)
};
let should_remove_titlebars = REMOVE_TITLEBARS.load(Ordering::SeqCst);
let no_titlebar = NO_TITLEBAR.lock().clone();