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

+63 -3
View File
@@ -5,9 +5,14 @@
//!
//! [`Buffer::apply`]: super::buffer::Buffer::apply
use super::panes::Arrange;
use super::popup::{MenuFor, PickKind};
use super::text_edit::StartEdit;
use crate::command::parse::SetArg;
use crate::model::{EditOp, Group, NodeId};
use crate::input::command::Command;
use crate::input::key::Key;
use crate::model::{Direction, EditOp, Group, NodeId};
use crate::view::ViewKind;
/// How a status-bar message is meant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -78,9 +83,12 @@ pub enum DocAction {
/// What happens around the documents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppAction {
/// Start editing the cursor's title.
/// Start editing the cursor's title, tags or body.
StartEdit(StartEdit),
/// Put the text cursor here, in the title being edited (a click).
/// The text edit is done: back to normal mode, or to the `K` card the
/// body was edited in.
EndEdit,
/// Put the text cursor here, in the text being edited (a click).
TextCursor(usize),
/// Move mode: hjkl moves the cursor's branch.
EnterMoveMode,
@@ -89,15 +97,67 @@ pub enum AppAction {
/// Back to normal mode.
LeaveMode,
OpenCommandLine,
/// The command line, with this typed already: a `:` command from the
/// help that needs more.
CommandLineWith(String),
/// A command line, run as if typed (without its `:`).
RunLine(String),
/// A command, run as if its keys were typed: the help and the menu run
/// commands this way.
Run(Command),
/// Keys, as if typed: for a key that waits for more (`m` waits for a
/// mark's letter).
Type(Vec<Key>),
/// Focus mode: Open branches act like Auto ones.
ToggleFocus,
ToggleWhichKey,
/// Change a setting.
Set(SetArg),
// Popups.
/// Every key and command, with this already searched for.
OpenHelp(String),
/// The cursor's body in a card beside it.
OpenCard,
OpenPicker(PickKind),
/// The right-click menu, at a cell on the screen.
OpenMenu {
what: MenuFor,
col: u16,
row: u16,
},
// Panes and documents.
/// Split the focused pane: the new pane shows `view`, or the same view.
Split {
arrange: Arrange,
view: Option<ViewKind>,
},
FocusPane(Direction),
NextPane,
/// Close the focused pane (the last one quits).
ClosePane,
OnlyPane,
EqualizePanes,
/// The focused pane shows another view.
ShowView(ViewKind),
SwitchDocument(DocumentChoice),
Quit,
Say(Message),
}
/// Which document the focused pane switches to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocumentChoice {
Next,
Previous,
/// The open document listed at this place (the documents picker).
At(usize),
/// The open document with this name (`:b`).
Named(String),
}
/// Anything views, keys and the command line ask for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
+23 -14
View File
@@ -1,12 +1,14 @@
//! What `:` commands do: [`actions_for`] turns a parsed [`Call`] into
//! actions, the way views turn keys into actions.
use super::action::{Action, AppAction, CursorAfter, Message};
use super::action::{Action, AppAction, CursorAfter, DocumentChoice, Message};
use super::commands::set_fold;
use super::panes::Arrange;
use super::popup::PickKind;
use crate::command::parse::{Call, TagChange};
use crate::model::{CanvasPos, CanvasRect, EditOp, Inbox, Link, LinkKey, Node, NodeId, Title};
use crate::view::ViewContext;
use crate::view::canvas::placement::{child_spot, first_child_spot, free_spot};
use crate::view::{ViewContext, ViewKind};
/// The title of the loose node `:in` collects ideas under.
const INBOX: &str = "Inbox";
@@ -16,9 +18,18 @@ const INBOX: &str = "Inbox";
pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
let id = ctx.cursor;
match call {
Call::Quit { .. } | Call::QuitAll { .. } | Call::WriteQuit | Call::ClosePane => {
vec![Action::App(AppAction::Quit)]
}
// :q closes the pane, as in vim: the last one quits.
Call::Quit { .. } | Call::WriteQuit | Call::ClosePane => app(AppAction::ClosePane),
Call::QuitAll { .. } => app(AppAction::Quit),
Call::Help(topic) => app(AppAction::OpenHelp(topic.unwrap_or_default())),
Call::Split(view) => app(AppAction::Split { arrange: Arrange::Stacked, view }),
Call::VSplit(view) => app(AppAction::Split { arrange: Arrange::SideBySide, view }),
Call::Only => app(AppAction::OnlyPane),
Call::Show(ViewKind::Board) | Call::Board(_) => later("the board comes in v1.1"),
Call::Show(view) => app(AppAction::ShowView(view)),
Call::Buffers => app(AppAction::OpenPicker(PickKind::Document)),
Call::Buffer(name) => app(AppAction::SwitchDocument(DocumentChoice::Named(name))),
Call::Style(style) => vec![Action::edit(EditOp::SetStyle { id, style: Some(style) })],
Call::Fold(fold) => set_fold(ctx, fold),
Call::Focus => vec![Action::App(AppAction::ToggleFocus)],
Call::Color(color) => vec![Action::edit(EditOp::SetColor { id, color })],
@@ -44,29 +55,27 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
Call::Find(text) => find(ctx, &text),
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
Call::Version => say(Message::info(crate::version())),
Call::Help(_) => later("the help screen comes in stage 7; Tab completes any command"),
Call::Split(_) | Call::VSplit(_) | Call::Only | Call::Show(_) => {
later("splits and the other views come in stage 7")
}
Call::Edit { .. }
| Call::New(_)
| Call::Write
| Call::WriteAll
| Call::Buffers
| Call::Buffer(_)
| Call::CloseDocument { .. }
| Call::Portal(_)
| Call::Backlinks => later("documents, files and portals come in stage 8"),
| Call::Backlinks => later("files and portals come in stage 8"),
Call::Jumps | Call::Marks | Call::Registers => {
later("the lists of jumps, marks and registers come in stage 8")
}
Call::Style(_) | Call::Theme(_) | Call::Config => later("theme and config files come in stage 8"),
Call::Board(_) | Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
Call::Theme(_) | Call::Config => later("theme and config files come in stage 8"),
Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
later("this one comes in v1.1")
}
}
}
fn app(action: AppAction) -> Vec<Action> {
vec![Action::App(action)]
}
fn say(message: Message) -> Vec<Action> {
vec![Action::say(message)]
}
+93 -11
View File
@@ -7,15 +7,19 @@
use std::collections::BTreeMap;
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message};
use super::mode::Grab;
use super::panes::Arrange;
use super::popup::PickKind;
use super::text_edit::{EditAt, StartEdit};
use crate::input::command::{Command, Motion, Operator, Target};
use crate::input::engine::Typed;
use crate::model::{
CanvasPos, CanvasRect, CanvasSize, Document, EditOp, Fold, Group, Node, NodeId, Spot, Title, Visible,
CanvasPos, CanvasRect, CanvasSize, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot, Title,
Visible,
};
use crate::view::ViewContext;
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, spot_below};
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, free_spot, spot_below};
use crate::view::{ViewContext, ViewKind};
/// The actions for a command every view shares, or `None` when it isn't
/// one.
@@ -52,7 +56,32 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
Command::CommandLine => app(AppAction::OpenCommandLine),
Command::ToggleFocus => app(AppAction::ToggleFocus),
Command::ToggleWhichKey => app(AppAction::ToggleWhichKey),
Command::Close => app(AppAction::Quit),
// Popups.
Command::Help => app(AppAction::OpenHelp(String::new())),
Command::ShowBody => app(AppAction::OpenCard),
Command::PickColor => app(AppAction::OpenPicker(PickKind::Color)),
Command::PickBorder => app(AppAction::OpenPicker(PickKind::Border)),
Command::PickStyle => app(AppAction::OpenPicker(PickKind::Style)),
Command::EditTags => app(AppAction::OpenPicker(PickKind::Tags)),
Command::PickDocument => app(AppAction::OpenPicker(PickKind::Document)),
// Panes and documents.
Command::SplitVertical => app(AppAction::Split { arrange: Arrange::SideBySide, view: None }),
Command::SplitHorizontal => app(AppAction::Split { arrange: Arrange::Stacked, view: None }),
Command::FocusLeft => app(AppAction::FocusPane(Direction::Left)),
Command::FocusDown => app(AppAction::FocusPane(Direction::Down)),
Command::FocusUp => app(AppAction::FocusPane(Direction::Up)),
Command::FocusRight => app(AppAction::FocusPane(Direction::Right)),
Command::NextPane => app(AppAction::NextPane),
Command::ClosePane => app(AppAction::ClosePane),
Command::OnlyPane => app(AppAction::OnlyPane),
Command::EqualizePanes => app(AppAction::EqualizePanes),
Command::ShowCanvas => app(AppAction::ShowView(ViewKind::Canvas)),
Command::ShowOutline => app(AppAction::ShowView(ViewKind::Outline)),
Command::ShowNote => app(AppAction::ShowView(ViewKind::Note)),
Command::NextDocument => app(AppAction::SwitchDocument(DocumentChoice::Next)),
Command::PreviousDocument => app(AppAction::SwitchDocument(DocumentChoice::Previous)),
// ZZ closes the pane, as in vim: the last one quits.
Command::Close => app(AppAction::ClosePane),
// Esc always gets back to normal mode.
Command::Escape => app(AppAction::LeaveMode),
_ => return None,
@@ -76,20 +105,36 @@ pub fn set_fold(ctx: &ViewContext<'_>, fold: Fold) -> Vec<Action> {
/// Adds a node at `spot`, next to its parent or sibling, and starts typing
/// its title: rapid entry. The add and the typing are one undo step.
pub fn add(ctx: &ViewContext<'_>, spot: Spot) -> Vec<Action> {
// Until something is typed, the node holds a placeholder (a title can't
// be empty).
let Ok(title) = Title::parse("new idea") else { return Vec::new() };
let node = Node::idea(title, CanvasPos::default());
let Some(node) = new_idea() else { return Vec::new() };
let (size, rects) = (ctx.box_size(&node), ctx.box_rects());
let pos = match spot {
Spot::LastChildOf(parent) => child_spot(ctx.doc, parent, size, &rects),
_ => below_spot(ctx.cursor, size, &rects),
};
start_new(node, pos, spot)
}
/// "New idea here", from the menu on the empty canvas: a loose idea at that
/// spot (or the nearest free one), and typing its title.
pub fn add_loose(ctx: &ViewContext<'_>, at: CanvasPos) -> Vec<Action> {
let Some(node) = new_idea() else { return Vec::new() };
let pos = free_spot(at, ctx.box_size(&node), &ctx.box_rects());
start_new(node, pos, Spot::Loose)
}
/// A new idea. Until something is typed, it holds a placeholder (a title
/// can't be empty).
fn new_idea() -> Option<Node> {
Title::parse("new idea").ok().map(|title| Node::idea(title, CanvasPos::default()))
}
/// Adds a new node at `pos` and starts typing its title, as one undo step.
fn start_new(node: Node, pos: CanvasPos, spot: Spot) -> Vec<Action> {
let group = Group::new();
let op = Box::new(EditOp::Add { node: Node { pos, ..node }, spot });
vec![
Action::Doc(DocAction::Edit { op, cursor: CursorAfter::Added, group: Some(group) }),
Action::App(AppAction::StartEdit(StartEdit { at: EditAt::End, new_node: Some(group) })),
Action::App(AppAction::StartEdit(StartEdit::Title { at: EditAt::End, new_node: Some(group) })),
]
}
@@ -145,6 +190,43 @@ pub fn outdent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option<Group>) -> V
actions
}
/// Move mode in the outline and the Note view: the cursor's node moves
/// through the tree rather than across the canvas, with its branch. `j` and
/// `k` swap it with its siblings (`J` and `K` five places), `h` takes it out
/// a level and `l` puts it under the one above. Every move until the mode
/// ends is one undo step.
pub fn reorder(typed: &Typed, grab: Grab, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
let (doc, id) = (ctx.doc, ctx.cursor);
let steps = match typed.command {
Command::FarDown | Command::FarUp => FAR,
_ => typed.times(),
};
// `steps` siblings that way, or as far as the row goes.
let walk = |next: fn(&Document, NodeId) -> Option<NodeId>| {
let steps = usize::try_from(steps).unwrap_or(1);
std::iter::successors(Some(id), |&at| next(doc, at)).take(steps + 1).last().unwrap_or(id)
};
let actions = match typed.command {
Command::Move(Motion::Down) | Command::FarDown => match walk(Document::next_sibling) {
target if target == id => say("it's the last of its siblings already"),
target => vec![Action::edit_in(EditOp::MoveTo { id, spot: Spot::After(target) }, grab.group)],
},
Command::Move(Motion::Up) | Command::FarUp => match walk(Document::previous_sibling) {
target if target == id => say("it's the first of its siblings already"),
target => vec![Action::edit_in(EditOp::MoveTo { id, spot: Spot::Before(target) }, grab.group)],
},
Command::Move(Motion::Left) | Command::FarLeft => outdent(ctx, &[id], Some(grab.group)),
Command::Move(Motion::Right) | Command::FarRight => indent(ctx, &[id], Some(grab.group)),
Command::ToggleWhole => say("here a node always moves with its branch"),
Command::Confirm => app(AppAction::LeaveMode),
_ => return None,
};
Some(actions)
}
/// How many places `J` and `K` move a node in the outline's move mode.
const FAR: u32 = 5;
/// The boxes of every node except the moving branches (so they don't get in
/// their own way), and the size of each moving node's box.
fn make_room(
@@ -172,7 +254,7 @@ fn siblings(ctx: &ViewContext<'_>, count: u32) -> Vec<NodeId> {
}
fn edit(at: EditAt) -> Vec<Action> {
app(AppAction::StartEdit(StartEdit { at, new_node: None }))
app(AppAction::StartEdit(StartEdit::title(at)))
}
fn app(action: AppAction) -> Vec<Action> {
+9 -4
View File
@@ -4,18 +4,23 @@
//! 1. A key goes through the key engine and comes out as a command; the
//! focused view answers it with [`action`]s, and [`commands::shared`]
//! answers what the view leaves. A `:` command line is answered by
//! [`calls::actions_for`] instead, and keys while a title is edited by
//! its [`text_edit`].
//! [`calls::actions_for`] instead, keys while a text is edited by its
//! [`text_edit`], and keys while a popup is open by the [`popup`].
//! 2. Document actions go to the [`buffer`] holding the document: the one
//! place a document changes. The rest change the [`mode`] or the app.
//!
//! So far this is what stages 2 to 6 needed; stage 8 adds the main loop,
//! panes, files and settings.
//! The screen is split into [`panes`], each showing a document through a
//! view.
//!
//! So far this is what stages 2 to 7 needed; stage 8 adds the main loop,
//! files and settings.
pub mod action;
pub mod buffer;
pub mod calls;
pub mod commands;
pub mod mode;
pub mod panes;
pub mod popup;
pub mod registers;
pub mod text_edit;
+38 -3
View File
@@ -2,7 +2,8 @@
//! what it needs, so a mode's state can't outlive it (there's no "move mode
//! flag" to forget to clear).
use super::text_edit::TextEdit;
use super::popup::Popup;
use super::text_edit::{Field, TextEdit};
use crate::model::Group;
use crate::ui::command_line::CommandLine;
@@ -12,13 +13,16 @@ pub enum Mode {
/// Moving around and acting on nodes.
#[default]
Normal,
/// Editing a title: typing, or vim keys inside the text (the editor
/// knows which, so the two can't disagree).
/// Editing a node's title, tags or body in a view: typing, or vim keys
/// inside the text (the editor knows which, so the two can't disagree).
Text(TextEdit),
/// Moving the cursor's node (or its branch) with hjkl.
Move(Grab),
/// Typing a `:` command.
Command(CommandLine),
/// A popup has the keys: the help, the menu, a picker or the `K` card
/// (which can be editing the body, in itself).
Popup(Popup),
}
/// Move mode's state.
@@ -44,6 +48,36 @@ impl Default for Grab {
}
impl Mode {
/// The text being edited in a view, if any (the card draws its own).
pub fn edit(&self) -> Option<&TextEdit> {
match self {
Mode::Text(edit) => Some(edit),
_ => None,
}
}
/// The title being edited, if any: views draw it in place, as typed.
pub fn title_edit(&self) -> Option<&TextEdit> {
self.edit().filter(|edit| edit.field == Field::Title)
}
/// The text being edited anywhere: in a view, or in the `K` card.
pub fn any_edit(&self) -> Option<&TextEdit> {
match self {
Mode::Text(edit) => Some(edit),
Mode::Popup(popup) => popup.edit(),
_ => None,
}
}
pub fn edit_mut(&mut self) -> Option<&mut TextEdit> {
match self {
Mode::Text(edit) => Some(edit),
Mode::Popup(popup) => popup.edit_mut(),
_ => None,
}
}
/// The mode's name for the status bar.
pub fn label(&self) -> &'static str {
match self {
@@ -52,6 +86,7 @@ impl Mode {
Mode::Text(_) => "TEXT",
Mode::Move(_) => "MOVE",
Mode::Command(_) => "COMMAND",
Mode::Popup(popup) => popup.label(),
}
}
}
+438
View File
@@ -0,0 +1,438 @@
//! 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()));
}
}
+773
View File
@@ -0,0 +1,773 @@
//! Popups that take the keys for a while: the help (`?`), the right-click
//! menu, the pickers (`<Space>c` and the others) and the `K` card. An open
//! popup is a mode ([`Mode::Popup`](super::mode::Mode::Popup)): keys and
//! clicks go to it, and what it does comes out as actions, like everything
//! else.
//!
//! The help and the menu are lists of things to run: running one closes the
//! popup and does it as if its keys were typed. A picker sets something on
//! the node it opened on. The card shows the cursor's body, and edits it in
//! place.
use ratatui::Frame;
use ratatui::layout::{Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
use super::action::{Action, AppAction, DocumentChoice, Message};
use super::commands;
use super::registers::Registers;
use super::text_edit::{EditAt, StartEdit, TextEdit};
use crate::command::parse::border;
use crate::command::registry::{BORDERS, SPECS};
use crate::input::bindings::Context;
use crate::input::command::{Bound, Command, Operator, Target, TargetBound, help_for};
use crate::input::engine::Typed;
use crate::input::key::{Key, notation};
use crate::input::keymap::{Keymap, Tables};
use crate::input::mouse::{Cell, Gesture};
use crate::model::{Border, CanvasPos, ColorName, Content, Direction, EditOp, NodeId, StyleName, Tag, Tags};
use crate::ui::card::{CardText, CardView};
use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place};
use crate::ui::theme::Theme;
use crate::view::ViewContext;
/// How wide the help is, at most.
const HELP_WIDTH: u16 = 76;
/// How wide a picker is, at most.
const PICKER_WIDTH: u16 = 44;
/// The widest the keys column of the help gets.
const KEYS_WIDTH: usize = 12;
/// A popup, with the keys.
#[derive(Debug, Clone)]
pub enum Popup {
/// Every key and command, to search and run.
Help(Runs),
/// The right-click menu.
Menu(Runs),
Pick(Picker),
Card(Card),
}
/// What the menu is for: a node, or a spot on the empty canvas.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuFor {
Node(NodeId),
Canvas(CanvasPos),
}
/// What a picker picks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickKind {
Color,
Border,
Style,
Tags,
/// The document the pane shows.
Document,
}
/// A list whose rows run something: the help and the menu.
#[derive(Debug, Clone)]
pub struct Runs {
pub list: ListPopup,
/// What each row runs.
runs: Vec<Run>,
}
/// What running a row of the help or the menu does.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Run {
/// Nothing: a heading.
Nothing,
/// A command, as if its keys were typed.
Command(Command),
/// Keys, as if typed, for a key that waits for more.
Keys(Vec<Key>),
/// A `:` command, run at once.
Line(String),
/// The command line with this typed: a `:` command that needs more.
Ask(String),
/// A new loose idea, here on the canvas.
NewIdeaAt(CanvasPos),
}
/// A picker: the node it sets something on, and each row's value.
#[derive(Debug, Clone)]
pub struct Picker {
pub list: ListPopup,
node: NodeId,
values: Vec<Pick>,
/// The node's tags as they are now, in the tags picker.
tags: Tags,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Pick {
Color(Option<ColorName>),
Border(Option<Border>),
Style(Option<StyleName>),
Tag(Tag),
Document(usize),
}
/// The `K` card: the node it shows, where it is, and its body while it's
/// edited there.
#[derive(Debug, Clone)]
pub struct Card {
pub node: NodeId,
pub edit: Option<TextEdit>,
pub view: CardView,
}
impl Popup {
/// The help for the keys of `contexts` (a view's, in normal mode), then
/// every `:` command, with `search` already typed.
pub fn help(
keymap: &Keymap,
contexts: &'static [Context],
view: &str,
search: &str,
theme: &Theme,
) -> Popup {
let mut entries: Vec<Entry> = Vec::new();
let mut seen: Vec<Vec<Key>> = Vec::new();
for context in contexts {
let tables = keymap.tables(std::slice::from_ref(context));
for binding in tables.bindings() {
if seen.contains(&binding.keys) {
continue;
}
seen.push(binding.keys.clone());
let shown = notation(&binding.keys);
let section = section(&tables, *context, &binding.keys);
match binding.bound {
Bound::Command(command) => {
entries.push(Entry::new(section, shown, binding.help.clone(), Run::Command(command)));
}
Bound::WithChar(_) => {
let help = format!("{} (then a letter)", binding.help);
entries.push(Entry::new(
section,
format!("{shown}…"),
help,
Run::Keys(binding.keys.clone()),
));
}
Bound::Operator(operator) => {
// The operator with each target: its own key again,
// then the others.
let mut targets = vec![(binding.keys.clone(), Target::Current)];
for target in tables.targets_starting_with(&[]) {
if let TargetBound::Target(kind) = target.bound {
targets.push((target.keys, kind));
}
}
for (keys, target) in targets {
let keys = notation(&[binding.keys.clone(), keys].concat());
let run = Run::Command(Command::Operate(operator, target));
entries.push(Entry::new(
"operators".into(),
keys,
operator_help(operator, target),
run,
));
}
}
}
}
}
for spec in SPECS {
let needs_more = spec.args.iter().any(|arg| arg.required);
let run = if needs_more {
Run::Ask(format!("{} ", spec.name))
} else {
Run::Line(spec.name.to_string())
};
entries.push(Entry::new(
"commands".into(),
format!(":{}", spec.name),
spec.help.to_string(),
run,
));
}
// The entries under their headings, the headings in the order they
// come.
let key_style = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD);
let heading = Style::new().fg(theme.muted).add_modifier(Modifier::BOLD);
let width = entries.iter().map(|entry| entry.keys.width()).max().unwrap_or(0).min(KEYS_WIDTH);
let mut sections: Vec<&str> = Vec::new();
for entry in &entries {
if !sections.contains(&entry.section.as_str()) {
sections.push(&entry.section);
}
}
let (mut rows, mut runs) = (Vec::new(), Vec::new());
for section in sections {
rows.push(ListRow::heading(section, heading));
runs.push(Run::Nothing);
for entry in entries.iter().filter(|entry| entry.section == section) {
let keys = Span::styled(format!("{:<width$} ", entry.keys), key_style);
let search = format!("{} {}", entry.keys, entry.help);
rows.push(ListRow::new(vec![keys, Span::raw(entry.help.clone())], search));
runs.push(entry.run.clone());
}
}
let footer = "j/k move · / search · Enter run · Esc close";
let list =
ListPopup::new(format!("help · {view}"), footer, rows, Place::Center { width: HELP_WIDTH });
Popup::Help(Runs { list: list.with_search(search), runs })
}
/// The right-click menu at a cell, for a node or a spot on the canvas,
/// with the keys that do the same (from `contexts`).
pub fn menu(
what: MenuFor,
title: &str,
at: Position,
keymap: &Keymap,
contexts: &'static [Context],
theme: &Theme,
) -> Popup {
let tables = keymap.tables(contexts);
let items: Vec<(&str, Run)> = match what {
MenuFor::Node(_) => {
NODE_MENU.iter().map(|&(label, command)| (label, Run::Command(command))).collect()
}
MenuFor::Canvas(spot) => vec![
("new idea here", Run::NewIdeaAt(spot)),
("zoom in", Run::Command(Command::ZoomIn)),
("zoom out", Run::Command(Command::ZoomOut)),
],
};
let key_style = Style::new().fg(theme.muted);
let (mut rows, mut runs) = (Vec::new(), Vec::new());
for (label, run) in items {
let mut row = ListRow::new(vec![Span::raw(label)], label);
if let Run::Command(command) = run
&& let Some(keys) = keys_for(&tables, command)
{
row = row.with_note(Span::styled(keys, key_style));
}
rows.push(row);
runs.push(run);
}
let list = ListPopup::new(title, "Enter or a click: do it", rows, Place::At(at));
Popup::Menu(Runs { list, runs })
}
/// A picker for the cursor's node (or, for documents, for the pane).
/// `documents` are the open documents' names, and `current` the one the
/// pane shows.
pub fn picker(
kind: PickKind,
ctx: &ViewContext<'_>,
documents: &[String],
current: usize,
) -> Result<Popup, Message> {
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
let node = doc.node(ctx.cursor).ok_or_else(|| Message::problem("that node is gone"))?;
let check = |on: bool| on.then(|| Span::styled("✓", Style::new().fg(theme.accent)));
let row = |spans: Vec<Span<'static>>, search: &str, on: bool| {
let row = ListRow::new(spans, search);
match check(on) {
Some(mark) => row.with_note(mark),
None => row,
}
};
let muted = Style::new().fg(theme.muted);
let mut tags = Tags::default();
let (title, values, rows): (String, Vec<Pick>, Vec<ListRow>) = match kind {
PickKind::Color => {
let mut values = vec![Pick::Color(None)];
values.extend(theme.palette.keys().cloned().map(|name| Pick::Color(Some(name))));
let rows = values
.iter()
.map(|value| {
let Pick::Color(color) = value else { return ListRow::new(Vec::new(), "") };
let on = node.look.color == *color;
match color {
None => row(vec![Span::styled("theme default", muted)], "theme default none", on),
Some(name) => {
let swatch = Span::styled(
format!("{} ", glyphs.bullet()),
Style::new().fg(theme.palette.get(name).copied().unwrap_or_default()),
);
row(vec![swatch, Span::raw(name.to_string())], name.as_str(), on)
}
}
})
.collect();
(format!("color · {}", node.title()), values, rows)
}
PickKind::Border => {
let mut values = vec![Pick::Border(None)];
let mut rows = vec![row(
vec![Span::styled("theme default", muted)],
"theme default",
node.look.border.is_none(),
)];
for choice in BORDERS {
let Ok(kind) = border(choice.word) else { continue };
let on = node.look.border == Some(kind);
let spans =
vec![Span::raw(format!("{:<8}", choice.word)), Span::styled(choice.help, muted)];
rows.push(row(spans, choice.word, on));
values.push(Pick::Border(Some(kind)));
}
(format!("border · {}", node.title()), values, rows)
}
PickKind::Style => {
if theme.node_styles.is_empty() {
return Err(Message::info(
"this theme has no named styles yet: theme files come in stage 8",
));
}
let mut values = vec![Pick::Style(None)];
values.extend(theme.node_styles.keys().cloned().map(|name| Pick::Style(Some(name))));
let rows = values
.iter()
.map(|value| {
let Pick::Style(style) = value else { return ListRow::new(Vec::new(), "") };
let on = node.look.style == *style;
match style {
None => row(vec![Span::styled("no style", muted)], "no style none", on),
Some(name) => row(vec![Span::raw(name.to_string())], name.as_str(), on),
}
})
.collect();
(format!("style · {}", node.title()), values, rows)
}
PickKind::Tags => {
tags = node.tags.clone();
// Every tag in the document, and the theme's.
let mut all: Vec<Tag> = doc
.outline()
.into_iter()
.filter_map(|id| doc.node(id))
.flat_map(|node| node.tags.iter().cloned().collect::<Vec<_>>())
.collect();
all.extend(theme.tag_styles.iter().map(|(tag, _)| tag.clone()));
all.sort();
all.dedup();
let rows = all
.iter()
.map(|tag| row(vec![Span::raw(tag.to_string())], tag.name(), tags.contains(tag)))
.collect();
(format!("tags · {}", node.title()), all.into_iter().map(Pick::Tag).collect(), rows)
}
PickKind::Document => {
let rows = documents
.iter()
.enumerate()
.map(|(index, name)| row(vec![Span::raw(name.clone())], name, index == current))
.collect();
("documents".to_string(), (0..documents.len()).map(Pick::Document).collect(), rows)
}
};
let footer = match kind {
PickKind::Tags => "Enter: on or off · / then a new one: add it",
PickKind::Document => "Enter: show it here · Esc: close",
_ => "Enter: pick · Esc: close",
};
let mut list = ListPopup::new(title, footer, rows, Place::Center { width: PICKER_WIDTH });
// Start on the value the node has now.
if let Some(index) = values.iter().position(|value| picked(value, node, &tags, current)) {
list.select(index);
}
Ok(Popup::Pick(Picker { list, node: ctx.cursor, values, tags }))
}
/// The `K` card for a node.
pub fn card(node: NodeId) -> Popup {
Popup::Card(Card { node, edit: None, view: CardView::default() })
}
/// The mode's name for the status bar.
pub fn label(&self) -> &'static str {
match self {
Popup::Help(_) => "HELP",
Popup::Menu(_) => "MENU",
Popup::Pick(_) => "PICK",
Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => "INSERT",
Popup::Card(Card { edit: Some(_), .. }) => "TEXT",
Popup::Card(_) => "CARD",
}
}
/// The key tables while it's open.
pub fn contexts(&self) -> &'static [Context] {
match self {
Popup::Help(_) | Popup::Menu(_) | Popup::Pick(_) => &[Context::List],
Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => &[Context::Insert],
Popup::Card(Card { edit: Some(_), .. }) => &[Context::Text],
Popup::Card(_) => &[Context::Card],
}
}
/// Whether keys go straight to it rather than through the key engine:
/// while a search or a body is typed.
pub fn takes_typing(&self) -> bool {
match self {
Popup::Help(runs) | Popup::Menu(runs) => runs.list.is_searching(),
Popup::Pick(picker) => picker.list.is_searching(),
Popup::Card(card) => card.edit.as_ref().is_some_and(|edit| edit.editor.is_typing()),
}
}
/// The body being edited in the card.
pub fn edit(&self) -> Option<&TextEdit> {
match self {
Popup::Card(card) => card.edit.as_ref(),
_ => None,
}
}
pub fn edit_mut(&mut self) -> Option<&mut TextEdit> {
match self {
Popup::Card(card) => card.edit.as_mut(),
_ => None,
}
}
/// A key typed straight in (see [`Popup::takes_typing`]).
pub fn typing(&mut self, key: Key, keymap: &Keymap, ctx: &ViewContext<'_>) -> Vec<Action> {
let outcome = match self {
Popup::Help(runs) | Popup::Menu(runs) => runs.list.search_key(key),
Popup::Pick(picker) => picker.list.search_key(key),
Popup::Card(card) => {
let bound = keymap.tables(&[Context::Insert]).bound(&[key]);
return card.edit.as_mut().map(|edit| edit.typing(key, bound, ctx)).unwrap_or_default();
}
};
self.outcome(outcome, ctx)
}
/// A command from its key tables.
pub fn command(
&mut self,
typed: &Typed,
registers: &mut Registers,
ctx: &ViewContext<'_>,
) -> Vec<Action> {
let outcome = match self {
Popup::Help(runs) | Popup::Menu(runs) => runs.list.command(typed),
Popup::Pick(picker) => picker.list.command(typed),
Popup::Card(card) => return card.command(typed, registers, ctx),
};
self.outcome(outcome, ctx)
}
/// A mouse gesture, anywhere on the screen.
pub fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
let list = match self {
Popup::Help(runs) | Popup::Menu(runs) => &mut runs.list,
Popup::Pick(picker) => &mut picker.list,
Popup::Card(card) => return card.mouse(gesture, ctx),
};
let outcome = match gesture {
Gesture::Press { at, .. } => list.click(position(at)),
Gesture::Scroll { direction, .. } => {
list.wheel(direction == Direction::Down);
ListOutcome::Stay
}
Gesture::RightPress { at } if list.click(position(at)) == ListOutcome::Close => {
ListOutcome::Close
}
_ => ListOutcome::Stay,
};
self.outcome(outcome, ctx)
}
/// Works out where it goes on `screen`. `anchor` is where the cursor's
/// node is on screen, for the card.
pub fn layout(&mut self, screen: Rect, anchor: Option<Rect>, ctx: &ViewContext<'_>) {
match self {
Popup::Help(runs) | Popup::Menu(runs) => runs.list.layout(screen),
Popup::Pick(picker) => picker.list.layout(screen),
Popup::Card(card) => {
if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) {
card.view.layout(screen, anchor, &text);
}
}
}
}
pub fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
match self {
Popup::Help(runs) | Popup::Menu(runs) => runs.list.render(frame, ctx.theme),
Popup::Pick(picker) => picker.list.render(frame, ctx.theme),
Popup::Card(card) => {
if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) {
card.view.render(frame, &text, ctx.theme);
}
}
}
}
/// What a list's outcome means for this popup.
fn outcome(&mut self, outcome: ListOutcome, ctx: &ViewContext<'_>) -> Vec<Action> {
match (outcome, self) {
(ListOutcome::Stay, _) => Vec::new(),
(ListOutcome::Close, _) => leave(),
(ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs)) => runs.run(index, ctx),
(ListOutcome::Picked(index), Popup::Pick(picker)) => picker.pick(index, ctx),
(ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_tag(&text, ctx),
(ListOutcome::Picked(_) | ListOutcome::NoMatch(_), _) => Vec::new(),
}
}
}
/// The node menu: what you can do with a node, each with its keys.
const NODE_MENU: &[(&str, Command)] = &[
("edit the title", Command::EditTitle),
("add a child", Command::AddChild),
("add a sibling below", Command::AddBelow),
("show the body", Command::ShowBody),
("fold or unfold", Command::ToggleFold),
("color…", Command::PickColor),
("border…", Command::PickBorder),
("style…", Command::PickStyle),
("tags…", Command::EditTags),
("move it", Command::MoveMode),
("open it in the Note view", Command::ShowNote),
("delete just this node", Command::DeleteNode),
("delete it with its branch", Command::Operate(Operator::Delete, Target::Current)),
];
/// A row of the help, before it's laid out.
struct Entry {
section: String,
keys: String,
help: String,
run: Run,
}
impl Entry {
fn new(section: String, keys: String, help: String, run: Run) -> Entry {
Entry { section, keys, help, run }
}
}
/// The heading a key goes under in the help: its group (`g`: go to) when it
/// has more than one key, or else where it works.
fn section(tables: &Tables<'_>, context: Context, keys: &[Key]) -> String {
if keys.len() > 1
&& let Some(group) = tables.group_name(&keys[..1])
{
return group.to_string();
}
match context {
Context::Canvas => "on the canvas",
Context::Outline => "in the outline",
Context::Note => "in the Note view",
Context::Normal => "nodes",
Context::Motions => "moving around",
other => other.name(),
}
.to_string()
}
/// What an operator with a target does, in words.
fn operator_help(operator: Operator, target: Target) -> String {
let named = help_for(Bound::Command(Command::Operate(operator, target)));
if !named.is_empty() {
return named.to_string();
}
let verb = help_for(Bound::Operator(operator)).trim_end_matches('…');
format!("{verb} this node, with its branch")
}
/// The keys that run `command` in these tables, for the menu.
fn keys_for(tables: &Tables<'_>, command: Command) -> Option<String> {
let bindings = tables.bindings();
if let Some(binding) = bindings.iter().find(|binding| binding.bound == Bound::Command(command)) {
return Some(notation(&binding.keys));
}
let Command::Operate(operator, target) = command else { return None };
let keys = &bindings.iter().find(|binding| binding.bound == Bound::Operator(operator))?.keys;
let target_keys = match target {
Target::Current => keys.clone(),
_ => {
tables
.targets_starting_with(&[])
.into_iter()
.find(|bound| bound.bound == TargetBound::Target(target))?
.keys
}
};
Some(notation(&[keys.clone(), target_keys].concat()))
}
/// Whether a picker's value is the one the node has now.
fn picked(value: &Pick, node: &crate::model::Node, tags: &Tags, current: usize) -> bool {
match value {
Pick::Color(color) => node.look.color == *color,
Pick::Border(border) => node.look.border == *border,
Pick::Style(style) => node.look.style == *style,
Pick::Tag(tag) => tags.contains(tag),
Pick::Document(index) => *index == current,
}
}
fn leave() -> Vec<Action> {
vec![Action::App(AppAction::LeaveMode)]
}
fn position(cell: Cell) -> Position {
Position::new(cell.col, cell.row)
}
impl Runs {
/// Runs a row: the popup closes, and it's done as if its keys were
/// typed.
fn run(&self, index: usize, ctx: &ViewContext<'_>) -> Vec<Action> {
let Some(run) = self.runs.get(index) else { return Vec::new() };
let then = match run {
Run::Nothing => return Vec::new(),
Run::Command(command) => vec![Action::App(AppAction::Run(*command))],
Run::Keys(keys) => vec![Action::App(AppAction::Type(keys.clone()))],
Run::Line(line) => vec![Action::App(AppAction::RunLine(line.clone()))],
Run::Ask(text) => vec![Action::App(AppAction::CommandLineWith(text.clone()))],
Run::NewIdeaAt(spot) => commands::add_loose(ctx, *spot),
};
let mut actions = leave();
actions.extend(then);
actions
}
}
impl Picker {
/// Picks a row. A tag goes on or off and the picker stays open; the
/// rest close it.
fn pick(&mut self, index: usize, ctx: &ViewContext<'_>) -> Vec<Action> {
let id = self.node;
let op = match self.values.get(index) {
None => return Vec::new(),
Some(Pick::Color(color)) => EditOp::SetColor { id, color: color.clone() },
Some(Pick::Border(border)) => EditOp::SetBorder { id, border: *border },
Some(Pick::Style(style)) => EditOp::SetStyle { id, style: style.clone() },
Some(Pick::Document(at)) => {
let mut actions = leave();
actions.push(Action::App(AppAction::SwitchDocument(DocumentChoice::At(*at))));
return actions;
}
Some(Pick::Tag(tag)) => {
let tag = tag.clone();
let on = !self.tags.contains(&tag);
if on {
self.tags.insert(tag.clone());
} else {
self.tags.remove(&tag);
}
self.list.set_row(index, tag_row(&tag, on, ctx.theme));
let (add, remove) = if on { (vec![tag], Vec::new()) } else { (Vec::new(), vec![tag]) };
return vec![Action::edit(EditOp::Tag { id, add, remove })];
}
};
let mut actions = leave();
actions.push(Action::edit(op));
actions
}
/// Enter on a search that matches no tag: a new tag, put on the node.
fn add_tag(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec<Action> {
if !self.values.iter().all(|value| matches!(value, Pick::Tag(_))) {
return Vec::new();
}
match Tag::parse(text) {
Ok(tag) if self.tags.contains(&tag) => Vec::new(),
Ok(tag) => {
self.tags.insert(tag.clone());
self.list.push(tag_row(&tag, true, ctx.theme));
self.values.push(Pick::Tag(tag.clone()));
vec![Action::edit(EditOp::Tag { id: self.node, add: vec![tag], remove: Vec::new() })]
}
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
}
}
}
/// A row of the tags picker, checked when the node has the tag.
fn tag_row(tag: &Tag, on: bool, theme: &Theme) -> ListRow {
let row = ListRow::new(vec![Span::raw(tag.to_string())], tag.name());
if on { row.with_note(Span::styled("✓", Style::new().fg(theme.accent))) } else { row }
}
impl Card {
fn command(&mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>) -> Vec<Action> {
if let Some(edit) = &mut self.edit {
return edit.vim(typed, registers, ctx);
}
let times = isize::try_from(typed.times()).unwrap_or(1);
let start = |at| vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))];
match typed.command {
Command::ScrollDown => self.view.scroll_by(times),
Command::ScrollUp => self.view.scroll_by(-times),
Command::HalfPageDown => self.view.scroll_by(self.view.half() * times),
Command::HalfPageUp => self.view.scroll_by(-self.view.half() * times),
Command::EditBody => return start(EditAt::End),
Command::EditBodyAtStart => return start(EditAt::Start),
Command::Escape => return leave(),
_ => {}
}
Vec::new()
}
/// While the body is edited, a click in it moves the text cursor and a
/// click outside is done editing; otherwise a click outside closes the
/// card, and a double-click in the body edits it from there.
fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec<Action> {
let offset_at = |card: &Card, at: Position| {
card_text(card.node, card.edit.as_ref(), ctx).and_then(|text| card.view.offset_at(at, &text))
};
match gesture {
Gesture::Press { at, .. } => {
let at = position(at);
match (&self.edit, offset_at(self, at)) {
(Some(_), Some(offset)) => vec![Action::App(AppAction::TextCursor(offset))],
(_, _) if self.view.contains(at) => Vec::new(),
(Some(edit), None) => edit.finish(ctx),
(None, _) => leave(),
}
}
Gesture::DoublePress { at } if self.edit.is_none() => {
let at = match offset_at(self, position(at)) {
Some(offset) => EditAt::Offset(offset),
None if self.view.contains(position(at)) => EditAt::End,
None => return Vec::new(),
};
vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))]
}
Gesture::Scroll { direction, .. } => {
self.view.scroll_by(if direction == Direction::Down { 3 } else { -3 });
Vec::new()
}
_ => Vec::new(),
}
}
}
/// What the card shows for a node: its body, or the body as typed.
fn card_text<'a>(node: NodeId, edit: Option<&'a TextEdit>, ctx: &ViewContext<'a>) -> Option<CardText<'a>> {
let found = ctx.doc.node(node)?;
let body = match (edit, &found.content) {
(Some(edit), _) => edit.editor.text(),
(None, Content::Idea(idea)) => idea.body.as_str(),
(None, Content::Portal(_)) => "",
};
Some(CardText {
title: found.title().as_str(),
color: ctx.theme.node_style(found, node == ctx.doc.root()).color,
tags: found.tags.to_string(),
body,
cursor: edit.map(|edit| edit.editor.cursor()),
typing: edit.is_some_and(|edit| edit.editor.is_typing()),
})
}
#[cfg(test)]
mod tests;
+148
View File
@@ -0,0 +1,148 @@
//! The popups driven by their key tables' commands, as the app drives them.
use super::*;
use crate::input::key::KeyName;
use crate::input::keymap::DEFAULT_LEADER;
use crate::model::test_support::garden;
use crate::model::{Spot, Tags};
use crate::view::test_support::Setup;
const CANVAS: &[Context] = &[Context::Canvas, Context::Normal, Context::Motions];
fn typed(command: Command) -> Typed {
Typed { command, count: None, register: None }
}
/// Types a search into a list popup, the way `/` and the keys after it do.
fn search(popup: &mut Popup, text: &str, ctx: &ViewContext<'_>) {
let keymap = Keymap::new(DEFAULT_LEADER);
popup.command(&typed(Command::Search), &mut Registers::default(), ctx);
for c in text.chars() {
popup.typing(Key::char(c), &keymap, ctx);
}
}
/// Enter, while a search is typed.
fn enter(popup: &mut Popup, ctx: &ViewContext<'_>) -> Vec<Action> {
popup.typing(Key::plain(KeyName::Enter), &Keymap::new(DEFAULT_LEADER), ctx)
}
fn app(action: AppAction) -> Action {
Action::App(action)
}
#[test]
fn help_finds_keys_and_commands_and_runs_them() {
let ((doc, g), setup) = (garden(), Setup::default());
let ctx = setup.ctx(&doc, g.root);
let keymap = Keymap::new(DEFAULT_LEADER);
let help = || Popup::help(&keymap, CANVAS, "canvas", "", &setup.theme);
let mut popup = help();
search(&mut popup, "dd", &ctx);
let dd = Command::Operate(Operator::Delete, Target::Current);
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(dd))]);
let mut popup = help();
search(&mut popup, "zoom in", &ctx);
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Run(Command::ZoomIn))]);
// A key that waits for more is typed, so the rest can follow.
let mut popup = help();
search(&mut popup, "mark this", &ctx);
let m = vec![Key::char('m')];
assert_eq!(enter(&mut popup, &ctx), [app(AppAction::LeaveMode), app(AppAction::Type(m))]);
// A command that needs more opens the command line with it typed.
let mut popup = Popup::help(&keymap, CANVAS, "canvas", ":color", &setup.theme);
let confirm = popup.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
assert_eq!(confirm, [app(AppAction::LeaveMode), app(AppAction::CommandLineWith("color ".into()))]);
assert_eq!(popup.label(), "HELP");
}
#[test]
fn the_menu_shows_the_keys_that_do_the_same() {
let keymap = Keymap::new(DEFAULT_LEADER);
let tables = keymap.tables(CANVAS);
assert_eq!(keys_for(&tables, Command::AddChild).as_deref(), Some("a"));
assert_eq!(keys_for(&tables, Command::PickColor).as_deref(), Some("<Space>c"));
assert_eq!(keys_for(&tables, Command::Operate(Operator::Delete, Target::Current)).as_deref(), Some("dd"));
// On the empty canvas, the first thing is a new idea right there.
let ((doc, g), setup) = (garden(), Setup::default());
let ctx = setup.ctx(&doc, g.root);
let spot = CanvasPos::new(60, 30);
let mut menu =
Popup::menu(MenuFor::Canvas(spot), "canvas", Position::new(5, 5), &keymap, CANVAS, &setup.theme);
let actions = menu.command(&typed(Command::Confirm), &mut Registers::default(), &ctx);
assert_eq!(actions[0], app(AppAction::LeaveMode));
let Action::Doc(crate::app::action::DocAction::Edit { op, .. }) = &actions[1] else { panic!("an add") };
let EditOp::Add { node, spot: Spot::Loose } = op.as_ref() else { panic!("a loose node") };
assert_eq!(node.pos, spot, "where the click was");
}
#[test]
fn pickers_set_what_is_picked() {
let ((mut doc, g), setup) = (garden(), Setup::default());
doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#wood").unwrap() }).unwrap();
let ctx = setup.ctx(&doc, g.soil);
let pick = |kind| Popup::picker(kind, &ctx, &[], 0).unwrap();
let mut colors = pick(PickKind::Color);
search(&mut colors, "teal", &ctx);
let teal = ColorName::parse("teal").ok();
assert_eq!(
enter(&mut colors, &ctx),
[app(AppAction::LeaveMode), Action::edit(EditOp::SetColor { id: g.soil, color: teal })]
);
let mut borders = pick(PickKind::Border);
search(&mut borders, "dashed", &ctx);
let dashed = Some(Border::Dashed);
assert_eq!(
enter(&mut borders, &ctx),
[app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })]
);
let no_styles = Popup::picker(PickKind::Style, &ctx, &[], 0).unwrap_err();
assert_eq!(no_styles.text, "this theme has no named styles yet: theme files come in stage 8");
// Tags go on and off, and the picker stays open; a new one is added.
let mut tags = pick(PickKind::Tags);
let wood = Tag::parse("wood").unwrap();
let on = Action::edit(EditOp::Tag { id: g.soil, add: vec![wood.clone()], remove: Vec::new() });
let off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood] });
let mut registers = Registers::default();
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [on]);
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [off]);
search(&mut tags, "spring", &ctx);
let spring = Tag::parse("spring").unwrap();
assert_eq!(
enter(&mut tags, &ctx),
[Action::edit(EditOp::Tag { id: g.soil, add: vec![spring], remove: Vec::new() })]
);
// Documents: it starts on the one shown; the next one is a j away.
let documents = ["Garden plans".to_string(), "Kitchen".to_string()];
let mut picker = Popup::picker(PickKind::Document, &ctx, &documents, 0).unwrap();
picker.command(&typed(Command::Move(crate::input::command::Motion::Down)), &mut registers, &ctx);
assert_eq!(
picker.command(&typed(Command::Confirm), &mut registers, &ctx),
[app(AppAction::LeaveMode), app(AppAction::SwitchDocument(DocumentChoice::At(1)))]
);
}
#[test]
fn the_card_edits_the_body_and_closes() {
let ((doc, g), setup) = (garden(), Setup::default());
let ctx = setup.ctx(&doc, g.beds);
let mut card = Popup::card(g.beds);
assert_eq!((card.label(), card.contexts()), ("CARD", &[Context::Card][..]));
let mut registers = Registers::default();
let start = StartEdit::Body { at: EditAt::End, in_card: true };
assert_eq!(
card.command(&typed(Command::EditBody), &mut registers, &ctx),
[app(AppAction::StartEdit(start))]
);
assert_eq!(card.command(&typed(Command::Escape), &mut registers, &ctx), [app(AppAction::LeaveMode)]);
}
+180 -70
View File
@@ -1,5 +1,6 @@
//! Editing a node's title: the [`TextEditor`] does the typing and the vim
//! keys, and this is what the edit means for the document.
//! Editing a node's text: its title, its tags or its body. The
//! [`TextEditor`] does the typing and the vim keys, and this is what the edit
//! means for the document.
//!
//! The document follows the text as you type, so the layout and every view
//! stay current, and all of it joins one undo step. Rapid entry lives here
@@ -13,10 +14,13 @@ use super::registers::Registers;
use crate::input::command::{Bound, Command, Operator, Target};
use crate::input::engine::Typed;
use crate::input::key::Key;
use crate::model::{Content, EditOp, Group, NodeId, Spot, Title};
use crate::model::{Body, Content, EditOp, Group, NodeId, Spot, Tags, Title};
use crate::ui::text_editor::{Reply, TextEditor, Typing};
use crate::view::ViewContext;
/// How many spaces Tab types in a body (a body keeps no tab characters).
const TAB_WIDTH: usize = 4;
/// Where the text cursor starts when an edit begins.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditAt {
@@ -24,64 +28,128 @@ pub enum EditAt {
End,
/// Before the first one (`I`).
Start,
/// With the title cleared, to type it again (`cc`, `S`).
/// With the text cleared, to type it again (`cc`, `S`).
Retype,
/// At this byte offset (a double-click on a letter).
Offset(usize),
}
/// How an edit of the cursor's title starts.
/// Which of a node's texts is being edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StartEdit {
pub at: EditAt,
/// For a node `o`, `O` or `a` just added: the add's undo step, which
/// the edit joins. The node goes away if it's left empty.
pub new_node: Option<Group>,
pub enum Field {
Title,
/// The tags, as text: `#todo #idea`.
Tags,
Body,
}
/// A title being edited.
/// How an edit of the cursor's node starts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartEdit {
/// Its title. `new_node`: the node was just added by `o`, `O` or `a`,
/// and this is the add's undo step, which the edit joins (the node goes
/// away if it's left empty).
Title {
at: EditAt,
new_node: Option<Group>,
},
Tags(EditAt),
/// Its body. `in_card`: in the `K` card, which stays open around it.
Body {
at: EditAt,
in_card: bool,
},
}
impl StartEdit {
/// Editing an existing title.
pub fn title(at: EditAt) -> StartEdit {
StartEdit::Title { at, new_node: None }
}
pub fn field(self) -> Field {
match self {
StartEdit::Title { .. } => Field::Title,
StartEdit::Tags(_) => Field::Tags,
StartEdit::Body { .. } => Field::Body,
}
}
fn at(self) -> EditAt {
match self {
StartEdit::Title { at, .. } | StartEdit::Tags(at) | StartEdit::Body { at, .. } => at,
}
}
}
/// A node's text being edited.
#[derive(Debug, Clone)]
pub struct TextEdit {
/// The node whose title it is.
/// The node whose text it is.
pub node: NodeId,
pub field: Field,
pub editor: TextEditor,
/// The node was just added by `o`, `O` or `a`: rapid entry.
pub new_node: bool,
/// Every change the edit makes to the document joins this undo step.
group: Group,
/// The title before the edit, to put back if it's left empty.
/// The text before the edit, to put back if what's typed won't do.
original: String,
}
impl TextEdit {
/// Starts editing the cursor's title. A portal's title is its
/// document's name, so it isn't edited here.
/// Starts editing the cursor's node. A portal's title is its
/// document's name and its body is that document's, so neither is
/// edited here.
pub fn start(ctx: &ViewContext<'_>, start: StartEdit) -> Result<TextEdit, Message> {
let node = ctx.doc.node(ctx.cursor).ok_or_else(|| Message::problem("that node is gone"))?;
let Content::Idea(idea) = &node.content else {
return Err(Message::info("a portal shows its document's name: rename that document instead"));
let new_node = match start {
StartEdit::Title { new_node, .. } => new_node,
StartEdit::Tags(_) | StartEdit::Body { .. } => None,
};
// A new node's title is a placeholder until something is typed.
let original = if start.new_node.is_some() { String::new() } else { idea.title.to_string() };
let mut editor = TextEditor::new(&original, false);
// Typing starts the way vim's commands start it.
let command = match start.at {
EditAt::End => Command::InsertAtEnd,
EditAt::Start => Command::InsertAtStart,
EditAt::Retype => Command::Operate(Operator::Change, Target::Current),
EditAt::Offset(at) if at >= original.len() => Command::InsertAtEnd,
EditAt::Offset(at) => {
editor.set_cursor(at);
Command::InsertBefore
let field = start.field();
let original = match (field, &node.content) {
(Field::Tags, _) => node.tags.to_string(),
// A new node's title is a placeholder until something is typed.
(Field::Title, Content::Idea(_)) if new_node.is_some() => String::new(),
(Field::Title, Content::Idea(idea)) => idea.title.to_string(),
(Field::Body, Content::Idea(idea)) => idea.body.as_str().to_string(),
(Field::Title, Content::Portal(_)) => {
return Err(Message::info(
"a portal shows its document's name: rename that document instead",
));
}
(Field::Body, Content::Portal(_)) => {
return Err(Message::info("a portal has no body of its own: its document's root has one"));
}
};
editor.command(&Typed { command, count: None, register: None }, None);
let new_node = start.new_node.is_some();
let mut editor = TextEditor::new(&original, field == Field::Body);
// Typing starts the way vim's commands start it.
let (command, count) = match start.at() {
EditAt::Start => (Command::InsertAtStart, None),
EditAt::Retype => {
// `cc`, with a count in a body: all of its lines.
let lines = original.lines().count().max(1);
let count = (field == Field::Body).then(|| u32::try_from(lines).unwrap_or(u32::MAX));
(Command::Operate(Operator::Change, Target::Current), count)
}
EditAt::Offset(at) if at < original.len() => {
editor.set_cursor(at);
(Command::InsertBefore, None)
}
EditAt::End | EditAt::Offset(_) => {
// On the last line, in a body.
editor.set_cursor(original.len());
(Command::InsertAtEnd, None)
}
};
editor.command(&Typed { command, count, register: None }, None);
Ok(TextEdit {
node: ctx.cursor,
field,
editor,
new_node,
group: start.new_node.unwrap_or_default(),
new_node: new_node.is_some(),
group: new_node.unwrap_or_default(),
original,
})
}
@@ -89,32 +157,43 @@ impl TextEdit {
/// A key while typing, with what the `insert` table binds it to (keys it
/// doesn't bind type themselves).
pub fn typing(&mut self, key: Key, bound: Option<Bound>, ctx: &ViewContext<'_>) -> Vec<Action> {
let typing = match bound {
let typed: Vec<Typing> = match bound {
Some(Bound::Command(Command::Escape)) => {
self.editor.stop_typing();
return Vec::new();
}
Some(Bound::Command(Command::Confirm)) => return self.enter(ctx),
Some(Bound::Command(Command::Confirm)) => match self.field {
Field::Title => return self.enter(ctx),
Field::Tags => return self.finish(ctx),
// In a body, Enter starts a new line.
Field::Body => vec![Typing::Char('\n')],
},
Some(Bound::Command(Command::Operate(operator @ (Operator::Indent | Operator::Outdent), _))) => {
return self.change_level(ctx, operator == Operator::Indent);
match (self.field, operator) {
(Field::Title, _) => return self.change_level(ctx, operator == Operator::Indent),
(Field::Body, Operator::Indent) => vec![Typing::Char(' '); TAB_WIDTH],
_ => return Vec::new(),
}
}
Some(Bound::Command(command)) => Typing::Command(command),
Some(Bound::Command(command)) => vec![Typing::Command(command)],
Some(_) => return Vec::new(),
None => match key.as_char() {
Some(c) => Typing::Char(c),
Some(c) => vec![Typing::Char(c)],
None => return Vec::new(),
},
};
self.editor.type_in(typing);
for typing in typed {
self.editor.type_in(typing);
}
self.follow(ctx)
}
/// A command typed with vim keys inside the title.
/// A command typed with vim keys inside the text.
pub fn vim(&mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>) -> Vec<Action> {
match typed.command {
Command::Escape => return self.finish(ctx),
match (typed.command, self.field) {
(Command::Escape, _) => return self.finish(ctx),
// In a title, o and O start a new sibling, as they do on nodes.
Command::AddBelow | Command::AddAbove => {
(Command::AddBelow | Command::AddAbove, Field::Title) => {
let spot = if typed.command == Command::AddBelow {
Spot::After(self.node)
} else {
@@ -127,6 +206,9 @@ impl TextEdit {
}
return actions;
}
(Command::AddBelow | Command::AddAbove, Field::Tags) => {
return vec![Action::say(Message::info("tags are one line: o and O add lines in a body"))];
}
_ => {}
}
let paste = registers.get(typed.register).cloned();
@@ -140,30 +222,43 @@ impl TextEdit {
actions
}
/// The edit is done. The title stays as typed; left empty, the old one
/// comes back (and a new node goes away instead).
/// The edit is done. The text stays as typed, unless it won't do: an
/// empty title or tags that aren't tags come back as they were (and a
/// new node left empty goes away instead).
pub fn finish(&self, ctx: &ViewContext<'_>) -> Vec<Action> {
let mut actions = Vec::new();
if Title::parse(self.editor.text()).is_err() {
if self.new_node {
let doc = ctx.doc;
let back = doc.previous_sibling(self.node).or_else(|| doc.parent(self.node));
let op = Box::new(EditOp::RemoveBranches(vec![self.node]));
let cursor = back.map_or(CursorAfter::Stay, CursorAfter::To);
actions.push(Action::Doc(DocAction::Edit { op, cursor, group: Some(self.group) }));
} else {
if let Ok(title) = Title::parse(&self.original) {
actions.push(self.set_title(title));
match self.field {
Field::Title if Title::parse(self.editor.text()).is_err() => {
if self.new_node {
let doc = ctx.doc;
let back = doc.previous_sibling(self.node).or_else(|| doc.parent(self.node));
let op = Box::new(EditOp::RemoveBranches(vec![self.node]));
let cursor = back.map_or(CursorAfter::Stay, CursorAfter::To);
actions.push(Action::Doc(DocAction::Edit { op, cursor, group: Some(self.group) }));
} else {
if let Ok(title) = Title::parse(&self.original) {
actions.push(self.edit(EditOp::SetTitle { id: self.node, title }));
}
actions.push(Action::say(Message::info("a title can't be empty: it's back as it was")));
}
actions.push(Action::say(Message::info("a title can't be empty: it's back as it was")));
}
Field::Tags => {
if let Err(error) = Tags::parse(self.editor.text()) {
if let Ok(tags) = Tags::parse(&self.original) {
actions.push(self.edit(EditOp::SetTags { id: self.node, tags }));
}
let text = format!("{error}: the tags are back as they were");
actions.push(Action::say(Message::problem(text)));
}
}
Field::Title | Field::Body => {}
}
actions.push(Action::App(AppAction::LeaveMode));
actions.push(Action::App(AppAction::EndEdit));
actions
}
/// Enter: done with an existing title. In rapid entry, the next idea
/// starts below this one, or, on an empty idea, rapid entry stops.
/// Enter in a title: done. In rapid entry, the next idea starts below
/// this one, or, on an empty idea, rapid entry stops.
fn enter(&mut self, ctx: &ViewContext<'_>) -> Vec<Action> {
let kept = Title::parse(self.editor.text()).is_ok();
let mut actions = self.finish(ctx);
@@ -173,8 +268,8 @@ impl TextEdit {
actions
}
/// Tab and Shift-Tab: the new idea goes under the one above, or out a
/// level.
/// Tab and Shift-Tab in a title: the new idea goes under the one above,
/// or out a level.
fn change_level(&self, ctx: &ViewContext<'_>, indent: bool) -> Vec<Action> {
if !self.new_node {
return vec![Action::say(Message::info(
@@ -188,18 +283,33 @@ impl TextEdit {
}
}
/// The document follows the text: the title becomes what's typed (once
/// it's a title at all: not empty).
/// The document follows the text, once it's something the node can
/// hold: a title that isn't empty, tags that are all tags.
fn follow(&self, ctx: &ViewContext<'_>) -> Vec<Action> {
let Ok(title) = Title::parse(self.editor.text()) else { return Vec::new() };
if ctx.doc.node(self.node).is_some_and(|node| *node.title() == title) {
return Vec::new();
}
vec![self.set_title(title)]
let Some(node) = ctx.doc.node(self.node) else { return Vec::new() };
let text = self.editor.text();
let op = match self.field {
Field::Title => match Title::parse(text) {
Ok(title) if *node.title() != title => EditOp::SetTitle { id: self.node, title },
_ => return Vec::new(),
},
Field::Tags => match Tags::parse(text) {
Ok(tags) if node.tags != tags => EditOp::SetTags { id: self.node, tags },
_ => return Vec::new(),
},
Field::Body => {
let body = Body::new(text);
match &node.content {
Content::Idea(idea) if idea.body != body => EditOp::SetBody { id: self.node, body },
_ => return Vec::new(),
}
}
};
vec![self.edit(op)]
}
fn set_title(&self, title: Title) -> Action {
Action::edit_in(EditOp::SetTitle { id: self.node, title }, self.group)
fn edit(&self, op: EditOp) -> Action {
Action::edit_in(op, self.group)
}
}
+76 -3
View File
@@ -1,5 +1,5 @@
//! Editing titles: rapid entry, empty titles, `o` inside a title, and the
//! document following the text.
//! Editing a node's text: rapid entry, empty titles, `o` inside a title,
//! bodies and tags, and the document following the text.
use super::*;
use crate::app::buffer::Buffer;
@@ -91,7 +91,7 @@ impl App {
Err(message) => self.said.push(message.text),
}
}
Action::App(AppAction::LeaveMode) => self.mode = Mode::Normal,
Action::App(AppAction::LeaveMode | AppAction::EndEdit) => self.mode = Mode::Normal,
Action::App(AppAction::Say(message)) => self.said.push(message.text),
Action::App(other) => panic!("unexpected here: {other:?}"),
}
@@ -204,3 +204,76 @@ fn shift_tab_takes_a_new_idea_out_a_level() {
app.press("oMore<S-Tab>");
assert_eq!(app.said.last().map(String::as_str), Some("this node is right under the root already"));
}
impl App {
/// The body of a node.
fn body(&self, id: NodeId) -> &str {
match &self.doc().node(id).unwrap().content {
Content::Idea(idea) => idea.body.as_str(),
Content::Portal(_) => "",
}
}
fn tags(&self, id: NodeId) -> String {
self.doc().node(id).unwrap().tags.to_string()
}
}
#[test]
fn bodies_take_lines_and_tab() {
let (doc, g) = garden();
let mut app = App::new(doc, g.beds);
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Body { at: EditAt::End, in_card: false }))]);
app.press("Cedar.<CR><Tab>Three of them.");
assert_eq!(app.body(g.beds), "Cedar.\n Three of them.", "Enter starts a line, Tab types spaces");
// Vim keys work across the lines: k goes up a line, o opens one below.
app.press("<Esc>kA!<Esc>oFrom the mill.<Esc><Esc>");
assert_eq!(app.body(g.beds), "Cedar.!\nFrom the mill.\n Three of them.");
assert_eq!(app.mode.label(), "NORMAL");
app.buffer.apply(DocAction::Undo(1));
assert_eq!(app.body(g.beds), "", "the whole edit is one undo step");
}
#[test]
fn retyping_a_body_clears_every_line() {
let (doc, g) = garden();
let mut app = App::new(doc, g.compost);
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Body { at: EditAt::Retype, in_card: false }))]);
app.press("Ask on Sunday.<Esc><Esc>");
assert_eq!(app.body(g.compost), "Ask on Sunday.");
}
#[test]
fn tags_follow_the_text_and_bad_ones_come_back() {
let (doc, g) = garden();
let mut app = App::new(doc, g.soil);
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Tags(EditAt::End)))]);
app.press(" #Spring");
assert_eq!(app.tags(g.soil), "#spring", "tags are lowercase");
app.press("<CR>");
assert_eq!(app.mode.label(), "NORMAL", "Enter is done, in tags");
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Tags(EditAt::End)))]);
app.press(" #<Esc><Esc>");
assert_eq!(app.tags(g.soil), "#spring");
assert_eq!(
app.said.last().map(String::as_str),
Some("a tag can't be empty: the tags are back as they were")
);
}
#[test]
fn a_portal_keeps_its_title_and_body() {
let (mut doc, g) = garden();
let portal = crate::model::test_support::portal(&mut doc, g.root);
let mut app = App::new(doc, portal);
app.press("i");
assert_eq!(app.mode.label(), "NORMAL");
app.run(vec![Action::App(AppAction::StartEdit(StartEdit::Body { at: EditAt::End, in_card: false }))]);
assert_eq!(
app.said,
[
"a portal shows its document's name: rename that document instead",
"a portal has no body of its own: its document's root has one"
]
);
}