//! 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));
}
}