Vectrum
Sign In
691 lines · 28.8 KB · Rust
//! T3 Code's terminal drawer (ThreadTerminalDrawer): shells under the
//! conversation, per thread, started in the thread's folder (its worktree
//! when it has one). 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::{Workspace, actions};
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<u32>,
    /// 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<u32>,
    pub groups: Vec<TerminalGroup>,
    pub active: Option<u32>,
}

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<u32> {
        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<u32, (Entity<TerminalView>, Subscription)>,
}

impl TerminalDrawer {
    fn view(&self, id: u32) -> Option<&Entity<TerminalView>> {
        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.
    fn terminal_cwd(&self, cx: &App) -> Option<PathBuf> {
        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())
    }

    /// Whether the open thread can have terminals.
    pub(super) fn terminal_available(&self, cx: &App) -> bool {
        self.terminal_cwd(cx).is_some()
    }

    /// 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<Self>) {
        if self.settings_open {
            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();
    }

    /// 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<Self>) {
        let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
        let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open) 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<Self>) {
        let Some(session) = self.active.as_ref().map(|session| session.entity_id()) else { return };
        let Some(drawer) = self.terminals.get_mut(&session).filter(|drawer| drawer.open) else { return };
        let Some(id) = drawer.layout.split(vertical) else { return };
        self.start_terminal(session, id, window, cx);
    }

    /// Starts terminal `id`'s shell and focuses it.
    fn start_terminal(&mut self, session: EntityId, id: u32, window: &mut Window, cx: &mut Context<Self>) {
        let Some(cwd) = self.terminal_cwd(cx) else { return };
        let view = cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx));
        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<u32> {
        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<Self>) {
        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<u32>, window: &mut Window, cx: &mut Context<Self>) {
        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<Self>) {
        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.active.as_ref().is_some_and(|active| active.entity_id() == session) {
            let refocus = move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
                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<Self>) {
        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>) {
        self.terminal_drag = None;
        cx.notify();
    }

    /// The open thread's drawer, when it shows.
    pub(super) fn render_terminal_drawer(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<AnyElement> {
        let session = self.active.as_ref()?.entity_id();
        let drawer = self.terminals.get(&session).filter(|drawer| drawer.open)?;
        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().flex_none().w_full().h(px(height)).overflow_hidden().child(metal::rule(p));

        if layout.is_empty() {
            return Some(
                base.child(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<u32> = 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(
            base.child(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<Self>) -> 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<AnyElement> = 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<Workspace>) -> Vec<AnyElement> {
    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<Workspace>) -> 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.);
    }
}