The outline shows the tree as an indented list: a row per node with its fold marker, tree lines in the parent's color, tags, a ¶ for a body and +N for what a fold hides. zb shows every body under its title, to read a document like a page. j/k go row by row, h/l to the parent and the first child, a click on a marker folds, and titles are edited in their row, rapid entry included. In move mode, j/k swap the node with its siblings, h takes it out a level and l puts it under the one above. The Note view shows one node at full size, as parts: the title, the tags, the body, each link and each child. j/k step through them, Enter or i edits the part (or goes to that link or child), and h/l turn the page to the sibling before or after. Bodies and tags are edited with the same editor as titles: Enter starts a line in a body, Tab types spaces, and tags that don't parse come back as they were. cc, C and s now start typing on an empty line too, as in vim. Panes split the screen the way vim's windows do (Ctrl-w v s h j k l w c o =, :split, :vsplit, :only), each in a box with its view and document, and any pane can show any open document (gt, gT, :b, the documents picker, a click on the document bar). The status bar has two lines: the mode, the view, the breadcrumb or a message, the keys typed; then key hints for what you're doing, which <Space>w hides with which-key. Popups are a mode of their own, built on one list: ? lists every key and command, / searches it and Enter runs what you pick; the right-click menu lists what you can do with a node (with the keys that do the same) or adds a new idea on the empty canvas; the pickers set a node's color, border, style or tags. K opens the body in a card beside the node, where i edits it in place. The example now opens two documents (the garden, and the kitchen its portal leads to) and its tests drive all of the above end to end. Co-Authored-By: Claude Opus 5.5 <[email protected]>
439 lines
16 KiB
Rust
439 lines
16 KiB
Rust
//! Panes: the screen split into rectangles, the way vim splits windows.
|
|
//!
|
|
//! The layout is a tree. A leaf is a pane; a split lays its parts side by
|
|
//! side or one above the other, each with a share of the room. Splitting a
|
|
//! pane halves its room, closing one gives its room to a neighbor, and
|
|
//! `Ctrl-w =` evens the shares out.
|
|
//!
|
|
//! [`Panes`] holds what each pane shows (`T`: a view and a document, in the
|
|
//! app) apart from the layout, so the layout logic stays plain and testable.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use ratatui::layout::{Position, Rect};
|
|
|
|
use crate::model::Direction;
|
|
|
|
/// A pane, while it's open. Ids aren't reused.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub struct PaneId(u32);
|
|
|
|
/// How a split arranges its parts.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Arrange {
|
|
/// Next to each other, left to right (`Ctrl-w v`, `:vsplit`).
|
|
SideBySide,
|
|
/// One above the other (`Ctrl-w s`, `:split`).
|
|
Stacked,
|
|
}
|
|
|
|
/// A part's room when a split is made or evened out. Splitting halves a
|
|
/// share, so it starts big enough to be halved many times over.
|
|
const FULL_SHARE: u32 = 1 << 20;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum Layout {
|
|
Pane(PaneId),
|
|
Split { arrange: Arrange, parts: Vec<Part> },
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct Part {
|
|
layout: Layout,
|
|
/// This part's room, relative to the other parts of its split.
|
|
share: u32,
|
|
}
|
|
|
|
/// The panes, their layout, and which one has the focus.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Panes<T> {
|
|
layout: Layout,
|
|
/// What each pane shows. Every pane in the layout is here and the other
|
|
/// way round, and `focused` is always one of them: every method keeps it
|
|
/// so.
|
|
contents: BTreeMap<PaneId, T>,
|
|
focused: PaneId,
|
|
next_id: u32,
|
|
}
|
|
|
|
impl<T> Panes<T> {
|
|
/// One pane, showing `first`.
|
|
pub fn new(first: T) -> Panes<T> {
|
|
let id = PaneId(0);
|
|
Panes { layout: Layout::Pane(id), contents: BTreeMap::from([(id, first)]), focused: id, next_id: 1 }
|
|
}
|
|
|
|
/// The pane with the focus.
|
|
pub fn focused(&self) -> PaneId {
|
|
self.focused
|
|
}
|
|
|
|
/// What the focused pane shows.
|
|
pub fn current(&self) -> &T {
|
|
self.contents.get(&self.focused).expect("the focused pane is always open")
|
|
}
|
|
|
|
pub fn current_mut(&mut self) -> &mut T {
|
|
self.contents.get_mut(&self.focused).expect("the focused pane is always open")
|
|
}
|
|
|
|
pub fn get(&self, id: PaneId) -> Option<&T> {
|
|
self.contents.get(&id)
|
|
}
|
|
|
|
pub fn get_mut(&mut self, id: PaneId) -> Option<&mut T> {
|
|
self.contents.get_mut(&id)
|
|
}
|
|
|
|
/// How many panes there are.
|
|
pub fn count(&self) -> usize {
|
|
self.contents.len()
|
|
}
|
|
|
|
/// Every pane and what it shows, in no particular order.
|
|
pub fn iter_mut(&mut self) -> impl Iterator<Item = (PaneId, &mut T)> {
|
|
self.contents.iter_mut().map(|(&id, content)| (id, content))
|
|
}
|
|
|
|
/// Splits the focused pane in two. The new pane, showing `content`,
|
|
/// comes first (left or above, as in vim) and takes the focus.
|
|
pub fn split(&mut self, arrange: Arrange, content: T) -> PaneId {
|
|
let id = PaneId(self.next_id);
|
|
self.next_id += 1;
|
|
self.contents.insert(id, content);
|
|
let focused = self.focused;
|
|
split_in(&mut self.layout, focused, id, arrange);
|
|
self.focused = id;
|
|
id
|
|
}
|
|
|
|
/// Closes the focused pane and gives its room to a neighbor, which takes
|
|
/// the focus. The last pane can't close: `None`.
|
|
pub fn close(&mut self) -> Option<T> {
|
|
if self.contents.len() < 2 {
|
|
return None;
|
|
}
|
|
let closing = self.focused;
|
|
let next = close_in(&mut self.layout, closing)?;
|
|
self.focused = next;
|
|
self.contents.remove(&closing)
|
|
}
|
|
|
|
/// Closes every pane but the focused one, and returns what they showed.
|
|
pub fn only(&mut self) -> Vec<T> {
|
|
self.layout = Layout::Pane(self.focused);
|
|
let others: Vec<PaneId> = self.contents.keys().copied().filter(|&id| id != self.focused).collect();
|
|
others.into_iter().filter_map(|id| self.contents.remove(&id)).collect()
|
|
}
|
|
|
|
/// Gives every part of every split the same room (`Ctrl-w =`).
|
|
pub fn equalize(&mut self) {
|
|
equalize(&mut self.layout);
|
|
}
|
|
|
|
/// Where each pane is in `area`, in reading order: left to right, top to
|
|
/// bottom, the way the layout nests.
|
|
pub fn areas(&self, area: Rect) -> Vec<(PaneId, Rect)> {
|
|
let mut found = Vec::new();
|
|
areas(&self.layout, area, &mut found);
|
|
found
|
|
}
|
|
|
|
/// The pane at a position on screen, if there's one.
|
|
pub fn at(&self, area: Rect, position: Position) -> Option<PaneId> {
|
|
self.areas(area).into_iter().find(|(_, rect)| rect.contains(position)).map(|(id, _)| id)
|
|
}
|
|
|
|
/// Gives the focus to a pane. Returns false if there's no such pane.
|
|
pub fn focus(&mut self, id: PaneId) -> bool {
|
|
let open = self.contents.contains_key(&id);
|
|
if open {
|
|
self.focused = id;
|
|
}
|
|
open
|
|
}
|
|
|
|
/// `Ctrl-w h j k l`: the focus goes to the nearest pane that way, the
|
|
/// one beside the focused pane's top-left corner first. Returns false if
|
|
/// there's none.
|
|
pub fn focus_toward(&mut self, direction: Direction, area: Rect) -> bool {
|
|
let areas = self.areas(area);
|
|
let Some(&(_, from)) = areas.iter().find(|(id, _)| *id == self.focused) else { return false };
|
|
let best = areas
|
|
.iter()
|
|
.filter(|(id, _)| *id != self.focused)
|
|
.filter_map(|&(id, rect)| Some((distance(from, rect, direction)?, id)))
|
|
.min_by_key(|&(key, _)| key);
|
|
match best {
|
|
Some((_, id)) => self.focus(id),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// `Ctrl-w w`: the next pane in reading order, back to the first after
|
|
/// the last.
|
|
pub fn focus_next(&mut self) {
|
|
let mut order = Vec::new();
|
|
panes_in(&self.layout, &mut order);
|
|
let here = order.iter().position(|&id| id == self.focused).unwrap_or(0);
|
|
if let Some(&next) = order.get((here + 1) % order.len().max(1)) {
|
|
self.focused = next;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// How far `to` is from `from` going `direction`, for picking the pane to
|
|
/// focus: `None` unless it's that way and level with `from`. Nearer wins,
|
|
/// then the one beside `from`'s top-left corner, then the top-most or
|
|
/// left-most.
|
|
fn distance(from: Rect, to: Rect, direction: Direction) -> Option<(u16, bool, u16)> {
|
|
let level_rows = to.top() < from.bottom() && from.top() < to.bottom();
|
|
let level_cols = to.left() < from.right() && from.left() < to.right();
|
|
let (gap, level, beside, along) = match direction {
|
|
Direction::Left => {
|
|
(from.left().checked_sub(to.right())?, level_rows, row_in(from.top(), to), to.top())
|
|
}
|
|
Direction::Right => {
|
|
(to.left().checked_sub(from.right())?, level_rows, row_in(from.top(), to), to.top())
|
|
}
|
|
Direction::Up => {
|
|
(from.top().checked_sub(to.bottom())?, level_cols, col_in(from.left(), to), to.left())
|
|
}
|
|
Direction::Down => {
|
|
(to.top().checked_sub(from.bottom())?, level_cols, col_in(from.left(), to), to.left())
|
|
}
|
|
};
|
|
level.then_some((gap, !beside, along))
|
|
}
|
|
|
|
fn row_in(row: u16, rect: Rect) -> bool {
|
|
rect.top() <= row && row < rect.bottom()
|
|
}
|
|
|
|
fn col_in(col: u16, rect: Rect) -> bool {
|
|
rect.left() <= col && col < rect.right()
|
|
}
|
|
|
|
/// Splits pane `target` in `layout`, putting `new` before it.
|
|
fn split_in(layout: &mut Layout, target: PaneId, new: PaneId, arrange: Arrange) -> bool {
|
|
match layout {
|
|
Layout::Pane(id) if *id == target => {
|
|
let old = Part { layout: Layout::Pane(target), share: FULL_SHARE };
|
|
let part = Part { layout: Layout::Pane(new), share: FULL_SHARE };
|
|
*layout = Layout::Split { arrange, parts: vec![part, old] };
|
|
true
|
|
}
|
|
Layout::Pane(_) => false,
|
|
Layout::Split { arrange: own, parts } => {
|
|
let same_way = *own == arrange;
|
|
for index in 0..parts.len() {
|
|
let is_target = parts[index].layout == Layout::Pane(target);
|
|
if is_target && same_way {
|
|
// Same way as this split: the new pane joins it, and the
|
|
// two share the old pane's room.
|
|
let share = parts[index].share;
|
|
parts[index].share = share - share / 2;
|
|
let part = Part { layout: Layout::Pane(new), share: (share / 2).max(1) };
|
|
parts.insert(index, part);
|
|
return true;
|
|
}
|
|
if split_in(&mut parts[index].layout, target, new, arrange) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Takes pane `target` out of `layout`, giving its room to the part before
|
|
/// it (or after it, for a first part). Returns the pane that gets the
|
|
/// focus.
|
|
fn close_in(layout: &mut Layout, target: PaneId) -> Option<PaneId> {
|
|
let Layout::Split { parts, .. } = layout else { return None };
|
|
let index = parts.iter().position(|part| part.layout == Layout::Pane(target));
|
|
let next = match index {
|
|
Some(index) => {
|
|
let removed = parts.remove(index);
|
|
let neighbor = index.saturating_sub(1).min(parts.len().saturating_sub(1));
|
|
let part = parts.get_mut(neighbor)?;
|
|
part.share = part.share.saturating_add(removed.share);
|
|
first_pane(&part.layout)
|
|
}
|
|
None => parts.iter_mut().find_map(|part| close_in(&mut part.layout, target))?,
|
|
};
|
|
// A split left with one part is just that part.
|
|
if parts.len() == 1
|
|
&& let Some(only) = parts.pop()
|
|
{
|
|
*layout = only.layout;
|
|
}
|
|
flatten(layout);
|
|
Some(next)
|
|
}
|
|
|
|
/// A split inside a split that arranges the same way joins its parent, so
|
|
/// `Ctrl-w v` twice gives three panes side by side, not a nest.
|
|
fn flatten(layout: &mut Layout) {
|
|
let Layout::Split { arrange, parts } = layout else { return };
|
|
let mut flat = Vec::with_capacity(parts.len());
|
|
for mut part in std::mem::take(parts) {
|
|
flatten(&mut part.layout);
|
|
match part.layout {
|
|
Layout::Split { arrange: inner, parts: inner_parts } if inner == *arrange => {
|
|
let total: u64 = inner_parts.iter().map(|inner| u64::from(inner.share)).sum::<u64>().max(1);
|
|
for mut inner_part in inner_parts {
|
|
let share = u64::from(part.share) * u64::from(inner_part.share) / total;
|
|
inner_part.share = u32::try_from(share).unwrap_or(u32::MAX).max(1);
|
|
flat.push(inner_part);
|
|
}
|
|
}
|
|
layout => flat.push(Part { layout, ..part }),
|
|
}
|
|
}
|
|
*parts = flat;
|
|
}
|
|
|
|
fn equalize(layout: &mut Layout) {
|
|
if let Layout::Split { parts, .. } = layout {
|
|
for part in parts {
|
|
part.share = FULL_SHARE;
|
|
equalize(&mut part.layout);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn first_pane(layout: &Layout) -> PaneId {
|
|
match layout {
|
|
Layout::Pane(id) => *id,
|
|
Layout::Split { parts, .. } => parts.first().map_or(PaneId(0), |part| first_pane(&part.layout)),
|
|
}
|
|
}
|
|
|
|
fn panes_in(layout: &Layout, found: &mut Vec<PaneId>) {
|
|
match layout {
|
|
Layout::Pane(id) => found.push(*id),
|
|
Layout::Split { parts, .. } => parts.iter().for_each(|part| panes_in(&part.layout, found)),
|
|
}
|
|
}
|
|
|
|
/// Lays `layout` out in `area`: each part gets room in proportion to its
|
|
/// share, and the last one takes what rounding leaves.
|
|
fn areas(layout: &Layout, area: Rect, found: &mut Vec<(PaneId, Rect)>) {
|
|
let (arrange, parts) = match layout {
|
|
Layout::Pane(id) => return found.push((*id, area)),
|
|
Layout::Split { arrange, parts } => (*arrange, parts),
|
|
};
|
|
let length = match arrange {
|
|
Arrange::SideBySide => area.width,
|
|
Arrange::Stacked => area.height,
|
|
};
|
|
let total: u64 = parts.iter().map(|part| u64::from(part.share)).sum::<u64>().max(1);
|
|
let mut start = 0u16;
|
|
for (index, part) in parts.iter().enumerate() {
|
|
let size = if index + 1 == parts.len() {
|
|
length - start
|
|
} else {
|
|
let size = u64::from(length) * u64::from(part.share) / total;
|
|
u16::try_from(size).unwrap_or(length).min(length - start)
|
|
};
|
|
let rect = match arrange {
|
|
Arrange::SideBySide => Rect::new(area.x + start, area.y, size, area.height),
|
|
Arrange::Stacked => Rect::new(area.x, area.y + start, area.width, size),
|
|
};
|
|
areas(&part.layout, rect, found);
|
|
start += size;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SCREEN: Rect = Rect { x: 0, y: 0, width: 80, height: 20 };
|
|
|
|
fn rects<'a>(panes: &Panes<&'a str>) -> Vec<(&'a str, Rect)> {
|
|
panes.areas(SCREEN).into_iter().map(|(id, rect)| (*panes.get(id).unwrap(), rect)).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn splits_halve_the_pane_and_the_new_one_comes_first() {
|
|
let mut panes = Panes::new("canvas");
|
|
panes.split(Arrange::SideBySide, "outline");
|
|
assert_eq!(
|
|
rects(&panes),
|
|
[("outline", Rect::new(0, 0, 40, 20)), ("canvas", Rect::new(40, 0, 40, 20))]
|
|
);
|
|
assert_eq!(*panes.current(), "outline", "the new pane has the focus");
|
|
panes.split(Arrange::Stacked, "note");
|
|
assert_eq!(
|
|
rects(&panes),
|
|
[
|
|
("note", Rect::new(0, 0, 40, 10)),
|
|
("outline", Rect::new(0, 10, 40, 10)),
|
|
("canvas", Rect::new(40, 0, 40, 20)),
|
|
]
|
|
);
|
|
// Side by side again, from the canvas: it joins the top split.
|
|
panes.focus_toward(Direction::Right, SCREEN);
|
|
panes.split(Arrange::SideBySide, "board");
|
|
let widths: Vec<(&str, u16)> =
|
|
rects(&panes).into_iter().map(|(name, rect)| (name, rect.width)).collect();
|
|
assert_eq!(widths, [("note", 40), ("outline", 40), ("board", 20), ("canvas", 20)]);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_moves_to_the_pane_that_way() {
|
|
let mut panes = Panes::new("right");
|
|
panes.split(Arrange::SideBySide, "top left");
|
|
panes.split(Arrange::Stacked, "top left 2");
|
|
// top left 2 above top left, both left of right.
|
|
assert_eq!(*panes.current(), "top left 2");
|
|
assert!(panes.focus_toward(Direction::Down, SCREEN));
|
|
assert_eq!(*panes.current(), "top left");
|
|
assert!(panes.focus_toward(Direction::Right, SCREEN));
|
|
assert_eq!(*panes.current(), "right");
|
|
assert!(panes.focus_toward(Direction::Left, SCREEN));
|
|
assert_eq!(*panes.current(), "top left 2", "level with the top: the top one");
|
|
assert!(!panes.focus_toward(Direction::Up, SCREEN), "nothing above");
|
|
panes.focus_next();
|
|
assert_eq!(*panes.current(), "top left");
|
|
panes.focus_next();
|
|
panes.focus_next();
|
|
assert_eq!(*panes.current(), "top left 2", "after the last, the first");
|
|
}
|
|
|
|
#[test]
|
|
fn closing_gives_the_room_to_a_neighbor() {
|
|
let mut panes = Panes::new("canvas");
|
|
panes.split(Arrange::SideBySide, "outline");
|
|
panes.split(Arrange::Stacked, "note");
|
|
assert_eq!(panes.close(), Some("note"));
|
|
assert_eq!(*panes.current(), "outline", "the neighbor takes the focus");
|
|
assert_eq!(
|
|
rects(&panes),
|
|
[("outline", Rect::new(0, 0, 40, 20)), ("canvas", Rect::new(40, 0, 40, 20))]
|
|
);
|
|
assert_eq!(panes.close(), Some("outline"));
|
|
assert_eq!(panes.close(), None, "the last pane stays");
|
|
assert_eq!(rects(&panes), [("canvas", SCREEN)]);
|
|
}
|
|
|
|
#[test]
|
|
fn only_and_even_sizes() {
|
|
let mut panes = Panes::new("a");
|
|
panes.split(Arrange::SideBySide, "b");
|
|
panes.split(Arrange::SideBySide, "c");
|
|
let widths =
|
|
|panes: &Panes<&str>| rects(panes).into_iter().map(|(_, rect)| rect.width).collect::<Vec<_>>();
|
|
assert_eq!(widths(&panes), [20, 20, 40]);
|
|
panes.equalize();
|
|
assert_eq!(widths(&panes), [26, 26, 28]);
|
|
let mut closed = panes.only();
|
|
closed.sort_unstable();
|
|
assert_eq!(closed, ["a", "b"]);
|
|
assert_eq!(rects(&panes), [("c", SCREEN)]);
|
|
assert_eq!(panes.at(SCREEN, Position::new(70, 3)), Some(panes.focused()));
|
|
}
|
|
}
|