diff --git a/komorebi/src/scrolling.rs b/komorebi/src/scrolling.rs index 6f3164aa..868c67bf 100644 --- a/komorebi/src/scrolling.rs +++ b/komorebi/src/scrolling.rs @@ -4,6 +4,11 @@ //! `komorebi-scrolling` crate, so that every komorebi shares it. What is here is what needs a //! [`Workspace`]: a container is a tile in the strip, and is known to it by its id. +use crate::WINDOW_HANDLING_BEHAVIOUR; +use crate::animation::ANIMATION_ENABLED_GLOBAL; +use crate::animation::ANIMATION_ENABLED_PER_ANIMATION; +use crate::animation::ANIMATION_MANAGER; +use crate::animation::prefix::AnimationPrefix; use crate::container::Container; use crate::core::DefaultLayout; use crate::core::Layout; @@ -12,6 +17,8 @@ use crate::core::OperationDirection; use crate::core::Rect; use crate::core::ScrollingLayoutOptions; use crate::core::Sizing; +use crate::core::WindowHandlingBehaviour; +use crate::windows_api::WindowsApi; use crate::workspace::Workspace; use color_eyre::eyre; use komorebi_layouts::scrolling; @@ -25,7 +32,98 @@ pub(crate) fn scrolling_options(layout_options: Option) -> Option scrolling::scrolling_options(layout_options) } +/// How much wider than its column a window can be before it is taken to refuse to be narrower. +const WIDTH_TOLERANCE: i32 = 16; + +/// Whether what windows say about their size can be trusted to be what they will stay at: their +/// moves are done by the time they are asked, and none is being animated. +fn window_sizes_are_settled() -> bool { + matches!( + WINDOW_HANDLING_BEHAVIOUR.load(), + WindowHandlingBehaviour::Sync + ) && ANIMATION_MANAGER.lock().count() == 0 +} + +/// Whether windows that are moved are animated to where they go. +pub(crate) fn animations_enabled() -> bool { + ANIMATION_ENABLED_GLOBAL.load(Ordering::SeqCst) + || ANIMATION_ENABLED_PER_ANIMATION + .lock() + .get(&AnimationPrefix::Movement) + .is_some_and(|enabled| *enabled) +} + impl Workspace { + /// Learn from what windows turned out to be when they were sent to where the layout put them, + /// which is that some of them are wider than the layout said and will not be any narrower. + /// + /// A column is never made narrower than the window in it needs, and everything after it is + /// placed where it ends. Only ever wider: what an app will not do is a fact about the app, and + /// a limit that came and went would make the strip bounce between two layouts. + /// + /// `around_window` is what a column takes up around its window on either side, and the strip + /// is `viewport_width` wide. Returns whether any column changed. + pub(crate) fn adopt_actual_widths(&mut self, around_window: i32, viewport_width: i32) -> bool { + // a window that is still on its way, or whose move was only asked for, is not what it + // is going to be, and taking its size for a minimum would make the column too wide for good + if !window_sizes_are_settled() { + return false; + } + + let Some(space) = self.scrolling.as_mut() else { + return false; + }; + + let mut adopted = false; + + for (idx, container) in self.containers.elements().iter().enumerate() { + let Some(placed) = self.latest_layout.get(idx) else { + continue; + }; + + // a stack is one tile and as wide as the widest of its windows needs + let excess = container + .windows() + .iter() + .filter_map(|window| WindowsApi::window_rect(window.hwnd).ok()) + .map(|actual| actual.right - placed.right) + .max() + .unwrap_or_default(); + + // a window that is not wider than it was sent to be, give or take what rounding, the + // borders of the window and a terminal's grid of cells do to it, has nothing to say + if excess <= WIDTH_TOLERANCE { + continue; + } + + let Some(width) = space.column_width(&container.id) else { + continue; + }; + let Some((min, max)) = space.width_limits(&container.id) else { + continue; + }; + + let needed = ((width.round() as i32) + excess) + .min(viewport_width) + .max(around_window); + + if min.is_some_and(|min| min >= needed) { + continue; + } + + tracing::info!( + "a window in column {} is {excess}px wider than the {}px that it was placed at, so the column will not be narrower than {needed}px", + container.id, + placed.right + ); + + space.set_width_limits(&container.id, Some(needed), max); + adopted = true; + } + + adopted + } + /// 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)) @@ -455,13 +553,21 @@ impl Workspace { /// Put every column back to the width that the `columns` option gives, which forgets every /// width that was set by resizing, by a preset, or by taking the full width, and every window - /// back to an equal share of the height of its column. + /// back to an equal share of the height of its column. The narrowest that windows were found + /// to accept is forgotten as well. /// /// The focused column stays where it is on screen unless it has to scroll to stay in view. pub(crate) fn reset_scrolling_sizes(&mut self) { if let Some(space) = self.scrolling.as_mut() { space.reset_widths(); space.reset_heights(); + + // what windows were found to refuse is forgotten too, to be found out again if it is + // still so: a limit that was learned wrongly would otherwise survive the one command + // that is there to put everything back + for id in space.ids() { + space.set_width_limits(&id, None, None); + } } } diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index a7efcd2d..a72c0c62 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -712,89 +712,115 @@ impl Workspace { } else if let Some(window) = &mut self.maximized_window { window.maximize(); } else if !self.containers().is_empty() { - let effective_layout_options = self.effective_layout_options(); - - tracing::debug!( - "Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})", - self.name.as_deref().unwrap_or("unnamed"), - effective_layout_options, - self.layout_options, - self.layout_options_rules.len(), - ); - 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(); - let regex_identifiers = REGEX_IDENTIFIERS.lock().clone(); - - let containers = self.containers_mut(); - - for (i, container) in containers.iter_mut().enumerate() { - let window_count = container.windows().len(); - - if let Some(layout) = layouts.get_mut(i) { - layout.add_padding(border_offset); - layout.add_padding(border_width); - - if stackbar_manager::should_have_stackbar(window_count) { - let tab_height = STACKBAR_TAB_HEIGHT.load(Ordering::SeqCst); - let total_height = tab_height + container_padding; - - layout.top += total_height; - layout.bottom -= total_height; - } - - for window in container.windows() { - if container - .focused_window() - .is_some_and(|w| w.hwnd == window.hwnd) - { - let should_remove_titlebar_for_window = should_act( - &window.title().unwrap_or_default(), - &window.exe().unwrap_or_default(), - &window.class().unwrap_or_default(), - &window.path().unwrap_or_default(), - &no_titlebar, - ®ex_identifiers, - ) - .is_some(); - - if should_remove_titlebars && should_remove_titlebar_for_window { - window.remove_title_bar()?; - } else if should_remove_titlebar_for_window { - window.add_title_bar()?; - } - - // If a window has been unmaximized via toggle-maximize, this block - // will make sure that it is unmaximized via restore_window - if window.is_maximized() && !managed_maximized_window { - WindowsApi::restore_window(window.hwnd); - } - } - window.set_position(layout, false)?; - } - } + // windows that were not what they were told to be since the last layout, which + // is something that animations only show once they are done + let around_window = container_padding + border_offset + border_width; + if self.is_scrolling() && crate::scrolling::animations_enabled() { + self.adopt_actual_widths(around_window, adjusted_work_area.right); } - self.latest_layout = layouts; + let mut pass = 0; + loop { + let effective_layout_options = self.effective_layout_options(); + + tracing::debug!( + "Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})", + self.name.as_deref().unwrap_or("unnamed"), + effective_layout_options, + self.layout_options, + self.layout_options_rules.len(), + ); + 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(); + let regex_identifiers = REGEX_IDENTIFIERS.lock().clone(); + + let containers = self.containers_mut(); + + for (i, container) in containers.iter_mut().enumerate() { + let window_count = container.windows().len(); + + if let Some(layout) = layouts.get_mut(i) { + layout.add_padding(border_offset); + layout.add_padding(border_width); + + if stackbar_manager::should_have_stackbar(window_count) { + let tab_height = STACKBAR_TAB_HEIGHT.load(Ordering::SeqCst); + let total_height = tab_height + container_padding; + + layout.top += total_height; + layout.bottom -= total_height; + } + + for window in container.windows() { + if container + .focused_window() + .is_some_and(|w| w.hwnd == window.hwnd) + { + let should_remove_titlebar_for_window = should_act( + &window.title().unwrap_or_default(), + &window.exe().unwrap_or_default(), + &window.class().unwrap_or_default(), + &window.path().unwrap_or_default(), + &no_titlebar, + ®ex_identifiers, + ) + .is_some(); + + if should_remove_titlebars && should_remove_titlebar_for_window + { + window.remove_title_bar()?; + } else if should_remove_titlebar_for_window { + window.add_title_bar()?; + } + + // If a window has been unmaximized via toggle-maximize, this block + // will make sure that it is unmaximized via restore_window + if window.is_maximized() && !managed_maximized_window { + WindowsApi::restore_window(window.hwnd); + } + } + window.set_position(layout, false)?; + } + } + } + + self.latest_layout = layouts; + + // with animations off every window that changed width has been asked what it + // is by now, so a window that refused is known, and everything after it is + // placed again with room for it. Once is enough: a layout is never made to + // chase the sizes that are read back this time round. + if pass == 0 + && self.is_scrolling() + && !crate::scrolling::animations_enabled() + && self.adopt_actual_widths(around_window, adjusted_work_area.right) + { + pass += 1; + continue; + } + + break; + } } }