feat(scrolling): make room for windows that will not get as narrow

After windows are placed, ask each how wide it really is. A window that is
wider than its column was made to be widens the column to fit, and the columns
after it move along. A column never gets narrower again because of this, and
never wider than the screen.

What a window says about its size is only used once it can be trusted: with
synchronous window handling and no animation in flight, and for an overhang
of more than 16px, so that rounding, a terminal's cell grid, or a window that
is still moving does not widen a column for good. Adopting a width is logged
at info level.
This commit is contained in:
LGUG2Z committed 2026-10-30 15:45:37 -07:00
1 parent 7c79625ba7
commit f170e8e2ac
2 files changed
+214 -82

No files matched your search

+107 -1
View File
@@ -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<LayoutOptions>) -> 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);
}
}
}
+107 -81
View File
@@ -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,
&regex_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,
&regex_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;
}
}
}