use crate::{ config::{self, Action, Config}, input, layout::{Axis, Direction, Layout, PaneId, Tab}, pane::{self, Pane}, theme::{self, Theme}, }; use anyhow::{Result, ensure}; use egui::{CornerRadius, Rect, Stroke, vec2}; use std::{ collections::{BTreeMap, HashSet}, path::PathBuf, }; pub struct App { config: Config, theme: Theme, config_path: PathBuf, panes: BTreeMap, tabs: Vec, current: usize, next_id: u64, error: Option, suppressed: HashSet, } /// Width kept free at the left of the tab bar for the macOS traffic lights, /// which float over the content once the titlebar is hidden. const TITLEBAR_INSET: f32 = 70.0; /// Height the tab bar needs so the traffic lights fit inside it. const TITLEBAR_HEIGHT: f32 = 28.0; /// With the titlebar hidden the tab bar takes its place, so let the empty space /// beside the tabs drag the window and double-click to zoom, like a titlebar. fn drag_window(ui: &mut egui::Ui) { let used = ui.min_rect(); let free = Rect::from_min_max( egui::pos2(used.right(), used.top()), egui::pos2(ui.max_rect().right(), used.bottom()), ); if free.width() <= 0.0 { return; } let response = ui.interact( free, ui.id().with("titlebar drag"), egui::Sense::click_and_drag(), ); if response.double_clicked() { let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false)); ui.ctx() .send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); } else if response.drag_started_by(egui::PointerButton::Primary) { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } } pub fn fonts(config: &Config) -> Result { crate::fonts::load(config) } impl App { pub fn new( cc: &eframe::CreationContext<'_>, config: Config, config_path: PathBuf, ) -> Result { let theme = config.theme()?; cc.egui_ctx.set_visuals(theme.visuals()); cc.egui_ctx.set_fonts(fonts(&config)?); let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?; Ok(Self { config, theme, config_path, panes: BTreeMap::from([(1, pane)]), tabs: vec![Tab::new(1, 1)], current: 0, next_id: 2, error: None, suppressed: HashSet::new(), }) } fn spawn(&mut self, ctx: &egui::Context) -> Result { ensure!( self.panes.len() < self.config.multiplexer.max_panes, "Pane limit reached" ); // A new pane starts where the pane it was spawned from currently sits. let cwd = self .tabs .get(self.current) .and_then(|tab| self.panes.get(&tab.active)) .and_then(Pane::cwd); let pane = Pane::new(&self.config, &self.theme, ctx, cwd.as_deref())?; let id = self.next_id; self.next_id += 1; self.panes.insert(id, pane); Ok(id) } /// Switches theme for this session. Every open pane is retinted, since a /// terminal keeps its palette rather than asking for it each frame. fn set_theme(&mut self, name: &str, ctx: &egui::Context) -> Result<()> { let previous = std::mem::replace(&mut self.config.theme, name.to_owned()); self.theme = self.config.theme().inspect_err(|_| { self.config.theme = previous; })?; for pane in self.panes.values_mut() { pane::apply_config(&mut pane.terminal, &self.config, &self.theme)?; } ctx.set_visuals(self.theme.visuals()); Ok(()) } fn action(&mut self, action: Action, ctx: &egui::Context) -> Result<()> { let active = self.tabs[self.current].active; match action { Action::NewPane => { let tab = &self.tabs[self.current]; let action = if tab.floats_visible && !tab.floats.is_empty() { Action::NewFloating } else { let rect = self.panes[&active].last_rect; if rect.is_positive() && rect.height() > rect.width() { Action::SplitDown } else { Action::SplitRight } }; self.action(action, ctx)?; } Action::SplitRight | Action::SplitDown => { let id = self.spawn(ctx)?; let tab = &mut self.tabs[self.current]; let axis = if action == Action::SplitRight { Axis::Horizontal } else { Axis::Vertical }; if let Some(root) = &mut tab.root { root.split( if tab.floats.contains(&active) { root.first() } else { active }, id, axis, ); } else { tab.root = Some(Layout::Pane(id)); } tab.active = id; tab.zoomed = false; } Action::NewFloating => { let id = self.spawn(ctx)?; let tab = &mut self.tabs[self.current]; tab.floats.push(id); tab.active = id; tab.floats_visible = true; tab.zoomed = false; } Action::ToggleFloating => { let tab = &mut self.tabs[self.current]; let was_floating = tab.floats.contains(&active); tab.detach(active); if was_floating { if let Some(root) = &mut tab.root { root.split(root.first(), active, Axis::Horizontal); } else { tab.root = Some(Layout::Pane(active)); } } else { tab.floats.push(active); tab.floats_visible = true; } } Action::ToggleFloats => { if self.tabs[self.current].floats.is_empty() { self.action(Action::NewFloating, ctx)?; } else { self.tabs[self.current].toggle_floats(); } } Action::FocusLeft | Action::FocusDown | Action::FocusUp | Action::FocusRight => { let direction = match action { Action::FocusLeft => Direction::Left, Action::FocusDown => Direction::Down, Action::FocusUp => Direction::Up, _ => Direction::Right, }; let rects = self .panes .iter() .map(|(id, pane)| (*id, pane.last_rect)) .collect::>(); self.tabs[self.current].focus_direction(direction, &rects); } Action::NextPane | Action::PreviousPane => { self.tabs[self.current].cycle(action == Action::PreviousPane) } Action::ClosePane => self.close(active, ctx), Action::ZoomPane => { let tab = &mut self.tabs[self.current]; if tab.is_visible(tab.active) { tab.zoomed = !tab.zoomed; } } Action::NewTab => { let id = self.spawn(ctx)?; self.tabs.push(Tab::new(id, id)); self.current = self.tabs.len() - 1; } Action::NextTab => self.current = (self.current + 1) % self.tabs.len(), Action::PreviousTab => { self.current = (self.current + self.tabs.len() - 1) % self.tabs.len() } Action::ReloadConfig => { let config = Config::load(&self.config_path)?; let fonts = fonts(&config)?; self.theme = config.theme()?; for pane in self.panes.values_mut() { pane::apply_config(&mut pane.terminal, &config, &self.theme)?; } ctx.set_fonts(fonts); ctx.set_visuals(self.theme.visuals()); self.config = config; } Action::Copy => { if let Some(text) = self.panes[&active].copy()? { ctx.copy_text(text); } } Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), Action::OpenScrollback => { self.panes.get_mut(&active).unwrap().open_scrollback( &self.config, &self.theme, ctx, )?; } } Ok(()) } fn close(&mut self, id: PaneId, ctx: &egui::Context) { let closed_rect = self .panes .remove(&id) .map(|pane| pane.last_rect) .filter(|rect| rect.is_positive() && rect.is_finite()); for tab in &mut self.tabs { if !tab.ids().contains(&id) { continue; } let was_floating = tab.floats.contains(&id); tab.detach(id); if tab.root.is_none() { tab.floats_visible = true; } if tab.active == id { if was_floating && !tab.floats.is_empty() { let nearest = tab .floats .iter() .map(|candidate| { let rect = self.panes[candidate].last_rect; let distance = closed_rect .filter(|_| rect.is_positive() && rect.is_finite()) .map_or(f32::INFINITY, |source| { source.center().distance_sq(rect.center()) }); (*candidate, distance) }) .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0))); if let Some((next, _)) = nearest { tab.active = next; tab.floats_visible = true; } } else if let Some(next) = tab.navigation_ids().first() { tab.active = *next; } } } self.tabs.retain(|t| !t.ids().is_empty()); self.current = self.current.min(self.tabs.len().saturating_sub(1)); if self.tabs.is_empty() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> { let events = ctx.input(|i| i.events.clone()); let modifiers = ctx.input(|i| i.modifiers); let mut terminal_events = vec![]; let mut suppress_text = false; for event in events { if let egui::Event::Key { key, pressed, repeat, modifiers, .. } = &event { suppress_text = false; if !pressed && self.suppressed.remove(key) { continue; } let action = self.config.keys.iter().find_map(|(s, a)| { let shortcut = config::shortcut(s).ok()?; let m = shortcut.modifiers; (shortcut.logical_key == *key && m.ctrl == modifiers.ctrl && m.shift == modifiers.shift && m.alt == modifiers.alt && m.mac_cmd == modifiers.mac_cmd) .then_some(*a) }); if *pressed && let Some(action) = action { self.flush_keys(&mut terminal_events)?; self.suppressed.insert(*key); suppress_text = true; if !repeat { self.action(action, ctx)?; } if self.tabs.is_empty() { return Ok(()); } continue; } } if suppress_text && matches!(event, egui::Event::Text(_)) { continue; } let active = self.tabs[self.current].active; if !self.tabs[self.current].is_visible(active) { continue; } match event { egui::Event::Paste(text) => { self.flush_keys(&mut terminal_events)?; self.panes.get_mut(&active).unwrap().paste(&text)?; } egui::Event::Copy => { self.flush_keys(&mut terminal_events)?; if modifiers.shift || modifiers.mac_cmd { self.action(Action::Copy, ctx)?; } else { self.panes.get_mut(&active).unwrap().send(vec![3])?; } } egui::Event::Cut => { self.flush_keys(&mut terminal_events)?; self.panes.get_mut(&active).unwrap().send(vec![24])?; } other => terminal_events.push(other), } } self.flush_keys(&mut terminal_events) } fn flush_keys(&mut self, events: &mut Vec) -> Result<()> { if events.is_empty() { return Ok(()); } let active = self.tabs[self.current].active; if !self.tabs[self.current].is_visible(active) { events.clear(); return Ok(()); } let pane = self.panes.get_mut(&active).unwrap(); let encoded = input::encode(&pane.terminal, events)?; pane.send(encoded)?; events.clear(); Ok(()) } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if self.tabs.is_empty() { return; } for pane in self.panes.values_mut() { pane.drain(ctx); } grab_keys(ctx); if let Err(e) = self.keyboard(ctx) { self.error = Some(e.to_string()); } if self.tabs.is_empty() { return; } let mut action = None; let mut picked = None; egui::TopBottomPanel::top("tabs").show(ctx, |ui| { ui.horizontal_wrapped(|ui| { if cfg!(target_os = "macos") { ui.add_space(TITLEBAR_INSET); ui.set_min_height(TITLEBAR_HEIGHT); } for (index, tab) in self.tabs.iter().enumerate() { let title = self.panes[&tab.active].terminal.title().unwrap_or(""); let label = if title.is_empty() { format!("{}: shell", index + 1) } else { format!( "{}: {}", index + 1, title.chars().take(28).collect::() ) }; if ui.selectable_label(self.current == index, label).clicked() { self.current = index; } } if ui.button("+").on_hover_text("New tab").clicked() { action = Some(Action::NewTab); } ui.menu_button("Pane", |ui| { for (label, a) in [ ("New pane", Action::NewPane), ("Split right", Action::SplitRight), ("Split down", Action::SplitDown), ("New floating pane", Action::NewFloating), ("Float / dock", Action::ToggleFloating), ("Show / hide floating panes", Action::ToggleFloats), ("Zoom / restore", Action::ZoomPane), ("Next pane", Action::NextPane), ("Close pane", Action::ClosePane), ("Open scrollback in editor", Action::OpenScrollback), ] { if ui.button(label).clicked() { action = Some(a); ui.close(); } } }); ui.menu_button("Settings", |ui| { if ui.button("Reload config").clicked() { action = Some(Action::ReloadConfig); ui.close(); } ui.menu_button("Theme", |ui| { ui.label("Applies now; set `theme` in the config to keep it."); egui::ScrollArea::vertical() .max_height(420.0) .show(ui, |ui| { for name in theme::names() { let current = self.config.theme == name; if ui.selectable_label(current, name).clicked() && !current { picked = Some(name); ui.close(); } } }); }); }); }); if cfg!(target_os = "macos") { drag_window(ui); } }); if let Some(error) = self.error.clone() { egui::TopBottomPanel::bottom("error").show(ctx, |ui| { ui.horizontal_wrapped(|ui| { ui.colored_label(theme::color(self.theme.ansi[9]), error); if ui.button("Dismiss").clicked() { self.error = None; } }); }); } let bg = theme::color(self.theme.background); let tab = &mut self.tabs[self.current]; let mut tiled = vec![]; let mut workspace = Rect::NOTHING; egui::CentralPanel::default() .frame(egui::Frame::NONE.fill(bg)) .show(ctx, |ui| { workspace = ui.max_rect(); if tab.zoomed { tiled.push((tab.active, workspace)); } else if let Some(root) = &mut tab.root { root.arrange(ui, workspace, &mut tiled, 1); } for (id, rect) in &tiled { let pane = self.panes.get_mut(id).unwrap(); let active = tab.active == *id; if !tab.zoomed { pane.last_rect = *rect; } if show_pane(ui, *id, pane, *rect, &self.config, &self.theme, active) { tab.active = *id; } } }); if tab.floats_visible && !tab.zoomed { for (index, id) in tab.floats.clone().into_iter().enumerate() { let pane = self.panes.get_mut(&id).unwrap(); let window_id = egui::Id::new(("float", tab.id, id)); let active = tab.active == id; let window = egui::Window::new(pane_title(id, pane)) .id(window_id) .title_bar(false) .constrain_to(workspace) .min_size(vec2(160.0, 100.0)) .frame(egui::Frame::NONE.fill(bg)); let result = position_float( ctx, window, window_id, index, workspace, &self.config.multiplexer, ) .show(ctx, |ui| { let rect = ui.available_rect_before_wrap(); ui.allocate_rect(rect, egui::Sense::hover()); pane.last_rect = rect; if show_pane(ui, id, pane, rect, &self.config, &self.theme, active) { tab.active = id; } }); if let Some(result) = result && result.response.contains_pointer() && ctx.input(|i| i.pointer.any_pressed()) { tab.active = id; } if active { ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, window_id)); } } } let active = tab.active; for (id, pane) in &mut self.panes { if let Err(e) = pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused)) { pane.error = Some(e.to_string()); } } if let Some(name) = picked && let Err(e) = self.set_theme(name, ctx) { self.error = Some(e.to_string()); } if !self.tabs.is_empty() && let Some(action) = action && let Err(e) = self.action(action, ctx) { self.error = Some(e.to_string()); } } } /// Parks keyboard focus on a widget that never exists, with every navigation key /// filtered out. Otherwise egui spends Tab, the arrows and Escape on moving focus /// between the tab bar and the menu buttons instead of leaving them for the shell. fn grab_keys(ctx: &egui::Context) { let id = egui::Id::new("keys"); // Registers the id for this pass so egui's dead-mans-switch keeps the focus. ctx.check_for_id_clash(id, Rect::NOTHING, "key sink"); ctx.memory_mut(|m| { if m.focused() != Some(id) { m.request_focus(id); } // Only applies once the sink has held focus for a full frame. m.set_focus_lock_filter( id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true, }, ); }); } /// Corner rounding shared by a pane's border and its title bar. const CORNER: u8 = theme::CORNER_RADIUS; fn show_pane( ui: &mut egui::Ui, id: PaneId, pane: &mut Pane, rect: Rect, config: &Config, theme: &Theme, active: bool, ) -> bool { let accent = theme::color(theme.accent); let title_rect = Rect::from_min_size(rect.min, vec2(rect.width(), 25.0_f32.min(rect.height()))); // The title bar only rounds off the top of the pane, unless the pane is so // short that the bar is all there is of it. let bottom = if title_rect.height() < rect.height() { 0 } else { CORNER }; ui.painter().rect_filled( title_rect, CornerRadius { nw: CORNER, ne: CORNER, sw: bottom, se: bottom, }, theme::color(if active { theme.chrome_active_bg } else { theme.chrome_bg }), ); ui.painter() .with_clip_rect(title_rect.shrink(3.0).intersect(ui.clip_rect())) .text( title_rect.min + vec2(7.0, 4.0), egui::Align2::LEFT_TOP, pane_title(id, pane), egui::FontId::proportional(13.0), if active { accent } else { theme::color(theme.chrome_fg) }, ); // Click-only leaves dragging to the floating window's existing move handler. let header = ui.interact( title_rect, ui.id().with(("title", id)), egui::Sense::click(), ); let mut focused = header.clicked() || (header.contains_pointer() && ui.input(|i| i.pointer.any_pressed())); let content = Rect::from_min_max(egui::pos2(rect.left(), title_rect.bottom()), rect.max); // The pane rectangle is already allocated; a layout scope would grow its parent. let mut content_ui = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(content)); content_ui.set_clip_rect(content.intersect(ui.clip_rect())); match pane.show(&mut content_ui, content, config, theme, active) { Ok(clicked) => focused |= clicked, Err(e) => pane.error = Some(e.to_string()), } ui.painter().rect_stroke( rect, CORNER, Stroke::new( theme::BORDER_WIDTH, if active { accent } else { theme::color(theme.border) }, ), egui::StrokeKind::Inside, ); focused } /// 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 /// from it, so the same slot always lands in the same spot. The cascade wraps /// every six panes, with each new stack shifted slightly to the right. fn float_placement( workspace: Rect, index: usize, config: &config::Multiplexer, ) -> (egui::Pos2, egui::Vec2) { // The fractions are of the workspace, so on a large display they hand a scratch // terminal far more room than it needs; the maximums keep it in hand. let size = (workspace.size() * vec2(config.floating_width, config.floating_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 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 { let title = pane.terminal.title().unwrap_or(""); format!( "{id} {}{}", if title.is_empty() { "shell" } else { title }, if pane.dead { " [exited]" } else { "" } ) } #[cfg(test)] mod tests { use super::*; fn test_app(ctx: &egui::Context) -> App { let config = Config { shell: vec!["/bin/sh".into()], ..Default::default() }; let theme = config.theme().unwrap(); let pane = Pane::new(&config, &theme, ctx, None).unwrap(); App { config, theme, config_path: PathBuf::new(), panes: BTreeMap::from([(1, pane)]), tabs: vec![Tab::new(1, 1)], current: 0, next_id: 2, error: None, suppressed: HashSet::new(), } } #[test] fn floating_pane_size_stays_constant_across_frames() { let ctx = egui::Context::default(); let mut app = test_app(&ctx); let mut sizes = vec![]; for frame in 0..20 { let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0))), time: Some(frame as f64 / 60.0), ..Default::default() }; let _ = ctx.run(input, |ctx| { let window = egui::Window::new("float") .title_bar(false) .default_size(vec2(400.0, 240.0)) .frame(egui::Frame::NONE) .show(ctx, |ui| { let rect = ui.available_rect_before_wrap(); ui.allocate_rect(rect, egui::Sense::hover()); show_pane( ui, 1, app.panes.get_mut(&1).unwrap(), rect, &app.config, &app.theme, true, ); }) .unwrap(); if frame >= 2 { sizes.push(window.response.rect.size()); } }); } assert!( sizes.iter().all(|size| *size == sizes[0]), "floating pane grew: {sizes:?}" ); } #[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, 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] fn floats_open_centered_then_cascade_and_stay_within_the_maximums() { let config = config::Multiplexer::default(); let workspace = Rect::from_min_size(egui::pos2(0.0, 30.0), vec2(3000.0, 1800.0)); let (first, size) = float_placement(workspace, 0, &config); assert_eq!( size, vec2(config.floating_max_width, config.floating_max_height), "a large workspace must not hand out a proportionally large float" ); assert_eq!( first + size / 2.0, workspace.center(), "the first is centered" ); let (second, _) = float_placement(workspace, 1, &config); assert!( second.x > first.x && second.y > first.y, "later floats step off it" ); // Each group of six restarts the cascade slightly to the right. assert_eq!( 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. let small = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); let (pos, size) = float_placement(small, 0, &config); assert_eq!( size, small.size() * vec2(config.floating_width, config.floating_height) ); assert_eq!(pos + size / 2.0, small.center()); } #[test] fn toggle_creates_first_float_then_reuses_it_and_close_removes_focus() { let ctx = egui::Context::default(); let mut app = test_app(&ctx); app.action(Action::ToggleFloats, &ctx).unwrap(); assert_eq!(app.tabs[0].floats, vec![2]); assert_eq!(app.tabs[0].active, 2); assert!(app.tabs[0].floats_visible); app.action(Action::ToggleFloats, &ctx).unwrap(); assert!(!app.tabs[0].floats_visible); assert_eq!(app.tabs[0].active, 1); app.action(Action::ToggleFloats, &ctx).unwrap(); assert_eq!(app.panes.len(), 2); assert_eq!(app.tabs[0].active, 2); app.action(Action::ClosePane, &ctx).unwrap(); assert_eq!(app.tabs[0].active, 1); assert!(!app.panes.contains_key(&2)); app.action(Action::ToggleFloats, &ctx).unwrap(); assert_eq!(app.tabs[0].floats, vec![3]); assert!(app.tabs[0].floats_visible); } #[test] fn new_pane_floats_only_when_existing_floats_are_visible() { let ctx = egui::Context::default(); let mut app = test_app(&ctx); app.action(Action::NewPane, &ctx).unwrap(); assert!(app.tabs[0].floats.is_empty()); assert_eq!(app.tabs[0].active, 2); app.action(Action::ToggleFloats, &ctx).unwrap(); assert_eq!(app.tabs[0].floats, vec![3]); app.action(Action::NewPane, &ctx).unwrap(); assert_eq!(app.tabs[0].floats, vec![3, 4]); app.action(Action::ToggleFloats, &ctx).unwrap(); app.action(Action::NewPane, &ctx).unwrap(); assert_eq!(app.tabs[0].floats, vec![3, 4]); assert_eq!(app.tabs[0].active, 5); assert!(!app.tabs[0].floats_visible); app.action(Action::ToggleFloats, &ctx).unwrap(); app.action(Action::ClosePane, &ctx).unwrap(); app.action(Action::ClosePane, &ctx).unwrap(); assert!(app.tabs[0].floats.is_empty()); assert!(!app.tabs[0].floats_visible); app.action(Action::NewPane, &ctx).unwrap(); assert!(app.tabs[0].floats.is_empty()); assert_eq!(app.tabs[0].active, 6); app.action(Action::ToggleFloats, &ctx).unwrap(); assert_eq!(app.tabs[0].floats, vec![7]); app.action(Action::ToggleFloating, &ctx).unwrap(); assert!(!app.tabs[0].floats_visible); app.action(Action::NewPane, &ctx).unwrap(); assert!(app.tabs[0].floats.is_empty()); assert_eq!(app.tabs[0].active, 8); } #[test] fn closing_float_focuses_another_float_before_tiles() { let ctx = egui::Context::default(); let mut app = test_app(&ctx); app.action(Action::NewFloating, &ctx).unwrap(); app.action(Action::NewFloating, &ctx).unwrap(); app.action(Action::ClosePane, &ctx).unwrap(); assert_eq!(app.tabs[0].active, 2); app.action(Action::NewFloating, &ctx).unwrap(); app.close(1, &ctx); app.action(Action::ToggleFloats, &ctx).unwrap(); app.action(Action::ClosePane, &ctx).unwrap(); assert_eq!(app.tabs[0].active, 2); assert!(app.tabs[0].floats_visible); } #[test] fn closing_float_selects_nearest_float_not_first_or_nearer_tile() { let ctx = egui::Context::default(); let mut app = test_app(&ctx); for _ in 0..3 { app.action(Action::NewFloating, &ctx).unwrap(); } for (id, x, y) in [ (1, 200.0, 200.0), (2, 0.0, 0.0), (3, 180.0, 180.0), (4, 200.0, 200.0), ] { app.panes.get_mut(&id).unwrap().last_rect = Rect::from_min_size(egui::pos2(x, y), vec2(100.0, 100.0)); } app.action(Action::ClosePane, &ctx).unwrap(); assert_eq!(app.tabs[0].active, 3); app.action(Action::ClosePane, &ctx).unwrap(); assert_eq!(app.tabs[0].active, 2); app.action(Action::ClosePane, &ctx).unwrap(); assert_eq!(app.tabs[0].active, 1); } }