//! T3 Code's terminal drawer (ThreadTerminalDrawer): shells under the //! conversation, per thread, started in the thread's folder (its worktree //! when it has one), on the device the thread runs on. Ctrl+J shows and //! hides it; its top edge drags. One //! terminal fills it; splits lay a group out side by side or stacked; with //! more than one terminal a sidebar lists the groups. A terminal whose shell //! exits closes, and closing the last one closes the drawer. use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use gpui_kit::component::{Icon, h_flex, tooltip::Tooltip, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::surfaces::{Surface, TERMINAL_PANEL_WIDTH}; use super::{Workspace, actions}; use crate::remote::client::ConnectionStatus; use crate::ui::icons::Lucide; use crate::ui::metal::{self, MetalButton, Palette}; use crate::ui::terminal::{TerminalEvent, TerminalView}; /// T3's DEFAULT_THREAD_TERMINAL_HEIGHT. pub(super) const DEFAULT_HEIGHT: f32 = 280.; const MIN_HEIGHT: f32 = 180.; /// The drawer takes at most this much of the window's height. const MAX_HEIGHT_RATIO: f32 = 0.75; /// T3's MAX_TERMINALS_PER_GROUP. const MAX_TERMINALS_PER_GROUP: usize = 4; /// The grab area along the drawer's top edge. const HANDLE_HEIGHT: f32 = 6.; /// The terminal sidebar's width (T3's `w-36`). const SIDEBAR_WIDTH: f32 = 144.; /// A little longer than a confirmation dialog takes to close. const CONFIRM_FADE: Duration = Duration::from_millis(320); /// The drawer's height, kept between its minimum and three quarters of /// the window. pub(super) fn clamp_height(height: f32, window_height: f32) -> f32 { let max = MIN_HEIGHT.max((window_height * MAX_HEIGHT_RATIO).floor()); let height = if height.is_finite() { height } else { DEFAULT_HEIGHT }; height.round().clamp(MIN_HEIGHT, max) } /// `Terminal 3`, as T3 labels `term-3`. pub(super) fn label(id: u32) -> String { format!("Terminal {id}") } /// Terminals sharing the drawer at once. #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct TerminalGroup { pub terminals: Vec, /// Stacked, rather than side by side. pub vertical: bool, } /// A thread's terminals and their groups, as T3's terminal UI store keeps /// them. Terminals are numbered from 1, reusing the lowest free number. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(super) struct TerminalLayout { /// In the order they were opened. pub terminals: Vec, pub groups: Vec, pub active: Option, } impl TerminalLayout { /// T3's nextTerminalId: the lowest number not in use. fn next_id(&self) -> u32 { (1..).find(|id| !self.terminals.contains(id)).unwrap_or(1) } pub fn is_empty(&self) -> bool { self.terminals.is_empty() } /// The group showing: the active terminal's. pub fn active_group(&self) -> Option<&TerminalGroup> { let active = self.active?; self.groups.iter().find(|group| group.terminals.contains(&active)) } /// Whether the showing group can't take another split. pub fn at_split_limit(&self) -> bool { self.active_group().is_some_and(|group| group.terminals.len() >= MAX_TERMINALS_PER_GROUP) } /// Opens a terminal in a group of its own, and returns its number. pub fn new_terminal(&mut self) -> u32 { let id = self.next_id(); self.terminals.push(id); self.groups.push(TerminalGroup { terminals: vec![id], vertical: false }); self.active = Some(id); id } /// Opens a terminal next to the active one, turning its group the way /// asked. `None` when the group is full. pub fn split(&mut self, vertical: bool) -> Option { let Some(active) = self.active else { return Some(self.new_terminal()) }; if self.at_split_limit() { return None; } let id = self.next_id(); let group = self.groups.iter_mut().find(|group| group.terminals.contains(&active))?; let anchor = group.terminals.iter().position(|terminal| *terminal == active).map_or(group.terminals.len(), |ix| ix + 1); group.terminals.insert(anchor, id); group.vertical = vertical; self.terminals.push(id); self.active = Some(id); Some(id) } pub fn set_active(&mut self, id: u32) { if self.terminals.contains(&id) { self.active = Some(id); } } /// Takes a terminal out; the one after it in opening order (or the new /// last one) becomes active if it was. pub fn close(&mut self, id: u32) { let Some(ix) = self.terminals.iter().position(|terminal| *terminal == id) else { return }; self.terminals.remove(ix); for group in &mut self.groups { group.terminals.retain(|terminal| *terminal != id); } self.groups.retain(|group| !group.terminals.is_empty()); if self.active == Some(id) { self.active = self.terminals.get(ix.min(self.terminals.len().saturating_sub(1))).copied(); } } } /// One thread's drawer. #[derive(Default)] pub(super) struct TerminalDrawer { pub open: bool, pub layout: TerminalLayout, views: HashMap, Subscription)>, } impl TerminalDrawer { fn view(&self, id: u32) -> Option<&Entity> { self.views.get(&id).map(|(view, _)| view) } } impl Workspace { /// Where the open thread's terminals start: its folder on this device. /// `None` for threads on other devices, whose shells start there. fn terminal_cwd(&self, cx: &App) -> Option { let session = self.active.as_ref()?.read(cx); let project = self.active_project()?; if session.is_remote() || project.environment.is_some() { return None; } Some(session.cwd.clone()).filter(|cwd| cwd.is_dir()) } /// The open thread runs on another device that's connected now, so its /// terminals' shells can start there. fn remote_terminal_available(&self, cx: &App) -> bool { let Some(session) = self.active.as_ref() else { return false }; let Some(env) = session.read(cx).remote.as_ref().map(|remote| remote.env.clone()) else { return false }; self.remote .environments .iter() .any(|candidate| candidate.host.id == env && matches!(candidate.status, ConnectionStatus::Connected(_))) } /// Whether the open thread can have terminals. pub(super) fn terminal_available(&self, cx: &App) -> bool { self.terminal_cwd(cx).is_some() || self.remote_terminal_available(cx) } /// Whether the open thread's drawer shows. pub(super) fn terminal_open(&self) -> bool { self.active.as_ref().and_then(|session| self.terminals.get(&session.entity_id())).is_some_and(|drawer| drawer.open) } /// T3's terminal.toggle: shows the drawer (with a first terminal if it /// has none) and focuses it, or hides it and goes back to the composer. pub(super) fn toggle_terminal(&mut self, window: &mut Window, cx: &mut Context) { if self.settings_open { return; } // In the right panel, it hides from there. if self.surface_open(Surface::Terminal) { self.set_right_panel(None); self.focus_active_composer(window, cx); cx.notify(); return; } let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; if self.terminal_open() { if let Some(drawer) = self.terminals.get_mut(&session) { drawer.open = false; } self.focus_active_composer(window, cx); cx.notify(); return; } if !self.terminal_available(cx) { return; } let drawer = self.terminals.entry(session).or_default(); drawer.open = true; if drawer.layout.is_empty() { self.new_terminal(window, cx); } else { self.focus_terminal(window, cx); } cx.notify(); } /// Shows the open thread's terminals in the right panel instead of the /// drawer, starting one if it has none. pub(super) fn dock_terminal(&mut self, window: &mut Window, cx: &mut Context) { let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; let drawer = self.terminals.entry(session).or_default(); drawer.open = false; let empty = drawer.layout.is_empty(); self.set_right_panel(Some(Surface::Terminal)); if empty { self.new_terminal(window, cx); } else { self.focus_terminal(window, cx); } } /// T3's terminal.new: a terminal in a group of its own. pub(super) fn new_terminal(&mut self, window: &mut Window, cx: &mut Context) { let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; let docked = self.surface_open(Surface::Terminal); if docked { self.terminals.entry(session).or_default(); } let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open || docked) else { return }; let id = drawer.layout.new_terminal(); self.start_terminal(session, id, window, cx); } /// T3's terminal.split and terminal.splitVertical. pub(super) fn split_terminal(&mut self, vertical: bool, window: &mut Window, cx: &mut Context) { let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; let docked = self.surface_open(Surface::Terminal); let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open || docked) else { return }; let Some(id) = drawer.layout.split(vertical) else { return }; self.start_terminal(session, id, window, cx); } /// Starts terminal `id`'s shell, here or on the thread's device, and /// focuses it. fn start_terminal(&mut self, session: EntityId, id: u32, window: &mut Window, cx: &mut Context) { let view = match self.terminal_cwd(cx) { Some(cwd) => Some(cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx))), None => self .active .clone() .filter(|active| active.entity_id() == session) .and_then(|active| self.open_remote_terminal(&active, label(id), window, cx)), }; let Some(view) = view else { // Nothing to show in the place the layout made for it. if let Some(drawer) = self.terminals.get_mut(&session) { drawer.layout.close(id); } return; }; let subscription = cx.subscribe_in(&view, window, move |this, _, event: &TerminalEvent, window, cx| match event { TerminalEvent::Exited => this.close_terminal(session, id, false, window, cx), TerminalEvent::AddToChat(text) => { if let Some(chat) = this.chats.get(&session).cloned() { chat.update(cx, |chat, cx| chat.insert_into_composer(text, window, cx)); } } }); if let Some(drawer) = self.terminals.get_mut(&session) { drawer.views.insert(id, (view, subscription)); } self.focus_terminal(window, cx); cx.notify(); } /// Hands focus to the open thread's active terminal. fn focus_terminal(&self, window: &mut Window, cx: &mut App) { let Some(session) = self.active.as_ref() else { return }; let Some(drawer) = self.terminals.get(&session.entity_id()) else { return }; if let Some(view) = drawer.layout.active.and_then(|id| drawer.view(id)) { view.read(cx).focus_handle(cx).focus(window, cx); } } fn focus_active_composer(&self, window: &mut Window, cx: &mut App) { if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())).cloned() { chat.update(cx, |chat, cx| chat.focus_composer(window, cx)); } } /// The open thread's active terminal. fn active_terminal(&self) -> Option { let session = self.active.as_ref()?; self.terminals.get(&session.entity_id())?.layout.active } pub(super) fn set_active_terminal(&mut self, id: u32, window: &mut Window, cx: &mut Context) { let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; if let Some(drawer) = self.terminals.get_mut(&session) { drawer.layout.set_active(id); } self.focus_terminal(window, cx); cx.notify(); } /// T3's terminal.close: asks first, as its confirmTerminalClose does. pub(super) fn request_close_terminal(&mut self, id: Option, window: &mut Window, cx: &mut Context) { let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return }; let Some(drawer) = self.terminals.get(&session).filter(|drawer| drawer.open) else { return }; let Some(id) = id.or(drawer.layout.active) else { return }; let message = format!("Close terminal \"{}\"?\nThis stops the running process and clears its history.", label(id)); let workspace = cx.weak_entity(); window.defer(cx, move |window, cx| { actions::confirm(&message, true, window, cx, move |window, cx| { let _ = workspace.update(cx, |this, cx| this.close_terminal(session, id, true, window, cx)); }) }); } /// Stops a terminal's shell and takes it out of the drawer, which closes /// with its last terminal. fn close_terminal(&mut self, session: EntityId, id: u32, confirmed: bool, window: &mut Window, cx: &mut Context) { let Some(drawer) = self.terminals.get_mut(&session) else { return }; drawer.views.remove(&id); drawer.layout.close(id); let empty = drawer.layout.is_empty(); if empty { self.terminals.remove(&session); if self.thread_surface(session) == Some(Surface::Terminal) { self.set_thread_surface(session, None); } } if self.active.as_ref().is_some_and(|active| active.entity_id() == session) { let refocus = move |this: &mut Self, window: &mut Window, cx: &mut Context| { if this.active.as_ref().is_some_and(|active| active.entity_id() == session) { if empty { this.focus_active_composer(window, cx); } else { this.focus_terminal(window, cx); } } }; refocus(self, window, cx); if confirmed { // The confirmation hands focus back to the closed terminal as // its dialog fades out; take it once that's done. cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(CONFIRM_FADE).await; let _ = this.update_in(cx, |this, window, cx| refocus(this, window, cx)); }) .detach(); } } cx.notify(); } /// Forgets a thread's terminals, stopping their shells. pub(super) fn drop_terminals(&mut self, session: EntityId) { self.terminals.remove(&session); } /// Dragging the drawer's top edge. pub(super) fn drag_terminal_edge(&mut self, event: &MouseMoveEvent, window: &mut Window, cx: &mut Context) { let Some((start_y, start_height)) = self.terminal_drag else { return }; let height = start_height + (start_y - event.position.y).as_f32(); self.terminal_height = clamp_height(height, window.viewport_size().height.as_f32()); cx.notify(); } pub(super) fn end_terminal_drag(&mut self, cx: &mut Context) { self.terminal_drag = None; cx.notify(); } /// The open thread's drawer, when it shows. /// The open thread's terminals: in the drawer under the conversation, /// or `docked` in the right panel. pub(super) fn render_terminal_drawer(&self, docked: bool, window: &mut Window, cx: &mut Context) -> Option { let session = self.active.as_ref()?.entity_id(); let no_terminals = TerminalDrawer::default(); let drawer = match self.terminals.get(&session) { Some(drawer) if docked || drawer.open => drawer, None if docked => &no_terminals, _ => return None, }; if !docked && self.surface_open(Surface::Terminal) { return None; } let p = metal::palette(cx); let height = clamp_height(self.terminal_height, window.viewport_size().height.as_f32()); let layout = &drawer.layout; let handle = div() .id("terminal-drawer-edge") .absolute() .top_0() .left_0() .right_0() .h(px(HANDLE_HEIGHT)) .cursor_row_resize() .on_mouse_down( MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, _, cx| { cx.stop_propagation(); this.terminal_drag = Some((event.position.y, height)); cx.notify(); }), ); let base = v_flex().relative().overflow_hidden().map(|this| { if docked { this.size_full().rounded(px(18.)).border_1().border_color(p.rule) } else { this.flex_none().w_full().h(px(height)).child(metal::rule(p)) } }); // Docked, it fills the panel and has no edge to drag. let handle = (!docked).then_some(handle); let finish = move |body: AnyElement| -> AnyElement { if docked { div().w(px(TERMINAL_PANEL_WIDTH)).flex_none().h_full().p(px(12.)).pl(px(0.)).child(body).into_any_element() } else { body } }; if layout.is_empty() { return Some(finish( base.children(handle) .child( v_flex() .flex_1() .min_h_0() .items_center() .justify_center() .gap(px(12.)) .px(px(16.)) .py(px(24.)) .text_size(px(14.)) .text_color(p.ink3) .child("No terminal sessions for this thread yet.") .child( MetalButton::new("terminal-empty-new") .compact() .label(NEW_TERMINAL_LABEL) .on_click(cx.listener(|this, _, window, cx| this.new_terminal(window, cx))), ), ) .into_any_element(), )); } let active = layout.active; let visible: Vec = layout.active_group().map(|group| group.terminals.clone()).unwrap_or_default(); let vertical = layout.active_group().is_some_and(|group| group.vertical); let has_sidebar = layout.terminals.len() > 1; let at_limit = layout.at_split_limit(); let panes = visible.iter().enumerate().filter_map(|(ix, id)| { let view = drawer.view(*id)?.clone(); let id = *id; let border = if Some(id) == active { p.rule } else { p.rule.opacity(0.7) }; Some( div() .id(("terminal-pane", id as usize)) .flex_1() .min_w_0() .min_h_0() .when(ix > 0 && vertical, |this| this.border_t_1().border_color(border)) .when(ix > 0 && !vertical, |this| this.border_l_1().border_color(border)) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, window, cx| { if this.active_terminal() != Some(id) { this.set_active_terminal(id, window, cx); } }), ) .child(view), ) }); let grid = if vertical { v_flex().size_full().children(panes).into_any_element() } else { h_flex().size_full().items_stretch().children(panes).into_any_element() }; let body = h_flex() .flex_1() .min_h_0() .w_full() .items_stretch() .when(has_sidebar, |this| this.gap(px(6.))) .child(div().flex_1().min_w_0().h_full().child(grid)) .when(has_sidebar, |this| this.child(self.render_terminal_sidebar(drawer, at_limit, cx))); Some(finish( base.children(handle) .child(body) .when(!has_sidebar, |this| { this.child( div() .absolute() .top(px(8.)) .right(px(8.)) .child( h_flex() .items_center() .rounded(px(8.)) .border_1() .border_color(p.rule) .bg(p.menu_bg) .shadow(p.raise_sm.clone()) .children(terminal_actions("terminal-actions", at_limit, cx).into_iter().enumerate().flat_map( |(ix, button)| { let separator = (ix > 0).then(|| metal::vrule(p).into_any_element()); separator.into_iter().chain(Some(button)) }, )), ), ) }) .into_any_element(), )) } /// The list of groups beside the terminals once there's more than one. fn render_terminal_sidebar(&self, drawer: &TerminalDrawer, at_limit: bool, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let layout = &drawer.layout; let active = layout.active; let show_group_headers = layout.groups.len() > 1 || layout.groups.iter().any(|group| group.terminals.len() > 1); v_flex() .flex_none() .w(px(SIDEBAR_WIDTH)) .h_full() .border_1() .border_color(p.rule) .bg(p.ink.opacity(0.02)) .child( h_flex() .flex_none() .h(px(22.)) .justify_end() .border_b_1() .border_color(p.rule) .children(terminal_actions("terminal-sidebar-actions", at_limit, cx)), ) .child( v_flex() .id("terminal-groups") .flex_1() .min_h_0() .overflow_y_scroll() .p(px(4.)) .children(layout.groups.iter().enumerate().map(|(group_ix, group)| { let rows: Vec = group .terminals .iter() .map(|id| terminal_row(*id, active == Some(*id), p, cx).into_any_element()) .collect(); let group_active = active.is_some_and(|id| group.terminals.contains(&id)); let focus_id = if group_active { active } else { group.terminals.first().copied() }; let split = group.terminals.len() > 1; let (icon, name) = match (split, group.vertical) { (false, _) => (Lucide::Square, "Single"), (true, true) => (Lucide::SquareSplitVertical, "Stacked"), (true, false) => (Lucide::SquareSplitHorizontal, "Side by side"), }; v_flex() .pb(px(2.)) .when(show_group_headers, |this| { this.child( h_flex() .id(("terminal-group", group_ix)) .h(px(22.)) .gap(px(4.)) .px(px(6.)) .rounded(px(4.)) .cursor_pointer() .text_size(px(11.)) .map(|this| { if group_active { this.bg(p.row_hover).text_color(p.ink) } else { this.text_color(p.ink3).hover(|style| style.bg(p.row_hover).text_color(p.ink)) } }) .on_click(cx.listener(move |this, _, window, cx| { if let Some(id) = focus_id { this.set_active_terminal(id, window, cx); } })) .child(Icon::new(icon).size(px(12.)).flex_none()) .child(div().flex_1().min_w_0().truncate().child(name)) .child(div().text_size(px(10.)).text_color(p.ink3.opacity(0.7)).child(group.terminals.len().to_string())), ) }) .child(v_flex().gap(px(2.)).children(rows)) })), ) } } /// T3's newTerminalActionLabel, with the shortcut a focused terminal takes. const NEW_TERMINAL_LABEL: &str = "New Terminal (Ctrl+N)"; /// The split, new and close buttons, as the floating group and the sidebar /// header show them. fn terminal_actions(id: &'static str, at_limit: bool, cx: &mut Context) -> Vec { let split_label = |direction: &str, shortcut: &str| -> SharedString { if at_limit { format!("Split Terminal {direction} (max {MAX_TERMINALS_PER_GROUP} per group)").into() } else { format!("Split Terminal {direction} ({shortcut})").into() } }; let button = |name: &'static str, icon: Lucide, tooltip: SharedString| { MetalButton::new(SharedString::from(format!("{id}-{name}"))) .ghost() .mini() .icon(icon) .rounded(px(6.)) .tooltip(tooltip) }; vec![ button("split", Lucide::SquareSplitHorizontal, split_label("Horizontally", "Ctrl+D")) .disabled(at_limit) .on_click(cx.listener(|this, _, window, cx| this.split_terminal(false, window, cx))) .into_any_element(), button("split-vertical", Lucide::SquareSplitVertical, split_label("Vertically", "Ctrl+Shift+D")) .disabled(at_limit) .on_click(cx.listener(|this, _, window, cx| this.split_terminal(true, window, cx))) .into_any_element(), button("new", Lucide::Plus, NEW_TERMINAL_LABEL.into()) .on_click(cx.listener(|this, _, window, cx| this.new_terminal(window, cx))) .into_any_element(), button("close", Lucide::Trash, "Close Terminal (Ctrl+W)".into()) .on_click(cx.listener(|this, _, window, cx| this.request_close_terminal(None, window, cx))) .into_any_element(), ] } /// A terminal in the sidebar: its icon, which turns into a close button on /// hover, and its label. fn terminal_row(id: u32, active: bool, p: &Palette, cx: &mut Context) -> impl IntoElement { let name = label(id); let close = SharedString::from(format!("Close {name}{}", if active { " (Ctrl+W)" } else { "" })); let group = SharedString::from(format!("terminal-row-{id}")); h_flex() .id(("terminal-row", id as usize)) .group(group.clone()) .h(px(24.)) .gap(px(2.)) .pl(px(6.)) .pr(px(8.)) .rounded(px(6.)) .cursor_pointer() .text_size(px(12.)) .map(|this| { if active { this.bg(metal::vertical(p.row_on)).text_color(p.ink) } else { this.text_color(p.ink3).hover(|style| style.bg(p.row_hover).text_color(p.ink)) } }) .on_click(cx.listener(move |this, _, window, cx| this.set_active_terminal(id, window, cx))) .child( div() .id(("terminal-row-close", id as usize)) .relative() .flex_none() .size(px(16.)) .flex() .items_center() .justify_center() .rounded(px(4.)) .tooltip(move |window, cx| Tooltip::new(close.clone()).build(window, cx)) .on_click(cx.listener(move |this, _, window, cx| { cx.stop_propagation(); this.request_close_terminal(Some(id), window, cx); })) .child( div() .group_hover(group.clone(), |style| style.invisible()) .child(Icon::new(Lucide::SquareTerminal).size(px(12.))), ) .child( div() .absolute() .inset_0() .flex() .items_center() .justify_center() .invisible() .group_hover(group, |style| style.visible()) .child(Icon::new(Lucide::X).size(px(12.))), ), ) .child(div().flex_1().min_w_0().truncate().child(name)) } #[cfg(test)] mod tests { use super::{MAX_TERMINALS_PER_GROUP, TerminalLayout, clamp_height, label}; #[test] fn labels_number_from_one_and_reuse_gaps() { let mut layout = TerminalLayout::default(); assert_eq!(layout.new_terminal(), 1); assert_eq!(layout.new_terminal(), 2); assert_eq!(layout.new_terminal(), 3); layout.close(2); assert_eq!(layout.new_terminal(), 2); assert_eq!(label(2), "Terminal 2"); assert_eq!(layout.terminals, vec![1, 3, 2]); } #[test] fn splits_join_the_active_group() { let mut layout = TerminalLayout::default(); layout.new_terminal(); assert_eq!(layout.split(false), Some(2)); assert_eq!(layout.groups.len(), 1); assert_eq!(layout.groups[0].terminals, vec![1, 2]); layout.set_active(1); assert_eq!(layout.split(true), Some(3)); // After the terminal it split from, and the group turns stacked. assert_eq!(layout.groups[0].terminals, vec![1, 3, 2]); assert!(layout.groups[0].vertical); assert_eq!(layout.split(false), Some(4)); assert_eq!(layout.groups[0].terminals.len(), MAX_TERMINALS_PER_GROUP); assert_eq!(layout.split(false), None); assert!(layout.at_split_limit()); } #[test] fn closing_moves_on_and_empties() { let mut layout = TerminalLayout::default(); layout.new_terminal(); layout.new_terminal(); layout.new_terminal(); layout.set_active(2); layout.close(2); assert_eq!(layout.active, Some(3)); assert_eq!(layout.groups.len(), 2); layout.close(3); assert_eq!(layout.active, Some(1)); layout.close(1); assert!(layout.is_empty()); assert_eq!(layout.active, None); } #[test] fn heights_clamp_to_the_window() { assert_eq!(clamp_height(100., 900.), 180.); assert_eq!(clamp_height(300.4, 900.), 300.); assert_eq!(clamp_height(800., 900.), 675.); assert_eq!(clamp_height(400., 200.), 180.); assert_eq!(clamp_height(f32::NAN, 900.), 280.); } }