diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index 3b0deb60..904df3c7 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -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; diff --git a/komorebi/src/scrolling.rs b/komorebi/src/scrolling.rs new file mode 100644 index 00000000..78beff65 --- /dev/null +++ b/komorebi/src/scrolling.rs @@ -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) -> 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 = 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 { + 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() + } +} diff --git a/komorebi/src/state.rs b/komorebi/src/state.rs index e43eccc8..931500e5 100644 --- a/komorebi/src/state.rs +++ b/komorebi/src/state.rs @@ -286,6 +286,7 @@ impl From<&WindowManager> for State { initial_window_placement_rules: workspace .initial_window_placement_rules .clone(), + scrolling: None, }) .collect::>(); ws.focus(monitor.workspaces.focused_idx()); diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index d0ee4676..867d4188 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -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, + /// The strip of columns that the Scrolling layout lays containers out in + #[serde(skip)] + #[cfg_attr(feature = "schemars", schemars(skip))] + pub(crate) scrolling: Option>, } #[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 { + pub(crate) fn effective_layout_options(&self) -> Option { 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();