use bevy::app::{App, Plugin, Update};
use bevy::ecs::change_detection::{DetectChanges, DetectChangesMut, Ref};
use bevy::ecs::component::Component;
use bevy::ecs::entity::{Entity, EntityHashMap, EntityHashSet};
use bevy::ecs::hierarchy::ChildOf;
use bevy::ecs::query::{Changed, Has, Or, With, Without};
use bevy::ecs::schedule::IntoScheduleConfigs as _;
use bevy::ecs::schedule::common_conditions::{not, resource_exists};
use bevy::ecs::system::{Commands, ParamSet, Populated, Query, Res};
use bevy::math::IRect;
use std::collections::VecDeque;
use stdext::function_name;
use tracing::{Level, instrument, trace};
use crate::config::Config;
use crate::ecs::params::Windows;
use crate::ecs::workspace::SnapStripMarker;
use crate::ecs::{
ActiveWorkspaceMarker, Bounds, DockPosition, EnsureVisibleMarker, Initializing, LayoutPosition,
ManualStripOffset, Position, RepositionMarker, ReshuffleAroundMarker, Scrolling,
SpawnCommandsExt,
};
use crate::errors::{Error, Result};
use crate::manager::{Display, Origin, Size, Window};
use crate::platform::WorkspaceId;
use crate::util::round_px;
pub(crate) const MIN_WINDOW_HEIGHT: i32 = 200;
pub struct LayoutEventsPlugin;
pub(crate) const PARKED_STRIP_SLIVER: i32 = 10;
type StripPlacements<'w, 's> = Query<
'w,
's,
(
&'static LayoutStrip,
Entity,
&'static Position,
&'static ChildOf,
Option<Ref<'static, ActiveWorkspaceMarker>>,
Option<&'static RepositionMarker>,
),
>;
type DisplayViewports<'w, 's> = Query<'w, 's, (&'static Display, Option<&'static DockPosition>)>;
type StripsForWindowPositioning<'w, 's> = Query<
'w,
's,
(
Entity,
&'static LayoutStrip,
&'static Position,
Has<Scrolling>,
&'static ChildOf,
),
With<LayoutStrip>,
>;
type ResizedWindows<'w, 's> = Populated<
'w,
's,
Entity,
Or<(
(Changed<Bounds>, With<Window>),
(Changed<Position>, With<Window>),
)>,
>;
type WindowFrames<'w, 's> = Query<
'w,
's,
(
&'static Position,
&'static mut Bounds,
&'static mut LayoutPosition,
),
(Without<LayoutStrip>, With<Window>),
>;
type RepositionedWindows<'w, 's> = Populated<
'w,
's,
(
Entity,
&'static Window,
&'static LayoutPosition,
&'static mut Position,
&'static mut Bounds,
),
(Changed<LayoutPosition>, With<Window>, Without<LayoutStrip>),
>;
pub(crate) fn clamp_origin_to_viewport(origin: Origin, size: Size, viewport: IRect) -> Origin {
let far_edge = viewport.max - size;
let minimum = viewport.min.min(far_edge);
let maximum = viewport.min.max(far_edge);
origin.clamp(minimum, maximum)
}
const SIGNATURE_WIDTH_TOLERANCE: i32 = 2;
pub(crate) fn strip_signature(strip: &LayoutStrip, windows: &Windows) -> Vec<(Entity, i32)> {
strip
.all_columns()
.into_iter()
.filter_map(|entity| {
windows
.moving_frame(entity)
.map(|frame| (entity, frame.width()))
})
.collect()
}
fn signature_matches(current: &[(Entity, i32)], stored: &[(Entity, i32)]) -> bool {
current.len() == stored.len()
&& current.iter().zip(stored).all(|(now, before)| {
now.0 == before.0 && (now.1 - before.1).abs() <= SIGNATURE_WIDTH_TOLERANCE
})
}
pub(crate) fn origin_exposing(
layout: Origin,
size: Size,
origin: Origin,
viewport: IRect,
) -> Origin {
clamp_origin_to_viewport(layout + origin, size, viewport) - layout
}
impl Plugin for LayoutEventsPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
(
layout_sizes_changed,
layout_strip_changed,
reshuffle_layout_strip,
ensure_visible_in_strip,
position_layout_strips,
position_layout_windows,
)
.chain()
.after(super::systems::finish_setup)
.after(super::triggers::apply_window_positions)
.before(super::workspace::show_active_workspace)
.run_if(not(resource_exists::<Initializing>)),
),
);
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum StackItem {
Single(Entity),
Tabs(Vec<Entity>),
}
impl StackItem {
pub fn top(&self) -> Option<Entity> {
match self {
StackItem::Single(id) => Some(*id),
StackItem::Tabs(tabs) => tabs.first().copied(),
}
}
pub fn contains(&self, entity: Entity) -> bool {
match self {
StackItem::Single(id) => *id == entity,
StackItem::Tabs(tabs) => tabs.contains(&entity),
}
}
pub fn window_iter(&self) -> StackItemIter<'_> {
match self {
StackItem::Single(entity) => StackItemIter::Single(std::iter::once(*entity)),
StackItem::Tabs(tabs) => StackItemIter::Tabs(tabs.iter().copied()),
}
}
}
pub enum StackItemIter<'a> {
Single(std::iter::Once<Entity>),
Tabs(std::iter::Copied<std::slice::Iter<'a, Entity>>),
}
impl Iterator for StackItemIter<'_> {
type Item = Entity;
fn next(&mut self) -> Option<Self::Item> {
match self {
StackItemIter::Single(iter) => iter.next(),
StackItemIter::Tabs(iter) => iter.next(),
}
}
}
impl DoubleEndedIterator for StackItemIter<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
match self {
StackItemIter::Single(iter) => iter.next_back(),
StackItemIter::Tabs(iter) => iter.next_back(),
}
}
}
#[derive(Clone, Debug)]
pub enum Column {
Single(Entity),
Stack(Vec<StackItem>),
Tabs(Vec<Entity>),
Fullscren(Entity),
}
impl Column {
pub fn top(&self) -> Option<Entity> {
match self {
Column::Single(id) | Column::Fullscren(id) => Some(*id),
Column::Stack(stack) => stack.first().and_then(StackItem::top),
Column::Tabs(tabs) => tabs.first().copied(),
}
}
pub fn window_iter(&self) -> ColumnWindowIter<'_> {
match self {
Column::Single(entity) | Column::Fullscren(entity) => {
ColumnWindowIter::Single(std::iter::once(*entity))
}
Column::Tabs(tabs) => ColumnWindowIter::Tabs(tabs.iter().copied()),
Column::Stack(items) => {
ColumnWindowIter::Stack(items.iter().flat_map(StackItem::window_iter))
}
}
}
pub fn width<W>(&self, get_window_frame: &W) -> Option<i32>
where
W: Fn(Entity) -> Option<IRect>,
{
self.window_iter()
.filter_map(get_window_frame)
.map(|frame| frame.width())
.max()
}
pub fn at_or_last(&self, index: usize) -> Option<Entity> {
match self {
Column::Single(id) | Column::Fullscren(id) => Some(*id),
Column::Stack(stack) => stack
.get(index)
.or_else(|| stack.last())
.and_then(StackItem::top),
Column::Tabs(tabs) => tabs.first().copied(),
}
}
pub fn position_of(&self, entity: Entity) -> Option<usize> {
match self {
Column::Single(id) | Column::Fullscren(id) => (*id == entity).then_some(0),
Column::Stack(stack) => stack.iter().position(|item| item.contains(entity)),
Column::Tabs(tabs) => tabs.contains(&entity).then_some(0),
}
}
pub fn move_to_front(&mut self, entity: Entity) {
match self {
Column::Single(_) | Column::Fullscren(_) => {}
Column::Stack(stack) => {
if let Some(StackItem::Tabs(tabs)) =
stack.iter_mut().find(|item| item.contains(entity))
&& let Some(pos) = tabs.iter().position(|&e| e == entity)
{
tabs.swap(0, pos);
}
}
Column::Tabs(tabs) => {
if let Some(pos) = tabs.iter().position(|&e| e == entity) {
tabs.swap(0, pos);
}
}
}
}
}
pub enum ColumnWindowIter<'a> {
Single(std::iter::Once<Entity>),
Tabs(std::iter::Copied<std::slice::Iter<'a, Entity>>),
Stack(
std::iter::FlatMap<
std::slice::Iter<'a, StackItem>,
StackItemIter<'a>,
fn(&'a StackItem) -> StackItemIter<'a>,
>,
),
}
impl Iterator for ColumnWindowIter<'_> {
type Item = Entity;
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Single(iter) => iter.next(),
Self::Tabs(iter) => iter.next(),
Self::Stack(iter) => iter.next(),
}
}
}
#[derive(Component, Debug, Default)]
pub struct LayoutStrip {
id: WorkspaceId,
pub virtual_index: u32,
columns: VecDeque<Column>,
tabbed_stacks: EntityHashSet,
}
impl LayoutStrip {
pub fn new(id: WorkspaceId, virtual_index: u32) -> Self {
Self {
id,
virtual_index,
columns: VecDeque::new(),
tabbed_stacks: EntityHashSet::default(),
}
}
pub fn fullscreen(id: WorkspaceId, entity: Entity) -> Self {
let mut columns = VecDeque::new();
columns.push_back(Column::Fullscren(entity));
Self {
id,
virtual_index: 0,
columns,
tabbed_stacks: EntityHashSet::default(),
}
}
pub fn index_of(&self, entity: Entity) -> Result<usize> {
self.columns
.iter()
.position(|column| match column {
Column::Single(id) | Column::Fullscren(id) => *id == entity,
Column::Stack(stack) => stack.iter().any(|item| item.contains(entity)),
Column::Tabs(stack) => stack.contains(&entity),
})
.ok_or(Error::NotFound(format!(
"{}: can not find window {entity} in the current pane.",
function_name!()
)))
}
pub fn contains(&self, entity: Entity) -> bool {
self.columns.iter().any(|column| match column {
Column::Single(id) | Column::Fullscren(id) => *id == entity,
Column::Stack(stack) => stack.iter().any(|item| item.contains(entity)),
Column::Tabs(stack) => stack.contains(&entity),
})
}
pub fn insert_at(&mut self, index: usize, entity: Entity) {
if index >= self.len() {
self.columns.push_back(Column::Single(entity));
} else {
self.columns.insert(index, Column::Single(entity));
}
}
pub fn append(&mut self, entity: Entity) {
if self.contains(entity) {
return;
}
self.columns.push_back(Column::Single(entity));
}
pub(crate) fn append_strip(&mut self, other: &mut Self) {
self.columns.append(&mut other.columns);
}
pub fn append_tab_group(&mut self, entities: &[Entity]) {
let group = dedup_entities(entities);
if group.is_empty() {
return;
}
let index = group
.iter()
.filter_map(|entity| self.index_of(*entity).ok())
.min()
.unwrap_or(self.len());
self.insert_tab_group_at(index, &group);
}
pub fn insert_tab_group_at(&mut self, index: usize, entities: &[Entity]) {
let group = dedup_entities(entities);
if group.is_empty() {
return;
}
for entity in &group {
self.remove(*entity);
}
let index = index.min(self.len());
if group.len() == 1 {
self.insert_at(index, group[0]);
} else if index >= self.len() {
self.columns.push_back(Column::Tabs(group));
} else {
self.columns.insert(index, Column::Tabs(group));
}
}
pub fn convert_to_tabs(&mut self, leader: Entity, follower: Entity) -> Result<()> {
self.remove(follower);
let index = self.index_of(leader)?;
let column = self.columns.remove(index).unwrap();
match column {
Column::Single(id) | Column::Fullscren(id) => {
self.columns.insert(index, Column::Tabs(vec![follower, id]));
}
Column::Stack(mut items) => {
if let Some(pos) = items.iter().position(|item| item.contains(leader)) {
match &mut items[pos] {
StackItem::Single(id) => {
let id = *id;
items[pos] = StackItem::Tabs(vec![follower, id]);
}
StackItem::Tabs(tabs) => {
if !tabs.contains(&follower) {
tabs.insert(0, follower);
}
}
}
}
self.columns.insert(index, Column::Stack(items));
}
Column::Tabs(mut tabs) => {
if !tabs.contains(&follower) {
tabs.insert(0, follower);
}
self.columns.insert(index, Column::Tabs(tabs));
}
}
Ok(())
}
pub fn remove(&mut self, entity: Entity) {
let removed = self
.index_of(entity)
.ok()
.and_then(|index| self.columns.remove(index).zip(Some(index)));
if let Some((column, index)) = removed {
match column {
Column::Single(_) | Column::Fullscren(_) => {
}
Column::Stack(mut stack) => {
self.tabbed_stacks.remove(&entity);
for item in &mut stack {
match item {
StackItem::Single(_) => {}
StackItem::Tabs(tabs) => {
tabs.retain(|id| *id != entity);
}
}
}
stack.retain(|item| match item {
StackItem::Single(id) => *id != entity,
StackItem::Tabs(tabs) => !tabs.is_empty(),
});
if stack.len() > 1 {
self.columns.insert(index, Column::Stack(stack));
} else if let Some(remaining_item) = stack.first() {
for e in remaining_item.window_iter() {
self.tabbed_stacks.remove(&e);
}
match remaining_item {
StackItem::Single(id) => {
self.columns.insert(index, Column::Single(*id));
}
StackItem::Tabs(tabs) => {
self.columns.insert(index, Column::Tabs(tabs.clone()));
}
}
}
}
Column::Tabs(mut tabs) => {
tabs.retain(|id| *id != entity);
if tabs.len() > 1 {
self.columns.insert(index, Column::Tabs(tabs));
} else if let Some(remaining_id) = tabs.first() {
self.columns.insert(index, Column::Single(*remaining_id));
}
}
}
}
}
pub fn get(&self, at: usize) -> Result<Column> {
self.columns
.get(at)
.cloned()
.ok_or(Error::InvalidInput(format!(
"{}: {at} out of bounds",
function_name!()
)))
}
pub fn swap(&mut self, left: usize, right: usize) {
self.columns.swap(left, right);
}
pub fn len(&self) -> usize {
self.columns.len()
}
pub fn first(&self) -> Result<Column> {
self.columns.front().cloned().ok_or(Error::NotFound(format!(
"{}: can not find first element.",
function_name!()
)))
}
pub fn last(&self) -> Result<Column> {
self.columns.back().cloned().ok_or(Error::NotFound(format!(
"{}: can not find last element.",
function_name!()
)))
}
pub fn right_neighbour(&self, entity: Entity) -> Option<Entity> {
let index = self.index_of(entity).ok()?;
let stack_pos = self.columns.get(index)?.position_of(entity)?;
(index < self.columns.len())
.then_some(index + 1)
.and_then(|i| self.columns.get(i))
.and_then(|col| col.at_or_last(stack_pos))
}
pub fn left_neighbour(&self, entity: Entity) -> Option<Entity> {
let index = self.index_of(entity).ok()?;
let stack_pos = self.columns.get(index)?.position_of(entity)?;
(index > 0)
.then(|| index - 1)
.and_then(|i| self.columns.get(i))
.and_then(|col| col.at_or_last(stack_pos))
}
pub fn stack(&mut self, entity: Entity) -> Result<()> {
let index = self.index_of(entity)?;
if index == 0 {
return Ok(());
}
let column_to_stack = self.columns.remove(index).unwrap();
let items_to_stack = match column_to_stack {
Column::Fullscren(_) => return Ok(()),
Column::Single(id) => vec![StackItem::Single(id)],
Column::Tabs(tabs) => vec![StackItem::Tabs(tabs)],
Column::Stack(items) => items,
};
let target_column = self.columns.remove(index - 1).unwrap();
if let Column::Stack(items) = &target_column
&& items
.iter()
.flat_map(StackItem::window_iter)
.any(|e| self.tabbed_stacks.contains(&e))
{
for item in &items_to_stack {
self.tabbed_stacks.extend(item.window_iter());
}
}
let new_column = match target_column {
Column::Fullscren(_) => return Ok(()),
Column::Single(id) => {
Column::Stack([vec![StackItem::Single(id)], items_to_stack].concat())
}
Column::Tabs(tabs) => {
Column::Stack([vec![StackItem::Tabs(tabs)], items_to_stack].concat())
}
Column::Stack(items) => Column::Stack([items, items_to_stack].concat()),
};
self.columns.insert(index - 1, new_column);
Ok(())
}
pub fn unstack(&mut self, entity: Entity) -> Result<()> {
let index = self.index_of(entity)?;
let column = self.columns.remove(index).unwrap();
if let Column::Stack(mut items) = column {
let item_index = items
.iter()
.position(|item| item.contains(entity))
.ok_or(Error::NotFound(format!("Entity {entity} not in stack")))?;
let removed_item = items.remove(item_index);
for e in removed_item.window_iter() {
self.tabbed_stacks.remove(&e);
}
let unstacked_column = match removed_item {
StackItem::Single(id) => Column::Single(id),
StackItem::Tabs(tabs) => Column::Tabs(tabs),
};
self.columns.insert(index, unstacked_column);
if !items.is_empty() {
let new_column = if items.len() == 1 {
for e in items[0].window_iter() {
self.tabbed_stacks.remove(&e);
}
match items.remove(0) {
StackItem::Single(id) => Column::Single(id),
StackItem::Tabs(tabs) => Column::Tabs(tabs),
}
} else {
Column::Stack(items)
};
self.columns.insert(index, new_column);
}
Ok(())
} else {
self.columns.insert(index, column);
Ok(())
}
}
pub fn all_windows(&self) -> Vec<Entity> {
self.columns
.iter()
.flat_map(|column| match column {
Column::Single(entity) | Column::Fullscren(entity) => vec![*entity],
Column::Stack(items) => items.iter().flat_map(StackItem::window_iter).collect(),
Column::Tabs(ids) => ids.clone(),
})
.collect()
}
pub fn get_column_mut(&mut self, index: usize) -> Option<&mut Column> {
self.columns.get_mut(index)
}
pub fn all_columns(&self) -> Vec<Entity> {
self.columns.iter().filter_map(Column::top).collect()
}
pub fn id(&self) -> WorkspaceId {
self.id
}
pub fn columns(&self) -> impl Iterator<Item = &Column> {
self.columns.iter()
}
#[instrument(level = Level::TRACE, skip_all, fields(layout_strip_height))]
pub fn relative_positions<W>(
&self,
layout_strip_height: i32,
get_window_frame: &W,
) -> impl Iterator<Item = (Entity, IRect)>
where
W: Fn(Entity) -> Option<IRect>,
{
self.column_positions(get_window_frame)
.filter_map(move |(column, position)| {
let items: Vec<StackItem> = match column {
Column::Single(entity) | Column::Fullscren(entity) => {
vec![StackItem::Single(*entity)]
}
Column::Stack(stack) => stack.clone(),
Column::Tabs(tabs) => vec![StackItem::Tabs(tabs.clone())],
};
let is_tabbed_column = matches!(column, Column::Stack(_))
&& items
.iter()
.flat_map(StackItem::window_iter)
.any(|e| self.tabbed_stacks.contains(&e));
let heights = if is_tabbed_column {
vec![layout_strip_height; items.len()]
} else {
let current_heights = items
.iter()
.filter_map(|item| item.top().and_then(get_window_frame))
.map(|frame| frame.height())
.collect::<Vec<_>>();
binpack_heights(¤t_heights, MIN_WINDOW_HEIGHT, layout_strip_height)?
};
let column_width = items
.first()
.and_then(StackItem::top)
.and_then(&get_window_frame)
.map(|frame| frame.width())?;
let mut next_y = 0;
let frames = items
.into_iter()
.zip(heights)
.filter_map(|(item, height)| {
let entity = item.top()?;
let mut frame = get_window_frame(entity)?;
frame.min.x = position;
frame.max.x = frame.min.x + column_width;
frame.min.y = next_y;
frame.max.y = frame.min.y + height;
if !is_tabbed_column {
next_y = frame.max.y;
}
let results = item.window_iter().map(|e| (e, frame)).collect::<Vec<_>>();
Some(results)
})
.flatten()
.collect::<Vec<_>>();
Some(frames)
})
.flatten()
}
#[instrument(level = Level::TRACE, skip_all)]
pub fn column_positions<W>(&self, get_window_frame: &W) -> impl Iterator<Item = (&Column, i32)>
where
W: Fn(Entity) -> Option<IRect>,
{
let mut left_edge = 0;
self.columns().filter_map(move |column| {
let width = column.width(get_window_frame);
width.map(|width| {
let temp = left_edge;
left_edge += width;
(column, temp)
})
})
}
pub fn above(&self, entity: Entity) -> Option<Entity> {
let index = self.index_of(entity).ok()?;
let column = self.get(index).ok()?;
match column {
Column::Single(_) | Column::Tabs(_) | Column::Fullscren(_) => None,
Column::Stack(items) => {
let pos = items.iter().position(|item| item.contains(entity))?;
(pos > 0).then(|| items[pos - 1].top()).flatten()
}
}
}
pub fn tabbed(&self, entity: Entity) -> bool {
self.index_of(entity)
.and_then(|idx| self.get(idx))
.map(|col| match col {
Column::Tabs(tabs) => tabs.contains(&entity),
Column::Stack(items) => items.iter().any(|item| {
if let StackItem::Tabs(tabs) = item {
tabs.contains(&entity)
} else {
false
}
}),
Column::Single(_) | Column::Fullscren(_) => false,
})
.is_ok_and(|t| t)
}
pub fn tab_group(&self, entity: Entity) -> Option<Vec<Entity>> {
self.columns.iter().find_map(|column| match column {
Column::Tabs(tabs) if tabs.contains(&entity) && tabs.len() > 1 => Some(tabs.clone()),
Column::Stack(items) => items.iter().find_map(|item| match item {
StackItem::Tabs(tabs) if tabs.contains(&entity) && tabs.len() > 1 => {
Some(tabs.clone())
}
StackItem::Single(_) | StackItem::Tabs(_) => None,
}),
Column::Single(_) | Column::Fullscren(_) | Column::Tabs(_) => None,
})
}
pub fn is_fullscreen(&self) -> bool {
self.columns
.front()
.is_some_and(|column| matches!(column, Column::Fullscren(_)))
}
pub fn toggle_tabbed_display(&mut self, entity: Entity) -> Option<bool> {
let index = self.index_of(entity).ok()?;
let Column::Stack(items) = self.get(index).ok()? else {
return None;
};
if items.len() < 2 {
return None;
}
let members: Vec<Entity> = items.iter().flat_map(StackItem::window_iter).collect();
let now_tabbed = !members.iter().any(|e| self.tabbed_stacks.contains(e));
if now_tabbed {
self.tabbed_stacks.extend(members);
} else {
for member in members {
self.tabbed_stacks.remove(&member);
}
}
Some(now_tabbed)
}
pub fn is_tabbed_display(&self, entity: Entity) -> bool {
self.tabbed_stacks.contains(&entity)
}
pub fn tab_display_sibling(&self, entity: Entity) -> Option<Entity> {
let index = self.index_of(entity).ok()?;
let Column::Stack(items) = self.get(index).ok()? else {
return None;
};
let pos = items.iter().position(|item| item.contains(entity))?;
items
.get(pos + 1)
.or_else(|| pos.checked_sub(1).and_then(|p| items.get(p)))
.and_then(StackItem::top)
}
}
impl std::fmt::Display for LayoutStrip {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let out = self
.columns
.iter()
.map(|column| format!("{column:?}"))
.collect::<Vec<_>>();
write!(f, "[{}]", out.join(", "))
}
}
fn dedup_entities(entities: &[Entity]) -> Vec<Entity> {
let mut seen = EntityHashSet::default();
entities
.iter()
.copied()
.filter(|entity| seen.insert(*entity))
.collect()
}
fn binpack_heights(heights: &[i32], min_height: i32, total_height: i32) -> Option<Vec<i32>> {
let mut count = heights.len();
let mut output = vec![];
loop {
let mut idx = 0;
let mut remaining = total_height;
while idx < count {
let remaining_windows = heights.len() - idx;
if heights[idx] < remaining {
if idx + 1 == count {
output.push(remaining);
} else {
output.push(heights[idx]);
}
remaining -= heights[idx];
} else if remaining >= min_height * i32::try_from(remaining_windows).ok()? {
output.push(remaining);
remaining = 0;
} else {
break;
}
idx += 1;
}
if idx == count {
break;
}
count -= 1;
output.clear();
}
let remaining = i32::try_from(heights.len() - count).ok()?;
if remaining > 0 && count > 0 {
count -= 1;
output.truncate(count);
let sum = output.iter().sum::<i32>();
let avg_height = (total_height - sum) / (remaining + 1);
if avg_height < min_height {
return None;
}
while count < heights.len() {
output.push(avg_height);
count += 1;
}
}
Some(output)
}
#[instrument(level = Level::DEBUG, skip_all)]
fn layout_sizes_changed(changed_sizes: ResizedWindows, workspaces: Query<&mut LayoutStrip>) {
let changed_entities = changed_sizes.iter().collect::<EntityHashSet>();
workspaces.into_iter().for_each(|mut strip| {
if strip_has_changed_window(&strip, &changed_entities) {
strip.set_changed();
}
});
}
fn strip_has_changed_window(strip: &LayoutStrip, changed_entities: &EntityHashSet) -> bool {
strip
.columns
.iter()
.any(|column| column_has_changed_window(column, changed_entities))
}
fn column_has_changed_window(column: &Column, changed_entities: &EntityHashSet) -> bool {
match column {
Column::Single(entity) | Column::Fullscren(entity) => changed_entities.contains(entity),
Column::Stack(stack) => stack
.iter()
.any(|item| stack_item_has_changed_window(item, changed_entities)),
Column::Tabs(entities) => entities
.iter()
.any(|entity| changed_entities.contains(entity)),
}
}
fn stack_item_has_changed_window(item: &StackItem, changed_entities: &EntityHashSet) -> bool {
match item {
StackItem::Single(entity) => changed_entities.contains(entity),
StackItem::Tabs(entities) => entities
.iter()
.any(|entity| changed_entities.contains(entity)),
}
}
#[instrument(level = Level::DEBUG, skip_all)]
fn layout_strip_changed(
changed_strips: Populated<(&LayoutStrip, &ChildOf), Changed<LayoutStrip>>,
mut windows: WindowFrames,
displays: DisplayViewports,
config: Res<Config>,
) {
let get_window_frame = |entity| {
windows
.get(entity)
.map(|(position, bounds, _)| IRect::from_corners(position.0, position.0 + bounds.0))
.ok()
};
let changed = changed_strips
.into_iter()
.filter_map(|(layout_strip, child_of)| {
displays
.get(child_of.parent())
.map(|(display, dock)| {
let height = display.actual_display_bounds(dock, &config).height();
layout_strip.relative_positions(height, &get_window_frame)
})
.ok()
})
.flatten()
.collect::<Vec<_>>();
for (entity, frame) in changed {
if let Ok((_, mut bounds, mut layout_position)) = windows.get_mut(entity) {
if layout_position.0 != frame.min {
layout_position.0 = frame.min;
}
if bounds.0 != frame.size() {
bounds.0 = frame.size();
}
}
}
}
#[instrument(level = Level::DEBUG, skip_all)]
fn reshuffle_layout_strip(
markers: Query<(Entity, &LayoutPosition), With<ReshuffleAroundMarker>>,
strips: StripPlacements,
manual_offsets: Query<&ManualStripOffset>,
displays: DisplayViewports,
windows: Windows,
config: Res<Config>,
mut commands: Commands,
) {
markers.into_iter().for_each(|(entity, layout_position)| {
if let Ok(mut cmd) = commands.get_entity(entity) {
cmd.try_remove::<ReshuffleAroundMarker>();
}
let Some((strip, strip_entity, active_strip, child, active_marker, strip_reposition)) =
strips.into_iter().find(|strip| strip.0.contains(entity))
else {
return;
};
if active_marker.is_some_and(|m| m.is_added()) {
trace!("reshuffle_layout_strip: skipping newly active workspace {strip_entity}");
return;
}
let Ok((active_display, dock)) = displays.get(child.parent()) else {
return;
};
let display_bounds = active_display.actual_display_bounds(dock, &config);
let Some(mut frame) = windows.moving_frame(entity) else {
return;
};
let size = frame.size();
let visible_width = display_bounds.intersect(frame).width();
if let Ok(manual) = manual_offsets.get(strip_entity) {
let current = strip_signature(strip, &windows);
let strip_target = strip_reposition.map_or(active_strip.0, |reposition| reposition.0);
let projected = layout_position.0 + strip_target;
if signature_matches(¤t, &manual.signature)
&& clamp_origin_to_viewport(projected, size, display_bounds) == projected
{
trace!("reshuffle_layout_strip: keeping manual offset on {strip_entity}");
return;
}
trace!("reshuffle_layout_strip: manual offset on {strip_entity} is stale");
if let Ok(mut cmd) = commands.get_entity(strip_entity) {
cmd.try_remove::<ManualStripOffset>();
}
}
frame.min = clamp_origin_to_viewport(frame.min, size, display_bounds);
frame.max = frame.min + size;
let mut strip_position = (frame.min - layout_position.0).with_y(display_bounds.min.y);
if !config.auto_center()
&& !config.continuous_swipe()
&& let Some(total_strip_width) = strip
.last()
.ok()
.and_then(|column| column.top())
.and_then(|last| {
windows
.layout_position(last)
.map(|position| position.0.x)
.zip(windows.moving_frame(last).map(|frame| frame.width()))
})
.map(|(last_x, last_width)| last_x + last_width)
{
strip_position.x = if display_bounds.width() < total_strip_width {
strip_position.x.clamp(
display_bounds.max.x - total_strip_width,
display_bounds.min.x,
)
} else {
display_bounds.min.x
};
}
let hidden_ratio = config.window_hidden_ratio();
if hidden_ratio > 0.0 {
let meaningful = (visible_width - config.sliver_width()).max(0);
let visible_fraction = f64::from(meaningful) / f64::from(frame.width().max(1));
let hidden_fraction = 1.0 - visible_fraction;
let strip_movement = (active_strip.x - strip_position.x).abs();
if hidden_fraction <= hidden_ratio && frame.width() - visible_width >= strip_movement {
return;
}
}
trace!("reshuffle_layout_strip: triggered for entity {entity}, offset {strip_position}");
commands.reposition_entity(strip_entity, strip_position);
});
}
#[allow(clippy::type_complexity)]
#[instrument(level = Level::DEBUG, skip_all)]
fn ensure_visible_in_strip(
markers: Query<(Entity, &LayoutPosition, &EnsureVisibleMarker)>,
mut strip_params: ParamSet<(
StripPlacements,
Query<&mut Position, (With<LayoutStrip>, Without<Window>)>,
)>,
displays: DisplayViewports,
windows: Windows,
config: Res<Config>,
mut commands: Commands,
) {
for (entity, layout_position, marker) in markers {
if let Ok(mut cmd) = commands.get_entity(entity) {
cmd.try_remove::<EnsureVisibleMarker>();
}
let Some((strip_entity, display_entity, strip_target, is_new_activation)) = strip_params
.p0()
.into_iter()
.find(|s| s.0.contains(entity))
.map(
|(_, strip_entity, strip_position, child, active_marker, strip_reposition)| {
let target = strip_reposition.map_or(strip_position.0, |r| r.0);
(
strip_entity,
child.parent(),
target,
active_marker.is_some_and(|m| m.is_added()),
)
},
)
else {
continue;
};
if is_new_activation {
trace!("ensure_visible_in_strip: skipping newly active workspace {strip_entity}");
continue;
}
let Ok((display, dock)) = displays.get(display_entity) else {
continue;
};
let Some(size) = windows.size(entity) else {
continue;
};
let viewport = display.actual_display_bounds(dock, &config);
let candidate_min = layout_position.0 + strip_target;
let clamped_min = clamp_origin_to_viewport(candidate_min, size, viewport);
if clamped_min == candidate_min {
continue;
}
let scroll_to = clamped_min - layout_position.0;
trace!("ensure_visible_in_strip: entity {entity}, scroll strip to {scroll_to}");
if let Ok(mut cmd) = commands.get_entity(strip_entity) {
cmd.try_remove::<ManualStripOffset>();
}
if marker.snap {
if let Ok(mut position) = strip_params.p1().get_mut(strip_entity) {
position.0 = scroll_to;
}
if let Ok(mut cmd) = commands.get_entity(strip_entity) {
cmd.try_remove::<RepositionMarker>();
}
} else {
commands.reposition_entity(strip_entity, scroll_to);
}
}
}
#[instrument(level = Level::DEBUG, skip_all)]
fn position_layout_strips(
moved_strips: Populated<&LayoutStrip, Changed<Position>>,
mut windows: Query<&mut LayoutPosition, (With<Window>, Without<LayoutStrip>)>,
) {
for strip in moved_strips {
for entity in strip.all_windows() {
if let Ok(mut position) = windows.get_mut(entity) {
position.set_changed();
}
}
}
}
#[derive(Clone, Copy)]
struct StripWindowContext {
strip_position: Origin,
swiping: bool,
display_entity: Entity,
stacked: bool,
snap_settling: bool,
}
#[allow(clippy::too_many_arguments)]
fn insert_strip_window_contexts(
contexts: &mut EntityHashMap<StripWindowContext>,
strip: &LayoutStrip,
strip_position: Origin,
swiping: bool,
display_entity: Entity,
snap_settling: bool,
) {
for column in &strip.columns {
insert_column_window_contexts(
contexts,
column,
strip_position,
swiping,
display_entity,
matches!(column, Column::Stack(_)),
snap_settling,
);
}
}
#[allow(clippy::too_many_arguments)]
fn insert_column_window_contexts(
contexts: &mut EntityHashMap<StripWindowContext>,
column: &Column,
strip_position: Origin,
swiping: bool,
display_entity: Entity,
stacked: bool,
snap_settling: bool,
) {
match column {
Column::Single(entity) | Column::Fullscren(entity) => {
contexts.insert(
*entity,
StripWindowContext {
strip_position,
swiping,
display_entity,
stacked,
snap_settling,
},
);
}
Column::Stack(items) => {
for item in items {
insert_stack_item_window_contexts(
contexts,
item,
strip_position,
swiping,
display_entity,
stacked,
snap_settling,
);
}
}
Column::Tabs(entities) => {
for entity in entities {
contexts.insert(
*entity,
StripWindowContext {
strip_position,
swiping,
display_entity,
stacked,
snap_settling,
},
);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn insert_stack_item_window_contexts(
contexts: &mut EntityHashMap<StripWindowContext>,
item: &StackItem,
strip_position: Origin,
swiping: bool,
display_entity: Entity,
stacked: bool,
snap_settling: bool,
) {
match item {
StackItem::Single(entity) => {
contexts.insert(
*entity,
StripWindowContext {
strip_position,
swiping,
display_entity,
stacked,
snap_settling,
},
);
}
StackItem::Tabs(entities) => {
for entity in entities {
contexts.insert(
*entity,
StripWindowContext {
strip_position,
swiping,
display_entity,
stacked,
snap_settling,
},
);
}
}
}
}
#[instrument(level = Level::DEBUG, skip_all)]
fn position_layout_windows(
positioned_windows: RepositionedWindows,
workspaces: StripsForWindowPositioning,
snap_guards: Query<&SnapStripMarker>,
displays: DisplayViewports,
config: Res<Config>,
mut commands: Commands,
) {
let offscreen_sliver_width = config.sliver_width();
let (_, pad_right, _, pad_left) = config.edge_padding();
let mut strip_contexts = EntityHashMap::default();
for (strip_entity, layout_strip, Position(strip_position), swiping, child_of) in &workspaces {
let snap_settling = snap_guards.iter().any(|guard| guard.strip == strip_entity);
insert_strip_window_contexts(
&mut strip_contexts,
layout_strip,
*strip_position,
swiping,
child_of.parent(),
snap_settling,
);
}
for (entity, window, layout_position, mut position, mut bounds) in positioned_windows {
let Some(context) = strip_contexts.get(&entity) else {
return;
};
let Ok((display, dock)) = displays.get(context.display_entity) else {
return;
};
let viewport = display.actual_display_bounds(dock, &config);
let Ok(vertical_move_threshold) = u32::try_from(viewport.height() * 8 / 10) else {
continue;
};
let h_pad = window.horizontal_padding();
let mut frame = IRect::from_corners(layout_position.0, layout_position.0 + bounds.0);
let width = frame.width();
frame.min += context.strip_position;
frame.max += context.strip_position;
let mut offscreen = false;
if frame.max.x <= viewport.min.x + h_pad {
frame.min.x = viewport.min.x - width + offscreen_sliver_width - pad_left + h_pad;
offscreen = true;
} else if frame.min.x >= viewport.max.x - h_pad {
frame.min.x = viewport.max.x - offscreen_sliver_width + pad_right - h_pad;
offscreen = true;
}
frame.max.x = frame.min.x + width;
if !context.swiping && offscreen {
if !context.stacked {
let inset =
round_px(f64::from(viewport.height()) * (1.0 - config.sliver_height()) / 2.0);
frame.min.y += inset;
frame.max.y += inset;
}
}
let park_row = viewport.max.y - PARKED_STRIP_SLIVER;
if frame.min.y > park_row {
let height = frame.height();
frame.min.y = park_row;
frame.max.y = park_row + height;
}
if bounds.0 != frame.size() {
bounds.0 = frame.size();
}
if position.0 != frame.min {
let parking = frame.min.y >= park_row || position.0.y >= park_row;
let offscreen_move =
parking || position.0.y.abs_diff(frame.min.y) > vertical_move_threshold;
if context.swiping
|| context.snap_settling
|| offscreen_move && !config.virtual_workspace_animations()
{
position.0 = frame.min;
if let Ok(mut entity_commands) = commands.get_entity(entity) {
entity_commands.try_remove::<RepositionMarker>();
}
} else {
commands.reposition_entity(entity, frame.min);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::prelude::*;
#[test]
fn signature_tolerates_pixel_drift_but_not_a_reshape() {
let mut world = World::new();
let first = world.spawn_empty().id();
let second = world.spawn_empty().id();
let stored = vec![(first, 400), (second, 400)];
assert!(signature_matches(&[(first, 401), (second, 399)], &stored));
assert!(!signature_matches(&[(first, 420), (second, 400)], &stored));
assert!(!signature_matches(&[(second, 400), (first, 400)], &stored));
assert!(!signature_matches(&[(first, 400)], &stored));
}
#[test]
fn origin_exposing_moves_only_by_the_shortfall() {
let viewport = IRect::new(0, 0, 1000, 800);
let size = Size::new(400, 700);
assert_eq!(
origin_exposing(Origin::new(0, 0), size, Origin::new(-600, 0), viewport),
Origin::new(0, 0)
);
assert_eq!(
origin_exposing(Origin::new(800, 0), size, Origin::ZERO, viewport),
Origin::new(-200, 0)
);
assert_eq!(
origin_exposing(Origin::new(100, 0), size, Origin::new(50, 0), viewport),
Origin::new(50, 0)
);
}
#[test]
fn clamp_origin_supports_oversized_windows() {
let viewport = IRect::new(0, 20, 1024, 768);
let size = Size::new(2048, 748);
assert_eq!(
clamp_origin_to_viewport(Origin::new(300, 20), size, viewport),
Origin::new(0, 20)
);
assert_eq!(
clamp_origin_to_viewport(Origin::new(-1600, 20), size, viewport),
Origin::new(-1024, 20)
);
assert_eq!(
clamp_origin_to_viewport(Origin::new(-600, 20), size, viewport),
Origin::new(-600, 20)
);
}
#[test]
fn clamp_origin_keeps_regular_windows_inside_viewport() {
let viewport = IRect::new(0, 20, 1024, 768);
let size = Size::new(400, 300);
assert_eq!(
clamp_origin_to_viewport(Origin::new(-100, 900), size, viewport),
Origin::new(0, 468)
);
}
fn setup_world_and_strip() -> (World, LayoutStrip, Vec<Entity>) {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
strip.append(entities[0]);
strip.append(entities[1]);
strip.append(entities[2]);
(world, strip, entities)
}
#[test]
fn strip_has_changed_window_matches_nested_entities() {
let (mut world, mut strip, entities) = setup_world_and_strip();
strip.stack(entities[1]).unwrap();
strip.convert_to_tabs(entities[0], entities[1]).unwrap();
let mut changed = EntityHashSet::default();
changed.insert(entities[1]);
assert!(strip_has_changed_window(&strip, &changed));
changed.clear();
changed.insert(world.spawn_empty().id());
assert!(!strip_has_changed_window(&strip, &changed));
}
#[test]
fn strip_window_contexts_capture_stack_membership_once() {
let (mut world, mut strip, entities) = setup_world_and_strip();
strip.stack(entities[1]).unwrap();
let display_entity = world.spawn_empty().id();
let strip_position = Origin::new(10, 20);
let mut contexts = EntityHashMap::default();
insert_strip_window_contexts(
&mut contexts,
&strip,
strip_position,
true,
display_entity,
false,
);
let stacked_leader = contexts.get(&entities[0]).unwrap();
let stacked_follower = contexts.get(&entities[1]).unwrap();
let single_window = contexts.get(&entities[2]).unwrap();
assert_eq!(stacked_leader.strip_position, strip_position);
assert_eq!(stacked_leader.display_entity, display_entity);
assert!(stacked_leader.swiping);
assert!(stacked_leader.stacked);
assert!(stacked_follower.stacked);
assert!(!single_window.stacked);
}
#[test]
fn test_window_pane_index_of() {
let (_world, strip, entities) = setup_world_and_strip();
assert_eq!(strip.index_of(entities[0]).unwrap(), 0);
assert_eq!(strip.index_of(entities[1]).unwrap(), 1);
assert_eq!(strip.index_of(entities[2]).unwrap(), 2);
}
#[test]
fn test_window_pane_swap() {
let (_world, mut strip, entities) = setup_world_and_strip();
strip.swap(0, 2);
assert_eq!(strip.index_of(entities[2]).unwrap(), 0);
assert_eq!(strip.index_of(entities[0]).unwrap(), 2);
}
#[test]
fn test_window_pane_stack_and_unstack() {
let (_world, mut strip, entities) = setup_world_and_strip();
strip.stack(entities[1]).unwrap();
assert_eq!(strip.len(), 2);
assert_eq!(strip.index_of(entities[0]).unwrap(), 0);
assert_eq!(strip.index_of(entities[1]).unwrap(), 0);
match strip.get(0).unwrap() {
Column::Stack(stack) => {
assert_eq!(stack.len(), 2);
assert_eq!(stack[0], StackItem::Single(entities[0]));
assert_eq!(stack[1], StackItem::Single(entities[1]));
}
Column::Single(_) | Column::Fullscren(_) | Column::Tabs(_) => {
panic!("Expected a stack")
}
}
strip.unstack(entities[0]).unwrap();
assert_eq!(strip.len(), 3);
assert_eq!(strip.index_of(entities[1]).unwrap(), 0);
assert_eq!(strip.index_of(entities[0]).unwrap(), 1);
assert_eq!(strip.index_of(entities[2]).unwrap(), 2);
}
#[test]
fn test_binpack() {
const MIN_HEIGHT: i32 = 100;
let heights = [300, 300, 300, 300];
let out = binpack_heights(&heights, MIN_HEIGHT, 1500).unwrap();
assert_eq!(out, vec![300, 300, 300, 600]);
let out = binpack_heights(&heights, MIN_HEIGHT, 1024).unwrap();
assert_eq!(out, vec![300, 300, 300, 124]);
let out = binpack_heights(&heights, MIN_HEIGHT, 800).unwrap();
assert_eq!(out, vec![300, 300, 100, 100]);
let out = binpack_heights(&heights, MIN_HEIGHT, 440).unwrap();
assert_eq!(out, vec![110, 110, 110, 110]);
let out = binpack_heights(&heights, MIN_HEIGHT, 390);
assert_eq!(out, None);
}
#[test]
fn test_layout_positioning() {
let mut world = World::new();
let entities = world
.spawn_batch(vec![(), (), (), ()])
.collect::<Vec<Entity>>();
let sizes = [
IRect::new(0, 0, 300, 300),
IRect::new(0, 0, 300, 300),
IRect::new(0, 0, 300, 300),
IRect::new(0, 0, 300, 300),
];
let mut strip = LayoutStrip::default();
strip.append(entities[0]);
strip.append(entities[1]);
strip.append(entities[2]);
strip.append(entities[3]);
_ = strip.stack(entities[2]);
let get_window_frame = |_| Some(sizes[0]);
let out = strip
.relative_positions(500, &get_window_frame)
.collect::<Vec<_>>();
let xpos = out.iter().map(|(_, frame)| frame.min.x).collect::<Vec<_>>();
assert_eq!(xpos, vec![0, 300, 300, 600]);
let height = out
.iter()
.map(|(_, frame)| frame.height())
.collect::<Vec<_>>();
assert_eq!(height, vec![500, 300, 200, 500]);
}
#[test]
fn test_layout_singles_get_full_viewport_height() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
let get_window_frame = |_| Some(IRect::new(0, 0, 300, 400));
let out: Vec<_> = strip.relative_positions(800, &get_window_frame).collect();
assert_eq!(out.len(), 3);
for (_, f) in &out {
assert_eq!(f.height(), 800, "single window should fill viewport height");
assert_eq!(f.min.y, 0);
}
let xs: Vec<_> = out.iter().map(|(_, f)| f.min.x).collect();
assert_eq!(xs, vec![0, 300, 600]);
}
#[test]
fn test_layout_stack_shares_height_and_width() {
let mut world = World::new();
let entities = world
.spawn_batch(vec![(), (), (), ()])
.collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
strip.stack(entities[2]).unwrap();
let get_window_frame = |e: Entity| {
if e == entities[0] {
Some(IRect::new(0, 0, 500, 200))
} else if e == entities[1] || e == entities[2] {
Some(IRect::new(0, 0, 300, 200))
} else {
Some(IRect::new(0, 0, 400, 500))
}
};
let out: Vec<_> = strip.relative_positions(600, &get_window_frame).collect();
assert_eq!(out.len(), 4);
for e in [entities[0], entities[1], entities[2]] {
let frame = out.iter().find(|(entity, _)| *entity == e).unwrap().1;
assert_eq!(
frame.width(),
500,
"stacked window must share the master's (top) width"
);
}
let stack_heights: i32 = out
.iter()
.filter(|(e, _)| *e != entities[3])
.map(|(_, f)| f.height())
.sum();
assert_eq!(stack_heights, 600, "stack heights must sum to viewport");
let stack_frames: Vec<_> = out
.iter()
.filter(|(e, _)| *e != entities[3])
.map(|(_, f)| *f)
.collect();
assert_eq!(stack_frames[0].min.y, 0);
assert_eq!(stack_frames[0].max.y, stack_frames[1].min.y);
assert_eq!(stack_frames[1].max.y, stack_frames[2].min.y);
assert_eq!(stack_frames[2].max.y, 600);
let e3_frame = out.iter().find(|(e, _)| *e == entities[3]).unwrap().1;
assert_eq!(e3_frame.height(), 600);
}
#[test]
fn test_toggle_tabbed_display_marks_and_unmarks_all_stack_members() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
strip.stack(entities[2]).unwrap();
assert!(entities.iter().all(|&e| !strip.is_tabbed_display(e)));
let now_tabbed = strip.toggle_tabbed_display(entities[1]);
assert_eq!(now_tabbed, Some(true));
assert!(entities.iter().all(|&e| strip.is_tabbed_display(e)));
let now_tabbed = strip.toggle_tabbed_display(entities[0]);
assert_eq!(now_tabbed, Some(false));
assert!(entities.iter().all(|&e| !strip.is_tabbed_display(e)));
}
#[test]
fn test_toggle_tabbed_display_noop_on_single_column() {
let mut world = World::new();
let entity = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(entity);
assert_eq!(
strip.toggle_tabbed_display(entity),
None,
"toggling a lone Single column must be a no-op, not report 'off'"
);
assert!(!strip.is_tabbed_display(entity));
}
#[test]
fn test_tabbed_stack_shares_full_column_frame() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
strip.stack(entities[2]).unwrap();
strip.toggle_tabbed_display(entities[1]);
let get_window_frame = |_| Some(IRect::new(0, 0, 400, 300));
let out: Vec<_> = strip.relative_positions(700, &get_window_frame).collect();
assert_eq!(out.len(), 3);
for (_, frame) in &out {
assert_eq!(frame.min.y, 0, "every tab must start at the column's top");
assert_eq!(
frame.height(),
700,
"every tab must fill the full column height"
);
assert_eq!(frame.width(), 400);
}
let xs: std::collections::HashSet<_> = out.iter().map(|(_, f)| f.min.x).collect();
assert_eq!(xs.len(), 1, "every tab must share the same column x");
}
#[test]
fn test_tabbed_stack_untoggle_restores_split_heights() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
strip.stack(entities[2]).unwrap();
let get_window_frame = |_| Some(IRect::new(0, 0, 400, 200));
strip.toggle_tabbed_display(entities[1]);
strip.toggle_tabbed_display(entities[1]);
let out: Vec<_> = strip.relative_positions(600, &get_window_frame).collect();
let heights: Vec<_> = out.iter().map(|(_, f)| f.height()).collect();
assert_eq!(
heights.iter().sum::<i32>(),
600,
"heights must fill the viewport"
);
assert!(
heights.iter().any(|&h| h != 600),
"split display must not leave every window at full column height"
);
}
#[test]
fn test_toggle_tabbed_display_never_reorders_the_stack() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
strip.stack(entities[2]).unwrap();
strip.toggle_tabbed_display(entities[1]);
let index = strip.index_of(entities[2]).unwrap();
let Column::Stack(items) = strip.get(index).unwrap() else {
panic!("expected a Stack column");
};
let order: Vec<_> = items.iter().filter_map(StackItem::top).collect();
assert_eq!(
order, entities,
"toggling tabbed display must not reorder the stack"
);
}
#[test]
fn test_tab_display_sibling_falls_back_at_ends() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
strip.stack(entities[2]).unwrap();
strip.toggle_tabbed_display(entities[1]);
assert_eq!(strip.tab_display_sibling(entities[0]), Some(entities[1]));
assert_eq!(strip.tab_display_sibling(entities[1]), Some(entities[2]));
assert_eq!(strip.tab_display_sibling(entities[2]), Some(entities[1]));
}
#[test]
fn test_removing_from_tabbed_stack_collapsing_to_single_clears_membership() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
strip.toggle_tabbed_display(entities[1]);
assert!(strip.is_tabbed_display(entities[0]));
strip.remove(entities[1]);
assert!(
!strip.is_tabbed_display(entities[0]),
"a lone surviving window can't remain a tabbed display"
);
let index = strip.index_of(entities[0]).unwrap();
assert!(matches!(strip.get(index).unwrap(), Column::Single(_)));
}
#[test]
fn test_tabs_in_stack() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let e3 = world.spawn_empty().id();
let e4 = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(e1);
strip.append(e2);
strip.append(e3);
strip.stack(e2).unwrap();
strip.convert_to_tabs(e1, e4).unwrap();
assert_eq!(strip.len(), 2);
match strip.get(0).unwrap() {
Column::Stack(items) => {
assert_eq!(items.len(), 2);
match &items[0] {
StackItem::Tabs(tabs) => assert_eq!(tabs, &vec![e4, e1]),
StackItem::Single(_) => panic!("Expected Tabs in stack"),
}
}
_ => panic!("Expected Stack"),
}
let get_window_frame = |_| Some(IRect::new(0, 0, 100, 100));
let out: Vec<_> = strip.relative_positions(400, &get_window_frame).collect();
assert_eq!(out.len(), 4);
let e1_frame = out.iter().find(|(e, _)| *e == e1).unwrap().1;
let e4_frame = out.iter().find(|(e, _)| *e == e4).unwrap().1;
let e2_frame = out.iter().find(|(e, _)| *e == e2).unwrap().1;
assert_eq!(e1_frame, e4_frame);
assert_eq!(e1_frame.max.y, e2_frame.min.y);
}
#[test]
fn test_layout_unstack_gives_full_height() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
strip.stack(entities[1]).unwrap();
let get_window_frame = |_| Some(IRect::new(0, 0, 300, 250));
let out: Vec<_> = strip.relative_positions(500, &get_window_frame).collect();
let e1_height = out
.iter()
.find(|(e, _)| *e == entities[1])
.unwrap()
.1
.height();
assert!(e1_height < 500, "stacked e1 should not have full height");
strip.unstack(entities[1]).unwrap();
assert_eq!(strip.len(), 3);
let out: Vec<_> = strip.relative_positions(500, &get_window_frame).collect();
for (_, f) in &out {
assert_eq!(
f.height(),
500,
"after unstack every single column gets full viewport height"
);
}
}
#[test]
fn test_layout_restack_restores_shared_heights() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
strip.append(entities[0]);
strip.append(entities[1]);
let get_window_frame = |_| Some(IRect::new(0, 0, 300, 250));
strip.stack(entities[1]).unwrap();
let out: Vec<_> = strip.relative_positions(500, &get_window_frame).collect();
let heights: Vec<_> = out.iter().map(|(_, f)| f.height()).collect();
assert_eq!(heights.iter().sum::<i32>(), 500);
assert_eq!(heights.len(), 2);
strip.unstack(entities[1]).unwrap();
let out: Vec<_> = strip.relative_positions(500, &get_window_frame).collect();
for (_, f) in &out {
assert_eq!(f.height(), 500);
}
strip.stack(entities[1]).unwrap();
let out: Vec<_> = strip.relative_positions(500, &get_window_frame).collect();
let heights: Vec<_> = out.iter().map(|(_, f)| f.height()).collect();
assert_eq!(heights.iter().sum::<i32>(), 500);
assert_eq!(heights.len(), 2);
}
#[test]
fn test_column_positions_with_padded_frames() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
let padded_frames = [
IRect::new(0, 0, 300, 600), IRect::new(0, 0, 400, 600),
IRect::new(0, 0, 500, 600),
];
let get_window_frame = |e: Entity| {
if e == entities[0] {
Some(padded_frames[0])
} else if e == entities[1] {
Some(padded_frames[1])
} else {
Some(padded_frames[2])
}
};
let out: Vec<_> = strip.relative_positions(600, &get_window_frame).collect();
assert_eq!(out.len(), 3);
let xs: Vec<_> = out.iter().map(|(_, f)| f.min.x).collect();
assert_eq!(
xs,
vec![0, 300, 700],
"columns must be edge-to-edge using logical widths"
);
for i in 0..out.len() - 1 {
assert_eq!(
out[i].1.max.x,
out[i + 1].1.min.x,
"window {} right edge must equal window {} left edge",
i,
i + 1
);
}
}
#[test]
fn test_column_positions_no_padding() {
let mut world = World::new();
let entities = world.spawn_batch(vec![(), (), ()]).collect::<Vec<Entity>>();
let mut strip = LayoutStrip::default();
for &e in &entities {
strip.append(e);
}
let get_window_frame = |_| Some(IRect::new(0, 0, 300, 600));
let out: Vec<_> = strip.relative_positions(600, &get_window_frame).collect();
let xs: Vec<_> = out.iter().map(|(_, f)| f.min.x).collect();
assert_eq!(xs, vec![0, 300, 600]);
for i in 0..out.len() - 1 {
assert_eq!(out[i].1.max.x, out[i + 1].1.min.x);
}
}
#[test]
fn test_convert_to_tabs() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let e3 = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(e1);
strip.append(e3);
strip.convert_to_tabs(e1, e2).unwrap();
assert_eq!(strip.len(), 2);
match strip.get(0).unwrap() {
Column::Tabs(tabs) => {
assert_eq!(tabs, vec![e2, e1]);
}
_ => panic!("Expected Tabs column"),
}
let e4 = world.spawn_empty().id();
strip.convert_to_tabs(e1, e4).unwrap();
assert_eq!(strip.len(), 2);
match strip.get(0).unwrap() {
Column::Tabs(tabs) => {
assert_eq!(tabs, vec![e4, e2, e1]);
}
_ => panic!("Expected Tabs column"),
}
}
#[test]
fn test_remove_from_tabs() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let e3 = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(e1);
strip.convert_to_tabs(e1, e2).unwrap();
strip.convert_to_tabs(e1, e3).unwrap();
assert_eq!(strip.len(), 1);
strip.remove(e2);
assert_eq!(strip.len(), 1);
match strip.get(0).unwrap() {
Column::Tabs(tabs) => assert_eq!(tabs, vec![e3, e1]),
_ => panic!(),
}
strip.remove(e1);
assert_eq!(strip.len(), 1);
match strip.get(0).unwrap() {
Column::Single(id) => assert_eq!(id, e3),
_ => panic!("Expected Single column after removing all but one tab"),
}
}
#[test]
fn test_tab_group_returns_all_siblings() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let e3 = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(e1);
strip.convert_to_tabs(e1, e2).unwrap();
strip.append(e3);
assert_eq!(strip.tab_group(e1), Some(vec![e2, e1]));
assert_eq!(strip.tab_group(e2), Some(vec![e2, e1]));
assert_eq!(strip.tab_group(e3), None);
}
#[test]
fn test_append_tab_group_merges_existing_members_without_duplicate_columns() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let e3 = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(e2);
strip.append(e3);
strip.append_tab_group(&[e1, e2]);
assert_eq!(strip.len(), 2);
match strip.get(0).unwrap() {
Column::Tabs(tabs) => assert_eq!(tabs, vec![e1, e2]),
_ => panic!("Expected merged Tabs column"),
}
assert_eq!(strip.index_of(e1).unwrap(), 0);
assert_eq!(strip.index_of(e2).unwrap(), 0);
assert_eq!(strip.index_of(e3).unwrap(), 1);
assert_eq!(strip.all_windows(), vec![e1, e2, e3]);
}
#[test]
fn test_tab_relative_positions_use_stable_slot_representative() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(e1);
strip.convert_to_tabs(e1, e2).unwrap();
strip
.get_column_mut(0)
.expect("tab column")
.move_to_front(e2);
let get_window_frame = |entity| {
if entity == e1 {
Some(IRect::new(0, 0, 300, 600))
} else {
Some(IRect::new(900, 0, 1200, 400))
}
};
let out = strip
.relative_positions(600, &get_window_frame)
.collect::<Vec<_>>();
assert_eq!(out.len(), 2);
assert!(
out.iter()
.all(|(_, frame)| *frame == IRect::new(0, 0, 300, 600))
);
}
#[test]
fn test_overlapping_frame_strategy_simulation() {
let mut world = World::new();
let e1 = world.spawn_empty().id();
let e2 = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(e1);
let leader_match = Some(e1);
if let Some(leader) = leader_match {
strip.convert_to_tabs(leader, e2).unwrap();
} else {
strip.append(e2);
}
assert_eq!(strip.len(), 1);
match strip.get(0).unwrap() {
Column::Tabs(tabs) => assert_eq!(tabs, vec![e2, e1]),
_ => panic!(),
}
}
#[test]
fn test_convert_to_tabs_removes_pre_existing_follower_column() {
let mut world = World::new();
let leader = world.spawn_empty().id();
let follower = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(leader);
strip.append(follower);
strip.convert_to_tabs(leader, follower).unwrap();
assert_eq!(
strip.len(),
1,
"follower must not remain in its own column after being tabbed onto leader",
);
match strip.get(0).unwrap() {
Column::Tabs(tabs) => assert_eq!(tabs, vec![follower, leader]),
other => panic!("expected Tabs column, got {other:?}"),
}
}
#[test]
fn test_convert_to_tabs_no_self_loop_on_neighbour() {
let mut world = World::new();
let a = world.spawn_empty().id();
let leader = world.spawn_empty().id();
let follower = world.spawn_empty().id();
let b = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(a);
strip.append(leader);
strip.append(follower);
strip.append(b);
strip.convert_to_tabs(leader, follower).unwrap();
assert_eq!(strip.right_neighbour(leader), Some(b));
assert_eq!(strip.right_neighbour(follower), Some(b));
assert_eq!(strip.left_neighbour(leader), Some(a));
assert_eq!(strip.left_neighbour(follower), Some(a));
}
#[test]
fn test_convert_to_tabs_handles_follower_left_of_leader() {
let mut world = World::new();
let follower = world.spawn_empty().id();
let leader = world.spawn_empty().id();
let b = world.spawn_empty().id();
let mut strip = LayoutStrip::default();
strip.append(follower);
strip.append(leader);
strip.append(b);
strip.convert_to_tabs(leader, follower).unwrap();
assert_eq!(strip.len(), 2);
match strip.get(0).unwrap() {
Column::Tabs(tabs) => assert_eq!(tabs, vec![follower, leader]),
other => panic!("expected Tabs column, got {other:?}"),
}
assert_eq!(strip.right_neighbour(leader), Some(b));
assert_eq!(strip.right_neighbour(follower), Some(b));
}
}