//! The outline: the tree as an indented list, to reorganize and to read. //! //! ```text //! ▾ Garden plans //! ├ Soil #todo //! ├ ▸ Raised beds ¶ +2 //! ├ ▾ Watering //! │ ├ Rain barrel //! │ └ ▸ Drip line +1 //! └ » Kitchen //! ◦ ask about compost ¶ //! ``` //! //! A row for each node on show (it folds the way every view does), with its //! fold marker, its tags, `¶` when it has a body and `+N` for what a fold //! hides. The tree lines take the parent's color, as branches do on the //! canvas. `zb` shows every body under its title, to read the document like //! a page. //! //! `j` and `k` go row by row, `h` and `l` to the parent and the first child. //! In move mode a node moves through the tree rather than across the canvas: //! `j` and `k` swap it with its siblings, `h` takes it out a level, and `l` //! puts it under the one above. //! //! Rows wider than the view (a deep tree, a long title) are cut at its right //! edge; `zh` and `zl` or the wheel sideways scroll them. use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use crate::app::action::{Action, AppAction, Message}; use crate::app::commands; use crate::app::mode::Mode; use crate::app::popup::MenuFor; use crate::app::text_edit::{EditAt, StartEdit, TextEdit}; use crate::input::bindings::Context; use crate::input::command::{Command, Motion}; use crate::input::engine::Typed; use crate::input::mouse::{Cell, Gesture}; use crate::model::{Content, Direction, EditOp, Fold, NodeId, Visible}; use crate::ui::text_editor::layout; use crate::view::{View, ViewContext, ViewKind, contexts_for}; /// How close the cursor's row may come to the top or the bottom before the /// outline scrolls, like vim's `scrolloff`. const SCROLL_OFF: usize = 3; /// How many rows the mouse wheel scrolls. const WHEEL_ROWS: usize = 3; /// How many columns `zh`, `zl` and the wheel sideways scroll. const SCROLL_COLS: u16 = 6; /// How many columns of a title stay in view, at least, when the outline /// scrolls sideways to show the cursor's row. const TITLE_ROOM: u16 = 12; /// How many columns each level of the tree is indented. const INDENT: u16 = 4; /// The outline view. #[derive(Debug, Default)] pub struct OutlineView { /// Where on screen the view is. area: Rect, /// The first row on show. scroll: usize, /// How many columns the rows are scrolled to the left. side: u16, /// Every body shows under its title (`zb`). bodies: bool, /// The rows laid out last: what's drawn, and what a click lands on. rows: Vec, /// The cursor at the last layout. When it moves, the view follows. last_cursor: Option, /// The view was scrolled by hand: it stays put until the cursor moves. scrolled: bool, /// The title being edited, as drawn. editing: Option, } /// One row, ready to draw. #[derive(Debug, Clone)] struct Row { /// The node it's about (a line of a body is about its node). id: NodeId, line: Line<'static>, /// Where the title, or the line of the body, starts. text_col: u16, /// On the title row of a node with children: where the fold marker is, /// and whether the children are folded away. marker: Option<(u16, bool)>, /// The title row, rather than a line of the body. title: bool, } /// The title being edited, as drawn: where its text starts, scrolled /// sideways so that the text cursor stays in view. #[derive(Debug, Clone)] struct Editing { row: usize, text_col: u16, /// How many columns of the text are scrolled off to the left. skip: usize, text: String, /// The text cursor's column, counted from the start of the text. cursor_col: usize, } impl OutlineView { pub fn new() -> OutlineView { OutlineView::default() } /// Lays out a row for every node on show (and the lines of their bodies, /// when bodies show). fn build(&mut self, ctx: &ViewContext<'_>) { let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs); let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus); let edit = ctx.mode.title_edit(); // The color of the lines from a node to its children, as on the // canvas: the node's own, or dim when it has none. let line_style = |id: NodeId| { let color = doc.node(id).map_or(Color::Reset, |node| theme.node_style(node, id == doc.root()).color); Style::new().fg(if color == Color::Reset { theme.muted } else { color }) }; self.rows.clear(); self.editing = None; for &id in &visible.order { // Top-down: the top-level node this one is under, …, its parent. let mut path: Vec = doc.ancestors(id).collect(); path.reverse(); // The lines of the levels above, going on down past this row, // then this node's joint to its parent's line. The lines of its // body go on below the way its children's would. let mut lines = Lines::default(); let mut body_lines = Lines::default(); body_lines.push(" ", Style::new()); if let Some(&parent) = path.last() { lines.push(" ", Style::new()); for pair in path.windows(2) { let going_on = doc.next_sibling(pair[1]).is_some(); lines.push_level(going_on, glyphs.tree_line(), line_style(pair[0])); } let last = doc.next_sibling(id).is_none(); body_lines = lines.clone(); body_lines.push_level(!last, glyphs.tree_line(), line_style(parent)); lines.push(format!("{} ", glyphs.tree_joint(last)), line_style(parent)); } self.add_title(ctx, id, &visible, lines, edit); self.add_body(ctx, id, body_lines); } } /// The title row of a node: its marker, title, tags, `¶` and `+N`. fn add_title( &mut self, ctx: &ViewContext<'_>, id: NodeId, visible: &Visible, mut lines: Lines, edit: Option<&TextEdit>, ) { let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs); let Some(node) = doc.node(id) else { return }; let top_level = lines.spans.is_empty(); let style = theme.node_style(node, id == doc.root()); let color = style.color; let has_children = !doc.children(id).is_empty(); let folded = visible.hidden.contains_key(&id); let marker = if node.is_portal() { Some(glyphs.portal()) } else if has_children { Some(glyphs.fold(folded)) } else if top_level { Some(glyphs.loose()) } else { None }; let marker_at = (has_children && !node.is_portal()).then_some((lines.width, folded)); if let Some(marker) = marker { lines.push(format!("{marker} "), Style::new().fg(color)); } let text_col = lines.width; let muted = Style::new().fg(theme.muted); let title_style = if id == ctx.cursor { Style::new().fg(Color::Black).bg(theme.accent).add_modifier(Modifier::BOLD) } else if style.faded { muted } else { Style::new() }; match edit.filter(|edit| edit.node == id) { Some(edit) => { // The title as typed, scrolled so the text cursor shows. let text = edit.editor.text(); let room = usize::from(self.area.width.saturating_add(self.side).saturating_sub(text_col)).max(1); let cursor_col = text[..edit.editor.cursor()].width(); let (shown, skip) = cut_left(text, (cursor_col + 1).saturating_sub(room)); let color = if edit.editor.is_typing() { theme.insert } else { theme.accent }; lines.push(shown.to_string(), Style::new().fg(color).add_modifier(Modifier::BOLD)); self.editing = Some(Editing { row: self.rows.len(), text_col, skip, text: text.to_string(), cursor_col, }); } None => lines.push(node.title().to_string(), title_style), } if !node.tags.is_empty() { lines.push(format!(" {}", node.tags), muted); } let has_body = matches!(&node.content, Content::Idea(idea) if !idea.body.is_empty()); if has_body && !self.bodies { lines.push(format!(" {}", glyphs.body_mark()), muted); } if let Some(count) = visible.hidden.get(&id) { lines.push(format!(" +{count}"), muted); } self.rows.push(Row { id, line: Line::from(lines.spans), text_col, marker: marker_at, title: true }); } /// The lines of a node's body, under its title, when bodies show. fn add_body(&mut self, ctx: &ViewContext<'_>, id: NodeId, lines: Lines) { if !self.bodies { return; } let Some(Content::Idea(idea)) = ctx.doc.node(id).map(|node| &node.content) else { return }; let body = idea.body.as_str(); if body.is_empty() { return; } let text_col = lines.width; let room = usize::from(self.area.width.saturating_sub(text_col)).max(8); for range in layout::rows(body, room) { let text = body[range].trim_end_matches('\n').to_string(); let mut spans = lines.spans.clone(); spans.push(Span::raw(text)); self.rows.push(Row { id, line: Line::from(spans), text_col, marker: None, title: false }); } } /// Scrolls so the cursor's row shows, a few rows from the edges. fn show_cursor_row(&mut self, cursor: NodeId) { let Some(index) = self.rows.iter().position(|row| row.id == cursor && row.title) else { return }; let height = usize::from(self.area.height).max(1); let off = SCROLL_OFF.min(height.saturating_sub(1) / 2); if index < self.scroll + off { self.scroll = index.saturating_sub(off); } else if index + off >= self.scroll + height { self.scroll = index + off + 1 - height; } } /// Scrolls by some rows, up when negative. The cursor stays where it is. fn scroll_by(&mut self, rows: isize) { self.scroll = self.scroll.saturating_add_signed(rows).min(self.rows.len().saturating_sub(1)); self.scrolled = true; } /// Scrolls sideways by some columns, left when negative: as far as it /// takes for the widest row to end at the right edge. fn scroll_side(&mut self, cols: i32) { let side = i32::from(self.side).saturating_add(cols).clamp(0, i32::from(self.widest_overhang())); self.side = u16::try_from(side).unwrap_or(0); } /// How far the widest row goes past the view's right edge. fn widest_overhang(&self) -> u16 { let widest = self.rows.iter().map(|row| row.line.width()).max().unwrap_or(0); u16::try_from(widest).unwrap_or(u16::MAX).saturating_sub(self.area.width) } /// Scrolls sideways, if it has to, so the start of the cursor's title is /// in view with some of the title after it. Returns whether it scrolled. fn show_cursor_title(&mut self, cursor: NodeId) -> bool { let Some(row) = self.rows.iter().find(|row| row.id == cursor && row.title) else { return false }; let before = self.side; let room = TITLE_ROOM.min(self.area.width / 2); if row.text_col < self.side { self.side = row.text_col.saturating_sub(INDENT); } else if row.text_col + room > self.side + self.area.width { self.side = row.text_col + room - self.area.width; } self.side != before } /// The row and column under a terminal cell, if it's in the view: the /// column counted from the rows' start, however far they're scrolled. fn local(&self, cell: Cell) -> Option<(u16, usize)> { let at = Position::new(cell.col, cell.row); self.area.contains(at).then(|| { let col = (at.x - self.area.x).saturating_add(self.side); (col, self.scroll + usize::from(at.y - self.area.y)) }) } /// Where a click lands in the title being edited, if it's on its text. fn text_offset_at(&self, (col, row): (u16, usize)) -> Option { let editing = self.editing.as_ref().filter(|editing| editing.row == row && col >= editing.text_col)?; let column = usize::from(col - editing.text_col) + editing.skip; Some(layout::offset(&editing.text, &layout::rows(&editing.text, usize::MAX), 0, column)) } /// The terminal cell of the text cursor, while a title is edited. fn text_cursor(&self) -> Option { let editing = self.editing.as_ref()?; let row = u16::try_from(editing.row.checked_sub(self.scroll)?).ok()?; let col = usize::from(editing.text_col) + editing.cursor_col - editing.skip; let col = u16::try_from(col.checked_sub(usize::from(self.side))?).ok()?; (row < self.area.height && col < self.area.width) .then(|| Position::new(self.area.x + col, self.area.y + row)) } /// A left press: on a row, its node becomes the cursor; on a fold marker, /// the branch folds or unfolds too. fn press(&self, at: (u16, usize), ctx: &ViewContext<'_>) -> Vec { let Some(row) = self.rows.get(at.1) else { return Vec::new() }; let mut actions = vec![Action::cursor(row.id)]; if row.title && let Some((col, folded)) = row.marker && col == at.0 && ctx.doc.contains(row.id) { let fold = if folded { Fold::Open } else { Fold::Closed }; actions.push(Action::edit(EditOp::SetFold { ids: vec![row.id], fold })); } actions } /// Where a double-click on a title lands in it, to start editing there. fn title_spot(&self, row: &Row, col: u16, ctx: &ViewContext<'_>) -> EditAt { let Some(node) = ctx.doc.node(row.id) else { return EditAt::End }; if col < row.text_col { return EditAt::End; } let title = node.title().as_str(); EditAt::Offset(layout::offset( title, &layout::rows(title, usize::MAX), 0, usize::from(col - row.text_col), )) } } /// The spans of a row as it's built, and how wide they are so far. #[derive(Debug, Clone, Default)] struct Lines { spans: Vec>, width: u16, } impl Lines { fn push(&mut self, text: impl Into, style: Style) { let text = text.into(); self.width = self.width.saturating_add(u16::try_from(text.width()).unwrap_or(u16::MAX)); self.spans.push(Span::styled(text, style)); } /// One level of the tree: its line going on down, or room where it ended. fn push_level(&mut self, going_on: bool, line: char, style: Style) { let indent = usize::from(INDENT); let text = if going_on { format!("{line}{}", " ".repeat(indent - 1)) } else { " ".repeat(indent) }; self.push(text, style); } } /// A row without its first `columns` columns: what shows when the outline /// is scrolled sideways. fn skip_columns(line: &Line<'static>, columns: usize) -> Line<'static> { if columns == 0 { return line.clone(); } let mut left = columns; let mut spans = Vec::new(); for span in &line.spans { let width = span.content.width(); if left == 0 { spans.push(span.clone()); } else if width <= left { left -= width; } else { let (rest, cut) = cut_left(&span.content, left); // A wide character cut in half leaves its other half blank. let blank = " ".repeat(cut - left); spans.push(Span::styled(format!("{blank}{rest}"), span.style)); left = 0; } } Line::from(spans) } /// `text` without its first `columns` columns, and how many columns that /// took off (a wide character isn't cut in half, so it can be one more). fn cut_left(text: &str, columns: usize) -> (&str, usize) { let mut cut = 0; for (at, c) in text.char_indices() { if cut >= columns { return (&text[at..], cut); } cut += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0); } ("", cut) } fn say(text: &str) -> Vec { vec![Action::say(Message::info(text))] } impl View for OutlineView { fn kind(&self) -> ViewKind { ViewKind::Outline } fn contexts(&self, mode: &Mode) -> &'static [Context] { contexts_for(mode, &[Context::Outline, Context::Normal, Context::Motions]) } fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) { self.area = area; let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor); if moved { self.scrolled = false; } self.build(ctx); if !self.scrolled || self.editing.is_some() { self.show_cursor_row(ctx.cursor); } // Sideways, the cursor's title comes into view when the cursor // moves, and stays in view while it's edited (and the title being // typed is laid out for where the rows are, so it's laid out again). let before = self.side; if moved || self.editing.is_some() { self.show_cursor_title(ctx.cursor); } self.side = self.side.min(self.widest_overhang()); if self.side != before && self.editing.is_some() { self.build(ctx); } self.scroll = self.scroll.min(self.rows.len().saturating_sub(1)); } fn render(&self, frame: &mut Frame, _ctx: &ViewContext<'_>) { let buf = frame.buffer_mut(); let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height)); for (row, y) in rows.zip(self.area.y..) { let line = skip_columns(&row.line, usize::from(self.side)); buf.set_line(self.area.x, y, &line, self.area.width); } if let Some(cell) = self.text_cursor() { frame.set_cursor_position(cell); } } fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option> { match ctx.mode { Mode::Move(grab) => return commands::reorder(typed, *grab, ctx), Mode::Normal => {} Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None, } let rows = isize::try_from(typed.times()).unwrap_or(1); let cols = i32::from(SCROLL_COLS) * i32::try_from(typed.times()).unwrap_or(1); let half_page = isize::try_from(self.area.height / 2).unwrap_or(1).max(1); // hjkl follow the rows: the next and previous in reading order, the // parent and the first child. let tree = |motion: Motion| commands::shared(&Typed { command: Command::Move(motion), ..*typed }, ctx); match typed.command { Command::Move(Motion::Down) => return tree(Motion::Next), Command::Move(Motion::Up) => return tree(Motion::Previous), Command::Move(Motion::Left) => return tree(Motion::Parent), Command::Move(Motion::Right) => return tree(Motion::FirstChild), Command::ScrollDown => self.scroll_by(rows), Command::ScrollUp => self.scroll_by(-rows), Command::HalfPageDown => self.scroll_by(half_page * rows), Command::HalfPageUp => self.scroll_by(-half_page * rows), Command::ScrollLeft => self.scroll_side(-cols), Command::ScrollRight => self.scroll_side(cols), Command::Center => { if let Some(index) = self.rows.iter().position(|row| row.id == ctx.cursor && row.title) { self.scroll = index.saturating_sub(usize::from(self.area.height / 2)); self.scrolled = true; } } Command::ToggleBodies => { self.bodies = !self.bodies; let text = if self.bodies { "every body shows under its title: zb hides them" } else { "bodies hidden: ¶ marks the nodes that have one" }; return Some(say(text)); } _ => return None, } Some(Vec::new()) } fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec { match gesture { Gesture::Press { at, .. } => { let Some(at) = self.local(at) else { return Vec::new() }; let Some(edit) = ctx.mode.title_edit() else { return self.press(at, ctx) }; // While a title is edited, a click in it moves the text // cursor; a click anywhere else keeps the edit, finishes it, // and does what a click does. if let Some(offset) = self.text_offset_at(at) { return vec![Action::App(AppAction::TextCursor(offset))]; } let mut actions = edit.finish(ctx); actions.extend(self.press(at, ctx)); actions } Gesture::DoublePress { at } => { let Some(at) = self.local(at) else { return Vec::new() }; if let Some(offset) = self.text_offset_at(at) { return vec![Action::App(AppAction::TextCursor(offset))]; } let Some(row) = self.rows.get(at.1).filter(|row| row.title) else { return Vec::new() }; let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default(); let start = StartEdit::title(self.title_spot(row, at.0, ctx)); actions.extend([Action::cursor(row.id), Action::App(AppAction::StartEdit(start))]); actions } Gesture::Scroll { direction, .. } => { let rows = isize::try_from(WHEEL_ROWS).unwrap_or(1); match direction { Direction::Down => self.scroll_by(rows), Direction::Up => self.scroll_by(-rows), Direction::Left => self.scroll_side(-i32::from(SCROLL_COLS)), Direction::Right => self.scroll_side(i32::from(SCROLL_COLS)), } Vec::new() } Gesture::RightPress { at: cell } => { let Some(row) = self.local(cell).and_then(|at| self.rows.get(at.1)) else { return Vec::new(); }; let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default(); let (what, col, row_at) = (MenuFor::Node(row.id), cell.col, cell.row); actions.extend([ Action::cursor(row.id), Action::App(AppAction::OpenMenu { what, col, row: row_at }), ]); actions } Gesture::Drag { .. } | Gesture::Release { .. } => Vec::new(), } } fn cursor_area(&self, ctx: &ViewContext<'_>) -> Option { let index = self.rows.iter().position(|row| row.id == ctx.cursor && row.title)?; let row = u16::try_from(index.checked_sub(self.scroll)?).ok().filter(|&row| row < self.area.height)?; let width = self.rows[index].line.width(); let width = u16::try_from(width).unwrap_or(u16::MAX).min(self.area.width).max(1); Some(Rect::new(self.area.x, self.area.y + row, width, 1)) } } #[cfg(test)] mod tests;