Add the outline and Note views, splits, popups and the bars

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]>
This commit is contained in:
skkeyeandClaude Opus 5.5 committed 2026-10-01 19:10:09 -04:00
1 parent 84aefbecdb
commit c587c42115
37 files changed
+5579 -730

No files matched your search

+1 -5
View File
@@ -13,7 +13,6 @@ use super::boxes::{NodeBox, draft_box, truncate};
use super::camera::{Camera, ScreenPos, ScreenRect, Zoom};
use super::lines::{LineLayer, LineOwner, Paint};
use super::route::{Bend, route};
use crate::app::mode::Mode;
use crate::app::text_edit::TextEdit;
use crate::model::{Arrows, Border, Content, Document, Node, NodeId, Place, Visible};
use crate::ui::glyphs::Glyphs;
@@ -96,10 +95,7 @@ impl Scene {
/// shows as typed, with the text cursor in it.
pub fn build(ctx: &ViewContext<'_>, camera: Camera, size: Size) -> Scene {
let (doc, cursor, theme, settings) = (ctx.doc, ctx.cursor, ctx.theme, ctx.settings);
let edit = match ctx.mode {
Mode::Text(edit) => Some(edit),
_ => None,
};
let edit = ctx.mode.title_edit();
let typing = edit.is_some_and(|edit| edit.editor.is_typing());
let visible = Visible::new(doc, cursor, settings.focus);
let mut scene = Scene {
+43 -14
View File
@@ -10,6 +10,7 @@ use super::navigate::nearest;
use super::scene::Scene;
use crate::app::action::{Action, AppAction, Message};
use crate::app::mode::{Grab, Mode};
use crate::app::popup::MenuFor;
use crate::app::text_edit::{EditAt, StartEdit};
use crate::input::bindings::Context;
use crate::input::command::{Command, Motion};
@@ -17,7 +18,7 @@ use crate::input::engine::Typed;
use crate::input::mouse::{Cell, Gesture};
use crate::model::{CanvasOffset, Direction, EditOp, Group, NodeId, Visible};
use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind};
use crate::view::{View, ViewContext, ViewKind, contexts_for};
/// How close (in cells) the cursor's node may come to an edge before the
/// view follows it, like vim's `scrolloff`.
@@ -189,6 +190,15 @@ impl CanvasView {
}
}
/// The part of a box (in the view's cells) that's on screen, in terminal
/// cells: `None` when none of it is.
fn clip(rect: ScreenRect, area: Rect) -> Option<Rect> {
let cell = |value: i32, size: u16| u16::try_from(value.clamp(0, i32::from(size))).unwrap_or(0);
let (left, right) = (cell(rect.left(), area.width), cell(rect.right(), area.width));
let (top, bottom) = (cell(rect.top(), area.height), cell(rect.bottom(), area.height));
(right > left && bottom > top).then(|| Rect::new(area.x + left, area.y + top, right - left, bottom - top))
}
/// A command's count, as a number of cells to move or scroll by.
fn cells(typed: &Typed) -> i32 {
// The key engine keeps counts under 10 000, so this always fits.
@@ -212,20 +222,13 @@ impl View for CanvasView {
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
match mode {
Mode::Normal => &[Context::Canvas, Context::Normal, Context::Motions],
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
Mode::Text(_) => &[Context::Text],
Mode::Move(_) => &[Context::Move],
// The command line reads its own keys.
Mode::Command(_) => &[],
}
contexts_for(mode, &[Context::Canvas, Context::Normal, Context::Motions])
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
self.area = area;
// A title is edited in its box: zoomed out, there's no box.
if matches!(ctx.mode, Mode::Text(_)) && self.camera.zoom != Zoom::Normal {
if ctx.mode.title_edit().is_some() && self.camera.zoom != Zoom::Normal {
self.zoom(Zoom::Normal, ctx);
}
let moved = self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor);
@@ -262,7 +265,7 @@ impl View for CanvasView {
match ctx.mode {
Mode::Move(grab) => return self.move_command(typed, *grab, ctx),
// Keys inside a title are the edit's, not the view's.
Mode::Text(_) => return None,
Mode::Text(_) | Mode::Popup(_) => return None,
Mode::Normal | Mode::Command(_) => {}
}
let steps = cells(typed);
@@ -309,7 +312,7 @@ impl View for CanvasView {
Mode::Text(edit) => edit.finish(ctx),
_ => Vec::new(),
};
let start = StartEdit { at: self.title_spot(id, at, ctx), new_node: None };
let start = StartEdit::title(self.title_spot(id, at, ctx));
actions.extend([Action::cursor(id), Action::App(AppAction::StartEdit(start))]);
actions
}
@@ -328,11 +331,37 @@ impl View for CanvasView {
self.camera.pan(cols, rows);
Vec::new()
}
Gesture::RightPress { .. } => {
vec![Action::say(Message::info("the right-click menu comes in stage 7"))]
Gesture::RightPress { at: cell } => {
// A title being edited is done first.
let mut actions = ctx.mode.title_edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
let at = self.screen(cell);
let (col, row) = (cell.col, cell.row);
match self.scene.node_at(at) {
Some(id) => actions.extend([
Action::cursor(id),
Action::App(AppAction::OpenMenu { what: MenuFor::Node(id), col, row }),
]),
None => {
let what = MenuFor::Canvas(self.camera.to_canvas(at));
actions.push(Action::App(AppAction::OpenMenu { what, col, row }));
}
}
actions
}
}
}
fn status(&self) -> String {
match self.camera.zoom {
Zoom::Normal => "canvas".to_string(),
zoom => format!("canvas · {}", zoom.name().to_lowercase()),
}
}
fn cursor_area(&self, ctx: &ViewContext<'_>) -> Option<Rect> {
let rect = self.scene.rect_of(ctx.cursor)?;
clip(rect, self.area)
}
}
#[cfg(test)]
+28
View File
@@ -11,6 +11,8 @@
//! 3. [`View::command`] and [`View::mouse`] turn input into actions.
pub mod canvas;
pub mod note;
pub mod outline;
use std::collections::BTreeMap;
@@ -92,6 +94,19 @@ impl ViewContext<'_> {
}
}
/// The key tables a view reads in a mode: `normal` in normal mode (its own
/// table first), and what every view reads in the other modes.
pub fn contexts_for(mode: &Mode, normal: &'static [Context]) -> &'static [Context] {
match mode {
Mode::Normal => normal,
Mode::Text(edit) if edit.editor.is_typing() => &[Context::Insert],
Mode::Text(_) => &[Context::Text],
Mode::Move(_) => &[Context::Move],
// The command line reads its own keys, and a popup has its own tables.
Mode::Command(_) | Mode::Popup(_) => &[],
}
}
/// What every view does.
pub trait View {
/// Which view this is.
@@ -113,6 +128,19 @@ pub trait View {
/// A mouse gesture over the view.
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action>;
/// How the status bar names the view: its kind, and anything else worth
/// knowing (the canvas's zoom).
fn status(&self) -> String {
self.kind().name().to_string()
}
/// Where the cursor's node is on screen, as laid out last: the `K` card
/// opens beside it. `None` when it doesn't show (or, in the Note view,
/// when it's the whole view).
fn cursor_area(&self, _ctx: &ViewContext<'_>) -> Option<Rect> {
None
}
}
#[cfg(test)]
+504
View File
@@ -0,0 +1,504 @@
//! The Note view: one node at full size, to write in.
//!
//! ```text
//! ▌ Raised beds
//! #todo #wood ● orange
//!
//! Cedar, three of them.
//! 40 cm high.
//!
//! Links
//! → ask about compost needs
//! Children
//! · Cedar, untreated
//! ```
//!
//! It shows the cursor's node, and follows it. A note is made of parts: the
//! title, the tags, the body, each link and each child. `j` and `k` go from
//! part to part (the bar on the left marks the one you're on), and Enter or
//! `i` edits it, or goes to that link or child. The body is edited like a
//! title, with Enter starting a new line. `h` and `l` turn the page: the
//! sibling before or after.
use std::ops::Range;
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
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, Field, 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, NodeId};
use crate::ui::text_editor::layout;
use crate::view::{View, ViewContext, ViewKind, contexts_for};
/// The bar marking the part you're on, then a space, before the text.
const MARGIN: u16 = 2;
/// How many rows the mouse wheel scrolls.
const WHEEL_ROWS: usize = 3;
/// A part of a note: what `j` and `k` step through.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum Part {
#[default]
Title,
Tags,
Body,
/// A link, in the order the note lists them.
Link(usize),
/// A child, in order.
Child(usize),
}
/// The Note view.
#[derive(Debug, Default)]
pub struct NoteView {
/// Where on screen the view is.
area: Rect,
/// The node the note was about at the last layout. When the cursor goes
/// to another one, the note starts again at its title.
node: Option<NodeId>,
/// The part you're on.
part: Part,
/// The first row on show.
scroll: usize,
/// The view was scrolled by hand: it stays put until you change part.
scrolled: bool,
/// The rows laid out last: what's drawn, and what a click lands on.
rows: Vec<Row>,
/// Where each link leads, in the note's order.
links: Vec<NodeId>,
/// The node's children, in order.
children: Vec<NodeId>,
/// The part being edited, as drawn.
editing: Option<Editing>,
}
/// One row of the note, ready to draw.
#[derive(Debug, Clone)]
struct Row {
/// The part it shows (`None` for headings and gaps).
part: Option<Part>,
line: Line<'static>,
}
/// The text being edited, as drawn: the rows it wraps into, and where the
/// text cursor is.
#[derive(Debug, Clone)]
struct Editing {
/// The note's row the text starts on.
first_row: usize,
text: String,
rows: Vec<Range<usize>>,
/// The text cursor's row (among `rows`) and column.
cursor: (usize, usize),
}
impl NoteView {
pub fn new() -> NoteView {
NoteView::default()
}
/// How wide the text of the note can be.
fn text_width(&self) -> usize {
usize::from(self.area.width.saturating_sub(MARGIN)).max(8)
}
/// Lays out the note of the cursor's node.
fn build(&mut self, ctx: &ViewContext<'_>) {
self.rows.clear();
self.editing = None;
let (doc, theme, glyphs, id) = (ctx.doc, ctx.theme, ctx.theme.glyphs, ctx.cursor);
let Some(node) = doc.node(id) else { return };
let edit = ctx.mode.edit().filter(|edit| edit.node == id);
let draft = |field: Field| edit.filter(|edit| edit.field == field);
let muted = Style::new().fg(theme.muted);
// The title, in the node's color.
let color = theme.node_style(node, id == doc.root()).color;
let title_style = Style::new().fg(color).add_modifier(Modifier::BOLD);
match (draft(Field::Title), &node.content) {
(Some(edit), _) => self.add_draft(Part::Title, edit, title_style),
(None, Content::Idea(idea)) => self.add_text(Part::Title, idea.title.as_str(), title_style),
(None, Content::Portal(portal)) => {
let title = format!("{} {}", glyphs.portal(), portal.title);
self.add_text(Part::Title, &title, title_style);
let path = format!("a portal to {}", portal.path.as_str());
self.add_row(Some(Part::Title), vec![Span::styled(path, muted)]);
}
}
// The tags, then the node's color by name.
match draft(Field::Tags) {
Some(edit) => self.add_draft(Part::Tags, edit, Style::new()),
None => {
let mut spans = if node.tags.is_empty() {
vec![Span::styled("no tags", muted)]
} else {
vec![Span::raw(node.tags.to_string())]
};
if let Some(name) = &node.look.color {
spans.push(Span::styled(format!(" {} {name}", glyphs.bullet()), Style::new().fg(color)));
}
self.add_row(Some(Part::Tags), spans);
}
}
self.add_row(None, Vec::new());
// The body.
match (draft(Field::Body), &node.content) {
(Some(edit), _) => self.add_draft(Part::Body, edit, Style::new()),
(None, Content::Idea(idea)) if !idea.body.is_empty() => {
self.add_text(Part::Body, idea.body.as_str(), Style::new());
}
(None, Content::Idea(_)) => {
self.add_row(Some(Part::Body), vec![Span::styled("no body yet: Enter to write one", muted)]);
}
(None, Content::Portal(_)) => {
let text = "a portal's body is its document's";
self.add_row(Some(Part::Body), vec![Span::styled(text, muted)]);
}
}
// Links out, then links in.
let mut links: Vec<(bool, NodeId, String)> = doc
.links_of(id)
.map(|link| {
let out = link.start() == id;
let other = if out { link.end() } else { link.start() };
(out, other, link.label.as_ref().map(ToString::to_string).unwrap_or_default())
})
.collect();
links.sort_by_key(|&(out, _, _)| !out);
self.links = links.iter().map(|&(_, other, _)| other).collect();
if !links.is_empty() {
self.add_row(None, Vec::new());
self.add_row(None, vec![Span::styled("Links", muted)]);
}
for (index, (out, other, label)) in links.into_iter().enumerate() {
let title = doc.node(other).map(|node| node.title().to_string()).unwrap_or_default();
let mut spans = vec![Span::raw(format!("{} {title}", glyphs.link_mark(out)))];
if !label.is_empty() {
spans.push(Span::styled(format!(" {label}"), muted));
}
self.add_row(Some(Part::Link(index)), spans);
}
// Children.
self.children = doc.children(id).to_vec();
if !self.children.is_empty() {
if self.links.is_empty() {
self.add_row(None, Vec::new());
}
self.add_row(None, vec![Span::styled("Children", muted)]);
}
for (index, &child) in self.children.clone().iter().enumerate() {
let title = doc.node(child).map(|node| node.title().to_string()).unwrap_or_default();
self.add_row(
Some(Part::Child(index)),
vec![Span::raw(format!("{} {title}", glyphs.child_mark()))],
);
}
}
fn add_row(&mut self, part: Option<Part>, spans: Vec<Span<'static>>) {
self.rows.push(Row { part, line: Line::from(spans) });
}
/// Text over as many rows as it wraps into.
fn add_text(&mut self, part: Part, text: &str, style: Style) {
for range in layout::rows(text, self.text_width()) {
let row = text[range].trim_end_matches('\n').to_string();
self.add_row(Some(part), vec![Span::styled(row, style)]);
}
}
/// Text being edited, as typed, with the text cursor in it.
fn add_draft(&mut self, part: Part, edit: &TextEdit, style: Style) {
let text = edit.editor.text();
let rows = layout::rows(text, self.text_width());
let cursor = layout::cell(text, &rows, edit.editor.cursor());
let first_row = self.rows.len();
for range in &rows {
let row = text[range.clone()].trim_end_matches('\n').to_string();
self.add_row(Some(part), vec![Span::styled(row, style)]);
}
self.editing = Some(Editing { first_row, text: text.to_string(), rows, cursor });
}
/// The parts of the note, in order.
fn parts(&self) -> Vec<Part> {
let mut parts: Vec<Part> = Vec::new();
for part in self.rows.iter().filter_map(|row| row.part) {
if parts.last() != Some(&part) {
parts.push(part);
}
}
parts
}
/// The first and last rows of the part you're on.
fn part_rows(&self) -> Option<(usize, usize)> {
let first = self.rows.iter().position(|row| row.part == Some(self.part))?;
let last = self.rows.iter().rposition(|row| row.part == Some(self.part))?;
Some((first, last))
}
/// Scrolls as little as it takes to show rows `first` to `last` (or as
/// many of them as fit, from `first`).
fn show_rows(&mut self, first: usize, last: usize) {
let height = usize::from(self.area.height).max(1);
if last >= self.scroll + height {
self.scroll = last + 1 - height;
}
if first < self.scroll || last + 1 - first > height {
self.scroll = first;
}
}
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;
}
/// `j` and `k`: a part further down or up.
fn step(&mut self, delta: isize) -> Vec<Action> {
let parts = self.parts();
let Some(here) = parts.iter().position(|&part| part == self.part) else { return Vec::new() };
let there = here.saturating_add_signed(delta).min(parts.len().saturating_sub(1));
if there == here {
let text = if delta > 0 { "that's the end of the note" } else { "that's the top of the note" };
return vec![Action::say(Message::info(text))];
}
self.part = parts[there];
self.scrolled = false;
Vec::new()
}
/// Enter, `i`, `I` or `cc` on the part you're on: edit it from `at`, or
/// go to that link or child. `None` for the title, which edits the way
/// it does everywhere (or goes through a portal).
fn open(&self, at: EditAt) -> Option<Vec<Action>> {
let start = match self.part {
Part::Title => return None,
Part::Tags => StartEdit::Tags(at),
Part::Body => StartEdit::Body { at, in_card: false },
Part::Link(_) | Part::Child(_) if at != EditAt::End => {
let text = "Enter goes there: the title, the tags and the body are the parts you type in";
return Some(vec![Action::say(Message::info(text))]);
}
// Following a link is a jump: '' comes back.
Part::Link(index) => {
return Some(self.links.get(index).map(|&to| Action::jump(to)).into_iter().collect());
}
Part::Child(index) => {
return Some(self.children.get(index).map(|&to| Action::cursor(to)).into_iter().collect());
}
};
Some(vec![Action::App(AppAction::StartEdit(start))])
}
/// The row and column under a terminal cell, if it's in the view.
fn local(&self, cell: Cell) -> Option<(u16, usize)> {
let at = Position::new(cell.col, cell.row);
self.area.contains(at).then(|| (at.x - self.area.x, self.scroll + usize::from(at.y - self.area.y)))
}
/// Where a click lands in the text being edited, if it's on its rows.
fn text_offset_at(&self, (col, row): (u16, usize)) -> Option<usize> {
let editing = self.editing.as_ref()?;
let text_row =
row.checked_sub(editing.first_row).filter(|&text_row| text_row < editing.rows.len())?;
let column = usize::from(col.saturating_sub(MARGIN));
Some(layout::offset(&editing.text, &editing.rows, text_row, column))
}
/// The terminal cell of the text cursor, while a part is edited.
fn text_cursor(&self) -> Option<Position> {
let editing = self.editing.as_ref()?;
let row = (editing.first_row + editing.cursor.0).checked_sub(self.scroll)?;
let row = u16::try_from(row).ok().filter(|&row| row < self.area.height)?;
let col = u16::try_from(editing.cursor.1).ok()?.saturating_add(MARGIN);
(col < self.area.width).then(|| Position::new(self.area.x + col, self.area.y + row))
}
/// Where a double-click on a part's text lands in it, to start editing
/// there.
fn spot_in(&self, part: Part, (col, row): (u16, usize), ctx: &ViewContext<'_>) -> EditAt {
let Some(node) = ctx.doc.node(ctx.cursor) else { return EditAt::End };
let text = match (part, &node.content) {
(Part::Title, Content::Idea(idea)) => idea.title.to_string(),
(Part::Tags, _) if !node.tags.is_empty() => node.tags.to_string(),
(Part::Body, Content::Idea(idea)) if !idea.body.is_empty() => idea.body.as_str().to_string(),
_ => return EditAt::End,
};
let Some(first) = self.rows.iter().position(|row| row.part == Some(part)) else { return EditAt::End };
let rows = layout::rows(&text, self.text_width());
EditAt::Offset(layout::offset(&text, &rows, row - first, usize::from(col.saturating_sub(MARGIN))))
}
}
impl View for NoteView {
fn kind(&self) -> ViewKind {
ViewKind::Note
}
fn contexts(&self, mode: &Mode) -> &'static [Context] {
contexts_for(mode, &[Context::Note, Context::Normal, Context::Motions])
}
fn layout(&mut self, area: Rect, ctx: &ViewContext<'_>) {
self.area = area;
if self.node.replace(ctx.cursor) != Some(ctx.cursor) {
(self.part, self.scroll, self.scrolled) = (Part::Title, 0, false);
}
self.build(ctx);
// The part you were on can be gone (a link removed elsewhere).
if !self.rows.iter().any(|row| row.part == Some(self.part)) {
self.part = Part::Title;
}
if let Some(edit) = ctx.mode.edit().filter(|edit| edit.node == ctx.cursor) {
self.part = match edit.field {
Field::Title => Part::Title,
Field::Tags => Part::Tags,
Field::Body => Part::Body,
};
}
match &self.editing {
Some(editing) => {
let row = editing.first_row + editing.cursor.0;
self.show_rows(row, row);
}
None if !self.scrolled => {
if let Some((first, last)) = self.part_rows() {
self.show_rows(first, last);
}
}
None => {}
}
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 bar = ctx.theme.glyphs.part_mark().to_string();
let bar_style = Style::new().fg(ctx.theme.accent);
let width = self.area.width.saturating_sub(MARGIN);
let rows = self.rows.iter().skip(self.scroll).take(usize::from(self.area.height));
for (row, y) in rows.zip(self.area.y..) {
if row.part == Some(self.part) {
buf.set_string(self.area.x, y, &bar, bar_style);
}
buf.set_line(self.area.x + MARGIN, y, &row.line, width);
}
if let Some(cell) = self.text_cursor() {
frame.set_cursor_position(cell);
}
}
fn command(&mut self, typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
match ctx.mode {
Mode::Move(grab) => return commands::reorder(typed, *grab, ctx),
Mode::Normal => {}
Mode::Text(_) | Mode::Command(_) | Mode::Popup(_) => return None,
}
let times = isize::try_from(typed.times()).unwrap_or(1);
let half_page = isize::try_from(self.area.height / 2).unwrap_or(1).max(1);
let tree =
|motion: Motion| commands::shared(&Typed { command: Command::Move(motion), ..*typed }, ctx);
match typed.command {
Command::Move(Motion::Down) => return Some(self.step(times)),
Command::Move(Motion::Up) => return Some(self.step(-times)),
// h and l turn the page: the sibling before or after.
Command::Move(Motion::Left) => return tree(Motion::PreviousSibling),
Command::Move(Motion::Right) => return tree(Motion::NextSibling),
Command::Activate | Command::EditTitle => return self.open(EditAt::End),
Command::EditTitleAtStart => return self.open(EditAt::Start),
Command::RetypeTitle => return self.open(EditAt::Retype),
Command::ScrollDown => self.scroll_by(times),
Command::ScrollUp => self.scroll_by(-times),
Command::HalfPageDown => self.scroll_by(half_page * times),
Command::HalfPageUp => self.scroll_by(-half_page * times),
Command::Center => {
if let Some((first, _)) = self.part_rows() {
self.scroll = first.saturating_sub(usize::from(self.area.height / 2));
self.scrolled = true;
}
}
_ => return None,
}
Some(Vec::new())
}
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
match gesture {
Gesture::Press { at, .. } => {
let Some(at) = self.local(at) else { return Vec::new() };
let edit = ctx.mode.edit().filter(|edit| edit.node == ctx.cursor);
if let Some(edit) = edit {
// A click in the text moves the text cursor; anywhere
// else, it keeps the edit and finishes it.
if let Some(offset) = self.text_offset_at(at) {
return vec![Action::App(AppAction::TextCursor(offset))];
}
if let Some(part) = self.rows.get(at.1).and_then(|row| row.part) {
self.part = part;
}
return edit.finish(ctx);
}
if let Some(part) = self.rows.get(at.1).and_then(|row| row.part) {
(self.part, self.scrolled) = (part, false);
}
Vec::new()
}
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(part) = self.rows.get(at.1).and_then(|row| row.part) else { return Vec::new() };
self.part = part;
let spot = self.spot_in(part, at, ctx);
let mut actions = ctx.mode.edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
let open = match part {
Part::Title => Some(vec![Action::App(AppAction::StartEdit(StartEdit::title(spot)))]),
Part::Tags => Some(vec![Action::App(AppAction::StartEdit(StartEdit::Tags(spot)))]),
Part::Body => {
let start = StartEdit::Body { at: spot, in_card: false };
Some(vec![Action::App(AppAction::StartEdit(start))])
}
Part::Link(_) | Part::Child(_) => self.open(EditAt::End),
};
actions.extend(open.unwrap_or_default());
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 | Direction::Right => {}
}
Vec::new()
}
Gesture::RightPress { at } => {
let mut actions = ctx.mode.edit().map(|edit| edit.finish(ctx)).unwrap_or_default();
let what = MenuFor::Node(ctx.cursor);
actions.push(Action::App(AppAction::OpenMenu { what, col: at.col, row: at.row }));
actions
}
Gesture::Drag { .. } | Gesture::Release { .. } => Vec::new(),
}
}
}
#[cfg(test)]
mod tests;
+154
View File
@@ -0,0 +1,154 @@
//! The Note view drawn on a test terminal, and driven by keys and clicks.
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use crate::model::test_support::{Garden, garden};
use crate::model::{Body, ColorName, Document, EditOp, Label, Link, Tags};
use crate::ui::text_editor::Typing;
use crate::view::test_support::Setup;
fn typed(command: Command) -> Typed {
Typed { command, count: None, register: None }
}
/// The garden, with Raised beds given a body, tags, a color and a link.
fn garden_note() -> (Document, Garden) {
let (mut doc, g) = garden();
doc.apply(&EditOp::SetBody { id: g.beds, body: Body::new("Cedar, three of them.\n40 cm high.") })
.unwrap();
doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#todo #wood").unwrap() }).unwrap();
doc.apply(&EditOp::SetColor { id: g.beds, color: ColorName::parse("orange").ok() }).unwrap();
let mut link = Link::new(g.beds, g.compost).unwrap();
link.label = Some(Label::parse("needs").unwrap());
doc.apply(&EditOp::Link(link)).unwrap();
(doc, g)
}
/// Lays the note out at the top-left of a test terminal and draws it.
/// Returns what's on screen, row by row.
fn draw(view: &mut NoteView, ctx: &ViewContext<'_>) -> Vec<String> {
let (width, height) = (40, 11);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
view.layout(Rect::new(0, 0, width, height), ctx);
terminal.draw(|frame| view.render(frame, ctx)).unwrap();
let buffer = terminal.backend().buffer();
(0..height)
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect::<String>().trim_end().to_string())
.collect()
}
/// The rows the bar marks.
fn marked(rows: &[String]) -> Vec<usize> {
rows.iter().enumerate().filter(|(_, row)| row.starts_with('▌')).map(|(index, _)| index).collect()
}
#[test]
fn a_note_shows_every_part() {
let ((doc, g), setup) = (garden_note(), Setup::default());
let mut view = NoteView::new();
assert_eq!(
draw(&mut view, &setup.ctx(&doc, g.beds)),
[
"▌ Raised beds",
" #todo #wood ● orange",
"",
" Cedar, three of them.",
" 40 cm high.",
"",
" Links",
" → ask about compost needs",
" Children",
" · Cedar",
"",
]
);
}
#[test]
fn j_and_k_go_from_part_to_part() {
let ((doc, g), setup) = (garden_note(), Setup::default());
let mut view = NoteView::new();
let ctx = setup.ctx(&doc, g.beds);
draw(&mut view, &ctx);
let press = |view: &mut NoteView, command: Command| view.command(&typed(command), &ctx);
press(&mut view, Command::Move(Motion::Down));
press(&mut view, Command::Move(Motion::Down));
assert_eq!(marked(&draw(&mut view, &ctx)), [3, 4], "the body: both its rows");
let body = StartEdit::Body { at: EditAt::End, in_card: false };
assert_eq!(press(&mut view, Command::Activate), Some(vec![Action::App(AppAction::StartEdit(body))]));
press(&mut view, Command::Move(Motion::Down));
assert_eq!(press(&mut view, Command::Activate), Some(vec![Action::jump(g.compost)]), "a link: a jump");
press(&mut view, Command::Move(Motion::Down));
assert_eq!(press(&mut view, Command::EditTitle), Some(vec![Action::cursor(g.cedar)]), "a child");
assert_eq!(
press(&mut view, Command::Move(Motion::Down)),
Some(vec![Action::say(Message::info("that's the end of the note"))])
);
// Back up to the tags: I edits them from the start.
view.command(&Typed { command: Command::Move(Motion::Up), count: Some(3), register: None }, &ctx);
let tags = StartEdit::Tags(EditAt::Start);
assert_eq!(
press(&mut view, Command::EditTitleAtStart),
Some(vec![Action::App(AppAction::StartEdit(tags))])
);
// On the title, the keys edit it the way they do everywhere.
press(&mut view, Command::Move(Motion::Up));
assert_eq!(press(&mut view, Command::EditTitle), None);
}
#[test]
fn the_note_follows_the_cursor() {
let ((doc, g), setup) = (garden_note(), Setup::default());
let mut view = NoteView::new();
draw(&mut view, &setup.ctx(&doc, g.beds));
view.command(&typed(Command::Move(Motion::Down)), &setup.ctx(&doc, g.beds));
let rows = draw(&mut view, &setup.ctx(&doc, g.soil));
assert_eq!(rows[..2], ["▌ Soil", " no tags"], "another node: its title first");
let mut page = |motion| view.command(&typed(Command::Move(motion)), &setup.ctx(&doc, g.beds));
assert_eq!(page(Motion::Left), Some(vec![Action::cursor(g.soil)]), "h: the sibling before");
assert_eq!(page(Motion::Right), Some(vec![Action::cursor(g.watering)]), "l: the sibling after");
}
#[test]
fn the_body_is_edited_in_place() {
let (doc, g) = garden_note();
let mut setup = Setup::default();
let start = StartEdit::Body { at: EditAt::End, in_card: false };
setup.mode = Mode::Text(TextEdit::start(&setup.ctx(&doc, g.beds), start).unwrap());
if let Mode::Text(edit) = &mut setup.mode {
"\nThree.".chars().for_each(|c| _ = edit.editor.type_in(Typing::Char(c)));
}
let mut view = NoteView::new();
let ctx = setup.ctx(&doc, g.beds);
let rows = draw(&mut view, &ctx);
assert_eq!(rows[3..6], ["▌ Cedar, three of them.", "▌ 40 cm high.", "▌ Three."]);
assert_eq!(view.text_cursor(), Some(Position::new(8, 5)), "after the last letter typed");
let click = Gesture::Press { at: Cell { col: 4, row: 3 }, ctrl: false };
assert_eq!(
view.mouse(click, &ctx),
[Action::App(AppAction::TextCursor(2))],
"a click moves the text cursor"
);
}
#[test]
fn clicks_pick_parts_and_double_clicks_open_them() {
let ((doc, g), setup) = (garden_note(), Setup::default());
let mut view = NoteView::new();
let ctx = setup.ctx(&doc, g.beds);
draw(&mut view, &ctx);
assert_eq!(view.mouse(Gesture::Press { at: Cell { col: 5, row: 9 }, ctrl: false }, &ctx), []);
assert_eq!(marked(&draw(&mut view, &ctx)), [9], "the click picked the child");
assert_eq!(
view.mouse(Gesture::DoublePress { at: Cell { col: 5, row: 9 } }, &ctx),
[Action::cursor(g.cedar)]
);
let start = StartEdit::Body { at: EditAt::Offset(5), in_card: false };
assert_eq!(
view.mouse(Gesture::DoublePress { at: Cell { col: 7, row: 3 } }, &ctx),
[Action::App(AppAction::StartEdit(start))],
"on the body: editing it from the letter clicked"
);
}
+480
View File
@@ -0,0 +1,480 @@
//! 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.
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 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,
/// 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<Row>,
/// The cursor at the last layout. When it moves, the view follows.
last_cursor: Option<NodeId>,
/// The view was scrolled by hand: it stays put until the cursor moves.
scrolled: bool,
/// The title being edited, as drawn.
editing: Option<Editing>,
}
/// 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<NodeId> = 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 color = theme.node_style(node, id == doc.root()).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 {
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_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;
}
/// The row and column under a terminal cell, if it's in the view.
fn local(&self, cell: Cell) -> Option<(u16, usize)> {
let at = Position::new(cell.col, cell.row);
self.area.contains(at).then(|| (at.x - self.area.x, 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<usize> {
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<Position> {
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).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<Action> {
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<Span<'static>>,
width: u16,
}
impl Lines {
fn push(&mut self, text: impl Into<String>, 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);
}
}
/// `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<Action> {
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;
if self.last_cursor.replace(ctx.cursor) != Some(ctx.cursor) {
self.scrolled = false;
}
self.build(ctx);
if !self.scrolled || self.editing.is_some() {
self.show_cursor_row(ctx.cursor);
}
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..) {
buf.set_line(self.area.x, y, &row.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<Vec<Action>> {
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 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::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<Action> {
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 | Direction::Right => {}
}
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<Rect> {
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;
+155
View File
@@ -0,0 +1,155 @@
//! The outline drawn on a test terminal, and driven by keys and clicks.
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::*;
use crate::app::action::DocAction;
use crate::app::buffer::Buffer;
use crate::app::mode::Grab;
use crate::model::test_support::garden;
use crate::model::{Body, Tags};
use crate::ui::text_editor::Typing;
use crate::view::test_support::Setup;
fn typed(command: Command) -> Typed {
Typed { command, count: None, register: None }
}
/// Lays the outline out at the top-left of a test terminal and draws it.
/// Returns what's on screen, row by row.
fn draw(view: &mut OutlineView, ctx: &ViewContext<'_>, width: u16, height: u16) -> Vec<String> {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
view.layout(Rect::new(0, 0, width, height), ctx);
terminal.draw(|frame| view.render(frame, ctx)).unwrap();
let buffer = terminal.backend().buffer();
(0..height)
.map(|y| (0..width).map(|x| buffer[(x, y)].symbol()).collect::<String>().trim_end().to_string())
.collect()
}
#[test]
fn the_garden_as_an_outline() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = OutlineView::new();
assert_eq!(
draw(&mut view, &setup.ctx(&doc, g.root), 40, 7),
[
"▾ Garden plans",
" ├ Soil",
" ├ ▾ Raised beds",
" │ └ Cedar",
" └ Watering",
"◦ ask about compost",
"",
]
);
}
#[test]
fn folds_tags_and_bodies() {
let ((mut doc, g), setup) = (garden(), Setup::default());
doc.apply(&EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed }).unwrap();
doc.apply(&EditOp::SetBody { id: g.beds, body: Body::new("Cedar, three of them.\n40 cm high.") })
.unwrap();
doc.apply(&EditOp::SetTags { id: g.soil, tags: Tags::parse("#todo").unwrap() }).unwrap();
let mut view = OutlineView::new();
let ctx = setup.ctx(&doc, g.root);
let rows = draw(&mut view, &ctx, 40, 6);
assert_eq!(
rows[1..4],
[" ├ Soil #todo", " ├ ▸ Raised beds ¶ +1", " └ Watering"],
"Cedar is folded away"
);
// zb: every body under its title, where the children hang.
view.command(&typed(Command::ToggleBodies), &ctx);
let rows = draw(&mut view, &ctx, 40, 6);
assert_eq!(rows[2..5], [" ├ ▸ Raised beds +1", " │ Cedar, three of them.", " │ 40 cm high."]);
}
#[test]
fn hjkl_follow_the_rows() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = OutlineView::new();
let at_root = setup.ctx(&doc, g.root);
draw(&mut view, &at_root, 40, 7);
let go = |view: &mut OutlineView, motion: Motion, ctx: &ViewContext<'_>| {
view.command(&typed(Command::Move(motion)), ctx)
};
assert_eq!(go(&mut view, Motion::Down, &at_root), Some(vec![Action::cursor(g.soil)]));
assert_eq!(
go(&mut view, Motion::Right, &at_root),
Some(vec![Action::cursor(g.soil)]),
"l: the first child"
);
let at_cedar = setup.ctx(&doc, g.cedar);
assert_eq!(go(&mut view, Motion::Left, &at_cedar), Some(vec![Action::cursor(g.beds)]), "h: the parent");
assert_eq!(go(&mut view, Motion::Down, &at_cedar), Some(vec![Action::cursor(g.watering)]));
}
#[test]
fn move_mode_reorders_as_one_undo_step() {
let (doc, g) = garden();
let setup = Setup { mode: Mode::Move(Grab::new()), ..Setup::default() };
let mut buffer = Buffer::new(doc);
buffer.apply(DocAction::MoveCursor { to: g.soil, jump: false });
let mut view = OutlineView::new();
// j: Soil goes after Raised beds; l: under the node above it, which is
// Raised beds now.
for command in [Command::Move(Motion::Down), Command::Move(Motion::Right)] {
for action in view.command(&typed(command), &setup.ctx(buffer.doc(), buffer.cursor())).unwrap() {
let Action::Doc(action) = action else { panic!("only edits") };
buffer.apply(action);
}
}
assert_eq!(buffer.doc().children(g.root), [g.beds, g.watering]);
assert_eq!(buffer.doc().children(g.beds), [g.cedar, g.soil]);
buffer.apply(DocAction::Undo(1));
assert_eq!(buffer.doc().children(g.root), [g.soil, g.beds, g.watering], "one undo puts it all back");
}
#[test]
fn a_title_is_edited_in_its_row() {
let (doc, g) = garden();
let mut setup = Setup::default();
let edit = TextEdit::start(&setup.ctx(&doc, g.soil), StartEdit::title(EditAt::End)).unwrap();
setup.mode = Mode::Text(edit);
if let Mode::Text(edit) = &mut setup.mode {
" mix".chars().for_each(|c| _ = edit.editor.type_in(Typing::Char(c)));
}
let mut view = OutlineView::new();
let ctx = setup.ctx(&doc, g.soil);
assert_eq!(draw(&mut view, &ctx, 40, 7)[1], " ├ Soil mix");
assert_eq!(view.text_cursor(), Some(Position::new(12, 1)), "after the last letter");
// Too narrow for the text: it scrolls sideways to keep the cursor in view.
assert_eq!(draw(&mut view, &ctx, 10, 7)[1], " ├ l mix");
assert_eq!(view.text_cursor(), Some(Position::new(9, 1)));
let click = Gesture::Press { at: Cell { col: 5, row: 1 }, ctrl: false };
assert_eq!(view.mouse(click, &ctx), [Action::App(AppAction::TextCursor(4))], "on the space before mix");
}
#[test]
fn clicks_select_fold_and_edit() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = OutlineView::new();
let ctx = setup.ctx(&doc, g.root);
draw(&mut view, &ctx, 40, 7);
let press = |col, row| Gesture::Press { at: Cell { col, row }, ctrl: false };
assert_eq!(view.mouse(press(6, 4), &ctx), [Action::cursor(g.watering)]);
let fold = Action::edit(EditOp::SetFold { ids: vec![g.beds], fold: Fold::Closed });
assert_eq!(view.mouse(press(4, 2), &ctx), [Action::cursor(g.beds), fold], "the marker folds");
let start = StartEdit::title(EditAt::Offset(2));
assert_eq!(
view.mouse(Gesture::DoublePress { at: Cell { col: 6, row: 1 } }, &ctx),
[Action::cursor(g.soil), Action::App(AppAction::StartEdit(start))],
"a double-click edits from the letter clicked"
);
}
#[test]
fn the_view_follows_the_cursor() {
let ((doc, g), setup) = (garden(), Setup::default());
let mut view = OutlineView::new();
let rows = draw(&mut view, &setup.ctx(&doc, g.compost), 40, 4);
assert_eq!(rows[2], "◦ ask about compost", "a row from the bottom");
}