Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

apps/splitmux.rs

//! A pane multiplexer: nested splits, tabs, and focus hopping, with the app
//! — not kui — owning the pane tree and the chord keymap. Splits are nested
//! Grow containers rebuilt from a plain enum tree each frame; a pane is just
//! its number here (the modal_editor example shows what real pane content
//! looks like). The whole keyboard arrives through one `on_key` sink as
//! `{kind="key"}` events, so the same chord dispatch would work verbatim
//! from Lua or C.
//!
//! Run: cargo run -p kui-native --example splitmux
//!
//! Keys — Alt is ⌥ Option on macOS: Alt-v/s split · Alt-o hop panes ·
//! Alt-w close · Alt-t new tab · Alt-1..9 jump to tab. Click a pane to
//! focus it. The chords read `code`, which follows the layout while the
//! layout speaks ASCII and falls back to the key's US-QWERTY position when
//! it does not — so ⌥v is on the key printed V for a Dvorak or AZERTY user,
//! and still works at all on a Cyrillic or Greek one. A keymap that wanted
//! the *shape* rather than the label (WASD) would read `physical` instead;
//! the payload carries both. Drag the strip between panes to resize a split; drag a tab
//! along the bar to reorder it (both are plain `on_drag` data — the drag
//! payload's parent rect gives the divider its ratio, and hover during the
//! drag gives tabs their live reorder). Splits ease into place: the two
//! halves carry a `transition`, so a new split slides open and a keyboard
//! resize glides — except while a divider drags, when the ratio must track
//! the cursor exactly.
//!
//! Hold ⌘ (Ctrl elsewhere) and drag a pane to move it: the modifier state
//! arrives as a `{kind="modifiers"}` event, the model keeps it, and while it
//! is held the view floats five drop-zone overlays (edges + center) over
//! every pane. They are the drag sources *and* the drop targets — no core
//! knowledge of "pane moving" at all, and without ⌘ the overlays don't
//! exist, so plain clicks still reach the pane. Hover over a zone during
//! the drag names the destination; dropping on an edge splits the target
//! on that side, on the center swaps the two panes. A ghost label follows
//! the cursor as a viewport-anchored float.

use kui_devtools::Example;
use kui_native::widgets;
use kui_native::{
    Align, App, Color, Core, Drag, DragPhase, Easing, FloatConfig, KeyCode, KeyMods, Message,
    NodeSpec, Sizing, TextStyle, Theme, Ui, UiEvent, WindowCommand,
};

const TABBAR_H: f32 = 30.0;
/// How long a split takes to ease into a new ratio.
const SPLIT_MS: f32 = 180.0;

#[cfg(target_os = "macos")]
const ALT: &str = "⌥";
#[cfg(not(target_os = "macos"))]
const ALT: &str = "Alt-";
#[cfg(target_os = "macos")]
const PRIMARY: &str = "⌘";
#[cfg(not(target_os = "macos"))]
const PRIMARY: &str = "Ctrl-";

// ---------------------------------------------------------------- palette

/// This app's names for the theme's roles. Every one of them *is* a role
/// — a mux is chrome all the way down — so the struct is a rename rather
/// than a palette, kept because `pal.bg2` reads better at the twenty call
/// sites below than `theme.sunken` does, and rebuilt from `ui.theme()`
/// each frame so the window follows the OS.
#[derive(Clone, Copy)]
struct Pal {
    bg: Color,
    bg2: Color,
    panel: Color,
    border: Color,
    border_focus: Color,
    fg: Color,
    dim: Color,
    faint: Color,
    accent: Color,
}

impl From<Theme> for Pal {
    fn from(t: Theme) -> Self {
        Self {
            bg: t.bg,
            bg2: t.sunken,
            panel: t.surface,
            border: t.border,
            border_focus: t.accent,
            fg: t.fg,
            dim: t.muted,
            faint: t.faint,
            accent: t.focus_ring,
        }
    }
}

// ---------------------------------------------------------------- model

/// What the view hangs on its nodes and `on_event` reads back (backlog
/// C50): a click hands one over as its payload, a drag inside the core's
/// `drag` event as its `tag`, and `ev.message::<Msg>()` reads either. The
/// payloads are the same plain data as before — `{kind: "focus", pane}` —
/// written and read by `#[derive(Message)]` instead of by string.
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
    /// A pane clicked into focus.
    Focus { pane: u64 },
    /// A tab chosen.
    Tab { tab: usize },
    /// The `+` after the tabs.
    TabNew,
    /// A tab dragged along the strip to reorder it.
    TabDrag { tab: usize },
    /// The divider of the split at `path` dragged.
    Split { path: String, dir: SplitDir },
    /// A pane ⌘-dragged from one of its zones.
    PaneDrag { pane: u64, zone: Zone },
}

#[derive(Message, Clone, Copy, PartialEq, Debug)]
#[message(string)]
enum SplitDir {
    /// Side by side.
    H,
    /// Stacked.
    V,
}

#[derive(Clone)]
enum Node {
    Pane(u64),
    Split {
        dir: SplitDir,
        ratio: f32,
        a: Box<Node>,
        b: Box<Node>,
        /// Just created: rendered once with the new half collapsed so the
        /// transition has somewhere to slide it open from (a node's first
        /// frame snaps).
        fresh: Fresh,
    },
}

impl Node {
    fn panes(&self, out: &mut Vec<u64>) {
        match self {
            Node::Pane(id) => out.push(*id),
            Node::Split { a, b, .. } => {
                a.panes(out);
                b.panes(out);
            }
        }
    }

    /// Replaces the leaf `target` with a split of it and `new_id`.
    fn split(&mut self, target: u64, dir: SplitDir, new_id: u64) -> bool {
        match self {
            Node::Pane(id) if *id == target => {
                let old = Node::Pane(*id);
                *self = Node::Split {
                    dir,
                    ratio: 0.5,
                    a: Box::new(old),
                    b: Box::new(Node::Pane(new_id)),
                    fresh: Fresh::B,
                };
                true
            }
            Node::Pane(_) => false,
            Node::Split { a, b, .. } => {
                a.split(target, dir, new_id) || b.split(target, dir, new_id)
            }
        }
    }

    /// Clears every `fresh` flag (after the collapsed first frame drew).
    fn settle(&mut self) {
        if let Node::Split { a, b, fresh, .. } = self {
            *fresh = Fresh::No;
            a.settle();
            b.settle();
        }
    }

    /// Replaces the leaf `target` with a split of it and `node`, `node`
    /// going first when `before`.
    fn split_with(&mut self, target: u64, dir: SplitDir, node: Node, before: bool) -> bool {
        match self {
            Node::Pane(id) if *id == target => {
                let old = Node::Pane(*id);
                let (a, b, fresh) = if before {
                    (node, old, Fresh::A)
                } else {
                    (old, node, Fresh::B)
                };
                *self = Node::Split {
                    dir,
                    ratio: 0.5,
                    a: Box::new(a),
                    b: Box::new(b),
                    fresh,
                };
                true
            }
            Node::Pane(_) => false,
            Node::Split { a, b, .. } => {
                // Descend into the side that holds the target so `node`
                // is moved, not cloned.
                if a.contains(target) {
                    a.split_with(target, dir, node, before)
                } else {
                    b.split_with(target, dir, node, before)
                }
            }
        }
    }

    fn contains(&self, target: u64) -> bool {
        match self {
            Node::Pane(id) => *id == target,
            Node::Split { a, b, .. } => a.contains(target) || b.contains(target),
        }
    }

    /// Exchanges two leaves in place.
    fn swap(&mut self, x: u64, y: u64) {
        match self {
            Node::Pane(id) if *id == x => *id = y,
            Node::Pane(id) if *id == y => *id = x,
            Node::Pane(_) => {}
            Node::Split { a, b, .. } => {
                a.swap(x, y);
                b.swap(x, y);
            }
        }
    }

    /// The split at `path` ("a"/"b" steps from the root), for divider drags.
    fn ratio_mut(&mut self, path: &str) -> Option<&mut f32> {
        match self {
            Node::Pane(_) => None,
            Node::Split { ratio, a, b, .. } => match path.split_at_checked(1) {
                None => Some(ratio),
                Some(("a", rest)) => a.ratio_mut(rest),
                Some((_, rest)) => b.ratio_mut(rest),
            },
        }
    }
}

/// Removes a leaf, collapsing its split; None if the tree became empty.
fn without(node: Node, target: u64) -> Option<Node> {
    match node {
        Node::Pane(id) if id == target => None,
        Node::Pane(id) => Some(Node::Pane(id)),
        Node::Split {
            dir,
            ratio,
            a,
            b,
            fresh,
        } => match (without(*a, target), without(*b, target)) {
            (Some(a), Some(b)) => Some(Node::Split {
                dir,
                ratio,
                a: Box::new(a),
                b: Box::new(b),
                fresh,
            }),
            (Some(x), None) | (None, Some(x)) => Some(x),
            (None, None) => None,
        },
    }
}

/// Which half of a split is new (see `Node::Split::fresh`).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Fresh {
    No,
    A,
    B,
}

/// Where a dragged pane lands on its target: an edge splits the target on
/// that side, the center swaps the two.
#[derive(Message, Clone, Copy, PartialEq, Eq, Debug)]
#[message(string)]
enum Zone {
    Left,
    Right,
    Top,
    Bottom,
    Center,
}

impl Zone {
    const ALL: [Zone; 5] = [
        Zone::Left,
        Zone::Right,
        Zone::Top,
        Zone::Bottom,
        Zone::Center,
    ];

    fn label(self) -> &'static str {
        match self {
            Zone::Left => "zl",
            Zone::Right => "zr",
            Zone::Top => "zt",
            Zone::Bottom => "zb",
            Zone::Center => "zc",
        }
    }

    /// The overlay: a percent-sized float pinned to the pane's edge (or its
    /// middle). Later zones paint and hit-test on top, so the center wins
    /// over the edges and top/bottom win in the corners.
    fn spec(self) -> NodeSpec {
        let (ax, ay, w, h) = match self {
            Zone::Left => (Align::Start, Align::Start, 0.25, 1.0),
            Zone::Right => (Align::End, Align::Start, 0.25, 1.0),
            Zone::Top => (Align::Start, Align::Start, 1.0, 0.25),
            Zone::Bottom => (Align::Start, Align::End, 1.0, 0.25),
            Zone::Center => (Align::Center, Align::Center, 0.5, 0.5),
        };
        NodeSpec::column()
            .float(FloatConfig::parent().inside(ax, ay))
            .width(Sizing::Percent(w))
            .height(Sizing::Percent(h))
    }
}

/// One tab: its split tree plus a stable id, so the tab's node key survives
/// reordering and its position transition has something to slide.
struct Tab {
    id: u64,
    root: Node,
}

// ---------------------------------------------------------------- app

struct Splitmux {
    pal: Pal,
    next_pane: u64,
    /// One split tree per tab — the whole layout is this data.
    tabs: Vec<Tab>,
    next_tab: u64,
    tab: usize,
    focused: u64,
    /// Path of the divider being dragged, for active styling while the
    /// cursor is off the strip.
    dragging: Option<String>,
    /// Tab drag in flight: (current slot, sign of the last horizontal
    /// motion, the last `dx` seen). The sign gates reorder direction so
    /// unequal-width tabs can't oscillate around the cursor; it is the
    /// difference between two `dx`es, since each is the displacement from
    /// the press point and not a step.
    tab_drag: Option<(usize, f32, f32)>,
    /// Physical modifiers, straight from `{kind="modifiers"}` events.
    mods: KeyMods,
    /// ⌘-drag of a pane in flight: (pane id, cursor x, cursor y).
    pane_drag: Option<(u64, f32, f32)>,
    /// Where the pane drag would land, recomputed by the view from which
    /// zone overlay is hovered (hover is last frame's layout, as always).
    drop_target: Option<(u64, Zone)>,
    quit: bool,
}

impl Splitmux {
    fn new() -> Self {
        // Launch looking like the app it wants to be: pane | (pane / pane).
        let root = Node::Split {
            dir: SplitDir::H,
            ratio: 0.5,
            a: Box::new(Node::Pane(1)),
            b: Box::new(Node::Split {
                dir: SplitDir::V,
                ratio: 0.5,
                a: Box::new(Node::Pane(2)),
                b: Box::new(Node::Pane(3)),
                fresh: Fresh::No,
            }),
            fresh: Fresh::No,
        };
        Self {
            pal: Theme::default().into(),
            next_pane: 4,
            tabs: vec![Tab { id: 1, root }],
            next_tab: 2,
            tab: 0,
            focused: 1,
            dragging: None,
            tab_drag: None,
            mods: KeyMods::default(),
            pane_drag: None,
            drop_target: None,
            quit: false,
        }
    }

    /// Whether the panes wear their drop-zone overlays: while the primary
    /// modifier is held, and for the whole of a pane drag (the modifier may
    /// be released mid-drag).
    fn overlays_on(&self) -> bool {
        self.mods.primary() || self.pane_drag.is_some()
    }

    /// Lands `src` on `dst`: an edge zone splits `dst` on that side, the
    /// center swaps them.
    fn move_pane(&mut self, src: u64, dst: u64, zone: Zone) {
        if src == dst {
            return;
        }
        if zone == Zone::Center {
            self.tabs[self.tab].root.swap(src, dst);
            return;
        }
        let root = std::mem::replace(&mut self.tabs[self.tab].root, Node::Pane(u64::MAX));
        let Some(mut root) = without(root, src) else {
            return;
        };
        let (dir, before) = match zone {
            Zone::Left => (SplitDir::H, true),
            Zone::Right => (SplitDir::H, false),
            Zone::Top => (SplitDir::V, true),
            Zone::Bottom => (SplitDir::V, false),
            Zone::Center => unreachable!(),
        };
        root.split_with(dst, dir, Node::Pane(src), before);
        self.tabs[self.tab].root = root;
        self.focused = src;
    }

    fn pane_ids(&self) -> Vec<u64> {
        let mut ids = Vec::new();
        self.tabs[self.tab].root.panes(&mut ids);
        ids
    }

    fn refocus(&mut self) {
        self.focused = self.pane_ids().first().copied().unwrap_or(0);
    }

    fn cycle_pane(&mut self) {
        let ids = self.pane_ids();
        if let Some(i) = ids.iter().position(|id| *id == self.focused) {
            self.focused = ids[(i + 1) % ids.len()];
        } else if let Some(id) = ids.first() {
            self.focused = *id;
        }
    }

    fn split(&mut self, dir: SplitDir) {
        let id = self.next_pane;
        self.next_pane += 1;
        self.tabs[self.tab].root.split(self.focused, dir, id);
        self.focused = id;
    }

    fn close_pane(&mut self) {
        let root = std::mem::replace(&mut self.tabs[self.tab].root, Node::Pane(u64::MAX));
        match without(root, self.focused) {
            Some(root) => {
                self.tabs[self.tab].root = root;
                self.refocus();
            }
            None => {
                self.tabs.remove(self.tab);
                if self.tabs.is_empty() {
                    self.quit = true;
                    return;
                }
                self.tab = self.tab.min(self.tabs.len() - 1);
                self.refocus();
            }
        }
    }

    fn new_tab(&mut self) {
        let id = self.next_pane;
        self.next_pane += 1;
        self.tabs.push(Tab {
            id: self.next_tab,
            root: Node::Pane(id),
        });
        self.next_tab += 1;
        self.tab = self.tabs.len() - 1;
        self.focused = id;
    }

    // ------------------------------------------------------------ keymap

    /// Alt chords, straight off the `code` string — the layout's own key
    /// where the layout is Latin, the key's US-QWERTY position where it is
    /// not, so these arms match on every layout without the app knowing one
    /// exists. A keymap binding a shape rather than a label (a game's WASD)
    /// would read `physical` here instead. Anything else would go to the
    /// focused pane's content — here panes have none, so it's dropped.
    fn chord(&mut self, code: &str) {
        match code {
            "o" => self.cycle_pane(),
            "v" => self.split(SplitDir::H),
            "s" => self.split(SplitDir::V),
            "w" => self.close_pane(),
            "t" => self.new_tab(),
            _ => {
                if let Ok(n @ 1..=9) = code.parse::<usize>()
                    && n <= self.tabs.len()
                {
                    self.tab = n - 1;
                    self.refocus();
                }
            }
        }
    }

    // ------------------------------------------------------------ view

    fn tab_bar(&mut self, ui: &mut Ui<'_>) {
        let pal = self.pal;
        ui.with(
            NodeSpec::row()
                .grow_width()
                .height(TABBAR_H)
                .bg(pal.bg2)
                .pad_xy(8.0, 0.0)
                .gap(4.0)
                .cross_align(Align::Center)
                // A tab or the `+` acts and leaves the keyboard with the
                // sink: this app owns the whole keyboard (DX10).
                .keep_focus(),
            |ui| {
                // Live reorder: while a tab drags, hovering another tab in
                // the direction of motion moves it there. Hover comes from
                // the previous frame's layout; the direction gate keeps
                // unequal widths from swap-oscillating under a still cursor.
                // Each tab wears a full-height hover column during the
                // drag (a transparent float below it), so the cursor's x
                // keeps reordering after it has left the bar vertically.
                let dragging_tab = self.tab_drag.is_some();
                let column_h = ui.viewport().h;
                if let Some((from, sign, last_dx)) = self.tab_drag
                    && sign != 0.0
                    && from < self.tabs.len()
                {
                    let to = (0..self.tabs.len()).find(|&j| {
                        let tab = ui.child_key(&format!("tab{}", self.tabs[j].id));
                        j != from
                            && (ui.is_hovered(tab) || ui.is_hovered(tab.str("col")))
                            && ((j > from && sign > 0.0) || (j < from && sign < 0.0))
                    });
                    if let Some(j) = to {
                        let node = self.tabs.remove(from);
                        self.tabs.insert(j, node);
                        self.tab = if self.tab == from {
                            j
                        } else if from < self.tab && j >= self.tab {
                            self.tab - 1
                        } else if from > self.tab && j <= self.tab {
                            self.tab + 1
                        } else {
                            self.tab
                        };
                        self.tab_drag = Some((j, sign, last_dx));
                    }
                }
                for i in 0..self.tabs.len() {
                    let active = i == self.tab;
                    let lifted = self.tab_drag.is_some_and(|(s, ..)| s == i);
                    let (bg, fg) = if active {
                        (pal.panel, pal.fg)
                    } else {
                        (Color::TRANSPARENT, pal.dim)
                    };
                    let mut ids = Vec::new();
                    self.tabs[i].root.panes(&mut ids);
                    // Keyed by tab id, not slot: a reordered tab keeps its
                    // identity, so `slide` eases it (and the tabs it
                    // displaced) into the new order instead of snapping —
                    // on a snappy spring, a trace of overshoot, so a row of
                    // tabs lands at once rather than wobbling.
                    let mut spec = NodeSpec::row()
                        .pad_xy(10.0, 4.0)
                        .radius(6.0)
                        .bg(bg)
                        .transition(220.0)
                        .easing(Easing::Snappy)
                        .slide()
                        .on_click(Msg::Tab { tab: i })
                        .on_drag(Msg::TabDrag { tab: i });
                    if lifted {
                        spec = spec.border(1.0, pal.border_focus);
                    }
                    ui.with_keyed(&format!("tab{}", self.tabs[i].id), spec, |ui| {
                        ui.text(
                            &format!("{}  {} pane(s)", i + 1, ids.len()),
                            TextStyle::new(12.0).color(fg),
                        );
                        if dragging_tab {
                            // Hangs from the tab's bottom edge: the tab
                            // keeps its own hover, so a press-release on
                            // it is still a click.
                            ui.leaf_keyed(
                                "col",
                                NodeSpec::column()
                                    .float(FloatConfig::parent().at(Align::Start, Align::End))
                                    .width(Sizing::Percent(1.0))
                                    .height(column_h)
                                    .hoverable());
                        }
                    });
                }
                ui.text_in_keyed("tab+", NodeSpec::row()
                        .pad_xy(8.0, 4.0)
                        .radius(6.0)
                        .on_click(Msg::TabNew), "+", TextStyle::new(12.0).color(pal.faint));
                ui.leaf(NodeSpec::row().grow_width());
                ui.text(
                    &format!(
                        "{ALT}v/{ALT}s split · {ALT}o hop · {ALT}w close · {ALT}t tab · {PRIMARY}drag moves a pane"
                    ),
                    TextStyle::new(11.0).color(pal.faint),
                );
            },
        );
    }

    fn render_node(&mut self, ui: &mut Ui<'_>, node: &Node, path: &str) {
        match node {
            Node::Pane(id) => self.render_pane(ui, *id),
            Node::Split {
                dir,
                ratio,
                a,
                b,
                fresh,
            } => {
                let pal = self.pal;
                let spec = match dir {
                    SplitDir::H => NodeSpec::row(),
                    SplitDir::V => NodeSpec::column(),
                };
                ui.with(spec.fill(), |ui| {
                    // A fresh split draws once with the new half collapsed;
                    // the transition then slides it open to the real ratio.
                    // That first frame snaps, so nothing is mid-flight yet
                    // to keep frames coming: ask for the next one.
                    if *fresh != Fresh::No {
                        ui.request_frame();
                    }
                    let (wa, wb) = match fresh {
                        Fresh::A => (0.0, 1.0),
                        Fresh::B => (1.0, 0.0),
                        Fresh::No => (ratio.clamp(0.05, 0.95), 1.0 - ratio.clamp(0.05, 0.95)),
                    };
                    // The halves ease between ratios, except under a divider
                    // drag, where the ratio has to follow the cursor exactly
                    // (the core snaps a transition that skipped a frame, so
                    // nothing replays when the drag ends).
                    let dragging = self.dragging.as_deref() == Some(path);
                    let grow = |f: f32| {
                        let spec = match dir {
                            SplitDir::H => NodeSpec::column().width(Sizing::Grow(f)).grow_height(),
                            SplitDir::V => NodeSpec::column().grow_width().height(Sizing::Grow(f)),
                        };
                        if dragging {
                            spec
                        } else {
                            spec.transition(SPLIT_MS)
                        }
                    };
                    ui.with_keyed("a", grow(wa), |ui| {
                        self.render_node(ui, a, &format!("{path}a"))
                    });
                    // The divider: a grabbable strip that drags the ratio.
                    // Its drag events carry the parent (this split) rect, so
                    // the handler turns absolute x/y into a ratio directly.
                    let divider = ui.child_key("divider");
                    let active = ui.is_hovered(divider)
                        || ui.is_pressed(divider)
                        || self.dragging.as_deref() == Some(path);
                    let bar = match dir {
                        SplitDir::H => NodeSpec::column().width(5.0).grow_height(),
                        SplitDir::V => NodeSpec::column().grow_width().height(5.0),
                    };
                    ui.leaf_keyed(
                        "divider",
                        bar.bg(if active { pal.border_focus } else { pal.bg2 })
                            .on_drag(Msg::Split {
                                path: path.to_string(),
                                dir: *dir,
                            }),
                    );
                    ui.with_keyed("b", grow(wb), |ui| {
                        self.render_node(ui, b, &format!("{path}b"))
                    });
                });
            }
        }
    }

    /// A pane is just its number — swap this fn for an editor, a terminal,
    /// whatever; the tree around it doesn't change.
    fn render_pane(&mut self, ui: &mut Ui<'_>, id: u64) {
        let pal = self.pal;
        let focused = self.focused == id;
        let overlays = self.overlays_on();
        let drag = self.pane_drag;
        let border = if focused {
            pal.border_focus
        } else {
            pal.border
        };
        ui.with_keyed(
            &format!("pane{id}"),
            NodeSpec::column()
                .fill()
                .center()
                .gap(8.0)
                .bg(pal.panel)
                .border(1.0, border)
                .clip()
                .on_click(Msg::Focus { pane: id }),
            |ui| {
                let color = if focused { pal.accent } else { pal.faint };
                ui.text(&format!("{id}"), TextStyle::new(48.0).color(color));
                ui.text(
                    if focused { "focused" } else { "click to focus" },
                    TextStyle::new(12.0).color(if focused { pal.fg } else { pal.dim }),
                );
                ui.text(
                    &format!("{ALT}v splits me · {ALT}w closes me"),
                    TextStyle::new(11.0).color(pal.faint),
                );
                if !overlays {
                    return;
                }
                // Drop-zone overlays: drag sources (any zone starts a drag
                // of this pane) and drop targets (the hovered zone during
                // a drag is the destination). Floats hit-test above the
                // pane's own on_click, so ⌘-clicks never focus by accident.
                for zone in Zone::ALL {
                    let key = ui.child_key(zone.label());
                    let hovered = ui.is_hovered(key);
                    let mut lit = false;
                    // Dropping a pane on itself is a no-op; don't advertise it.
                    if let Some((src, ..)) = drag
                        && hovered
                        && src != id
                    {
                        self.drop_target = Some((id, zone));
                        lit = true;
                    }
                    let bg = if lit {
                        Color {
                            a: 0.35,
                            ..pal.accent
                        }
                    } else {
                        Color::TRANSPARENT
                    };
                    ui.leaf_keyed(
                        zone.label(),
                        zone.spec()
                            .bg(bg)
                            .transition(80.0)
                            .hoverable()
                            .on_drag(Msg::PaneDrag { pane: id, zone }),
                    );
                }
            },
        );
    }

    /// The ghost that follows the cursor during a pane drag: a viewport
    /// float placed from the drag payload's cursor position.
    fn render_ghost(&self, ui: &mut Ui<'_>) {
        let Some((id, x, y)) = self.pane_drag else {
            return;
        };
        let pal = self.pal;
        // Hang off the cursor's bottom-right, or its bottom-left once the
        // right edge is near (the float's `fit` only clamps for viewport
        // anchors; flipping sides is the host's call).
        let vw = ui.viewport().w;
        let float = if x + 300.0 < vw {
            FloatConfig::viewport().offset(x + 14.0, y + 14.0)
        } else {
            FloatConfig::viewport()
                .inside(Align::End, Align::Start)
                .offset(x - vw - 14.0, y + 14.0)
        }
        .fit();
        let target = self
            .drop_target
            .map(|(dst, zone)| match zone {
                Zone::Center => format!("swap with {dst}"),
                Zone::Left => format!("left of {dst}"),
                Zone::Right => format!("right of {dst}"),
                Zone::Top => format!("above {dst}"),
                Zone::Bottom => format!("below {dst}"),
            })
            .unwrap_or_else(|| "drop on a pane".to_string());
        ui.with_keyed(
            "ghost",
            NodeSpec::row()
                .float(float)
                .pad_xy(10.0, 6.0)
                .gap(6.0)
                .radius(6.0)
                .bg(Color {
                    a: 0.92,
                    ..pal.panel
                })
                .border(1.0, pal.border_focus)
                .cross_align(Align::Center),
            |ui| {
                ui.text(&format!("pane {id}"), TextStyle::new(13.0).color(pal.fg));
                ui.text(&format!("→ {target}"), TextStyle::new(12.0).color(pal.dim));
            },
        );
    }
}

impl App for Splitmux {
    fn view(&mut self, ui: &mut Ui<'_>) {
        // Closing the last pane empties `tabs`, so don't build a frame from
        // them — just ask the runner to close and emit nothing.
        if self.quit {
            ui.window_command(WindowCommand::Close(ui.env().window.id));
            return;
        }
        // Rebuilt every frame from the theme, so an OS appearance change
        // repaints the mux without a message reaching the model.
        self.pal = ui.theme().into();
        let pal = self.pal;
        ui.with(NodeSpec::column().fill().bg(pal.bg), |ui| {
            widgets::titlebar(
                ui,
                "splitmux — the app owns the pane tree, kui owns the pixels",
            );
            self.tab_bar(ui);

            let root = self.tabs[self.tab].root.clone();
            // The view names the drop target from hover; start each frame blank
            // so a cursor that left every zone means "nowhere".
            self.drop_target = None;
            let sink = ui.with_keyed("main", NodeSpec::column().fill().key_sink(), |ui| {
                self.render_node(ui, &root, "")
            });
            ui.take_key_focus(sink);
            self.tabs[self.tab].root.settle();
            self.render_ghost(ui);
        });
    }

    fn on_event(&mut self, ev: UiEvent) {
        // Presses only — the sink never asked for releases (`key_up`), so
        // a chord fires once.
        if let Some((_, k)) = ev.key_press() {
            // The chord map: Alt (⌥ Option on macOS) + a letter or digit.
            if k.mods.alt && !k.mods.ctrl {
                self.chord(&k.code.name());
            } else if k.code == KeyCode::Escape {
                // Abandon a pane drag; the pointer capture runs on until
                // release, but its end lands on nothing.
                self.pane_drag = None;
            }
            return;
        }
        if let Some(mods) = ev.modifiers() {
            self.mods = mods;
            return;
        }
        match ev.kind() {
            Some("drag") => {
                let Some(d) = ev.drag() else { return };
                match ev.message::<Msg>() {
                    Some(Msg::TabDrag { tab }) => match d.phase {
                        DragPhase::Start => self.tab_drag = Some((tab, 0.0, 0.0)),
                        DragPhase::Move => {
                            // `delta` is measured from the press point, so
                            // the direction of this move is the change
                            // since the last one.
                            if let Some((_, sign, last)) = self.tab_drag.as_mut() {
                                let step = d.delta.x - *last;
                                if step != 0.0 {
                                    *sign = step;
                                }
                                *last = d.delta.x;
                            }
                        }
                        DragPhase::End => self.tab_drag = None,
                    },
                    Some(Msg::Split { path, dir }) => self.split_drag(d, path, dir),
                    Some(Msg::PaneDrag { pane, .. }) => self.pane_drag_event(d, pane),
                    _ => {}
                }
            }
            // Everything else the app hung on a node arrives as its payload.
            _ => match ev.message::<Msg>() {
                Some(Msg::Focus { pane }) => self.focused = pane,
                Some(Msg::Tab { tab }) => {
                    self.tab = tab;
                    self.refocus();
                }
                Some(Msg::TabNew) => self.new_tab(),
                _ => {}
            },
        }
    }
}

impl Splitmux {
    /// ⌘-drag of a pane: the payload's cursor drives the ghost, the view's
    /// hover bookkeeping names the target, and release performs the move.
    fn pane_drag_event(&mut self, d: Drag, pane: u64) {
        match d.phase {
            DragPhase::Start => self.pane_drag = Some((pane, d.pos.x, d.pos.y)),
            DragPhase::Move => {
                if let Some((_, x, y)) = self.pane_drag.as_mut() {
                    *x = d.pos.x;
                    *y = d.pos.y;
                }
            }
            DragPhase::End => {
                if let (Some((src, ..)), Some((dst, zone))) = (self.pane_drag, self.drop_target) {
                    self.move_pane(src, dst, zone);
                }
                self.pane_drag = None;
                self.drop_target = None;
            }
        }
    }

    /// Divider drags: absolute cursor position over the split's own rect
    /// (carried in the payload) is the new ratio directly.
    fn split_drag(&mut self, d: Drag, path: String, dir: SplitDir) {
        if d.phase == DragPhase::End {
            self.dragging = None;
            return;
        }
        let ratio = match dir {
            SplitDir::H => d.ratio().x,
            SplitDir::V => d.ratio().y,
        };
        self.dragging = Some(path.clone());
        if let Some(r) = self.tabs[self.tab].root.ratio_mut(&path) {
            *r = ratio.clamp(0.05, 0.95);
        }
    }
}

impl Example for Splitmux {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("Alt-v / Alt-s", "split"),
        ("Alt-o", "hop panes"),
        ("Alt-w", "close"),
        ("Alt-t", "new tab"),
        ("Alt-1..9", "jump to tab"),
        ("⌘-drag", "move a pane"),
    ];

    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default()
            .size(1100.0, 720.0)
            .custom_titlebar()
    }

    fn dock(&self) -> kui_devtools::Dock {
        kui_devtools::Dock::Bottom
    }

    /// The chord keymap, driven (backlog C36): two splits, a tab, and a
    /// jump back — the keys the smoke round once checked by hand.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        use kui_devtools::Drive;
        use kui_native::{InputEvent, Vec2};
        let alt = KeyMods::NONE.with_alt();
        let mut d = Drive::new(core, 1100.0, 720.0);
        d.frame(self);
        let panes = |app: &Splitmux| {
            fn count(n: &Node) -> usize {
                match n {
                    Node::Pane(_) => 1,
                    Node::Split { a, b, .. } => count(a) + count(b),
                }
            }
            count(&app.tabs[app.tab].root)
        };
        d.check(panes(self) == 3, "three panes to start")?;
        d.key(self, "v", alt);
        d.frame(self);
        d.check(panes(self) == 4, "Alt-v splits the focused pane")?;
        d.key(self, "s", alt);
        d.frame(self);
        d.check(panes(self) == 5, "Alt-s splits the focused half again")?;
        let focused = self.focused;
        d.key(self, "o", alt);
        d.frame(self);
        d.check(self.focused != focused, "Alt-o hops to another pane")?;
        d.key(self, "t", alt);
        d.frame(self);
        d.check(
            self.tabs.len() == 2 && self.tab == 1 && panes(self) == 1,
            "Alt-t opens a second tab with one pane and moves to it",
        )?;
        d.key(self, "1", alt);
        d.frame(self);
        d.check(
            self.tab == 0 && panes(self) == 5,
            "Alt-1 jumps back to the first tab, its five panes intact",
        )?;
        d.key(self, "w", alt);
        d.frame(self);
        d.check(panes(self) == 4, "Alt-w closes the focused pane")?;
        // The typed messages (backlog C50), each through real input: a
        // click's payload, a drag's tag.
        d.click(self, 60.0, 400.0);
        d.frame(self);
        let left = self.focused;
        d.click(self, 1040.0, 680.0);
        d.frame(self);
        d.check(
            self.focused != left,
            "a click on a pane focuses it (Msg::Focus)",
        )?;
        let plus = d.key_of("tab+").ok_or("no + tab")?;
        let r = d.rect_of(plus).ok_or("the + tab was not laid out")?;
        d.click(self, r.x + r.w / 2.0, r.y + r.h / 2.0);
        d.frame(self);
        d.check(
            self.tabs.len() == 3 && self.tab == 2,
            "the + tab opens a third tab (Msg::TabNew)",
        )?;
        let first = d
            .key_of(&format!("tab{}", self.tabs[0].id))
            .ok_or("no first tab")?;
        let r = d.rect_of(first).ok_or("the first tab was not laid out")?;
        d.click(self, r.x + r.w / 2.0, r.y + r.h / 2.0);
        d.frame(self);
        d.check(self.tab == 0, "a click on a tab selects it (Msg::Tab)")?;
        let divider = d.key_of("divider").ok_or("no divider")?;
        let r = d.rect_of(divider).ok_or("the divider was not laid out")?;
        let (x, y) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
        let ratios = |app: &Splitmux| {
            fn walk(n: &Node, out: &mut Vec<f32>) {
                if let Node::Split { a, b, ratio, .. } = n {
                    out.push(*ratio);
                    walk(a, out);
                    walk(b, out);
                }
            }
            let mut out = Vec::new();
            walk(&app.tabs[app.tab].root, &mut out);
            out
        };
        let before = ratios(self);
        let (dx, dy) = if r.w < r.h { (80.0, 0.0) } else { (0.0, 60.0) };
        d.input(self, InputEvent::CursorMoved(Vec2::new(x, y)));
        d.input(self, InputEvent::mouse_down(1));
        d.input(self, InputEvent::CursorMoved(Vec2::new(x + dx, y + dy)));
        d.input(self, InputEvent::mouse_up());
        d.frame(self);
        d.check(
            ratios(self) != before,
            "dragging a divider moves its split (Msg::Split, read from the drag's tag)",
        )?;

        // A click on a pane focuses it, and the chords still work after:
        // the trap the unit tests below pin.
        let before = self.focused;
        d.key(self, "o", alt);
        d.frame(self);
        d.check(self.focused != before, "and the keymap is still the sink's")
    }
}

kui_devtools::main!(Splitmux::new());

#[cfg(test)]
mod tests {
    //! The app driven headlessly through the `App` trait the runner calls:
    //! build a frame, feed pointer and key input to a `Core`, hand what comes
    //! back to `on_event`. Splitmux owns its whole keyboard through one sink
    //! *and* draws clickable surfaces inside it, which is the shape where a
    //! press quietly taking the keyboard shows up — a click on a pane once
    //! killed every chord for the life of the process, because
    //! `take_key_focus` is edge-triggered and does not ask twice.

    use super::*;
    use kui_core::widgets::TITLEBAR_H;
    use kui_core::{Core, InputEvent, KeyCode, KeyPress, Size, Vec2};

    fn viewport() -> Size {
        Size::new(900.0, 640.0)
    }

    /// One frame, the way the runner pumps it.
    fn frame(core: &mut Core, app: &mut Splitmux) {
        let mut ui = core.frame(viewport(), 1.0);
        app.view(&mut ui);
        ui.finish();
    }

    fn feed(core: &mut Core, app: &mut Splitmux, ev: InputEvent) {
        for e in core.handle_input(ev) {
            app.on_event(e);
        }
    }

    fn click(core: &mut Core, app: &mut Splitmux, x: f32, y: f32) {
        feed(core, app, InputEvent::CursorMoved(Vec2::new(x, y)));
        feed(core, app, InputEvent::mouse_down(1));
        feed(core, app, InputEvent::mouse_up());
    }

    /// ⌥ plus a key on a US layout: what the layout produced and which key
    /// it was are the same thing.
    fn chord(core: &mut Core, app: &mut Splitmux, c: char) {
        chord_on(core, app, c, c);
    }

    /// ⌥ plus a key on any layout: `layout` is what the active layout put on
    /// the key, `physical` which key it was, exactly as a driver reports the
    /// pair. `KeyPress::from_layout` resolves the `code` the keymap sees.
    fn chord_on(core: &mut Core, app: &mut Splitmux, layout: char, physical: char) {
        let mods = KeyMods::NONE.with_alt();
        let kp = KeyPress::from_layout(KeyCode::Char(layout), KeyCode::Char(physical), mods);
        feed(core, app, InputEvent::KeyDown(kp));
    }

    fn panes(app: &Splitmux) -> usize {
        fn count(n: &Node) -> usize {
            match n {
                Node::Pane(_) => 1,
                Node::Split { a, b, .. } => count(a) + count(b),
            }
        }
        count(&app.tabs[app.tab].root)
    }

    /// Asserts that ⌥v still reaches the keymap.
    fn splits(core: &mut Core, app: &mut Splitmux) -> bool {
        let before = panes(app);
        chord(core, app, 'v');
        frame(core, app);
        panes(app) == before + 1
    }

    fn started() -> (Core, Splitmux) {
        let (mut core, mut app) = (Core::new(), Splitmux::new());
        frame(&mut core, &mut app);
        (core, app)
    }

    #[test]
    fn chords_work_before_anything_is_clicked() {
        let (mut core, mut app) = started();
        assert!(splits(&mut core, &mut app));
    }

    /// The pane says "click to focus" on its own face, so this is the first
    /// thing anyone does.
    #[test]
    fn chords_survive_clicking_a_pane() {
        let (mut core, mut app) = started();
        click(&mut core, &mut app, 450.0, 540.0);
        frame(&mut core, &mut app);
        assert_eq!(app.focused, 1, "the click still focused the pane");
        assert!(splits(&mut core, &mut app));
    }

    /// A tab is a real control outside the sink, and pressing one leaves
    /// the keyboard with the sink (`keep_focus` on the bar, DX10).
    #[test]
    fn chords_survive_clicking_a_tab() {
        let (mut core, mut app) = started();
        chord(&mut core, &mut app, 't');
        frame(&mut core, &mut app);
        assert_eq!(app.tabs.len(), 2, "⌥t opened a tab");
        click(&mut core, &mut app, 40.0, TITLEBAR_H + TABBAR_H / 2.0);
        frame(&mut core, &mut app);
        assert_eq!(app.tab, 0, "the click selected the first tab");
        assert!(splits(&mut core, &mut app));
    }

    /// The keymap is written in Latin and the app never asks what layout is
    /// active, so these are the layouts it has to survive.
    #[test]
    fn chords_work_on_a_layout_that_is_not_latin() {
        let (mut core, mut app) = started();
        // Russian ЙЦУКЕН: the key US-QWERTY prints V on produces "м". Without
        // the fallback every arm of `chord` would miss and the app would be
        // silently keyboard-dead.
        let before = panes(&app);
        chord_on(&mut core, &mut app, 'м', 'v');
        frame(&mut core, &mut app);
        assert_eq!(panes(&app), before + 1, "⌥v splits on a Russian layout");
        // Greek: ⌥t opens a tab from the key printed Τ.
        chord_on(&mut core, &mut app, 'τ', 't');
        frame(&mut core, &mut app);
        assert_eq!(app.tabs.len(), 2, "⌥t opens a tab on a Greek layout");
    }

    /// A Latin layout keeps its own labels, so the chord is where the user
    /// reads it rather than where QWERTY would have put it.
    #[test]
    fn chords_follow_the_label_on_a_latin_layout() {
        let (mut core, mut app) = started();
        // Dvorak: the key printed V sits where QWERTY prints ".".
        let before = panes(&app);
        chord_on(&mut core, &mut app, 'v', '.');
        frame(&mut core, &mut app);
        assert_eq!(panes(&app), before + 1, "⌥v is on the key printed V");
        // And the QWERTY V position, which Dvorak prints K on, is not it.
        let before = panes(&app);
        chord_on(&mut core, &mut app, 'k', 'v');
        frame(&mut core, &mut app);
        assert_eq!(panes(&app), before, "the position alone does not split");
    }

    /// Grabbing the window to move it is the platform's business, not a
    /// request that the app give up its keyboard.
    #[test]
    fn chords_survive_grabbing_the_titlebar() {
        let (mut core, mut app) = started();
        click(&mut core, &mut app, 450.0, TITLEBAR_H / 2.0);
        frame(&mut core, &mut app);
        assert!(splits(&mut core, &mut app));
    }
}