Files
cumulus/src/app/action.rs
T
skkeyeandClaude Opus 5.5 ebe8a5988e Add connect mode, portals, backlinks and renames (stage 8d, v1)
Part 8d, the last of stage 8, and with it everything on DESIGN.md's v1
list.

Connect mode (C): the cursor goes to another node, a dashed arrow in the
visual color follows it, and Enter links the two; then the cursor goes
back to where C was typed (the user's pick). d removes the line between
them (a branch is cut, and the child comes loose), > puts the node under
the other one, and <Space> a, l, c, s and t style the line as you watch,
making it when it isn't there yet. Everything connect mode changes is one
undo group, and Esc takes it back with History::cancel, which leaves
nothing to redo. From visual mode every selected node connects, and the
line pickers go back to connect mode when they close.

Portals show their document live, from a cache of what each document
file holds (open documents from their buffers, the others from their
files, looked at every second): the root's title, its color and how many
nodes it has, or ? when the document isn't there. Enter or gf goes
through, as a jump, and the pane's breadcrumb starts with the documents
it came through. <Space>p types :portal (the user's pick): a path as :e
takes it, or the title of a new document. Backlinks are put right
whenever a document is opened or saved; the root shows how many, the
Note view lists them, gu goes up to them (the user's pick) and
:backlinks lists them.

File names follow the root's title: when it changed, the next save
renames the file (-2 when the name is taken; a folder's default document
keeps its name), and the portals pointing at it, its backlinks in the
documents it leads to, the recent documents, the capital marks and the
jump lists follow. A portal whose file was moved by hand finds its
document in that folder by uid. Portals in a register keep full paths,
so a pasted portal leads to the same document from any folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 08:24:43 -04:00

359 lines
11 KiB
Rust

//! Actions: what views, keys and the command line ask for. Views never
//! change the document, the cursor or the mode themselves. They answer with
//! actions, and the app carries them out: [`Buffer::apply`] for a document
//! and its cursor, the app for the rest.
//!
//! [`Buffer::apply`]: super::buffer::Buffer::apply
use super::documents::BufferId;
use super::jumps::Place;
use super::mode::Connect;
use super::panes::Arrange;
use super::popup::{MenuFor, PickKind};
use super::repeat::Look;
use super::text_edit::StartEdit;
use crate::command::parse::SetArg;
use crate::config::ConfigFile;
use crate::input::command::{Command, Register};
use crate::input::key::Key;
use crate::model::{Direction, EditOp, Group, NodeId, Title};
use crate::view::ViewKind;
/// How a status-bar message is meant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
/// Something to know.
Info,
/// Something got done.
Done,
/// Something didn't work.
Problem,
}
/// A message for the status bar.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub text: String,
pub tone: Tone,
}
/// "1 node", "3 nodes".
pub fn nodes(count: usize) -> String {
if count == 1 { "1 node".to_string() } else { format!("{count} nodes") }
}
impl Message {
pub fn info(text: impl Into<String>) -> Message {
Message { text: text.into(), tone: Tone::Info }
}
pub fn done(text: impl Into<String>) -> Message {
Message { text: text.into(), tone: Tone::Done }
}
pub fn problem(text: impl Into<String>) -> Message {
Message { text: text.into(), tone: Tone::Problem }
}
/// Several messages in one, with ` · ` between them: a problem if one of
/// them is. `None` when there are none.
pub fn joined(messages: Vec<Message>) -> Option<Message> {
let problem = messages.iter().any(|message| message.tone == Tone::Problem);
let tone = if problem { Tone::Problem } else { messages.first()?.tone };
let texts: Vec<&str> = messages.iter().map(|message| message.text.as_str()).collect();
Some(Message { text: texts.join(" · "), tone })
}
}
/// Where the cursor goes after an edit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorAfter {
/// Where it is (or to the root, if its node is gone).
Stay,
/// To the node the edit added.
Added,
/// To this node.
To(NodeId),
}
/// What happens to a document and its cursor.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocAction {
/// Move the cursor. A `jump` is one that `''` can come back from.
MoveCursor {
to: NodeId,
jump: bool,
},
/// Change the document, as one undo step (or as part of its group's).
/// The op is boxed because it can hold whole nodes, which would make
/// every action that size.
Edit {
op: Box<EditOp>,
cursor: CursorAfter,
group: Option<Group>,
},
Undo(u32),
Redo(u32),
SetMark(char),
/// Go to a mark; `'` and `` ` `` go back to before the last jump.
JumpToMark(char),
/// Take back the group's undo step, if it's the newest, leaving nothing
/// to redo: Esc in connect mode.
Cancel(Group),
}
/// What happens around the documents.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppAction {
/// Start editing the cursor's title, tags or body.
StartEdit(StartEdit),
/// 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),
/// Visual mode, `V` (`branches`) or `v`; in visual mode, switches
/// between the two.
Visual {
branches: bool,
},
/// In visual mode: the cursor goes to the other end (`o`).
SwapEnds,
/// A node into the selection, or out of it (a Ctrl+click).
ToggleSelected(NodeId),
/// Nodes into the selection (a Ctrl+drag).
SelectAlso(Vec<NodeId>),
/// Move mode: hjkl moves the cursor's branch, or these nodes (a
/// selection's).
EnterMoveMode(Vec<NodeId>),
/// In move mode: the whole branch, or just the node.
ToggleWhole,
/// Connect mode (`C`): from the cursor's node, or from these (a
/// selection's).
EnterConnectMode(Vec<NodeId>),
/// Back to connect mode, from a picker it opened.
ResumeConnect(Connect),
/// Enter or `gf` on a portal: through it, to its document's root.
FollowPortal(NodeId),
/// `:portal`: a portal under the cursor's node, to the document at the
/// path typed (a new document when nothing's there).
NewPortal(String),
/// `gu`: up to the portals that lead to this document; `list`
/// (`:backlinks`) lists them even when there's just one.
GoUp {
list: bool,
},
/// 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>),
/// Keep nodes in a register (`yy`, and before a delete): exactly
/// `nodes`, or (`branches`) each with its whole branch.
Yank {
register: Option<Register>,
nodes: Vec<NodeId>,
branches: bool,
},
/// Paste what a register holds (`p`, `P`): its nodes, or its text
/// made into nodes, `times` copies.
Paste {
register: Option<Register>,
at: PasteAt,
times: u32,
},
/// A value was picked in a picker: `.` can put it on another node.
Picked(Look),
/// 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 search.
OpenSearch,
/// A search went somewhere: `n` and `N` go on with it.
Searched(String),
/// `Ctrl-o` (`back`) and `Ctrl-i`: through the focused pane's jump list.
Travel {
back: bool,
times: u32,
},
/// `n` and `N`: the next or previous match of the last search.
FindAgain {
forward: bool,
times: u32,
},
/// `:find`: straight to the best match.
Find(String),
/// A capital mark on the cursor's node (`mA`).
SetFileMark(char),
/// To a capital mark, in any document (`'A`).
JumpToFileMark(char),
/// To a place from the jump list (`:jumps`).
GoToPlace(Place),
/// `:marks`, `:jumps` and `:registers`.
OpenMarks,
OpenJumps,
OpenRegisters,
/// `gd` on a node with several cross-links: which one to follow.
OpenLinks,
/// 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 (`:q`, `ZZ`, `Ctrl-w c`). The last one quits,
/// unless a document has unsaved changes; `force` (`:q!`) quits anyway.
ClosePane {
force: bool,
},
OnlyPane,
EqualizePanes,
/// The focused pane shows another view.
ShowView(ViewKind),
SwitchDocument(DocumentChoice),
// Files.
/// `:config`: the config files, to open one in the editor.
OpenConfig,
/// Open a config file in the editor (made first if it isn't there).
EditConfig(ConfigFile),
/// Save (`:w`, `:x`, `:wa`).
Save(SaveWhich),
/// Open a document in the focused pane (`:e`), from a path as typed:
/// relative to the focused document's folder, `~` for the home folder.
/// No path reads the focused document again from its file; `force`
/// (`:e!`) drops unsaved changes to do it.
Edit {
path: Option<String>,
force: bool,
},
/// Make a new document next to the focused one, and show it (`:new`).
NewDocument(Option<Title>),
/// Close the focused document (`:bd`); `force` drops unsaved changes.
CloseDocument {
force: bool,
},
/// A file changed outside cumulus while its document had unsaved
/// changes here: which version to keep.
Resolve {
buffer: BufferId,
keep: Keep,
},
/// Quit cumulus (`:qa`): not while a document has unsaved changes,
/// unless `force` (`:qa!`).
Quit {
force: bool,
},
Say(Message),
}
/// Where `p` and `P` paste, from the cursor's node (the user's pick after
/// trying part 8c: `p` pastes under the node, as `a` adds).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PasteAt {
/// Its last children (`p`).
Child,
/// Its next siblings, below it, where `o` adds one (`P`).
Sibling,
}
/// What `:w` and its kin save.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveWhich {
/// The focused document, changed or not (`:w`).
This,
/// The focused document, if it has unsaved changes (`:x`, `ZZ`).
IfChanged,
/// Every document with unsaved changes (`:wa`).
All,
}
/// Which version of a document to keep when its file changed outside
/// cumulus while it had unsaved changes here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Keep {
/// The one on screen: it's saved over the file.
Mine,
/// The file's: it takes the place of the one on screen (`u` brings that
/// back).
Theirs,
}
/// 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 {
Doc(DocAction),
App(AppAction),
}
impl Action {
/// Move the cursor to a node.
pub fn cursor(to: NodeId) -> Action {
Action::Doc(DocAction::MoveCursor { to, jump: false })
}
/// Jump to a node: `''` comes back.
pub fn jump(to: NodeId) -> Action {
Action::Doc(DocAction::MoveCursor { to, jump: true })
}
/// Change the document, as one undo step; the cursor stays.
pub fn edit(op: EditOp) -> Action {
Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group: None })
}
/// Change the document, as one undo step, then move the cursor.
pub fn edit_then(op: EditOp, cursor: CursorAfter) -> Action {
Action::Doc(DocAction::Edit { op: Box::new(op), cursor, group: None })
}
/// Change the document as part of a group's undo step.
pub fn edit_in(op: EditOp, group: Group) -> Action {
Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group: Some(group) })
}
/// Show a message.
pub fn say(message: Message) -> Action {
Action::App(AppAction::Say(message))
}
}