Compare commits

...
1 Commits
Author SHA1 Message Date
Gregor Lohaus 9b9fc8517d floating position fixes 2026-09-16 13:57:44 +02:00
2 changed files with 200 additions and 25 deletions
+134 -10
View File
@@ -504,15 +504,20 @@ impl eframe::App for App {
let pane = self.panes.get_mut(&id).unwrap(); let pane = self.panes.get_mut(&id).unwrap();
let window_id = egui::Id::new(("float", tab.id, id)); let window_id = egui::Id::new(("float", tab.id, id));
let active = tab.active == id; let active = tab.active == id;
let (pos, size) = float_placement(workspace, index, &self.config.multiplexer); let window = egui::Window::new(pane_title(id, pane))
let result = egui::Window::new(pane_title(id, pane))
.id(window_id) .id(window_id)
.title_bar(false) .title_bar(false)
.constrain_to(workspace) .constrain_to(workspace)
.default_pos(pos)
.default_size(size)
.min_size(vec2(160.0, 100.0)) .min_size(vec2(160.0, 100.0))
.frame(egui::Frame::NONE.fill(bg)) .frame(egui::Frame::NONE.fill(bg));
let result = position_float(
ctx,
window,
window_id,
index,
workspace,
&self.config.multiplexer,
)
.show(ctx, |ui| { .show(ctx, |ui| {
let rect = ui.available_rect_before_wrap(); let rect = ui.available_rect_before_wrap();
ui.allocate_rect(rect, egui::Sense::hover()); ui.allocate_rect(rect, egui::Sense::hover());
@@ -661,7 +666,7 @@ fn show_pane(
/// Where a floating pane opens, from its place in the tab's float list rather than /// Where a floating pane opens, from its place in the tab's float list rather than
/// its pane id: the first one is centred and every later one steps down and right /// its pane id: the first one is centred and every later one steps down and right
/// from it, so the same slot always lands in the same spot. The cascade wraps /// from it, so the same slot always lands in the same spot. The cascade wraps
/// before it can walk a float off the far edge. /// every six panes, with each new stack shifted slightly to the right.
fn float_placement( fn float_placement(
workspace: Rect, workspace: Rect,
index: usize, index: usize,
@@ -671,8 +676,43 @@ fn float_placement(
// terminal far more room than it needs; the maximums keep it in hand. // terminal far more room than it needs; the maximums keep it in hand.
let size = (workspace.size() * vec2(config.floating_width, config.floating_height)) let size = (workspace.size() * vec2(config.floating_width, config.floating_height))
.min(vec2(config.floating_max_width, config.floating_max_height)); .min(vec2(config.floating_max_width, config.floating_max_height));
(workspace.center() - size / 2.0 + float_offset(index), size)
}
fn float_offset(index: usize) -> egui::Vec2 {
let step = (index % 6) as f32 * 28.0; let step = (index % 6) as f32 * 28.0;
(workspace.center() - size / 2.0 + vec2(step, step), size) let stack = (index / 6) as f32 * 12.0;
vec2(step + stack, step)
}
/// Reapply the cascade when a pane changes slots or the workspace resizes.
/// Otherwise leave egui's remembered position alone so panes can still be dragged.
fn position_float<'a>(
ctx: &egui::Context,
window: egui::Window<'a>,
window_id: egui::Id,
index: usize,
workspace: Rect,
config: &config::Multiplexer,
) -> egui::Window<'a> {
let (pos, size) = float_placement(workspace, index, config);
let window = window.default_pos(pos).default_size(size);
let changed = ctx.data_mut(|data| {
let key = window_id.with("cascade placement");
let placement = (index, workspace);
let previous = data.get_temp::<(usize, Rect)>(key);
data.insert_temp(key, placement);
previous.is_some_and(|previous| previous != placement)
});
if changed {
// Center using the remembered size, including user resizing.
let size = egui::AreaState::load(ctx, window_id)
.and_then(|state| state.size)
.unwrap_or(size);
window.current_pos(workspace.center() - size / 2.0 + float_offset(index))
} else {
window
}
} }
fn pane_title(id: PaneId, pane: &Pane) -> String { fn pane_title(id: PaneId, pane: &Pane) -> String {
@@ -749,6 +789,80 @@ mod tests {
); );
} }
#[test]
fn removing_floats_repositions_survivors_in_cycle_order() {
let ctx = egui::Context::default();
let config = config::Multiplexer::default();
let workspace = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1200.0));
let mut tab = Tab::new(1, 1);
tab.floats = vec![2, 3, 4, 5];
tab.floats_visible = true;
let render = |tab: &Tab, moved: Option<PaneId>, workspace: Rect| {
let mut positions = BTreeMap::new();
// Allow egui's initial sizing pass to settle.
for _ in 0..3 {
let _ = ctx.run(
egui::RawInput {
screen_rect: Some(workspace),
..Default::default()
},
|ctx| {
for (index, &id) in tab.floats.iter().enumerate() {
let window_id = egui::Id::new(("float", tab.id, id));
let mut window = egui::Window::new(id.to_string())
.id(window_id)
.title_bar(false)
.frame(egui::Frame::NONE);
if moved == Some(id) {
window = window.current_pos(egui::pos2(100.0, 100.0));
}
let response =
position_float(ctx, window, window_id, index, workspace, &config)
.show(ctx, |ui| {
ui.allocate_rect(
ui.available_rect_before_wrap(),
egui::Sense::hover(),
);
})
.unwrap();
positions.insert(id, response.response.rect.min);
}
},
);
}
positions
};
render(&tab, Some(2), workspace);
assert_eq!(render(&tab, None, workspace)[&2], egui::pos2(100.0, 100.0));
// Resizing recenters even a dragged pane, using its existing size.
let resized = Rect::from_min_size(egui::Pos2::ZERO, vec2(1800.0, 1400.0));
let positions = render(&tab, None, resized);
for (index, &id) in tab.floats.iter().enumerate() {
assert_eq!(positions[&id], float_placement(resized, index, &config).0);
}
render(&tab, None, workspace);
render(&tab, Some(2), workspace);
// Remove the middle pane, then the first, then append a new float.
for (removed, added) in [(3, None), (2, None), (4, Some(6))] {
tab.detach(removed);
if let Some(id) = added {
tab.floats.push(id);
}
let positions = render(&tab, None, workspace);
tab.active = tab.floats[0];
for index in 0..tab.floats.len() {
let expected = if tab.active == 2 {
egui::pos2(100.0, 100.0)
} else {
float_placement(workspace, index, &config).0
};
assert_eq!(positions[&tab.active], expected);
tab.cycle(false);
}
assert_eq!(tab.active, tab.floats[0]);
}
}
#[test] #[test]
fn floats_open_centered_then_cascade_and_stay_within_the_maximums() { fn floats_open_centered_then_cascade_and_stay_within_the_maximums() {
let config = config::Multiplexer::default(); let config = config::Multiplexer::default();
@@ -770,9 +884,19 @@ mod tests {
second.x > first.x && second.y > first.y, second.x > first.x && second.y > first.y,
"later floats step off it" "later floats step off it"
); );
// Six slots later the cascade is back where it started, so it cannot walk away. // Each group of six restarts the cascade slightly to the right.
assert_eq!(float_placement(workspace, 6, &config).0, first); assert_eq!(
assert_eq!(float_placement(workspace, 7, &config).0, second); float_placement(workspace, 6, &config).0,
first + vec2(12.0, 0.0)
);
assert_eq!(
float_placement(workspace, 7, &config).0,
second + vec2(12.0, 0.0)
);
assert_eq!(
float_placement(workspace, 12, &config).0,
first + vec2(24.0, 0.0)
);
// A small workspace keeps the fractions instead of the maximums. // A small workspace keeps the fractions instead of the maximums.
let small = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); let small = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0));
+58 -7
View File
@@ -152,7 +152,8 @@ pub struct Tab {
pub floats: Vec<PaneId>, pub floats: Vec<PaneId>,
pub active: PaneId, pub active: PaneId,
pub floats_visible: bool, pub floats_visible: bool,
last_active_float: Option<PaneId>, // Remember both focus and zoom while floating panes are hidden.
last_active_float: Option<(PaneId, bool)>,
pub zoomed: bool, pub zoomed: bool,
} }
@@ -179,7 +180,7 @@ impl Tab {
pub fn detach(&mut self, id: PaneId) { pub fn detach(&mut self, id: PaneId) {
self.root = self.root.take().and_then(|r| r.remove(id)); self.root = self.root.take().and_then(|r| r.remove(id));
self.floats.retain(|p| *p != id); self.floats.retain(|p| *p != id);
if self.last_active_float == Some(id) { if self.last_active_float.is_some_and(|(pane, _)| pane == id) {
self.last_active_float = None; self.last_active_float = None;
} }
if self.floats.is_empty() { if self.floats.is_empty() {
@@ -204,17 +205,18 @@ impl Tab {
pub fn toggle_floats(&mut self) { pub fn toggle_floats(&mut self) {
self.floats_visible = !self.floats_visible; self.floats_visible = !self.floats_visible;
self.zoomed = false; let was_zoomed = std::mem::take(&mut self.zoomed);
if self.floats_visible { if self.floats_visible {
if let Some(id) = self if let Some((id, zoomed)) = self
.last_active_float .last_active_float
.filter(|id| self.floats.contains(id)) .filter(|(id, _)| self.floats.contains(id))
.or_else(|| self.floats.last().copied()) .or_else(|| self.floats.last().map(|id| (*id, false)))
{ {
self.active = id; self.active = id;
self.zoomed = zoomed;
} }
} else if self.floats.contains(&self.active) { } else if self.floats.contains(&self.active) {
self.last_active_float = Some(self.active); self.last_active_float = Some((self.active, was_zoomed));
if let Some(root) = &self.root { if let Some(root) = &self.root {
self.active = root.first(); self.active = root.first();
} }
@@ -374,6 +376,7 @@ mod tests {
tab.floats = vec![2, 3]; tab.floats = vec![2, 3];
tab.floats_visible = true; tab.floats_visible = true;
tab.active = 2; tab.active = 2;
tab.zoomed = true;
tab.toggle_floats(); tab.toggle_floats();
tab.detach(2); tab.detach(2);
if dock { if dock {
@@ -381,9 +384,57 @@ mod tests {
} }
tab.toggle_floats(); tab.toggle_floats();
assert_eq!(tab.active, 3); assert_eq!(tab.active, 3);
assert!(!tab.zoomed);
} }
} }
#[test]
fn showing_floats_restores_zoom_without_zooming_tiles() {
let mut tab = Tab::new(1, 1);
tab.root.as_mut().unwrap().split(1, 2, Axis::Horizontal);
tab.floats = vec![3, 4];
tab.floats_visible = true;
tab.active = 3;
tab.zoomed = true;
for _ in 0..2 {
tab.toggle_floats();
assert_eq!(tab.active, 1);
assert!(!tab.zoomed);
assert!(!tab.is_visible(3));
tab.cycle(false);
assert_eq!(tab.active, 2);
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(tab.zoomed);
}
// Unzooming the float replaces its remembered zoom, even if tiles zoom.
tab.zoomed = false;
tab.toggle_floats();
tab.zoomed = true;
tab.toggle_floats();
assert_eq!(tab.active, 3);
assert!(!tab.zoomed);
}
#[test]
fn floating_zoom_survives_hiding_without_tiles() {
let mut tab = Tab::new(1, 1);
tab.root = None;
tab.floats = vec![1];
tab.floats_visible = true;
tab.zoomed = true;
tab.toggle_floats();
assert!(!tab.is_visible(1));
assert!(!tab.zoomed);
tab.toggle_floats();
assert_eq!(tab.active, 1);
assert!(tab.is_visible(1));
assert!(tab.zoomed);
}
#[test] #[test]
fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() { fn directional_focus_uses_geometry_and_ignores_hidden_or_foreign_panes() {
let mut tab = Tab::new(1, 1); let mut tab = Tab::new(1, 1);