Add visual mode, node registers, search and the jump list (stage 8c)
The rest of vim, on nodes. Visual mode takes in what lies between where it started and the cursor: on the canvas, every node the rectangle from one end's box to the other's touches; in the outline and the Note view, the nodes in between in reading order. V takes whole branches, Ctrl+click and Ctrl+drag pick with the mouse, and the operators take exactly what's selected: d, y, > and <, M, and the pickers on every node at once. Registers now hold text or nodes, so yy, dd and x keep what they take and p, P and ]p paste it, layout and links included. "+ is the system clipboard through arboard: nodes go there as Markdown in the user's format (## headings, a --- tag list, the body), and come back whole while the clipboard still holds them. clipboard = "unnamedplus" makes plain yanks use it, as in vim. . repeats the last change made with keys, with the text typed after it. / opens a Telescope-style search: fuzzy matches over titles, tags and bodies, best first, with the note of the one you're on beside them; n and N go on with it, and :find searches straight away. Each pane keeps a jump list for Ctrl-o and Ctrl-i that crosses documents and reopens closed ones. Capital marks lead into any document and, like the small ones, are kept in memory.ron. gd follows a cross-link, and :marks, :jumps and :registers list what they hold. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
abe3dc1d84
commit
7bec5bf3fc
45 files changed
+3922
-272
No files matched your search
+77
-3
@@ -6,12 +6,14 @@
|
||||
//! [`Buffer::apply`]: super::buffer::Buffer::apply
|
||||
|
||||
use super::documents::BufferId;
|
||||
use super::jumps::Place;
|
||||
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;
|
||||
use crate::input::command::{Command, Register};
|
||||
use crate::input::key::Key;
|
||||
use crate::model::{Direction, EditOp, Group, NodeId, Title};
|
||||
use crate::view::ViewKind;
|
||||
@@ -34,6 +36,11 @@ pub struct Message {
|
||||
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 }
|
||||
@@ -101,8 +108,20 @@ pub enum AppAction {
|
||||
EndEdit,
|
||||
/// Put the text cursor here, in the text being edited (a click).
|
||||
TextCursor(usize),
|
||||
/// Move mode: hjkl moves the cursor's branch.
|
||||
EnterMoveMode,
|
||||
/// 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,
|
||||
/// Back to normal mode.
|
||||
@@ -119,6 +138,22 @@ pub enum AppAction {
|
||||
/// 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`, `]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,
|
||||
@@ -128,6 +163,34 @@ pub enum AppAction {
|
||||
// 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),
|
||||
@@ -193,6 +256,17 @@ pub enum AppAction {
|
||||
Say(Message),
|
||||
}
|
||||
|
||||
/// Where `p` and its kin paste, from the cursor's node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PasteAt {
|
||||
/// Its next siblings (`p`).
|
||||
After,
|
||||
/// Its siblings before it (`P`).
|
||||
Before,
|
||||
/// Its last children (`]p`).
|
||||
Child,
|
||||
}
|
||||
|
||||
/// What `:w` and its kin save.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SaveWhich {
|
||||
|
||||
+14
-7
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::action::{CursorAfter, DocAction, Message};
|
||||
use super::action::{self, CursorAfter, DocAction, Message};
|
||||
use crate::model::{Applied, Document, History, NodeId};
|
||||
|
||||
/// An open document, with what goes with it while it's open.
|
||||
@@ -42,6 +42,18 @@ impl Buffer {
|
||||
&self.history
|
||||
}
|
||||
|
||||
/// The marks (`ma`), by letter.
|
||||
pub fn marks(&self) -> &BTreeMap<char, NodeId> {
|
||||
&self.marks
|
||||
}
|
||||
|
||||
/// Marks kept from the last time the document was open: the ones whose
|
||||
/// nodes are still there.
|
||||
pub fn keep_marks(&mut self, marks: impl IntoIterator<Item = (char, NodeId)>) {
|
||||
let doc = &self.doc;
|
||||
self.marks.extend(marks.into_iter().filter(|(_, id)| doc.contains(*id)));
|
||||
}
|
||||
|
||||
/// Carries out an action on the document or its cursor, and says how it
|
||||
/// went when there's something to say.
|
||||
pub fn apply(&mut self, action: DocAction) -> Option<Message> {
|
||||
@@ -66,7 +78,7 @@ impl Buffer {
|
||||
// a drag), with nothing to say on its own: an idea left
|
||||
// empty goes away quietly.
|
||||
Applied::Removed(count) if group.is_none() => {
|
||||
Some(Message::done(format!("deleted {} · u to undo", nodes(count))))
|
||||
Some(Message::done(format!("deleted {} · u to undo", action::nodes(count))))
|
||||
}
|
||||
Applied::Removed(_) | Applied::Added(_) | Applied::Changed => None,
|
||||
}
|
||||
@@ -153,11 +165,6 @@ impl Buffer {
|
||||
}
|
||||
}
|
||||
|
||||
/// "1 node", "3 nodes".
|
||||
fn nodes(count: usize) -> String {
|
||||
if count == 1 { "1 node".to_string() } else { format!("{count} nodes") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+4
-41
@@ -62,13 +62,13 @@ pub fn actions_for(call: Call, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
None => say(Message::problem("a node can't link to itself")),
|
||||
},
|
||||
Call::Unlink(target) => unlink(ctx, target),
|
||||
Call::Find(text) => find(ctx, &text),
|
||||
Call::Find(text) => app(AppAction::Find(text)),
|
||||
Call::Set(arg) => vec![Action::App(AppAction::Set(arg))],
|
||||
Call::Version => say(Message::info(crate::version())),
|
||||
Call::Portal(_) | Call::Backlinks => later("portals come in part 8d of the build"),
|
||||
Call::Jumps | Call::Marks | Call::Registers => {
|
||||
later("the lists of jumps, marks and registers come in part 8c of the build")
|
||||
}
|
||||
Call::Jumps => app(AppAction::OpenJumps),
|
||||
Call::Marks => app(AppAction::OpenMarks),
|
||||
Call::Registers => app(AppAction::OpenRegisters),
|
||||
Call::Theme(name) => vec![Action::App(AppAction::Set(SetArg::Value("theme".into(), name)))],
|
||||
Call::Config => app(AppAction::OpenConfig),
|
||||
Call::Tidy(_) | Call::Align(_) | Call::BranchOut | Call::Export { .. } => {
|
||||
@@ -135,28 +135,6 @@ fn unlink(ctx: &ViewContext<'_>, target: Option<NodeId>) -> Vec<Action> {
|
||||
vec![Action::edit(EditOp::Unlink(keys))]
|
||||
}
|
||||
|
||||
/// `:find`: jumps to the next node whose title has the text in it, in
|
||||
/// reading order, starting over at the top after the last.
|
||||
fn find(ctx: &ViewContext<'_>, text: &str) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let wanted = text.to_lowercase();
|
||||
let order = doc.outline();
|
||||
let matches: Vec<usize> = (0..order.len())
|
||||
.filter(|&index| {
|
||||
doc.node(order[index]).is_some_and(|node| node.title().as_str().to_lowercase().contains(&wanted))
|
||||
})
|
||||
.collect();
|
||||
let here = order.iter().position(|&id| id == ctx.cursor).unwrap_or(0);
|
||||
let Some(nth) = matches.iter().position(|&index| index > here).or((!matches.is_empty()).then_some(0))
|
||||
else {
|
||||
return say(Message::problem(format!("nothing matches \"{text}\"")));
|
||||
};
|
||||
vec![
|
||||
Action::jump(order[matches[nth]]),
|
||||
Action::say(Message::info(format!("\"{text}\": match {} of {}", nth + 1, matches.len()))),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -194,19 +172,4 @@ mod tests {
|
||||
assert!(rects.iter().all(|(b, other)| a == b || !rect.overlaps(*other)), "{a:?} overlaps");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_goes_round_the_matches() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let mut buffer = Buffer::new(doc);
|
||||
// "Raised beds" and "Cedar" both have "ed" in them.
|
||||
run(&mut buffer, &setup, Call::Find("ED".into()));
|
||||
assert_eq!(buffer.cursor(), g.beds);
|
||||
run(&mut buffer, &setup, Call::Find("ed".into()));
|
||||
assert_eq!(buffer.cursor(), g.cedar);
|
||||
run(&mut buffer, &setup, Call::Find("ed".into()));
|
||||
assert_eq!(buffer.cursor(), g.beds, "back to the first");
|
||||
let nothing = actions_for(Call::Find("kale".into()), &setup.ctx(buffer.doc(), buffer.cursor()));
|
||||
assert_eq!(nothing, say(Message::problem("nothing matches \"kale\"")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! The system clipboard, behind the `"+` register (and behind every yank
|
||||
//! and paste with `clipboard = "unnamedplus"`). It's opened the first time
|
||||
//! it's used, and kept open: on Linux, text copied there lasts as long as
|
||||
//! cumulus runs, or until a clipboard manager takes it over.
|
||||
//!
|
||||
//! The tests keep their clipboard in memory, so they never touch yours.
|
||||
|
||||
/// The system clipboard.
|
||||
#[derive(Default)]
|
||||
pub struct Clipboard {
|
||||
#[cfg(not(test))]
|
||||
system: Option<arboard::Clipboard>,
|
||||
#[cfg(test)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Clipboard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("Clipboard")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
impl Clipboard {
|
||||
/// The text on the clipboard.
|
||||
pub fn read(&mut self) -> Result<String, String> {
|
||||
self.open()?.get_text().map_err(problem)
|
||||
}
|
||||
|
||||
/// Puts text on the clipboard.
|
||||
pub fn write(&mut self, text: &str) -> Result<(), String> {
|
||||
self.open()?.set_text(text.to_string()).map_err(problem)
|
||||
}
|
||||
|
||||
fn open(&mut self) -> Result<&mut arboard::Clipboard, String> {
|
||||
if self.system.is_none() {
|
||||
self.system = Some(arboard::Clipboard::new().map_err(problem)?);
|
||||
}
|
||||
self.system.as_mut().ok_or_else(|| "the clipboard isn't available".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// What went wrong with the clipboard, in words.
|
||||
#[cfg(not(test))]
|
||||
fn problem(error: arboard::Error) -> String {
|
||||
match error {
|
||||
arboard::Error::ContentNotAvailable => "the clipboard holds no text".to_string(),
|
||||
error => format!("the clipboard isn't available: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Clipboard {
|
||||
pub fn read(&mut self) -> Result<String, String> {
|
||||
self.text.clone().ok_or_else(|| "the clipboard holds no text".to_string())
|
||||
}
|
||||
|
||||
pub fn write(&mut self, text: &str) -> Result<(), String> {
|
||||
self.text = Some(text.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+100
-21
@@ -7,16 +7,16 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message, SaveWhich};
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Message, PasteAt, SaveWhich};
|
||||
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::command::{Command, Motion, Operator, Register, Target};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::model::{
|
||||
CanvasPos, CanvasRect, CanvasSize, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot, Title,
|
||||
Visible,
|
||||
CanvasPos, CanvasRect, CanvasSize, Clip, Direction, Document, EditOp, Fold, Group, Node, NodeId, Spot,
|
||||
Title, Visible,
|
||||
};
|
||||
use crate::view::canvas::placement::{below_spot, child_spot, first_child_spot, free_spot, spot_below};
|
||||
use crate::view::{ViewContext, ViewKind};
|
||||
@@ -27,11 +27,20 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
let times = typed.times();
|
||||
let cursor = ctx.cursor;
|
||||
let actions = match typed.command {
|
||||
Command::Search => app(AppAction::OpenSearch),
|
||||
Command::JumpBack => app(AppAction::Travel { back: true, times }),
|
||||
Command::JumpForward => app(AppAction::Travel { back: false, times }),
|
||||
Command::Move(Motion::SearchNext) => app(AppAction::FindAgain { forward: true, times }),
|
||||
Command::Move(Motion::SearchPrevious) => app(AppAction::FindAgain { forward: false, times }),
|
||||
Command::Move(motion) => return tree_motion(motion, times, ctx),
|
||||
Command::Undo => vec![Action::Doc(DocAction::Undo(times))],
|
||||
Command::Redo => vec![Action::Doc(DocAction::Redo(times))],
|
||||
// Capital marks lead into any document: the app keeps those.
|
||||
Command::SetMark(name) if name.is_ascii_uppercase() => app(AppAction::SetFileMark(name)),
|
||||
Command::JumpToMark(name) if name.is_ascii_uppercase() => app(AppAction::JumpToFileMark(name)),
|
||||
Command::SetMark(name) => vec![Action::Doc(DocAction::SetMark(name))],
|
||||
Command::JumpToMark(name) => vec![Action::Doc(DocAction::JumpToMark(name))],
|
||||
Command::FollowLink => follow_link(ctx),
|
||||
Command::Activate if ctx.doc.node(cursor).is_some_and(Node::is_portal) => {
|
||||
say("going through a portal comes in part 8d of the build")
|
||||
}
|
||||
@@ -41,9 +50,27 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
Command::AddChild => add(ctx, Spot::LastChildOf(cursor)),
|
||||
Command::AddBelow => add(ctx, Spot::After(cursor)),
|
||||
Command::AddAbove => add(ctx, Spot::Before(cursor)),
|
||||
Command::DeleteNode => delete_node(ctx),
|
||||
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => delete_branches(ctx, times),
|
||||
Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx),
|
||||
Command::DeleteNode => delete_node(ctx, typed.register),
|
||||
Command::Operate(Operator::Delete, Target::Current | Target::Branch) => {
|
||||
delete_branches(ctx, times, typed.register)
|
||||
}
|
||||
Command::Operate(Operator::Delete, Target::Children) => delete_children(ctx, typed.register),
|
||||
Command::Operate(Operator::Yank, Target::Current | Target::Branch) => {
|
||||
app(AppAction::Yank { register: typed.register, nodes: siblings(ctx, times), branches: true })
|
||||
}
|
||||
Command::Operate(Operator::Yank, Target::Children) => match ctx.doc.children(cursor) {
|
||||
[] => say("this node has no children to yank"),
|
||||
children => {
|
||||
app(AppAction::Yank { register: typed.register, nodes: children.to_vec(), branches: true })
|
||||
}
|
||||
},
|
||||
Command::PasteAfter => app(AppAction::Paste { register: typed.register, at: PasteAt::After, times }),
|
||||
Command::PasteBefore => {
|
||||
app(AppAction::Paste { register: typed.register, at: PasteAt::Before, times })
|
||||
}
|
||||
Command::PasteAsChild => {
|
||||
app(AppAction::Paste { register: typed.register, at: PasteAt::Child, times })
|
||||
}
|
||||
Command::Operate(Operator::Indent, Target::Current) => indent(ctx, &siblings(ctx, times), None),
|
||||
Command::Operate(Operator::Outdent, Target::Current) => outdent(ctx, &siblings(ctx, times), None),
|
||||
Command::ToggleFold => toggle_fold(ctx),
|
||||
@@ -52,7 +79,9 @@ pub fn shared(typed: &Typed, ctx: &ViewContext<'_>) -> Option<Vec<Action>> {
|
||||
Command::AutoFold => set_fold(ctx, Fold::Auto),
|
||||
Command::OpenAllFolds => fold_all(ctx, Fold::Open),
|
||||
Command::CloseAllFolds => fold_all(ctx, Fold::Closed),
|
||||
Command::MoveMode => app(AppAction::EnterMoveMode),
|
||||
Command::MoveMode => app(AppAction::EnterMoveMode(Vec::new())),
|
||||
Command::Visual => app(AppAction::Visual { branches: false }),
|
||||
Command::VisualBranches => app(AppAction::Visual { branches: true }),
|
||||
Command::CommandLine => app(AppAction::OpenCommandLine),
|
||||
// `<Space>i`: an idea to the Inbox, typed in `:in`.
|
||||
Command::Capture => app(AppAction::CommandLineWith("in ".into())),
|
||||
@@ -201,7 +230,7 @@ pub fn outdent(ctx: &ViewContext<'_>, ids: &[NodeId], group: Option<Group>) -> V
|
||||
/// `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>> {
|
||||
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,
|
||||
@@ -251,7 +280,7 @@ fn make_room(
|
||||
fn shift_to(doc: &Document, id: NodeId, pos: CanvasPos, group: Group) -> Option<Action> {
|
||||
let offset = pos - doc.node(id)?.pos;
|
||||
let moved = offset != Default::default();
|
||||
moved.then(|| Action::edit_in(EditOp::Shift { id, offset, branch: true }, group))
|
||||
moved.then(|| Action::edit_in(EditOp::Shift { ids: vec![id], offset, branch: true }, group))
|
||||
}
|
||||
|
||||
/// The cursor's node and the next siblings, `count` in all.
|
||||
@@ -329,16 +358,37 @@ fn tree_step(ctx: &ViewContext<'_>, from: NodeId, motion: Motion) -> Option<Node
|
||||
}
|
||||
}
|
||||
|
||||
/// `x`: deletes just the cursor's node; its children take its place.
|
||||
fn delete_node(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
/// `gd`: to the node at the other end of the cursor's cross-link; with
|
||||
/// several, a list of them to pick from. A jump: `Ctrl-o` comes back.
|
||||
fn follow_link(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let cursor = ctx.cursor;
|
||||
let others: Vec<NodeId> = ctx
|
||||
.doc
|
||||
.links_of(cursor)
|
||||
.map(|link| if link.start() == cursor { link.end() } else { link.start() })
|
||||
.collect();
|
||||
match others.as_slice() {
|
||||
[] => say("this node has no cross-links to follow"),
|
||||
[other] => vec![Action::jump(*other)],
|
||||
_ => app(AppAction::OpenLinks),
|
||||
}
|
||||
}
|
||||
|
||||
/// `x`: deletes just the cursor's node; its children take its place. The
|
||||
/// node goes in the register, as a delete's text does in vim.
|
||||
fn delete_node(ctx: &ViewContext<'_>, register: Option<Register>) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let next = doc.children(ctx.cursor).first().copied().or_else(|| doc.parent(ctx.cursor));
|
||||
vec![Action::edit_then(EditOp::RemoveKeepingChildren(ctx.cursor), after(next))]
|
||||
vec![
|
||||
Action::App(AppAction::Yank { register, nodes: vec![ctx.cursor], branches: false }),
|
||||
Action::edit_then(EditOp::RemoveKeepingChildren(vec![ctx.cursor]), after(next)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `dd` or `dab`: the cursor's node and its branch. With a count, the next
|
||||
/// siblings and their branches go too, as `3dd` deletes three lines in vim.
|
||||
fn delete_branches(ctx: &ViewContext<'_>, count: u32) -> Vec<Action> {
|
||||
/// What's deleted goes in the register.
|
||||
fn delete_branches(ctx: &ViewContext<'_>, count: u32, register: Option<Register>) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let branches = doc.siblings_from(ctx.cursor, usize::try_from(count).unwrap_or(1));
|
||||
let Some(&last) = branches.last() else { return Vec::new() };
|
||||
@@ -347,16 +397,44 @@ fn delete_branches(ctx: &ViewContext<'_>, count: u32) -> Vec<Action> {
|
||||
.next_sibling(last)
|
||||
.or_else(|| doc.previous_sibling(ctx.cursor))
|
||||
.or_else(|| doc.parent(ctx.cursor));
|
||||
vec![Action::edit_then(EditOp::RemoveBranches(branches), after(next))]
|
||||
vec![
|
||||
Action::App(AppAction::Yank { register, nodes: branches.clone(), branches: true }),
|
||||
Action::edit_then(EditOp::RemoveBranches(branches), after(next)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `dib`: everything below the cursor's node.
|
||||
fn delete_children(ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
fn delete_children(ctx: &ViewContext<'_>, register: Option<Register>) -> Vec<Action> {
|
||||
let children = ctx.doc.children(ctx.cursor).to_vec();
|
||||
if children.is_empty() {
|
||||
return say("this node has no children to delete");
|
||||
}
|
||||
vec![Action::edit(EditOp::RemoveBranches(children))]
|
||||
vec![
|
||||
Action::App(AppAction::Yank { register, nodes: children.clone(), branches: true }),
|
||||
Action::edit(EditOp::RemoveBranches(children)),
|
||||
]
|
||||
}
|
||||
|
||||
/// `p`, `P` and `]p`: copies of a clip around the cursor's node, placed
|
||||
/// where a new sibling or child would go, one below the other, each keeping
|
||||
/// the clip's own layout.
|
||||
pub fn paste(ctx: &ViewContext<'_>, clip: Clip, at: PasteAt, times: u32) -> Option<EditOp> {
|
||||
let (doc, cursor) = (ctx.doc, ctx.cursor);
|
||||
let bounds = clip.bounds(|node| ctx.box_size(node))?;
|
||||
let rects = ctx.box_rects();
|
||||
let (spot, start) = match at {
|
||||
PasteAt::After => (Spot::After(cursor), below_spot(cursor, bounds.size, &rects)),
|
||||
PasteAt::Before => (Spot::Before(cursor), below_spot(cursor, bounds.size, &rects)),
|
||||
PasteAt::Child => (Spot::LastChildOf(cursor), child_spot(doc, cursor, bounds.size, &rects)),
|
||||
};
|
||||
let mut offsets = Vec::new();
|
||||
let mut pos = start;
|
||||
for _ in 0..times.max(1) {
|
||||
pos = free_spot(pos, bounds.size, &rects);
|
||||
offsets.push(pos - bounds.pos);
|
||||
pos = CanvasPos::new(pos.x, pos.y + i32::from(bounds.size.height) + 1);
|
||||
}
|
||||
Some(EditOp::Paste { clip, spot, offsets })
|
||||
}
|
||||
|
||||
/// Where the cursor goes after a delete: to `next`, or the root when there's
|
||||
@@ -392,12 +470,13 @@ mod tests {
|
||||
fn a_count_deletes_the_next_siblings_too() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
let dd = Command::Operate(Operator::Delete, Target::Current);
|
||||
let yank = AppAction::Yank { register: None, nodes: vec![g.soil, g.beds], branches: true };
|
||||
assert_eq!(
|
||||
shared(&typed(dd, Some(2)), &setup.ctx(&doc, g.soil)),
|
||||
Some(vec![Action::edit_then(
|
||||
EditOp::RemoveBranches(vec![g.soil, g.beds]),
|
||||
CursorAfter::To(g.watering)
|
||||
)])
|
||||
Some(vec![
|
||||
Action::App(yank),
|
||||
Action::edit_then(EditOp::RemoveBranches(vec![g.soil, g.beds]), CursorAfter::To(g.watering))
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::mode::Mode;
|
||||
use super::popup::Popup;
|
||||
use crate::command::parse::SetArg;
|
||||
use crate::config::paths::Paths;
|
||||
use crate::config::settings::Settings;
|
||||
use crate::config::settings::{ClipboardUse, Settings};
|
||||
use crate::config::{ConfigFile, keys, theme_file};
|
||||
use crate::storage::stamp::Stamp;
|
||||
use crate::storage::write_atomically;
|
||||
@@ -67,6 +67,7 @@ impl App {
|
||||
/// titles wrap at. Returns what was wrong with the theme's file.
|
||||
fn settings_changed(&mut self, before: &Settings) -> Vec<String> {
|
||||
self.shared.view.max_node_width = self.settings.max_node_width;
|
||||
self.registers.unnamed_plus = self.settings.clipboard == ClipboardUse::UnnamedPlus;
|
||||
if self.settings.theme == before.theme && self.settings.glyphs == before.glyphs {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
+21
-5
@@ -19,12 +19,12 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use super::App;
|
||||
use super::action::{DocAction, Keep, Message, SaveWhich, Tone};
|
||||
use super::buffer::Buffer;
|
||||
use super::documents::{Access, BufferId, DocFile, Documents, Open, Trouble};
|
||||
use super::mode::Mode;
|
||||
use super::popup::Popup;
|
||||
use super::{App, Pane};
|
||||
use crate::config::paths::Paths;
|
||||
use crate::model::{CanvasPos, DocUid, Document, NodeId, Title};
|
||||
use crate::storage::backup::{self, Day};
|
||||
@@ -185,6 +185,7 @@ impl App {
|
||||
if open.buffer.doc().contains(cursor) {
|
||||
open.buffer.apply(DocAction::MoveCursor { to: cursor, jump: false });
|
||||
}
|
||||
open.buffer.keep_marks(place.marks.iter().map(|(&name, &id)| (name, NodeId::new(id))));
|
||||
open.camera =
|
||||
Some(Camera { origin: CanvasPos::new(place.x, place.y), zoom: zoom_named(&place.zoom) });
|
||||
}
|
||||
@@ -216,6 +217,7 @@ impl App {
|
||||
zoom: camera.zoom.name().to_lowercase(),
|
||||
x: camera.origin.x,
|
||||
y: camera.origin.y,
|
||||
marks: open.buffer.marks().iter().map(|(&name, id)| (name, id.get())).collect(),
|
||||
};
|
||||
self.memory.places.insert(open.buffer.doc().uid().to_string(), place);
|
||||
}
|
||||
@@ -556,7 +558,7 @@ impl App {
|
||||
let next = self.shared.docs.at(index.saturating_sub(1));
|
||||
let camera = self.shared.docs.get(next).camera;
|
||||
for (_, pane) in self.panes.iter_mut().filter(|(_, pane)| pane.buffer == id) {
|
||||
*pane = Pane::new(next, pane.view.kind(), camera);
|
||||
pane.show(next, pane.view.kind(), camera);
|
||||
}
|
||||
Some(Message::done(format!("closed {name}")))
|
||||
}
|
||||
@@ -618,9 +620,20 @@ impl App {
|
||||
None
|
||||
}
|
||||
|
||||
/// The focused pane shows document `id`, in the same kind of view. Its
|
||||
/// canvas camera stays with the document it showed.
|
||||
/// The focused pane shows document `id`, in the same kind of view: a
|
||||
/// jump, so `Ctrl-o` comes back.
|
||||
pub(super) fn show(&mut self, id: BufferId) {
|
||||
if self.buffer() != id {
|
||||
let from = self.place_here();
|
||||
self.jumping(from);
|
||||
self.switch_to(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused pane shows document `id`, in the same kind of view. Its
|
||||
/// canvas camera stays with the document it showed, and a selection
|
||||
/// ends.
|
||||
pub(super) fn switch_to(&mut self, id: BufferId) {
|
||||
let pane = self.panes.current_mut();
|
||||
if pane.buffer == id {
|
||||
return;
|
||||
@@ -629,7 +642,10 @@ impl App {
|
||||
self.shared.docs.get_mut(pane.buffer).camera = Some(camera);
|
||||
}
|
||||
let camera = self.shared.docs.get(id).camera;
|
||||
*pane = Pane::new(id, pane.view.kind(), camera);
|
||||
pane.show(id, pane.view.kind(), camera);
|
||||
if let Mode::Visual(_) = self.shared.mode {
|
||||
self.shared.mode = Mode::Normal;
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for a change refused because the document is read-only.
|
||||
|
||||
+36
-16
@@ -6,7 +6,7 @@ use ratatui::layout::Position;
|
||||
|
||||
use super::action::{Action, AppAction, DocumentChoice, Message};
|
||||
use super::mode::Mode;
|
||||
use super::{App, calls, commands};
|
||||
use super::{App, calls, commands, visual};
|
||||
use crate::command::parse::{Sources, parse};
|
||||
use crate::config::settings::Setting;
|
||||
use crate::config::theme_file;
|
||||
@@ -21,25 +21,39 @@ impl App {
|
||||
/// A key, as typed.
|
||||
pub fn key(&mut self, key: Key) {
|
||||
self.message = None;
|
||||
self.record(key);
|
||||
match self.shared.mode {
|
||||
Mode::Command(_) => return self.command_line_key(key),
|
||||
Mode::Text(_) => return self.text_key(key),
|
||||
Mode::Popup(_) => return self.popup_key(key),
|
||||
Mode::Normal | Mode::Move(_) => {}
|
||||
}
|
||||
match self.keys.press(key, &self.keymap.tables(self.contexts())) {
|
||||
Outcome::Run(typed) => self.dispatch(typed),
|
||||
Outcome::Unknown(keys) => self.message = Some(Message::info(format!("{keys} does nothing here"))),
|
||||
Outcome::Waiting | Outcome::Cancelled => {}
|
||||
Mode::Command(_) => self.command_line_key(key),
|
||||
Mode::Text(_) => self.text_key(key),
|
||||
Mode::Popup(_) => self.popup_key(key),
|
||||
Mode::Normal | Mode::Visual(_) | Mode::Move(_) => {
|
||||
match self.keys.press(key, &self.keymap.tables(self.contexts())) {
|
||||
Outcome::Run(typed) => self.run_typed(typed),
|
||||
Outcome::Unknown(keys) => {
|
||||
self.message = Some(Message::info(format!("{keys} does nothing here")));
|
||||
}
|
||||
Outcome::Waiting | Outcome::Cancelled => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.end_recording();
|
||||
}
|
||||
|
||||
/// A command: the focused view has the first say, the shared commands
|
||||
/// get the rest.
|
||||
fn dispatch(&mut self, typed: Typed) {
|
||||
/// A command: the focused view has the first say, then visual mode's
|
||||
/// commands (in visual mode), then the shared commands.
|
||||
pub(super) fn dispatch(&mut self, typed: Typed) {
|
||||
let selected = self.selection().unwrap_or_default();
|
||||
let pane = self.panes.current_mut();
|
||||
let kind = pane.view.kind();
|
||||
let ctx = self.shared.ctx(pane.buffer);
|
||||
let actions = pane.view.command(&typed, &ctx).or_else(|| commands::shared(&typed, &ctx));
|
||||
let actions = pane
|
||||
.view
|
||||
.command(&typed, &ctx)
|
||||
.or_else(|| match &self.shared.mode {
|
||||
Mode::Visual(visual) => visual::command(&typed, visual, &selected, kind, &ctx),
|
||||
_ => None,
|
||||
})
|
||||
.or_else(|| commands::shared(&typed, &ctx));
|
||||
let actions = actions.unwrap_or_else(|| {
|
||||
let later = format!("\"{}\" comes in a later part of the build", describe(typed.command));
|
||||
vec![Action::say(Message::info(later))]
|
||||
@@ -165,6 +179,12 @@ impl App {
|
||||
self.shared.mode = Mode::Normal;
|
||||
}
|
||||
}
|
||||
// A click without Ctrl ends a selection, as in most apps.
|
||||
if let (Mode::Visual(_), Gesture::Press { ctrl: false, .. } | Gesture::DoublePress { .. }) =
|
||||
(&self.shared.mode, gesture)
|
||||
{
|
||||
self.shared.mode = Mode::Normal;
|
||||
}
|
||||
if let Mode::Popup(_) = self.shared.mode {
|
||||
let Mode::Popup(mut popup) = std::mem::take(&mut self.shared.mode) else { return };
|
||||
let actions = popup.mouse(gesture, &self.shared.ctx(self.buffer()));
|
||||
@@ -195,10 +215,10 @@ impl App {
|
||||
}
|
||||
if target != self.panes.focused() {
|
||||
// Another pane: a text edited in this one is done first, and
|
||||
// move mode ends.
|
||||
// move or visual mode ends.
|
||||
let ctx = self.shared.ctx(self.buffer());
|
||||
let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default();
|
||||
if let Mode::Move(_) = self.shared.mode {
|
||||
if let Mode::Move(_) | Mode::Visual(_) = self.shared.mode {
|
||||
actions.push(Action::App(AppAction::LeaveMode));
|
||||
}
|
||||
self.update(actions);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! The jump list, as vim keeps one for each window: where the cursor was
|
||||
//! before each jump (`gg`, `G`, a mark, a search, `gd`, another document),
|
||||
//! so `Ctrl-o` goes back through them and `Ctrl-i` (or Tab) forward again.
|
||||
//! Each pane has its own; a new pane starts with a copy of its first
|
||||
//! pane's. A place in another document opens it, even after it was closed.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::action::{DocAction, Message};
|
||||
use super::{App, files};
|
||||
use crate::model::{DocUid, NodeId};
|
||||
|
||||
/// How many places a jump list keeps.
|
||||
const MOST: usize = 100;
|
||||
|
||||
/// A place to jump back to: a node in a document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Place {
|
||||
pub uid: DocUid,
|
||||
/// The document's file, to open it again if it was closed.
|
||||
pub path: PathBuf,
|
||||
pub node: NodeId,
|
||||
}
|
||||
|
||||
/// A pane's jump list: the places jumped from, oldest first, and where
|
||||
/// `Ctrl-o` and `Ctrl-i` are in it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Jumps {
|
||||
places: Vec<Place>,
|
||||
/// Where `Ctrl-o` and `Ctrl-i` are: past the end until they're used.
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl Jumps {
|
||||
/// A jump from `from`: it goes at the end (an older entry for the same
|
||||
/// place goes away), and `Ctrl-o` starts again from there.
|
||||
pub fn push(&mut self, from: Place) {
|
||||
self.places.retain(|place| *place != from);
|
||||
self.places.push(from);
|
||||
if self.places.len() > MOST {
|
||||
self.places.remove(0);
|
||||
}
|
||||
self.at = self.places.len();
|
||||
}
|
||||
|
||||
/// `Ctrl-o` from `here`: the place `times` back, if there's one. The
|
||||
/// first time, `here` goes at the end, so `Ctrl-i` can come back to it.
|
||||
pub fn back(&mut self, here: Place, times: usize) -> Option<Place> {
|
||||
if self.at >= self.places.len() {
|
||||
if self.places.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.places.retain(|place| *place != here);
|
||||
self.places.push(here);
|
||||
self.at = self.places.len() - 1;
|
||||
}
|
||||
self.at = self.at.checked_sub(times)?;
|
||||
self.places.get(self.at).cloned()
|
||||
}
|
||||
|
||||
/// `Ctrl-i`: the place `times` forward, if there's one.
|
||||
pub fn forward(&mut self, times: usize) -> Option<Place> {
|
||||
let to = self.at.checked_add(times).filter(|&to| to < self.places.len())?;
|
||||
self.at = to;
|
||||
self.places.get(to).cloned()
|
||||
}
|
||||
|
||||
/// The places, oldest first, and which one `Ctrl-o` and `Ctrl-i` are on
|
||||
/// (none until they're used), for `:jumps`.
|
||||
pub fn list(&self) -> (&[Place], Option<usize>) {
|
||||
(&self.places, (self.at < self.places.len()).then_some(self.at))
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Where the cursor is in the focused pane, as a place to come back to.
|
||||
pub(super) fn place_here(&self) -> Place {
|
||||
let open = self.shared.docs.get(self.buffer());
|
||||
Place { uid: open.buffer.doc().uid(), path: open.file.path.clone(), node: open.buffer.cursor() }
|
||||
}
|
||||
|
||||
/// A jump is about to happen from where the cursor is: the focused
|
||||
/// pane's list keeps the place.
|
||||
pub(super) fn jumping(&mut self, from: Place) {
|
||||
self.panes.current_mut().jumps.push(from);
|
||||
}
|
||||
|
||||
/// `Ctrl-o` (`back`) and `Ctrl-i`.
|
||||
pub(super) fn travel(&mut self, back: bool, times: u32) -> Option<Message> {
|
||||
let times = usize::try_from(times.max(1)).unwrap_or(1);
|
||||
let here = self.place_here();
|
||||
let jumps = &mut self.panes.current_mut().jumps;
|
||||
let place = if back { jumps.back(here, times) } else { jumps.forward(times) };
|
||||
match place {
|
||||
Some(place) => self.go_to(&place),
|
||||
None if back => Some(Message::info("no older jump: that's where the jump list starts")),
|
||||
None => Some(Message::info("no newer jump: Ctrl-o goes back")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts the focused pane on a place: its document (opened again if it
|
||||
/// was closed), and its node. Not a jump itself.
|
||||
pub(super) fn go_to(&mut self, place: &Place) -> Option<Message> {
|
||||
let id = match self.shared.docs.find_uid(place.uid) {
|
||||
Some(id) => id,
|
||||
None => match files::load(&place.path) {
|
||||
Ok(loaded) if loaded.doc.uid() == place.uid => self.add_document(loaded).0,
|
||||
Ok(_) => {
|
||||
return Some(Message::problem(format!(
|
||||
"{} holds another document now",
|
||||
place.path.display()
|
||||
)));
|
||||
}
|
||||
Err(error) => return Some(Message::problem(error.to_string())),
|
||||
},
|
||||
};
|
||||
self.switch_to(id);
|
||||
if !self.current().doc().contains(place.node) {
|
||||
return Some(Message::info("that node is gone: here's its document"));
|
||||
}
|
||||
self.doc_action(DocAction::MoveCursor { to: place.node, jump: false })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn place(node: u32) -> Place {
|
||||
Place {
|
||||
uid: DocUid::parse("0000000000000001").unwrap(),
|
||||
path: PathBuf::from("a.cum"),
|
||||
node: NodeId::new(node),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_and_forward_as_in_vim() {
|
||||
let mut jumps = Jumps::default();
|
||||
assert_eq!(jumps.back(place(9), 1), None, "nothing to go back to");
|
||||
jumps.push(place(1));
|
||||
jumps.push(place(2));
|
||||
// From 3: back to 2, then 1; forward to 2, then 3 (kept by the first
|
||||
// Ctrl-o), and no further.
|
||||
assert_eq!(jumps.back(place(3), 1), Some(place(2)));
|
||||
assert_eq!(jumps.back(place(2), 1), Some(place(1)));
|
||||
assert_eq!(jumps.back(place(1), 1), None);
|
||||
assert_eq!(jumps.forward(1), Some(place(2)));
|
||||
assert_eq!(jumps.forward(1), Some(place(3)));
|
||||
assert_eq!(jumps.forward(1), None);
|
||||
// A new jump goes at the end, without its older copy.
|
||||
jumps.push(place(1));
|
||||
assert_eq!(jumps.list().0, [place(2), place(3), place(1)]);
|
||||
assert_eq!(jumps.back(place(4), 2), Some(place(3)), "a count goes further");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Capital marks (`mA`, `'A`): marks that lead into any document, kept in
|
||||
//! `memory.ron` so they last from one run to the next, as vim's file marks
|
||||
//! do. The small ones (`ma`) belong to their document: the buffer keeps
|
||||
//! them, and `memory.ron` keeps them with where you left off.
|
||||
//!
|
||||
//! Also here: the lists `:marks`, `:jumps` and `:registers` open, and the
|
||||
//! one `gd` opens on a node with several cross-links.
|
||||
|
||||
use ratatui::layout::Position;
|
||||
|
||||
use super::App;
|
||||
use super::action::Message;
|
||||
use super::jumps::Place;
|
||||
use super::mode::Mode;
|
||||
use super::popup::{ListedMark, Popup};
|
||||
use crate::model::{DocUid, NodeId};
|
||||
use crate::storage::memory::FileMark;
|
||||
|
||||
impl App {
|
||||
/// `mA`: a capital mark on the cursor's node.
|
||||
pub(super) fn set_file_mark(&mut self, name: char) -> Option<Message> {
|
||||
let open = self.shared.docs.get(self.buffer());
|
||||
let doc = open.buffer.doc();
|
||||
let cursor = open.buffer.cursor();
|
||||
let mark = FileMark { path: open.file.path.clone(), uid: doc.uid().to_string(), node: cursor.get() };
|
||||
let title = doc.node(cursor).map(|node| node.title().to_string()).unwrap_or_default();
|
||||
let text = format!(
|
||||
"mark {name} is on \"{title}\" in {}: '{name} comes back here from any document",
|
||||
open.name()
|
||||
);
|
||||
self.memory.marks.insert(name, mark);
|
||||
Some(Message::done(text))
|
||||
}
|
||||
|
||||
/// `'A`: to a capital mark, in its document (opened if it isn't open).
|
||||
/// A jump: `Ctrl-o` comes back.
|
||||
pub(super) fn jump_to_file_mark(&mut self, name: char) -> Option<Message> {
|
||||
let Some(mark) = self.memory.marks.get(&name).cloned() else {
|
||||
return Some(Message::info(format!("no mark {name} yet: m{name} sets it")));
|
||||
};
|
||||
let Some(uid) = DocUid::parse(&mark.uid) else {
|
||||
return Some(Message::problem(format!("mark {name} is broken: m{name} sets it again")));
|
||||
};
|
||||
self.jump_to_place(&Place { uid, path: mark.path, node: NodeId::new(mark.node) })
|
||||
}
|
||||
|
||||
/// `:marks`: this document's marks, then the capital ones.
|
||||
pub(super) fn open_marks(&mut self) {
|
||||
let doc = self.current().doc();
|
||||
let small = self
|
||||
.current()
|
||||
.marks()
|
||||
.iter()
|
||||
.filter_map(|(&name, &id)| {
|
||||
Some(ListedMark { name, node: doc.node(id)?.title().to_string(), document: None })
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let capital = self
|
||||
.memory
|
||||
.marks
|
||||
.iter()
|
||||
.map(|(&name, mark)| {
|
||||
let open = DocUid::parse(&mark.uid).and_then(|uid| self.shared.docs.find_uid(uid));
|
||||
let (node, document) = match open {
|
||||
Some(id) => {
|
||||
let open = self.shared.docs.get(id);
|
||||
let node = open.buffer.doc().node(NodeId::new(mark.node));
|
||||
(
|
||||
node.map_or_else(|| "(gone)".to_string(), |node| node.title().to_string()),
|
||||
open.name(),
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let file = mark.path.file_name().map(|name| name.to_string_lossy().into_owned());
|
||||
(format!("node {}", mark.node), file.unwrap_or_default())
|
||||
}
|
||||
};
|
||||
ListedMark { name, node, document: Some(document) }
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.shared.mode = Mode::Popup(Popup::marks(&small, &capital, &self.shared.theme));
|
||||
}
|
||||
|
||||
/// `:jumps`: the focused pane's jump list.
|
||||
pub(super) fn open_jumps(&mut self) {
|
||||
let (places, at) = self.panes.current().jumps.list();
|
||||
let rows: Vec<(String, Place)> = places
|
||||
.iter()
|
||||
.map(|place| {
|
||||
let open = self.shared.docs.find_uid(place.uid).map(|id| self.shared.docs.get(id));
|
||||
let text = match open {
|
||||
Some(open) => {
|
||||
let node = open.buffer.doc().node(place.node).map(|node| node.title().to_string());
|
||||
format!("{} › {}", open.name(), node.unwrap_or_else(|| "(gone)".to_string()))
|
||||
}
|
||||
None => format!("{} › node {}", place.path.display(), place.node),
|
||||
};
|
||||
(text, place.clone())
|
||||
})
|
||||
.collect();
|
||||
self.shared.mode = Mode::Popup(Popup::jumps(&rows, at, &self.shared.theme));
|
||||
}
|
||||
|
||||
/// `:registers`: what the registers hold.
|
||||
pub(super) fn open_registers(&mut self) {
|
||||
let rows: Vec<(char, String)> =
|
||||
self.registers.list().into_iter().map(|(name, kept)| (name, kept.describe())).collect();
|
||||
self.shared.mode = Mode::Popup(Popup::registers(&rows, &self.shared.theme));
|
||||
}
|
||||
|
||||
/// `gd` on a node with several cross-links: a list of them, beside it.
|
||||
pub(super) fn open_links(&mut self) {
|
||||
let ctx = self.shared.ctx(self.buffer());
|
||||
let at = self
|
||||
.panes
|
||||
.current()
|
||||
.view
|
||||
.cursor_area(&ctx)
|
||||
.map_or(Position::new(self.screen.panes.x + 2, self.screen.panes.y + 1), |area| {
|
||||
Position::new(area.x, area.bottom())
|
||||
});
|
||||
let popup = Popup::links(&ctx, at);
|
||||
self.shared.mode = Mode::Popup(popup);
|
||||
}
|
||||
|
||||
/// Goes to a place (a capital mark's, or one picked in `:jumps`): a
|
||||
/// jump, so `Ctrl-o` comes back.
|
||||
pub(super) fn jump_to_place(&mut self, place: &Place) -> Option<Message> {
|
||||
let from = self.place_here();
|
||||
let said = self.go_to(place);
|
||||
if self.place_here() != from {
|
||||
self.jumping(from);
|
||||
}
|
||||
said
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -21,20 +21,27 @@
|
||||
pub mod action;
|
||||
pub mod buffer;
|
||||
pub mod calls;
|
||||
mod clipboard;
|
||||
pub mod commands;
|
||||
mod configure;
|
||||
pub mod documents;
|
||||
mod files;
|
||||
mod input;
|
||||
mod jumps;
|
||||
mod marks;
|
||||
pub mod mode;
|
||||
pub mod panes;
|
||||
pub mod popup;
|
||||
pub mod registers;
|
||||
mod render;
|
||||
mod repeat;
|
||||
pub mod search;
|
||||
pub mod startup;
|
||||
mod terminal;
|
||||
pub mod text_edit;
|
||||
mod update;
|
||||
pub mod visual;
|
||||
mod yank;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
@@ -47,7 +54,7 @@ pub use terminal::run;
|
||||
use crate::command::history::History as CommandHistory;
|
||||
use crate::config::Config;
|
||||
use crate::config::paths::Paths;
|
||||
use crate::config::settings::Settings;
|
||||
use crate::config::settings::{ClipboardUse, Settings};
|
||||
use crate::input::bindings::Context;
|
||||
use crate::input::engine::KeyEngine;
|
||||
use crate::input::keymap::Keymap;
|
||||
@@ -61,6 +68,7 @@ use crate::view::{View, ViewContext, ViewKind, ViewSettings};
|
||||
use action::Message;
|
||||
use buffer::Buffer;
|
||||
use documents::{BufferId, Documents};
|
||||
use jumps::Jumps;
|
||||
use mode::Mode;
|
||||
use panes::{PaneId, Panes};
|
||||
use registers::Registers;
|
||||
@@ -84,6 +92,14 @@ pub struct App {
|
||||
memory: Memory,
|
||||
/// The status bar's message, until the next key.
|
||||
message: Option<Message>,
|
||||
/// The last change, for `.`.
|
||||
last_change: Option<repeat::Repeat>,
|
||||
/// A text edit started by a key command, while it goes on.
|
||||
recording: Option<repeat::Recording>,
|
||||
/// `.` is making a change again: it isn't recorded again.
|
||||
repeating: bool,
|
||||
/// The last search, for `n` and `N`.
|
||||
last_search: Option<String>,
|
||||
screen: Screen,
|
||||
/// The pane a mouse drag started in: it gets the whole drag.
|
||||
dragging: Option<PaneId>,
|
||||
@@ -126,10 +142,11 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pane: one of the documents, through a view.
|
||||
/// A pane: one of the documents, through a view, and where it jumped from.
|
||||
struct Pane {
|
||||
buffer: BufferId,
|
||||
view: Box<dyn View>,
|
||||
jumps: Jumps,
|
||||
}
|
||||
|
||||
impl Pane {
|
||||
@@ -143,7 +160,14 @@ impl Pane {
|
||||
if let Some(camera) = camera {
|
||||
view.set_camera(camera);
|
||||
}
|
||||
Pane { buffer, view }
|
||||
Pane { buffer, view, jumps: Jumps::default() }
|
||||
}
|
||||
|
||||
/// The pane shows `buffer` in a new view (a canvas starting at `camera`);
|
||||
/// its jump list stays.
|
||||
fn show(&mut self, buffer: BufferId, kind: ViewKind, camera: Option<Camera>) {
|
||||
let jumps = std::mem::take(&mut self.jumps);
|
||||
*self = Pane { jumps, ..Pane::new(buffer, kind, camera) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +187,8 @@ impl App {
|
||||
let Config { settings, keymap, theme, problems } = config;
|
||||
let config_stamps = configure::ConfigStamps::read(&paths, &settings.theme);
|
||||
let view = ViewSettings { max_node_width: settings.max_node_width, ..ViewSettings::default() };
|
||||
let mut registers = Registers::default();
|
||||
registers.unnamed_plus = settings.clipboard == ClipboardUse::UnnamedPlus;
|
||||
let (docs, first_said) = files::open_first(first, &paths);
|
||||
let first = docs.at(0);
|
||||
let mut app = App {
|
||||
@@ -173,10 +199,14 @@ impl App {
|
||||
settings,
|
||||
keymap,
|
||||
keys: KeyEngine::default(),
|
||||
registers: Registers::default(),
|
||||
registers,
|
||||
mouse: MouseReader::default(),
|
||||
memory,
|
||||
message: None,
|
||||
last_change: None,
|
||||
recording: None,
|
||||
repeating: false,
|
||||
last_search: None,
|
||||
screen: Screen::default(),
|
||||
dragging: None,
|
||||
watched: Instant::now(),
|
||||
|
||||
+40
-3
@@ -2,9 +2,11 @@
|
||||
//! what it needs, so a mode's state can't outlive it (there's no "move mode
|
||||
//! flag" to forget to clear).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::popup::Popup;
|
||||
use super::text_edit::{Field, TextEdit};
|
||||
use crate::model::Group;
|
||||
use crate::model::{Group, NodeId};
|
||||
use crate::ui::command_line::CommandLine;
|
||||
|
||||
/// The app's mode.
|
||||
@@ -16,6 +18,8 @@ pub enum Mode {
|
||||
/// 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),
|
||||
/// Selecting nodes: what the cursor passes over from where it started.
|
||||
Visual(Visual),
|
||||
/// Moving the cursor's node (or its branch) with hjkl.
|
||||
Move(Grab),
|
||||
/// Typing a `:` command.
|
||||
@@ -25,19 +29,50 @@ pub enum Mode {
|
||||
Popup(Popup),
|
||||
}
|
||||
|
||||
/// Visual mode's state. What's selected also depends on the view (a
|
||||
/// rectangle on the canvas, rows in the outline): see
|
||||
/// [`visual::selection`](super::visual::selection).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Visual {
|
||||
/// Where the selection started. The cursor is its other end; `o` swaps
|
||||
/// them.
|
||||
pub anchor: NodeId,
|
||||
/// `V`: each node with its whole branch. `v`: just the nodes.
|
||||
pub branches: bool,
|
||||
/// Nodes a Ctrl+click or a Ctrl+drag added, on top of what the two ends
|
||||
/// take in.
|
||||
pub added: BTreeSet<NodeId>,
|
||||
/// Nodes a Ctrl+click took out.
|
||||
pub removed: BTreeSet<NodeId>,
|
||||
}
|
||||
|
||||
impl Visual {
|
||||
/// Selecting from `anchor`: `v`, or with `branches`, `V`.
|
||||
pub fn new(anchor: NodeId, branches: bool) -> Visual {
|
||||
Visual { anchor, branches, added: BTreeSet::new(), removed: BTreeSet::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Move mode's state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Grab {
|
||||
/// The whole branch moves, or just the node.
|
||||
pub whole: bool,
|
||||
/// Makes every move one undo step.
|
||||
pub group: Group,
|
||||
/// The nodes a selection put in move mode; empty, the cursor's node.
|
||||
pub nodes: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Grab {
|
||||
/// Moving a whole branch, as move mode starts.
|
||||
pub fn new() -> Grab {
|
||||
Grab { whole: true, group: Group::new() }
|
||||
Grab { whole: true, group: Group::new(), nodes: Vec::new() }
|
||||
}
|
||||
|
||||
/// Moving the nodes of a selection, each with its branch to start with.
|
||||
pub fn nodes(nodes: Vec<NodeId>) -> Grab {
|
||||
Grab { nodes, ..Grab::new() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +119,8 @@ impl Mode {
|
||||
Mode::Normal => "NORMAL",
|
||||
Mode::Text(edit) if edit.editor.is_typing() => "INSERT",
|
||||
Mode::Text(_) => "TEXT",
|
||||
Mode::Visual(Visual { branches: false, .. }) => "VISUAL",
|
||||
Mode::Visual(_) => "VISUAL BRANCH",
|
||||
Mode::Move(_) => "MOVE",
|
||||
Mode::Command(_) => "COMMAND",
|
||||
Mode::Popup(popup) => popup.label(),
|
||||
|
||||
+186
-26
@@ -17,10 +17,13 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::action::{Action, AppAction, DocumentChoice, Keep, Message};
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, DocumentChoice, Keep, Message};
|
||||
use super::commands;
|
||||
use super::documents::BufferId;
|
||||
use super::jumps::Place as JumpPlace;
|
||||
use super::registers::Registers;
|
||||
use super::repeat::Look;
|
||||
use super::search::Search;
|
||||
use super::text_edit::{EditAt, StartEdit, TextEdit};
|
||||
use crate::command::parse::border;
|
||||
use crate::command::registry::{BORDERS, SPECS};
|
||||
@@ -31,7 +34,9 @@ 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::model::{
|
||||
Border, CanvasPos, ColorName, Content, Direction, EditOp, Group, NodeId, StyleName, Tag, Tags,
|
||||
};
|
||||
use crate::ui::card::{CardText, CardView};
|
||||
use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place};
|
||||
use crate::ui::theme::Theme;
|
||||
@@ -59,6 +64,8 @@ pub enum Popup {
|
||||
Ask(Runs),
|
||||
Pick(Picker),
|
||||
Card(Card),
|
||||
/// `/`: finding a node.
|
||||
Search(Search),
|
||||
}
|
||||
|
||||
/// What the menu is for: a node, or a spot on the empty canvas.
|
||||
@@ -110,18 +117,23 @@ enum Run {
|
||||
Ask(String),
|
||||
/// A new loose idea, here on the canvas.
|
||||
NewIdeaAt(CanvasPos),
|
||||
/// A jump to a node of this document (`gd`'s list).
|
||||
Jump(NodeId),
|
||||
/// An action of the app's: an answer to a question.
|
||||
App(AppAction),
|
||||
}
|
||||
|
||||
/// A picker: the node it sets something on, and each row's value.
|
||||
/// A picker: the nodes it sets something on (the cursor's, or a
|
||||
/// selection), and each row's value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Picker {
|
||||
pub list: ListPopup,
|
||||
node: NodeId,
|
||||
nodes: Vec<NodeId>,
|
||||
values: Vec<Pick>,
|
||||
/// The node's tags as they are now, in the tags picker.
|
||||
/// The tags every one of the nodes has now, in the tags picker.
|
||||
tags: Tags,
|
||||
/// Makes the changes to several nodes one undo step.
|
||||
group: Group,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -284,17 +296,22 @@ impl Popup {
|
||||
Popup::Menu(Runs { list, runs })
|
||||
}
|
||||
|
||||
/// A picker for the cursor's node (or, for documents, for the pane).
|
||||
/// A picker for some nodes: the cursor's, or a selection's (or, for
|
||||
/// documents, for the pane). It starts on what the first node has.
|
||||
/// `documents` are the open documents, then the ones opened lately, and
|
||||
/// `current` is the one the pane shows, by its place among them.
|
||||
pub fn picker(
|
||||
kind: PickKind,
|
||||
ctx: &ViewContext<'_>,
|
||||
nodes: &[NodeId],
|
||||
documents: &[DocumentRow],
|
||||
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 all: Vec<&crate::model::Node> = nodes.iter().filter_map(|&id| doc.node(id)).collect();
|
||||
let node = *all.first().ok_or_else(|| Message::problem("that node is gone"))?;
|
||||
// What the picker's title says it's for: a title, or how many.
|
||||
let what = if all.len() == 1 { node.title().to_string() } else { format!("{} nodes", all.len()) };
|
||||
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);
|
||||
@@ -326,7 +343,7 @@ impl Popup {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(format!("color · {}", node.title()), values, rows)
|
||||
(format!("color · {what}"), values, rows)
|
||||
}
|
||||
PickKind::Border => {
|
||||
let mut values = vec![Pick::Border(None)];
|
||||
@@ -343,7 +360,7 @@ impl Popup {
|
||||
rows.push(row(spans, choice.word, on));
|
||||
values.push(Pick::Border(Some(kind)));
|
||||
}
|
||||
(format!("border · {}", node.title()), values, rows)
|
||||
(format!("border · {what}"), values, rows)
|
||||
}
|
||||
PickKind::Style => {
|
||||
let mut values = vec![Pick::Style(None)];
|
||||
@@ -359,10 +376,16 @@ impl Popup {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(format!("style · {}", node.title()), values, rows)
|
||||
(format!("style · {what}"), values, rows)
|
||||
}
|
||||
PickKind::Tags => {
|
||||
tags = node.tags.clone();
|
||||
// Checked: the tags every node has.
|
||||
tags = node
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| all.iter().all(|other| other.tags.contains(tag)))
|
||||
.cloned()
|
||||
.collect();
|
||||
// Every tag in the document, and the theme's.
|
||||
let mut all: Vec<Tag> = doc
|
||||
.outline()
|
||||
@@ -377,7 +400,7 @@ impl Popup {
|
||||
.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)
|
||||
(format!("tags · {what}"), all.into_iter().map(Pick::Tag).collect(), rows)
|
||||
}
|
||||
PickKind::Document => {
|
||||
let (mut values, mut rows) = (Vec::new(), Vec::new());
|
||||
@@ -410,7 +433,111 @@ impl Popup {
|
||||
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 }))
|
||||
Ok(Popup::Pick(Picker { list, nodes: nodes.to_vec(), values, tags, group: Group::new() }))
|
||||
}
|
||||
|
||||
/// `:marks`: this document's marks, then the capital ones. Enter goes
|
||||
/// to the mark.
|
||||
pub fn marks(small: &[ListedMark], capital: &[ListedMark], theme: &Theme) -> Popup {
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
let key = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD);
|
||||
let (mut rows, mut runs) = (Vec::new(), Vec::new());
|
||||
for (heading, marks) in [("this document", small), ("across documents (A–Z)", capital)] {
|
||||
if marks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
rows.push(ListRow::heading(heading, muted));
|
||||
runs.push(Run::Nothing);
|
||||
for mark in marks {
|
||||
let mut spans =
|
||||
vec![Span::styled(format!("{} ", mark.name), key), Span::raw(mark.node.clone())];
|
||||
if let Some(document) = &mark.document {
|
||||
spans.push(Span::styled(format!(" {document}"), muted));
|
||||
}
|
||||
rows.push(ListRow::new(spans, format!("{} {}", mark.name, mark.node)));
|
||||
runs.push(Run::Keys(vec![Key::char('\''), Key::char(mark.name)]));
|
||||
}
|
||||
}
|
||||
if rows.is_empty() {
|
||||
rows.push(ListRow::heading("no marks yet: m then a letter sets one", muted));
|
||||
runs.push(Run::Nothing);
|
||||
}
|
||||
let list = ListPopup::new(
|
||||
"marks",
|
||||
"Enter: go there · Esc: close",
|
||||
rows,
|
||||
Place::Center { width: CONFLICT_WIDTH },
|
||||
);
|
||||
Popup::Menu(Runs { list, runs })
|
||||
}
|
||||
|
||||
/// `:jumps`: the pane's jump list, oldest first, with where `Ctrl-o` and
|
||||
/// `Ctrl-i` are. Enter goes there.
|
||||
pub fn jumps(places: &[(String, JumpPlace)], at: Option<usize>, theme: &Theme) -> Popup {
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
let (mut rows, mut runs) = (Vec::new(), Vec::new());
|
||||
for (index, (text, place)) in places.iter().enumerate() {
|
||||
let mut row = ListRow::new(vec![Span::raw(text.clone())], text.as_str());
|
||||
if Some(index) == at {
|
||||
row = row.with_note(Span::styled("here", Style::new().fg(theme.accent)));
|
||||
}
|
||||
rows.push(row);
|
||||
runs.push(Run::App(AppAction::GoToPlace(place.clone())));
|
||||
}
|
||||
if rows.is_empty() {
|
||||
rows.push(ListRow::heading("no jumps yet: gg, G, marks, / and gd are jumps", muted));
|
||||
runs.push(Run::Nothing);
|
||||
}
|
||||
let footer = "Enter: go there · Ctrl-o / Ctrl-i: back / forward";
|
||||
let mut list = ListPopup::new("jumps", footer, rows, Place::Center { width: CONFLICT_WIDTH });
|
||||
list.select(at.unwrap_or(places.len().saturating_sub(1)));
|
||||
Popup::Menu(Runs { list, runs })
|
||||
}
|
||||
|
||||
/// `:registers`: what each register holds. Enter pastes it after the
|
||||
/// cursor's node.
|
||||
pub fn registers(registers: &[(char, String)], theme: &Theme) -> Popup {
|
||||
let key = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD);
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
let (mut rows, mut runs) = (Vec::new(), Vec::new());
|
||||
let named = registers.iter().map(|(name, what)| (*name, what.clone()));
|
||||
for (name, what) in named.chain(std::iter::once(('+', "the system clipboard".to_string()))) {
|
||||
let style = if name == '+' { muted } else { Style::new() };
|
||||
let spans = vec![Span::styled(format!("\"{name} "), key), Span::styled(what.clone(), style)];
|
||||
rows.push(ListRow::new(spans, what));
|
||||
runs.push(Run::Keys(vec![Key::char('"'), Key::char(name), Key::char('p')]));
|
||||
}
|
||||
let footer = "Enter: paste it after the cursor's node · Esc: close";
|
||||
let list = ListPopup::new("registers", footer, rows, Place::Center { width: CONFLICT_WIDTH });
|
||||
Popup::Menu(Runs { list, runs })
|
||||
}
|
||||
|
||||
/// `gd` on a node with several cross-links: the nodes they lead to (out,
|
||||
/// then in), beside the node. Enter goes there.
|
||||
pub fn links(ctx: &ViewContext<'_>, at: Position) -> Popup {
|
||||
let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs);
|
||||
let muted = Style::new().fg(theme.muted);
|
||||
let mut links: Vec<(bool, NodeId, String)> = doc
|
||||
.links_of(ctx.cursor)
|
||||
.map(|link| {
|
||||
let out = link.start() == ctx.cursor;
|
||||
let other = if out { link.end() } else { link.start() };
|
||||
(out, other, theme.link_label(link).unwrap_or_default().to_string())
|
||||
})
|
||||
.collect();
|
||||
links.sort_by_key(|&(out, _, _)| !out);
|
||||
let (mut rows, mut runs) = (Vec::new(), Vec::new());
|
||||
for (out, other, label) in links {
|
||||
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));
|
||||
}
|
||||
rows.push(ListRow::new(spans, title));
|
||||
runs.push(Run::Jump(other));
|
||||
}
|
||||
let list = ListPopup::new("follow a link", "Enter: go there", rows, Place::At(at));
|
||||
Popup::Menu(Runs { list, runs })
|
||||
}
|
||||
|
||||
/// The `K` card for a node.
|
||||
@@ -461,6 +588,7 @@ impl Popup {
|
||||
Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => "INSERT",
|
||||
Popup::Card(Card { edit: Some(_), .. }) => "TEXT",
|
||||
Popup::Card(_) => "CARD",
|
||||
Popup::Search(_) => "SEARCH",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +599,8 @@ impl Popup {
|
||||
Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => &[Context::Insert],
|
||||
Popup::Card(Card { edit: Some(_), .. }) => &[Context::Text],
|
||||
Popup::Card(_) => &[Context::Card],
|
||||
// Every key goes straight into it.
|
||||
Popup::Search(_) => &[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +611,7 @@ impl Popup {
|
||||
Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(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()),
|
||||
Popup::Search(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,6 +639,7 @@ impl Popup {
|
||||
let bound = keymap.tables(&[Context::Insert]).bound(&[key]);
|
||||
return card.edit.as_mut().map(|edit| edit.typing(key, bound, ctx)).unwrap_or_default();
|
||||
}
|
||||
Popup::Search(search) => return search.key(key, ctx),
|
||||
};
|
||||
self.outcome(outcome, ctx)
|
||||
}
|
||||
@@ -523,6 +655,7 @@ impl Popup {
|
||||
Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => runs.list.command(typed),
|
||||
Popup::Pick(picker) => picker.list.command(typed),
|
||||
Popup::Card(card) => return card.command(typed, registers, ctx),
|
||||
Popup::Search(_) => return Vec::new(),
|
||||
};
|
||||
self.outcome(outcome, ctx)
|
||||
}
|
||||
@@ -533,6 +666,7 @@ impl Popup {
|
||||
Popup::Help(runs) | Popup::Menu(runs) | Popup::Ask(runs) => &mut runs.list,
|
||||
Popup::Pick(picker) => &mut picker.list,
|
||||
Popup::Card(card) => return card.mouse(gesture, ctx),
|
||||
Popup::Search(search) => return search.mouse(gesture),
|
||||
};
|
||||
let outcome = match gesture {
|
||||
Gesture::Press { at, .. } => list.click(position(at)),
|
||||
@@ -560,6 +694,7 @@ impl Popup {
|
||||
card.view.layout(screen, anchor, &text);
|
||||
}
|
||||
}
|
||||
Popup::Search(search) => search.layout(screen),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +707,7 @@ impl Popup {
|
||||
card.view.render(frame, &text, ctx.theme);
|
||||
}
|
||||
}
|
||||
Popup::Search(search) => search.render(frame, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +743,16 @@ const NODE_MENU: &[(&str, Command)] = &[
|
||||
("delete it with its branch", Command::Operate(Operator::Delete, Target::Current)),
|
||||
];
|
||||
|
||||
/// A mark, as `:marks` lists it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListedMark {
|
||||
pub name: char,
|
||||
/// The title of the node it's on.
|
||||
pub node: String,
|
||||
/// For a capital mark: its document.
|
||||
pub document: Option<String>,
|
||||
}
|
||||
|
||||
/// A row of the help, before it's laid out.
|
||||
struct Entry {
|
||||
section: String,
|
||||
@@ -703,6 +849,7 @@ impl Runs {
|
||||
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),
|
||||
Run::Jump(id) => vec![Action::jump(*id)],
|
||||
Run::App(action) => vec![Action::App(action.clone())],
|
||||
};
|
||||
let mut actions = leave();
|
||||
@@ -712,15 +859,12 @@ impl Runs {
|
||||
}
|
||||
|
||||
impl Picker {
|
||||
/// Picks a row. A tag goes on or off and the picker stays open; the
|
||||
/// rest close it.
|
||||
/// Picks a row. A tag goes on or off (on every node) and the picker
|
||||
/// stays open; the rest close it. Setting several nodes is one undo
|
||||
/// step.
|
||||
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() },
|
||||
let look = match self.values.get(index) {
|
||||
None | Some(Pick::Heading) => return Vec::new(),
|
||||
Some(Pick::Document(at)) => {
|
||||
let mut actions = leave();
|
||||
actions.push(Action::App(AppAction::SwitchDocument(DocumentChoice::At(*at))));
|
||||
@@ -732,7 +876,7 @@ impl Picker {
|
||||
actions.push(Action::App(AppAction::Edit { path, force: false }));
|
||||
return actions;
|
||||
}
|
||||
Some(Pick::Heading) => return Vec::new(),
|
||||
// A tag goes on or off, and the picker stays open.
|
||||
Some(Pick::Tag(tag)) => {
|
||||
let tag = tag.clone();
|
||||
let on = !self.tags.contains(&tag);
|
||||
@@ -742,15 +886,31 @@ impl Picker {
|
||||
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 })];
|
||||
return self.set(Look::Tag { tag, on });
|
||||
}
|
||||
Some(Pick::Color(color)) => Look::Color(color.clone()),
|
||||
Some(Pick::Border(border)) => Look::Border(*border),
|
||||
Some(Pick::Style(style)) => Look::Style(style.clone()),
|
||||
};
|
||||
let mut actions = leave();
|
||||
actions.push(Action::edit(op));
|
||||
actions.extend(self.set(look));
|
||||
actions
|
||||
}
|
||||
|
||||
/// A look put on every node, and kept for `.`.
|
||||
fn set(&self, look: Look) -> Vec<Action> {
|
||||
let mut actions: Vec<Action> = self.nodes.iter().map(|&id| self.edit(look.op(id))).collect();
|
||||
actions.push(Action::App(AppAction::Picked(look)));
|
||||
actions
|
||||
}
|
||||
|
||||
/// A change to one of the nodes: the changes to several are one undo
|
||||
/// step.
|
||||
fn edit(&self, op: EditOp) -> Action {
|
||||
let group = (self.nodes.len() > 1).then_some(self.group);
|
||||
Action::Doc(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Stay, group })
|
||||
}
|
||||
|
||||
/// 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(_))) {
|
||||
@@ -762,7 +922,7 @@ impl Picker {
|
||||
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() })]
|
||||
self.set(Look::Tag { tag, on: true })
|
||||
}
|
||||
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
|
||||
}
|
||||
|
||||
+25
-9
@@ -86,14 +86,18 @@ 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 pick = |kind| Popup::picker(kind, &ctx, &[g.soil], &[], 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 })]
|
||||
[
|
||||
app(AppAction::LeaveMode),
|
||||
Action::edit(EditOp::SetColor { id: g.soil, color: teal.clone() }),
|
||||
app(AppAction::Picked(Look::Color(teal))),
|
||||
]
|
||||
);
|
||||
|
||||
let mut borders = pick(PickKind::Border);
|
||||
@@ -101,7 +105,11 @@ fn pickers_set_what_is_picked() {
|
||||
let dashed = Some(Border::Dashed);
|
||||
assert_eq!(
|
||||
enter(&mut borders, &ctx),
|
||||
[app(AppAction::LeaveMode), Action::edit(EditOp::SetBorder { id: g.soil, border: dashed })]
|
||||
[
|
||||
app(AppAction::LeaveMode),
|
||||
Action::edit(EditOp::SetBorder { id: g.soil, border: dashed }),
|
||||
app(AppAction::Picked(Look::Border(dashed))),
|
||||
]
|
||||
);
|
||||
|
||||
// The theme's own named styles.
|
||||
@@ -110,7 +118,11 @@ fn pickers_set_what_is_picked() {
|
||||
let important = StyleName::parse("important").ok();
|
||||
assert_eq!(
|
||||
enter(&mut styles, &ctx),
|
||||
[app(AppAction::LeaveMode), Action::edit(EditOp::SetStyle { id: g.soil, style: important })]
|
||||
[
|
||||
app(AppAction::LeaveMode),
|
||||
Action::edit(EditOp::SetStyle { id: g.soil, style: important.clone() }),
|
||||
app(AppAction::Picked(Look::Style(important))),
|
||||
]
|
||||
);
|
||||
|
||||
// Tags go on and off, and the picker stays open; a new one is added.
|
||||
@@ -120,15 +132,19 @@ fn pickers_set_what_is_picked() {
|
||||
tags.command(&typed(Command::Move(crate::input::command::Motion::Last)), &mut Registers::default(), &ctx);
|
||||
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 off = Action::edit(EditOp::Tag { id: g.soil, add: Vec::new(), remove: vec![wood.clone()] });
|
||||
let picked = |on| app(AppAction::Picked(Look::Tag { tag: wood.clone(), on }));
|
||||
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]);
|
||||
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [on, picked(true)]);
|
||||
assert_eq!(tags.command(&typed(Command::Confirm), &mut registers, &ctx), [off, picked(false)]);
|
||||
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() })]
|
||||
[
|
||||
Action::edit(EditOp::Tag { id: g.soil, add: vec![spring.clone()], remove: Vec::new() }),
|
||||
app(AppAction::Picked(Look::Tag { tag: spring, on: true })),
|
||||
]
|
||||
);
|
||||
|
||||
// Documents: it starts on the one shown; the next one is a j away, and
|
||||
@@ -136,7 +152,7 @@ fn pickers_set_what_is_picked() {
|
||||
let row =
|
||||
|name: &str, open| DocumentRow { name: name.into(), path: format!("/notes/{name}.cum").into(), open };
|
||||
let documents = [row("Garden plans", true), row("Kitchen", true), row("Reading", false)];
|
||||
let mut picker = Popup::picker(PickKind::Document, &ctx, &documents, 0).unwrap();
|
||||
let mut picker = Popup::picker(PickKind::Document, &ctx, &[g.soil], &documents, 0).unwrap();
|
||||
let down = typed(Command::Move(crate::input::command::Motion::Down));
|
||||
picker.command(&down, &mut registers, &ctx);
|
||||
assert_eq!(
|
||||
|
||||
+163
-32
@@ -1,46 +1,155 @@
|
||||
//! Registers, as in vim: where yanked and deleted text goes, and where `p`
|
||||
//! takes it from. There's one set for the whole app, so text yanked in one
|
||||
//! title can be pasted into another.
|
||||
//! Registers, as in vim: where yanked and deleted things go, and where `p`
|
||||
//! takes them from. There's one set for the whole app, so what's yanked in
|
||||
//! one document or title can be pasted into another.
|
||||
//!
|
||||
//! A register holds text (from inside a title or a body) or nodes (from
|
||||
//! `yy`, `dd`, `x`, or a selection). Either pastes anywhere: nodes pasted
|
||||
//! into a text give their titles, and text pasted on nodes becomes nodes
|
||||
//! (a node per line, or Markdown sections: see [`markdown`]).
|
||||
//!
|
||||
//! Everything yanked or deleted goes in the unnamed register, which `p`
|
||||
//! uses when no register is named, and also in the one named with `"a`–`"z`.
|
||||
//! `"_` keeps nothing. `"+` is kept inside cumulus for now: part 8c
|
||||
//! connects it to the system clipboard.
|
||||
//! uses when no register is named, and also in the one named with
|
||||
//! `"a`–`"z`. `"_` keeps nothing. `"+` is the system clipboard: nodes go
|
||||
//! there as Markdown, and while the clipboard still holds what cumulus put
|
||||
//! there, pasting it gives back everything (colors, bodies, links, the
|
||||
//! layout), not just what the text says. With `clipboard = "unnamedplus"`,
|
||||
//! the unnamed register is the clipboard too, as with vim's setting.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::clipboard::Clipboard;
|
||||
use crate::input::command::Register;
|
||||
use crate::model::Clip;
|
||||
use crate::storage::markdown;
|
||||
use crate::ui::text_editor::Yank;
|
||||
|
||||
/// What a register holds.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Kept {
|
||||
/// Text from inside a title or a body.
|
||||
Text(Yank),
|
||||
/// Nodes, with their shape.
|
||||
Nodes(Clip),
|
||||
}
|
||||
|
||||
impl Kept {
|
||||
/// As text, to paste inside a title or a body: nodes give their titles,
|
||||
/// a line each.
|
||||
pub fn text(&self) -> Yank {
|
||||
match self {
|
||||
Kept::Text(yank) => yank.clone(),
|
||||
Kept::Nodes(clip) => {
|
||||
let titles: Vec<String> =
|
||||
clip.nodes().iter().map(|clipped| clipped.node.title().to_string()).collect();
|
||||
Yank { text: titles.join("\n"), linewise: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// As nodes, to paste on nodes: text becomes a node per line (or per
|
||||
/// Markdown section). `None` for text with nothing in it.
|
||||
pub fn clip(&self) -> Option<Clip> {
|
||||
match self {
|
||||
Kept::Text(yank) => markdown::read(&yank.text),
|
||||
Kept::Nodes(clip) => Some(clip.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What goes on the system clipboard: nodes as Markdown.
|
||||
fn clipboard_text(&self) -> String {
|
||||
match self {
|
||||
Kept::Text(yank) => yank.text.clone(),
|
||||
Kept::Nodes(clip) => markdown::write(clip),
|
||||
}
|
||||
}
|
||||
|
||||
/// What it holds, in a few words: `3 nodes`, `"Soil"`.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Kept::Nodes(clip) if clip.count() == 1 => "1 node".to_string(),
|
||||
Kept::Nodes(clip) => format!("{} nodes", clip.count()),
|
||||
Kept::Text(yank) => format!("\"{}\"", yank.text.lines().next().unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The registers' contents.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Registers {
|
||||
unnamed: Option<Yank>,
|
||||
named: BTreeMap<char, Yank>,
|
||||
clipboard: Option<Yank>,
|
||||
unnamed: Option<Kept>,
|
||||
named: BTreeMap<char, Kept>,
|
||||
clipboard: Clipboard,
|
||||
/// What cumulus put on the clipboard last, and the text it put there.
|
||||
copied: Option<(String, Kept)>,
|
||||
/// `clipboard = "unnamedplus"`: plain yanks, deletes and pastes use the
|
||||
/// system clipboard too.
|
||||
pub unnamed_plus: bool,
|
||||
}
|
||||
|
||||
impl Registers {
|
||||
/// Keeps text that was yanked or deleted into `register` (or none).
|
||||
pub fn store(&mut self, register: Option<Register>, yank: Yank) {
|
||||
match register {
|
||||
Some(Register::BlackHole) => return,
|
||||
/// Keeps what was yanked or deleted in `register` (or the unnamed one).
|
||||
/// It's kept in cumulus even when the clipboard can't be written; the
|
||||
/// error says why, for `"+` (with `unnamedplus`, cumulus quietly makes
|
||||
/// do without it, as there may be no clipboard at all, over ssh).
|
||||
pub fn store(&mut self, register: Option<Register>, kept: Kept) -> Result<(), String> {
|
||||
let copied = match register {
|
||||
Some(Register::BlackHole) => return Ok(()),
|
||||
Some(Register::Named(name)) => {
|
||||
self.named.insert(name, yank.clone());
|
||||
self.named.insert(name, kept.clone());
|
||||
Ok(())
|
||||
}
|
||||
Some(Register::Clipboard) => self.clipboard = Some(yank.clone()),
|
||||
Some(Register::Unnamed) | None => {}
|
||||
}
|
||||
self.unnamed = Some(yank);
|
||||
Some(Register::Clipboard) => self.copy(&kept),
|
||||
Some(Register::Unnamed) | None if self.unnamed_plus => {
|
||||
let _ = self.copy(&kept);
|
||||
Ok(())
|
||||
}
|
||||
Some(Register::Unnamed) | None => Ok(()),
|
||||
};
|
||||
self.unnamed = Some(kept);
|
||||
copied
|
||||
}
|
||||
|
||||
/// What a register holds; the unnamed one when none is named.
|
||||
pub fn get(&self, register: Option<Register>) -> Option<&Yank> {
|
||||
/// What a register holds (the unnamed one when none is named), or why
|
||||
/// the clipboard can't be read.
|
||||
pub fn get(&mut self, register: Option<Register>) -> Result<Option<Kept>, String> {
|
||||
match register {
|
||||
None | Some(Register::Unnamed) => self.unnamed.as_ref(),
|
||||
Some(Register::Named(name)) => self.named.get(&name),
|
||||
Some(Register::Clipboard) => self.clipboard.as_ref(),
|
||||
Some(Register::BlackHole) => None,
|
||||
None | Some(Register::Unnamed) if self.unnamed_plus => {
|
||||
Ok(self.paste().ok().or_else(|| self.unnamed.clone()))
|
||||
}
|
||||
None | Some(Register::Unnamed) => Ok(self.unnamed.clone()),
|
||||
Some(Register::Named(name)) => Ok(self.named.get(&name).cloned()),
|
||||
Some(Register::Clipboard) => self.paste().map(Some),
|
||||
Some(Register::BlackHole) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The registers that hold something, for `:registers`: `"`, then `a`
|
||||
/// to `z`. (The clipboard isn't read for this.)
|
||||
pub fn list(&self) -> Vec<(char, &Kept)> {
|
||||
let unnamed = self.unnamed.iter().map(|kept| ('"', kept));
|
||||
unnamed.chain(self.named.iter().map(|(&name, kept)| (name, kept))).collect()
|
||||
}
|
||||
|
||||
/// The system clipboard (tests put text there as another app would).
|
||||
#[cfg(test)]
|
||||
pub fn clipboard(&mut self) -> &mut Clipboard {
|
||||
&mut self.clipboard
|
||||
}
|
||||
|
||||
fn copy(&mut self, kept: &Kept) -> Result<(), String> {
|
||||
let text = kept.clipboard_text();
|
||||
self.clipboard.write(&text)?;
|
||||
self.copied = Some((text, kept.clone()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the clipboard holds: what cumulus put there, if it's still
|
||||
/// there, or else its text.
|
||||
fn paste(&mut self) -> Result<Kept, String> {
|
||||
let text = self.clipboard.read()?;
|
||||
match &self.copied {
|
||||
Some((copied, kept)) if *copied == text => Ok(kept.clone()),
|
||||
_ => Ok(Kept::Text(Yank { linewise: text.ends_with('\n'), text })),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,15 +158,37 @@ impl Registers {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(text: &str) -> Kept {
|
||||
Kept::Text(Yank { text: text.into(), linewise: false })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_registers_and_the_black_hole() {
|
||||
let yank = |text: &str| Yank { text: text.into(), linewise: false };
|
||||
let mut registers = Registers::default();
|
||||
registers.store(Some(Register::Named('a')), yank("cedar"));
|
||||
registers.store(None, yank("soil"));
|
||||
registers.store(Some(Register::BlackHole), yank("gone"));
|
||||
assert_eq!(registers.get(Some(Register::Named('a'))), Some(&yank("cedar")));
|
||||
assert_eq!(registers.get(None), Some(&yank("soil")), "the black hole keeps nothing");
|
||||
assert_eq!(registers.get(Some(Register::Named('b'))), None);
|
||||
registers.store(Some(Register::Named('a')), text("cedar")).unwrap();
|
||||
registers.store(None, text("soil")).unwrap();
|
||||
registers.store(Some(Register::BlackHole), text("gone")).unwrap();
|
||||
assert_eq!(registers.get(Some(Register::Named('a'))), Ok(Some(text("cedar"))));
|
||||
assert_eq!(registers.get(None), Ok(Some(text("soil"))), "the black hole keeps nothing");
|
||||
assert_eq!(registers.get(Some(Register::Named('b'))), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipboard_gives_back_what_cumulus_put_there() {
|
||||
let mut registers = Registers::default();
|
||||
assert!(registers.get(Some(Register::Clipboard)).is_err(), "nothing there yet");
|
||||
let clip = markdown::read("## Soil\n---\n- todo\n---\nLoamy.").unwrap();
|
||||
registers.store(Some(Register::Clipboard), Kept::Nodes(clip.clone())).unwrap();
|
||||
assert_eq!(registers.clipboard().read().unwrap(), "## Soil\n---\n- todo\n---\nLoamy.\n");
|
||||
assert_eq!(registers.get(Some(Register::Clipboard)), Ok(Some(Kept::Nodes(clip))));
|
||||
// Another app copies something: that's text.
|
||||
registers.clipboard().write("mulch").unwrap();
|
||||
assert_eq!(registers.get(Some(Register::Clipboard)), Ok(Some(text("mulch"))));
|
||||
// unnamedplus: plain yanks and pastes go through the clipboard.
|
||||
registers.unnamed_plus = true;
|
||||
registers.store(None, text("straw")).unwrap();
|
||||
assert_eq!(registers.clipboard().read().unwrap(), "straw");
|
||||
registers.clipboard().write("hay").unwrap();
|
||||
assert_eq!(registers.get(None), Ok(Some(text("hay"))));
|
||||
}
|
||||
}
|
||||
+31
-7
@@ -11,7 +11,7 @@ use ratatui::style::{Style, Stylize};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
|
||||
use super::action::{Message, Tone};
|
||||
use super::action::{self, Message, Tone};
|
||||
use super::mode::{Grab, Mode};
|
||||
use super::popup::{Card, Popup};
|
||||
use super::text_edit::{Field, TextEdit};
|
||||
@@ -110,15 +110,23 @@ impl App {
|
||||
let focus = if self.shared.view.focus { " · focus" } else { "" };
|
||||
let view = format!("{}{focus}", pane.view.status());
|
||||
let room = usize::from(area.width).saturating_sub(mode.label().len() + view.len() + 16);
|
||||
let place = match &self.message {
|
||||
None => Span::raw(breadcrumb(buffer.doc(), buffer.cursor(), room)),
|
||||
Some(Message { text, tone: Tone::Problem }) => text.clone().light_red(),
|
||||
Some(Message { text, tone: Tone::Done }) => text.clone().green(),
|
||||
Some(Message { text, tone: Tone::Info }) => text.clone().yellow(),
|
||||
// In visual mode, how many nodes are selected takes the breadcrumb's
|
||||
// place.
|
||||
let selected = self.selection().map(|selected| format!("{} selected", action::nodes(selected.len())));
|
||||
let place = match (&self.message, selected) {
|
||||
(None, Some(selected)) => Span::raw(selected),
|
||||
(None, None) => Span::raw(breadcrumb(buffer.doc(), buffer.cursor(), room)),
|
||||
(Some(Message { text, tone: Tone::Problem }), _) => text.clone().light_red(),
|
||||
(Some(Message { text, tone: Tone::Done }), _) => text.clone().green(),
|
||||
(Some(Message { text, tone: Tone::Info }), _) => text.clone().yellow(),
|
||||
};
|
||||
let status = StatusBar {
|
||||
mode: mode.label(),
|
||||
mode_color: if typing { theme.insert } else { theme.accent },
|
||||
mode_color: match mode {
|
||||
_ if typing => theme.insert,
|
||||
Mode::Visual(_) => theme.visual,
|
||||
_ => theme.accent,
|
||||
},
|
||||
view,
|
||||
place,
|
||||
keys: self.keys.shown(),
|
||||
@@ -171,15 +179,31 @@ fn hints(mode: &Mode, view: ViewKind) -> &'static str {
|
||||
(Mode::Text(edit), _) | (Mode::Popup(Popup::Card(Card { edit: Some(edit), .. })), _) => {
|
||||
text_hints(edit)
|
||||
}
|
||||
(Mode::Visual(_), ViewKind::Canvas | ViewKind::Board) => {
|
||||
"hjkl more · o other end · d delete · y yank · > < in/out · M move · <Space>c color · Esc stop"
|
||||
}
|
||||
(Mode::Visual(_), ViewKind::Note) => {
|
||||
"h/l the siblings too · o other end · d delete · y yank · > < in/out · <Space>c color · Esc stop"
|
||||
}
|
||||
(Mode::Visual(_), _) => {
|
||||
"j/k more · o other end · d delete · y yank · > < in/out · <Space>c color · Esc stop"
|
||||
}
|
||||
(Mode::Move(_), ViewKind::Outline | ViewKind::Note) => {
|
||||
"j/k swap with a sibling (J/K: 5) · h out a level · l under the one above · Enter done"
|
||||
}
|
||||
(Mode::Move(Grab { whole: true, nodes, .. }), _) if !nodes.is_empty() => {
|
||||
"hjkl moves them with their branches (HJKL by 5) · M just them · Enter done"
|
||||
}
|
||||
(Mode::Move(Grab { nodes, .. }), _) if !nodes.is_empty() => {
|
||||
"hjkl moves just the selected nodes (HJKL by 5) · M with their branches · Enter done"
|
||||
}
|
||||
(Mode::Move(Grab { whole: true, .. }), _) => {
|
||||
"hjkl moves the branch (HJKL by 5) · M just this node · Enter done"
|
||||
}
|
||||
(Mode::Move(_), _) => "hjkl moves just this node (HJKL by 5) · M the whole branch · Enter done",
|
||||
(Mode::Command(_), _) => "Tab completes · ↑ ↓ history · Enter runs · Esc cancels",
|
||||
(Mode::Popup(Popup::Card(_)), _) => "j/k scroll · i edit the body · K or Esc close",
|
||||
(Mode::Popup(Popup::Search(_)), _) => "type to search · ↑ ↓ or Tab move · Enter go there · Esc close",
|
||||
(Mode::Popup(_), _) => "j/k move · / search · Enter pick · Esc close",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! `.` on nodes: the last change again, the way vim's `.` works. A change
|
||||
//! is a key command that changed the document (`dd`, `p`, `>>`, `zc`…),
|
||||
//! with everything typed after it when it started a text edit (`aMulch`
|
||||
//! then Esc), or a value picked in a picker (a color, a border, a style, a
|
||||
//! tag on or off). A change made on a selection repeats on the cursor's
|
||||
//! node: `d` as `x` (or `dd` for `V`), `>` as `>>`.
|
||||
|
||||
use super::App;
|
||||
use super::action::{Action, Message};
|
||||
use super::mode::Mode;
|
||||
use crate::input::command::{Command, Operator, Target};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::key::Key;
|
||||
use crate::model::{Border, ColorName, EditOp, NodeId, StateId, StyleName, Tag};
|
||||
|
||||
/// A change `.` can make again.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Repeat {
|
||||
/// A key command, as typed (its count and register too), and the keys
|
||||
/// typed after it while the text edit it started went on.
|
||||
Keys { typed: Typed, then: Vec<Key> },
|
||||
/// A value picked for a node.
|
||||
Look(Look),
|
||||
}
|
||||
|
||||
/// A value a picker sets on a node, for `.`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Look {
|
||||
Color(Option<ColorName>),
|
||||
Border(Option<Border>),
|
||||
Style(Option<StyleName>),
|
||||
Tag { tag: Tag, on: bool },
|
||||
}
|
||||
|
||||
impl Look {
|
||||
/// The change on a node.
|
||||
pub fn op(&self, id: NodeId) -> EditOp {
|
||||
match self {
|
||||
Look::Color(color) => EditOp::SetColor { id, color: color.clone() },
|
||||
Look::Border(border) => EditOp::SetBorder { id, border: *border },
|
||||
Look::Style(style) => EditOp::SetStyle { id, style: style.clone() },
|
||||
Look::Tag { tag, on: true } => EditOp::Tag { id, add: vec![tag.clone()], remove: Vec::new() },
|
||||
Look::Tag { tag, on: false } => EditOp::Tag { id, add: Vec::new(), remove: vec![tag.clone()] },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text edit started by a key command, while it goes on: what `.` will
|
||||
/// repeat once it's done.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct Recording {
|
||||
typed: Typed,
|
||||
then: Vec<Key>,
|
||||
/// The document's history when it started, to tell whether it changed
|
||||
/// anything.
|
||||
before: StateId,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// A key command typed in normal, visual or move mode: carried out, and
|
||||
/// kept for `.` when it changed the document.
|
||||
pub(super) fn run_typed(&mut self, typed: Typed) {
|
||||
if typed.command == Command::Repeat {
|
||||
return self.repeat(typed.count);
|
||||
}
|
||||
if self.repeating {
|
||||
return self.dispatch(typed);
|
||||
}
|
||||
// Only changes made in normal and visual mode count; for a
|
||||
// selection, whether it took whole branches.
|
||||
let (normal, branches) = match &self.shared.mode {
|
||||
Mode::Normal => (true, false),
|
||||
Mode::Visual(visual) => (false, visual.branches),
|
||||
_ => return self.dispatch(typed),
|
||||
};
|
||||
let (buffer, before) = (self.buffer(), self.current().history().state());
|
||||
self.dispatch(typed);
|
||||
let changed = self.buffer() == buffer && self.current().history().state() != before;
|
||||
match &self.shared.mode {
|
||||
// It started a text edit: the keys typed in it go with it.
|
||||
Mode::Text(_) if normal => {
|
||||
self.recording = Some(Recording { typed, then: Vec::new(), before });
|
||||
}
|
||||
Mode::Normal if changed && !matches!(typed.command, Command::Undo | Command::Redo) => {
|
||||
let typed = if normal { Some(typed) } else { on_the_cursor(typed, branches) };
|
||||
if let Some(typed) = typed {
|
||||
self.last_change = Some(Repeat::Keys { typed, then: Vec::new() });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Before each key: it joins the text edit being recorded, if any. (An
|
||||
/// edit a click ended is over first, without it.)
|
||||
pub(super) fn record(&mut self, key: Key) {
|
||||
self.end_recording();
|
||||
if let Some(recording) = &mut self.recording {
|
||||
recording.then.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// After each key: a recorded edit that's over becomes the change `.`
|
||||
/// repeats, if it changed anything.
|
||||
pub(super) fn end_recording(&mut self) {
|
||||
match &self.shared.mode {
|
||||
Mode::Text(_) => {}
|
||||
Mode::Normal => {
|
||||
if let Some(Recording { typed, then, before }) = self.recording.take()
|
||||
&& self.current().history().state() != before
|
||||
{
|
||||
self.last_change = Some(Repeat::Keys { typed, then });
|
||||
}
|
||||
}
|
||||
_ => self.recording = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A value picked in a picker, for `.`.
|
||||
pub(super) fn picked(&mut self, look: Look) {
|
||||
if !self.repeating {
|
||||
self.last_change = Some(Repeat::Look(look));
|
||||
}
|
||||
}
|
||||
|
||||
/// `.`: the last change again, with a new count if one is typed.
|
||||
fn repeat(&mut self, count: Option<u32>) {
|
||||
let Some(change) = self.last_change.clone() else {
|
||||
self.message = Some(Message::info("no change to repeat yet"));
|
||||
return;
|
||||
};
|
||||
self.repeating = true;
|
||||
match change {
|
||||
Repeat::Keys { typed, then } => {
|
||||
self.dispatch(Typed { count: count.or(typed.count), ..typed });
|
||||
for key in then {
|
||||
self.key(key);
|
||||
}
|
||||
}
|
||||
Repeat::Look(look) => {
|
||||
let op = look.op(self.current().cursor());
|
||||
self.update(vec![Action::edit(op)]);
|
||||
}
|
||||
}
|
||||
self.repeating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// A change made on a selection, as `.` makes it again on the cursor's
|
||||
/// node: `d` as `x` (`dd` for whole branches), `>` and `<` as `>>` and
|
||||
/// `<<`.
|
||||
fn on_the_cursor(typed: Typed, branches: bool) -> Option<Typed> {
|
||||
let command = match typed.command {
|
||||
Command::Operate(Operator::Delete, Target::Selection) if branches => {
|
||||
Command::Operate(Operator::Delete, Target::Current)
|
||||
}
|
||||
Command::Operate(Operator::Delete, Target::Selection) => Command::DeleteNode,
|
||||
Command::Operate(operator @ (Operator::Indent | Operator::Outdent), Target::Selection) => {
|
||||
Command::Operate(operator, Target::Current)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(Typed { command, count: None, ..typed })
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//! `/`: finding a node, fuzzily, in its title, its tags or its body. A
|
||||
//! popup in the middle of the screen, the way Telescope does it in neovim
|
||||
//! (the user's pick in part 8c): what you type at the top, the matches
|
||||
//! below it, best first, and on the right the note of the one you're on.
|
||||
//! Enter goes there (a jump: `Ctrl-o` comes back), Esc closes; `n` and `N`
|
||||
//! then go through the same matches, and `:find` searches straight away.
|
||||
//!
|
||||
//! ```text
|
||||
//! ╭ search · Garden plans ────────────────────────────────────────╮
|
||||
//! │ /compo │ ask about compost │
|
||||
//! │▌ask about compost │ no tags ● pink │
|
||||
//! │ Soil · Loamy, with comp… │ │
|
||||
//! │ │ The neighbor has a bin. │
|
||||
//! ╰ 1 of 2 · ↑↓ move · Enter go there · Esc close ────────────────╯
|
||||
//! ```
|
||||
//!
|
||||
//! Case only matters once the search has a capital in it, as with vim's
|
||||
//! `smartcase`.
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Position, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Clear};
|
||||
|
||||
use super::App;
|
||||
use super::action::{Action, AppAction, DocAction, Message};
|
||||
use super::mode::Mode;
|
||||
use crate::command::complete::fuzzy_match;
|
||||
use crate::input::key::{Key, KeyName};
|
||||
use crate::input::mouse::{Cell, Gesture};
|
||||
use crate::model::{Content, Direction, Document, NodeId};
|
||||
use crate::ui::text_input::TextInput;
|
||||
use crate::view::note::NoteView;
|
||||
use crate::view::{View, ViewContext};
|
||||
|
||||
/// A title match counts for more than a match in the tags, and those more
|
||||
/// than one in the body.
|
||||
const TITLE_BONUS: i32 = 64;
|
||||
const TAGS_BONUS: i32 = 32;
|
||||
/// The widest the popup gets.
|
||||
const MOST_WIDTH: u16 = 120;
|
||||
/// The tallest it gets.
|
||||
const MOST_HEIGHT: u16 = 30;
|
||||
/// Narrower than this, the note isn't shown beside the matches.
|
||||
const PREVIEW_FROM: u16 = 60;
|
||||
|
||||
/// A node a search found.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Found {
|
||||
pub id: NodeId,
|
||||
score: i32,
|
||||
/// Where it matched.
|
||||
pub place: Where,
|
||||
/// Which characters of that text matched, to show them.
|
||||
letters: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Where in a node a search matched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Where {
|
||||
Title,
|
||||
Tags,
|
||||
/// This line of the body.
|
||||
Body(String),
|
||||
}
|
||||
|
||||
impl Where {
|
||||
/// For the status bar: `in the body`.
|
||||
fn words(&self) -> &'static str {
|
||||
match self {
|
||||
Where::Title => "in the title",
|
||||
Where::Tags => "in the tags",
|
||||
Where::Body(_) => "in the body",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The nodes that match `query`, best first. An empty query matches every
|
||||
/// node, in reading order.
|
||||
pub fn find(doc: &Document, query: &str) -> Vec<Found> {
|
||||
let ignore_case = !query.chars().any(char::is_uppercase);
|
||||
let mut found: Vec<(usize, Found)> = doc
|
||||
.outline()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(order, id)| {
|
||||
let node = doc.node(id)?;
|
||||
if query.is_empty() {
|
||||
return Some((order, Found { id, score: 0, place: Where::Title, letters: Vec::new() }));
|
||||
}
|
||||
let title = fuzzy_match(query, node.title().as_str(), ignore_case)
|
||||
.map(|(score, letters)| (score + TITLE_BONUS, Where::Title, letters));
|
||||
let tags = (!node.tags.is_empty())
|
||||
.then(|| fuzzy_match(query, &node.tags.to_string(), ignore_case))
|
||||
.flatten()
|
||||
.map(|(score, letters)| (score + TAGS_BONUS, Where::Tags, letters));
|
||||
let body = match &node.content {
|
||||
Content::Idea(idea) => idea
|
||||
.body
|
||||
.as_str()
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (score, letters) = fuzzy_match(query, line, ignore_case)?;
|
||||
Some((score, Where::Body(line.to_string()), letters))
|
||||
})
|
||||
.max_by_key(|(score, _, _)| *score),
|
||||
Content::Portal(_) => None,
|
||||
};
|
||||
let (score, place, letters) =
|
||||
[title, tags, body].into_iter().flatten().max_by_key(|(score, _, _)| *score)?;
|
||||
Some((order, Found { id, score, place, letters }))
|
||||
})
|
||||
.collect();
|
||||
found.sort_by_key(|(order, found)| (-found.score, *order));
|
||||
found.into_iter().map(|(_, found)| found).collect()
|
||||
}
|
||||
|
||||
/// The search popup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Search {
|
||||
input: TextInput,
|
||||
found: Vec<Found>,
|
||||
/// The match you're on, among `found`.
|
||||
selected: usize,
|
||||
/// The first match on show.
|
||||
scroll: usize,
|
||||
/// Where it was drawn last: the box, and the rows of matches (clicks
|
||||
/// land on these).
|
||||
area: Rect,
|
||||
rows: Rect,
|
||||
}
|
||||
|
||||
impl Search {
|
||||
/// The search, with nothing typed yet: every node, in reading order.
|
||||
pub fn new(doc: &Document) -> Search {
|
||||
Search {
|
||||
input: TextInput::default(),
|
||||
found: find(doc, ""),
|
||||
selected: 0,
|
||||
scroll: 0,
|
||||
area: Rect::default(),
|
||||
rows: Rect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A key: typed into the search, or one that moves through the matches,
|
||||
/// goes to one, or closes.
|
||||
pub fn key(&mut self, key: Key, ctx: &ViewContext<'_>) -> Vec<Action> {
|
||||
let page = isize::try_from(self.rows.height.max(2) / 2).unwrap_or(1);
|
||||
match (key.name, key.ctrl) {
|
||||
(KeyName::Esc, _) | (KeyName::Char('c'), true) => return leave(),
|
||||
(KeyName::Enter, _) => return self.go(),
|
||||
(KeyName::Down | KeyName::Tab, _) | (KeyName::Char('n'), true) => self.move_by(1),
|
||||
(KeyName::Up | KeyName::BackTab, _) | (KeyName::Char('p'), true) => self.move_by(-1),
|
||||
(KeyName::PageDown, _) => self.move_by(page),
|
||||
(KeyName::PageUp, _) => self.move_by(-page),
|
||||
_ => {
|
||||
if self.input.edit(key) {
|
||||
self.found = find(ctx.doc, self.input.text());
|
||||
(self.selected, self.scroll) = (0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// A mouse gesture: a click on a match goes there, a click outside
|
||||
/// closes, the wheel moves through the matches.
|
||||
pub fn mouse(&mut self, gesture: Gesture) -> Vec<Action> {
|
||||
match gesture {
|
||||
Gesture::Press { at, .. } => {
|
||||
let at = position(at);
|
||||
if !self.area.contains(at) {
|
||||
return leave();
|
||||
}
|
||||
if self.rows.contains(at) {
|
||||
let row = self.scroll + usize::from(at.y - self.rows.y);
|
||||
if row < self.found.len() {
|
||||
self.selected = row;
|
||||
return self.go();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
Gesture::Scroll { direction: Direction::Down, .. } => {
|
||||
self.move_by(3);
|
||||
Vec::new()
|
||||
}
|
||||
Gesture::Scroll { direction: Direction::Up, .. } => {
|
||||
self.move_by(-3);
|
||||
Vec::new()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter: to the match you're on. A search with text in it is kept for
|
||||
/// `n` and `N`.
|
||||
fn go(&self) -> Vec<Action> {
|
||||
let Some(found) = self.found.get(self.selected) else { return leave() };
|
||||
let mut actions = leave();
|
||||
actions.push(Action::jump(found.id));
|
||||
let query = self.input.text();
|
||||
if !query.is_empty() {
|
||||
actions.push(Action::App(AppAction::Searched(query.to_string())));
|
||||
let (at, count) = (self.selected + 1, self.found.len());
|
||||
actions.push(Action::say(Message::info(format!(
|
||||
"/{query} · {at} of {count} · {}",
|
||||
found.place.words()
|
||||
))));
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn move_by(&mut self, delta: isize) {
|
||||
let last = self.found.len().saturating_sub(1);
|
||||
self.selected = self.selected.saturating_add_signed(delta).min(last);
|
||||
}
|
||||
|
||||
/// Works out where it goes on `screen`, and scrolls to keep the match
|
||||
/// you're on in view.
|
||||
pub fn layout(&mut self, screen: Rect) {
|
||||
let width = screen.width.saturating_sub(4).min(MOST_WIDTH).max(20.min(screen.width));
|
||||
let height = screen.height.saturating_sub(2).min(MOST_HEIGHT).max(5.min(screen.height));
|
||||
self.area = Rect {
|
||||
x: screen.x + (screen.width - width) / 2,
|
||||
y: screen.y + (screen.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
let inner = inner(self.area);
|
||||
let (list, _) = columns(inner);
|
||||
// The search is the first row; the matches are below it.
|
||||
self.rows = Rect { y: list.y + 1, height: list.height.saturating_sub(1), ..list };
|
||||
let shown = usize::from(self.rows.height).max(1);
|
||||
if self.selected < self.scroll {
|
||||
self.scroll = self.selected;
|
||||
} else if self.selected >= self.scroll + shown {
|
||||
self.scroll = self.selected + 1 - shown;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws it where [`Search::layout`] put it, with the note of the match
|
||||
/// you're on beside the matches.
|
||||
pub fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) {
|
||||
let theme = ctx.theme;
|
||||
let (accent, muted) = (Style::new().fg(theme.accent), Style::new().fg(theme.muted));
|
||||
frame.render_widget(Clear, self.area);
|
||||
let footer = if self.found.is_empty() {
|
||||
" nothing matches · Esc close ".to_string()
|
||||
} else {
|
||||
format!(" {} of {} · ↑↓ move · Enter go there · Esc close ", self.selected + 1, self.found.len())
|
||||
};
|
||||
let block = Block::bordered()
|
||||
.border_set(theme.glyphs.frame(true))
|
||||
.border_style(muted)
|
||||
.title(Line::styled(
|
||||
format!(" search · {} ", ctx.doc.title()),
|
||||
accent.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.title_bottom(Line::styled(footer, muted));
|
||||
frame.render_widget(block, self.area);
|
||||
let inner = inner(self.area);
|
||||
let (list, preview) = columns(inner);
|
||||
let buf = frame.buffer_mut();
|
||||
let prompt = Line::from(vec![Span::styled("/", accent), Span::raw(self.input.text().to_string())]);
|
||||
buf.set_line(list.x + 1, list.y, &prompt, list.width.saturating_sub(1));
|
||||
let bar = theme.glyphs.part_mark().to_string();
|
||||
let shown = self.found.iter().enumerate().skip(self.scroll).take(usize::from(self.rows.height));
|
||||
for ((index, found), y) in shown.zip(self.rows.y..) {
|
||||
if index == self.selected {
|
||||
buf.set_string(list.x, y, &bar, accent);
|
||||
}
|
||||
let line = row(found, ctx, accent.add_modifier(Modifier::BOLD), muted);
|
||||
buf.set_line(list.x + 1, y, &line, list.width.saturating_sub(1));
|
||||
}
|
||||
if let Some(preview) = preview {
|
||||
let line = theme.glyphs.frame(false).vertical_left;
|
||||
for y in inner.y..inner.bottom() {
|
||||
buf.set_string(preview.x - 1, y, line, muted);
|
||||
}
|
||||
if let Some(found) = self.found.get(self.selected) {
|
||||
let ctx = ViewContext { cursor: found.id, mode: &Mode::Normal, ..*ctx };
|
||||
let mut note = NoteView::preview();
|
||||
note.layout(preview, &ctx);
|
||||
note.render(frame, &ctx);
|
||||
}
|
||||
}
|
||||
let col = u16::try_from(1 + 1 + self.input.cursor_column()).unwrap_or(u16::MAX);
|
||||
frame.set_cursor_position(Position::new((list.x + col).min(list.right().saturating_sub(1)), list.y));
|
||||
}
|
||||
}
|
||||
|
||||
/// A match's row: the title, with the letters that matched standing out,
|
||||
/// then the tags or the line of the body it matched in.
|
||||
fn row(found: &Found, ctx: &ViewContext<'_>, lit: Style, muted: Style) -> Line<'static> {
|
||||
let Some(node) = ctx.doc.node(found.id) else { return Line::default() };
|
||||
let marked = |text: &str, letters: &[usize], style: Style| -> Vec<Span<'static>> {
|
||||
text.chars()
|
||||
.enumerate()
|
||||
.map(|(at, c)| Span::styled(c.to_string(), if letters.contains(&at) { lit } else { style }))
|
||||
.collect()
|
||||
};
|
||||
let title = node.title().to_string();
|
||||
let mut spans = match found.place {
|
||||
Where::Title => marked(&title, &found.letters, Style::new()),
|
||||
_ => vec![Span::raw(title)],
|
||||
};
|
||||
match &found.place {
|
||||
Where::Title if !node.tags.is_empty() => spans.push(Span::styled(format!(" {}", node.tags), muted)),
|
||||
Where::Title => {}
|
||||
Where::Tags => {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.extend(marked(&node.tags.to_string(), &found.letters, muted));
|
||||
}
|
||||
Where::Body(line) => {
|
||||
spans.push(Span::styled(" · ", muted));
|
||||
spans.extend(marked(line.trim_end(), &found.letters, muted));
|
||||
}
|
||||
}
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// Inside the border.
|
||||
fn inner(area: Rect) -> Rect {
|
||||
Rect {
|
||||
x: area.x + 1,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(2),
|
||||
height: area.height.saturating_sub(2),
|
||||
}
|
||||
}
|
||||
|
||||
/// The matches' column, and the note's beside it (with a line between), if
|
||||
/// there's room for it.
|
||||
fn columns(inner: Rect) -> (Rect, Option<Rect>) {
|
||||
if inner.width < PREVIEW_FROM {
|
||||
return (inner, None);
|
||||
}
|
||||
let list = inner.width * 2 / 5;
|
||||
let preview = Rect { x: inner.x + list + 1, width: inner.width - list - 1, ..inner };
|
||||
(Rect { width: list, ..inner }, Some(preview))
|
||||
}
|
||||
|
||||
fn leave() -> Vec<Action> {
|
||||
vec![Action::App(AppAction::LeaveMode)]
|
||||
}
|
||||
|
||||
fn position(cell: Cell) -> Position {
|
||||
Position::new(cell.col, cell.row)
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// `n` and `N`: the next (or previous) match of the last search, `times`
|
||||
/// over, going round. Off the matches, `n` goes to the best one.
|
||||
pub(super) fn find_again(&mut self, forward: bool, times: u32) -> Option<Message> {
|
||||
let Some(query) = self.last_search.clone() else {
|
||||
return Some(Message::info("no search yet: / starts one"));
|
||||
};
|
||||
let found = find(self.current().doc(), &query);
|
||||
let count = found.len();
|
||||
if count == 0 {
|
||||
return Some(Message::problem(format!("nothing matches /{query}")));
|
||||
}
|
||||
let cursor = self.current().cursor();
|
||||
let mut at = found.iter().position(|found| found.id == cursor);
|
||||
let mut wrapped = false;
|
||||
for _ in 0..times.max(1) {
|
||||
at = Some(match (at, forward) {
|
||||
// Not on a match: n goes to the best one, N to the last.
|
||||
(None, true) => 0,
|
||||
(None, false) => count - 1,
|
||||
(Some(last), true) if last + 1 == count => {
|
||||
wrapped = true;
|
||||
0
|
||||
}
|
||||
(Some(0), false) => {
|
||||
wrapped = true;
|
||||
count - 1
|
||||
}
|
||||
(Some(here), true) => here + 1,
|
||||
(Some(here), false) => here - 1,
|
||||
});
|
||||
}
|
||||
let at = at.unwrap_or(0);
|
||||
let said = self.doc_action(DocAction::MoveCursor { to: found[at].id, jump: true });
|
||||
let place = found[at].place.words();
|
||||
let again = match (wrapped, forward) {
|
||||
(false, _) => "",
|
||||
(true, true) => " · back to the best match",
|
||||
(true, false) => " · round to the last match",
|
||||
};
|
||||
said.or_else(|| Some(Message::info(format!("/{query} · {} of {count} · {place}{again}", at + 1))))
|
||||
}
|
||||
|
||||
/// `:find`: straight to the best match, kept for `n` and `N`.
|
||||
pub(super) fn find_now(&mut self, query: String) -> Option<Message> {
|
||||
let found = find(self.current().doc(), &query);
|
||||
let Some(best) = found.first() else {
|
||||
return Some(Message::problem(format!("nothing matches /{query}")));
|
||||
};
|
||||
let (id, place, count) = (best.id, best.place.words(), found.len());
|
||||
self.last_search = Some(query.clone());
|
||||
let said = self.doc_action(DocAction::MoveCursor { to: id, jump: true });
|
||||
said.or_else(|| Some(Message::info(format!("/{query} · 1 of {count} · {place}"))))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::model::{Body, EditOp, Tags};
|
||||
|
||||
#[test]
|
||||
fn titles_tags_and_bodies_match_fuzzily_best_first() {
|
||||
let (mut doc, g) = garden();
|
||||
doc.apply(&EditOp::SetBody { id: g.soil, body: Body::new("Loamy,\nwith compost.") }).unwrap();
|
||||
doc.apply(&EditOp::SetTags { id: g.beds, tags: Tags::parse("#compost").unwrap() }).unwrap();
|
||||
let found = find(&doc, "compost");
|
||||
let ids: Vec<NodeId> = found.iter().map(|found| found.id).collect();
|
||||
assert_eq!(ids, [g.compost, g.beds, g.soil], "the title first, then the tags, then the body");
|
||||
assert_eq!(found[2].place, Where::Body("with compost.".into()));
|
||||
// Fuzzy: the letters in order, anywhere.
|
||||
assert_eq!(find(&doc, "rsbd")[0].id, g.beds);
|
||||
// Smartcase: a capital makes case matter.
|
||||
assert!(find(&doc, "Compost").iter().all(|found| found.id != g.compost));
|
||||
assert_eq!(find(&doc, "").len(), doc.node_count(), "nothing typed: every node");
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -5,6 +5,7 @@
|
||||
|
||||
mod config;
|
||||
mod files;
|
||||
mod vim;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -303,7 +304,7 @@ fn command_lines_run_through_the_same_actions() {
|
||||
assert_eq!(h.cursor(), "Garden plans", "capturing leaves the cursor where it was");
|
||||
h.type_keys(":find drip<CR>");
|
||||
assert_eq!(h.cursor(), "Drip line");
|
||||
assert_eq!(h.message(), "\"drip\": match 1 of 1");
|
||||
assert_eq!(h.message(), "/drip · 1 of 1 · in the title");
|
||||
h.type_keys(":set completion_popup=on-tab<CR>");
|
||||
assert_eq!(h.app.settings.completion_popup, PopupMode::OnTab);
|
||||
h.type_keys(":frobnicate<CR>");
|
||||
@@ -502,10 +503,10 @@ fn indenting_and_outdenting_nodes() {
|
||||
#[test]
|
||||
fn keys_from_later_stages_say_so() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("yy");
|
||||
assert_eq!(h.message(), "\"yank (copy)…\" comes in a later part of the build");
|
||||
h.type_keys("v");
|
||||
assert_eq!(h.message(), "\"select\" comes in a later part of the build");
|
||||
h.type_keys("C");
|
||||
assert_eq!(h.message(), "\"connect mode\" comes in a later part of the build");
|
||||
h.type_keys("<Space>p");
|
||||
assert_eq!(h.message(), "\"new portal…\" comes in a later part of the build");
|
||||
// Kitchen is a portal: its title is the other document's name.
|
||||
h.type_keys("glgjgjgj");
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
//! The rest of vim (part 8c), end to end: node registers and the
|
||||
//! clipboard, visual mode, `.` on nodes, search, the jump list, marks and
|
||||
//! `gd`.
|
||||
|
||||
use ratatui::style::Color;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The titles of the root's children, in order.
|
||||
fn top(h: &Harness) -> Vec<String> {
|
||||
h.children("Garden plans")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_and_paste_nodes() {
|
||||
let mut h = Harness::new();
|
||||
// yy on Raised beds: the node and its branch.
|
||||
h.type_keys("glgjyy");
|
||||
assert_eq!(h.message(), "yanked 3 nodes");
|
||||
// p on Watering: the copy goes after it, with its children.
|
||||
h.type_keys("gjp");
|
||||
assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Raised beds", "Kitchen"]);
|
||||
let copy = h.doc().children(h.node("Garden plans"))[3];
|
||||
assert_eq!(h.app.current().cursor(), copy, "the cursor goes to what was pasted");
|
||||
let titles: Vec<String> = h.doc().children(copy).iter().map(|&id| h.title(id)).collect();
|
||||
assert_eq!(titles, ["Cedar, untreated", "40 cm high"]);
|
||||
assert_eq!(h.doc().node(copy).unwrap().look.color.as_ref().map(|c| c.as_str()), Some("orange"));
|
||||
// ]p: as the last children; 2p: two copies.
|
||||
h.type_keys("gg]p");
|
||||
assert_eq!(top(&h).len(), 6);
|
||||
h.type_keys("u2p");
|
||||
assert_eq!(h.message(), "pasted 6 nodes");
|
||||
assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Raised beds", "Kitchen"]);
|
||||
assert_eq!(h.doc().loose().len(), 4, "after the root: loose nodes, before the two there were");
|
||||
h.type_keys("uu");
|
||||
assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Kitchen"], "each paste is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletes_keep_what_they_take() {
|
||||
let mut h = Harness::new();
|
||||
// dd then p: the branch moves.
|
||||
h.type_keys("glgjdd");
|
||||
assert_eq!(h.cursor(), "Watering");
|
||||
h.type_keys("p");
|
||||
assert_eq!(top(&h), ["Soil", "Watering", "Raised beds", "Kitchen"]);
|
||||
assert_eq!(h.children("Raised beds"), ["Cedar, untreated", "40 cm high"]);
|
||||
// x keeps just the node: its children stay behind.
|
||||
h.type_keys("x");
|
||||
assert_eq!(top(&h), ["Soil", "Watering", "Cedar, untreated", "40 cm high", "Kitchen"]);
|
||||
h.type_keys("ggP");
|
||||
assert_eq!(h.message(), "nothing goes before the root");
|
||||
h.type_keys("glP");
|
||||
assert_eq!(top(&h)[0], "Raised beds", "P: before Soil");
|
||||
assert!(h.children("Raised beds").is_empty());
|
||||
// Named registers, and the black hole.
|
||||
h.type_keys("gl\"ayy");
|
||||
assert_eq!(h.message(), "yanked 1 node into \"a");
|
||||
h.type_keys("gj\"_dd");
|
||||
assert_eq!(h.message(), "deleted 1 node · u to undo");
|
||||
h.type_keys("gg]p");
|
||||
assert_eq!(
|
||||
h.children("Garden plans").last().map(String::as_str),
|
||||
Some("Raised beds"),
|
||||
"\"_ kept nothing"
|
||||
);
|
||||
h.type_keys("gg\"a]p");
|
||||
assert_eq!(h.children("Garden plans").iter().filter(|title| *title == "Raised beds").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipboard_holds_markdown() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glgj\"+yy");
|
||||
assert_eq!(h.message(), "copied 3 nodes to the clipboard");
|
||||
let text = h.app.registers.clipboard().read().unwrap();
|
||||
assert_eq!(text, "## Raised beds\n\n### Cedar, untreated\n\n### 40 cm high\n");
|
||||
// Back from the clipboard: everything, the color too.
|
||||
h.type_keys("G\"+p");
|
||||
let pasted = h.app.current().cursor();
|
||||
assert_eq!(h.doc().node(pasted).unwrap().look.color.as_ref().map(|c| c.as_str()), Some("orange"));
|
||||
// Text another app put there: a node per section, tags and body too.
|
||||
h.app.registers.clipboard().write("## Mulch\n---\n- todo\n---\nStraw or leaves.\n\n### Straw\n").unwrap();
|
||||
h.type_keys("gg\"+]p");
|
||||
assert_eq!(h.cursor(), "Mulch");
|
||||
assert_eq!((h.tags("Mulch").as_str(), h.body("Mulch").as_str()), ("#todo", "Straw or leaves."));
|
||||
assert_eq!(h.children("Mulch"), ["Straw"]);
|
||||
// A plain list makes nodes too.
|
||||
h.app.registers.clipboard().write("- Bucket\n - Hose\n").unwrap();
|
||||
h.type_keys("\"+p");
|
||||
assert_eq!(h.children("Bucket"), ["Hose"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_nodes_paste_into_each_other() {
|
||||
let mut h = Harness::new();
|
||||
// A word yanked in a title, pasted on nodes: a node.
|
||||
h.type_keys("gli<Esc>yiw<Esc>ggp");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
assert_eq!(h.doc().loose().len(), 3, "after the root: a loose node");
|
||||
// Nodes pasted into a title: their titles.
|
||||
h.type_keys("gg]pu");
|
||||
h.type_keys("glgjyyggi <Esc>p");
|
||||
assert_eq!(h.draft(), "Garden plans Raised beds Cedar, untreated 40 cm high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamedplus_uses_the_clipboard_for_everything() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys(":set clipboard=unnamedplus<CR>");
|
||||
assert_eq!(h.message(), "clipboard = unnamedplus");
|
||||
h.type_keys("glyy");
|
||||
assert_eq!(h.app.registers.clipboard().read().unwrap(), "## Soil\n---\n- todo\n---\n");
|
||||
h.app.registers.clipboard().write("Compost").unwrap();
|
||||
h.type_keys("p");
|
||||
assert_eq!(h.cursor(), "Compost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_mode_takes_in_a_rectangle() {
|
||||
let mut h = Harness::new();
|
||||
// v on Soil, then down twice: Soil, Raised beds and Watering.
|
||||
h.type_keys("glvjj");
|
||||
assert_eq!((h.label(), h.cursor().as_str()), ("VISUAL", "Watering"));
|
||||
assert!(h.rows()[h.rows().len() - 2].contains("3 nodes selected"), "{}", h.screen());
|
||||
let (col, row) = h.find("Raised beds");
|
||||
assert_eq!(h.rows()[usize::from(row) - 1].chars().nth(usize::from(col)), Some('━'), "thick");
|
||||
assert_eq!(h.color_at((col - 2, row)), Color::Magenta, "in the visual color");
|
||||
let (col, row) = h.find("Kitchen");
|
||||
assert_ne!(h.color_at((col - 2, row)), Color::Magenta, "Kitchen is below the rectangle");
|
||||
// d: exactly those go; the children that weren't selected move up.
|
||||
h.type_keys("d");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
assert_eq!(h.message(), "deleted 3 nodes · u to undo");
|
||||
assert_eq!(top(&h), ["Cedar, untreated", "40 cm high", "Rain barrel", "Drip line", "Kitchen"]);
|
||||
h.type_keys("u");
|
||||
assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Kitchen"]);
|
||||
// V: with their branches.
|
||||
h.type_keys("glVjj");
|
||||
assert_eq!(h.label(), "VISUAL BRANCH");
|
||||
h.type_keys("d");
|
||||
assert_eq!(top(&h), ["Kitchen"]);
|
||||
assert_eq!(h.message(), "deleted 8 nodes · u to undo");
|
||||
// What went is in the register: ]p puts it back under the root, where
|
||||
// the cursor went.
|
||||
assert_eq!(h.cursor(), "Garden plans");
|
||||
h.type_keys("]p");
|
||||
assert_eq!(top(&h), ["Kitchen", "Soil", "Raised beds", "Watering"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_mode_switches_swaps_and_stops() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glvjV");
|
||||
assert_eq!(h.label(), "VISUAL BRANCH", "V in v: whole branches");
|
||||
h.type_keys("v");
|
||||
assert_eq!(h.label(), "VISUAL", "and back");
|
||||
h.type_keys("o");
|
||||
assert_eq!(h.cursor(), "Soil", "o: the other end");
|
||||
h.type_keys("v");
|
||||
assert_eq!(h.label(), "NORMAL", "v again: stop");
|
||||
h.type_keys("vj<Esc>");
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
h.type_keys("vjy");
|
||||
assert_eq!((h.label(), h.message()), ("NORMAL", "yanked 2 nodes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_and_pickers_act_on_the_whole_selection() {
|
||||
let mut h = Harness::new();
|
||||
// > puts them under the node above the first.
|
||||
h.type_keys("glgjvj>");
|
||||
assert_eq!(h.children("Soil"), ["Raised beds", "Watering"]);
|
||||
h.type_keys("u");
|
||||
assert_eq!(top(&h), ["Soil", "Raised beds", "Watering", "Kitchen"]);
|
||||
// A color for all three, as one undo step.
|
||||
h.type_keys("glvjj<Space>c/teal<CR>");
|
||||
let color = |h: &Harness, title: &str| {
|
||||
h.doc().node(h.node(title)).unwrap().look.color.as_ref().map(|c| c.as_str().to_string())
|
||||
};
|
||||
for title in ["Soil", "Raised beds", "Watering"] {
|
||||
assert_eq!(color(&h, title).as_deref(), Some("teal"), "{title}");
|
||||
}
|
||||
h.type_keys("u");
|
||||
assert_eq!(color(&h, "Soil"), None);
|
||||
assert_eq!(color(&h, "Raised beds").as_deref(), Some("orange"));
|
||||
// Tags: on every one of them.
|
||||
h.type_keys("glvj<Space>t/wood<CR>");
|
||||
assert_eq!((h.tags("Soil").as_str(), h.tags("Raised beds").as_str()), ("#todo #wood", "#wood"));
|
||||
// M moves them all.
|
||||
h.type_keys("<Esc>ggglvjMll<CR>");
|
||||
assert_eq!((h.pos("Soil"), h.pos("Raised beds")), (CanvasPos::new(24, 2), CanvasPos::new(24, 7)));
|
||||
assert_eq!(h.pos("Cedar, untreated"), CanvasPos::new(45, 5), "with their branches");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_mode_in_the_outline_takes_rows() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("<Space>vojvjj");
|
||||
assert_eq!(h.label(), "VISUAL");
|
||||
// Soil, Raised beds and its first child: the rows in between.
|
||||
assert!(h.rows()[h.rows().len() - 2].contains("3 nodes selected"), "{}", h.screen());
|
||||
h.type_keys("d");
|
||||
assert_eq!(top(&h), ["40 cm high", "Watering", "Kitchen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_click_and_ctrl_drag_select() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gl");
|
||||
let watering = h.find("Watering");
|
||||
h.mouse(Gesture::Press { at: Cell { col: watering.0, row: watering.1 }, ctrl: true });
|
||||
assert_eq!(h.label(), "VISUAL");
|
||||
assert!(h.screen().contains("2 nodes selected"));
|
||||
// Again on Soil: out of the selection.
|
||||
let soil = h.find("Soil");
|
||||
h.mouse(Gesture::Press { at: Cell { col: soil.0, row: soil.1 }, ctrl: true });
|
||||
assert!(h.screen().contains("1 node selected"));
|
||||
// A plain click ends the selection.
|
||||
h.click(soil);
|
||||
assert_eq!(h.label(), "NORMAL");
|
||||
// A Ctrl+drag from empty space around Soil and Raised beds.
|
||||
let beds = h.find("Raised beds");
|
||||
let (from, to) = (Cell { col: soil.0 - 4, row: soil.1 - 1 }, Cell { col: beds.0 + 2, row: beds.1 });
|
||||
h.mouse(Gesture::Press { at: from, ctrl: true });
|
||||
h.mouse(Gesture::Drag { from, at: to, ctrl: true });
|
||||
assert!(h.screen().contains('┐'), "the rectangle shows while dragging");
|
||||
h.mouse(Gesture::Release { at: to, dragged: true });
|
||||
assert_eq!((h.label(), h.cursor().as_str()), ("VISUAL", "Soil"));
|
||||
assert!(h.screen().contains("2 nodes selected"), "{}", h.screen());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_repeats_the_last_change_on_nodes() {
|
||||
let mut h = Harness::new();
|
||||
// dd, then . on the next one.
|
||||
h.type_keys("gldd");
|
||||
assert_eq!(h.cursor(), "Raised beds");
|
||||
h.type_keys(".");
|
||||
assert_eq!(top(&h), ["Watering", "Kitchen"]);
|
||||
h.type_keys("uu");
|
||||
// An idea typed after a, with its title: . adds it again.
|
||||
h.type_keys("ggaMulch<Esc><Esc>gg.");
|
||||
assert_eq!(h.children("Garden plans").iter().filter(|title| *title == "Mulch").count(), 2);
|
||||
// A title edit: . types it again on another node.
|
||||
h.type_keys("ggglA!<Esc><Esc>gj.");
|
||||
assert_eq!(h.cursor(), "Raised beds!");
|
||||
// >> with a count, and the count of . taking its place.
|
||||
h.type_keys("gg");
|
||||
h.type_keys("glgj>>");
|
||||
assert_eq!(h.parent("Raised beds!"), "Soil!");
|
||||
// A color picked: . puts it on the cursor's node.
|
||||
h.type_keys("<Space>c/teal<CR>gg.");
|
||||
let root = h.doc().node(h.node("Garden plans")).unwrap();
|
||||
assert_eq!(root.look.color.as_ref().map(|c| c.as_str()), Some("teal"));
|
||||
// A change on a selection repeats on the cursor's node.
|
||||
h.type_keys("glvjdgl");
|
||||
let before = h.doc().node_count();
|
||||
h.type_keys(".");
|
||||
assert_eq!(h.doc().node_count(), before - 1, "v then d: . deletes one node, as x does");
|
||||
h.type_keys("u");
|
||||
assert_eq!(h.message(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_nodes_fuzzily_and_shows_their_note() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("/");
|
||||
assert_eq!(h.label(), "SEARCH");
|
||||
assert!(h.screen().contains("search · Garden plans"), "{}", h.screen());
|
||||
// The kitchen's compost bin isn't in this document; the garden's
|
||||
// compost note is, by its title, and so is its body.
|
||||
h.type_keys("bucket");
|
||||
let screen = h.screen();
|
||||
assert!(screen.contains("ask about compost · Bring a bucket."), "the body line it matched in:\n{screen}");
|
||||
assert!(screen.contains("The neighbor has a bin."), "the note of the match, beside it:\n{screen}");
|
||||
h.type_keys("<CR>");
|
||||
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "ask about compost"));
|
||||
assert_eq!(h.message(), "/bucket · 1 of 1 · in the body");
|
||||
// Esc goes back to where you were.
|
||||
h.type_keys("gg/rain<Esc>");
|
||||
assert_eq!(h.cursor(), "Garden plans");
|
||||
// Fuzzy: w, t and r in that order, best first.
|
||||
h.type_keys("/wtr");
|
||||
assert!(h.screen().contains("1 of"), "{}", h.screen());
|
||||
h.type_keys("<CR>");
|
||||
assert_eq!(h.cursor(), "Watering");
|
||||
// n and N go through the same matches, going round.
|
||||
h.type_keys(":find e<CR>");
|
||||
let first = h.cursor();
|
||||
h.type_keys("n");
|
||||
assert_ne!(h.cursor(), first);
|
||||
h.type_keys("N");
|
||||
assert_eq!(h.cursor(), first);
|
||||
h.type_keys("N");
|
||||
assert!(h.message().ends_with("round to the last match"), "{}", h.message());
|
||||
h.type_keys("n");
|
||||
assert!(h.message().ends_with("back to the best match"), "{}", h.message());
|
||||
assert_eq!(h.cursor(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_jump_list_goes_back_and_forward() {
|
||||
let mut h = Harness::new();
|
||||
// G and gg are jumps; Ctrl-o goes back, Tab (or Ctrl-i) forward.
|
||||
h.type_keys("G");
|
||||
let last = h.cursor();
|
||||
h.type_keys(":find soil<CR>");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("<C-o>");
|
||||
assert_eq!(h.cursor(), last);
|
||||
h.type_keys("<C-o>");
|
||||
assert_eq!(h.cursor(), "Garden plans");
|
||||
h.type_keys("<C-o>");
|
||||
assert_eq!(h.message(), "no older jump: that's where the jump list starts");
|
||||
h.type_keys("2<Tab>");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("<C-i>");
|
||||
assert_eq!(h.message(), "no newer jump: Ctrl-o goes back");
|
||||
// :jumps lists them; Enter goes to one.
|
||||
h.type_keys(":jumps<CR>");
|
||||
assert_eq!(h.label(), "MENU");
|
||||
assert!(h.screen().contains("Garden plans › Soil"), "{}", h.screen());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jumps_cross_documents_and_open_closed_ones() {
|
||||
let mut h = Harness::new();
|
||||
// gt is a jump too; in the kitchen, G is one.
|
||||
h.type_keys("gtG");
|
||||
assert_eq!(h.cursor(), "Compost bin");
|
||||
h.type_keys(":bd<CR>");
|
||||
assert_eq!(h.app.shared.docs.count(), 1);
|
||||
// Ctrl-o: back to the kitchen, opened again from its file.
|
||||
h.type_keys("<C-o>");
|
||||
assert!(h.screen().contains("canvas · Kitchen"), "{}", h.screen());
|
||||
assert_eq!(h.cursor(), "Kitchen");
|
||||
// Where Ctrl-o started is now ahead, as in vim: Tab goes there.
|
||||
h.type_keys("<Tab>");
|
||||
assert!(h.screen().contains("canvas · Garden plans"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capital_marks_lead_into_any_document_and_last() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glmA");
|
||||
assert_eq!(h.message(), "mark A is on \"Soil\" in Garden plans: 'A comes back here from any document");
|
||||
h.type_keys("gjma");
|
||||
h.type_keys("gt'A");
|
||||
assert!(h.screen().contains("canvas · Garden plans"));
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
h.type_keys("<C-o>");
|
||||
assert!(h.screen().contains("canvas · Kitchen"), "'A was a jump");
|
||||
h.type_keys(":marks<CR>");
|
||||
let screen = h.screen();
|
||||
assert!(screen.contains("A Soil Garden plans"), "{screen}");
|
||||
h.type_keys("<Esc>");
|
||||
// Both kinds last until the next run.
|
||||
h.app.remember().unwrap();
|
||||
let Harness { app, folder, .. } = h;
|
||||
drop(app);
|
||||
let mut h = Harness::open(folder, &["kitchen.cum"]);
|
||||
h.type_keys("'A");
|
||||
assert_eq!((h.app.shared.docs.count(), h.cursor().as_str()), (2, "Soil"), "the garden, opened for it");
|
||||
h.type_keys("gg'a");
|
||||
assert_eq!(h.cursor(), "Raised beds", "a small mark, kept with its document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gd_follows_a_cross_link() {
|
||||
let mut h = Harness::new();
|
||||
// Seeds to order links to the compost note ("needs").
|
||||
h.type_keys(":find seeds<CR>gd");
|
||||
assert_eq!(h.cursor(), "ask about compost");
|
||||
h.type_keys("<C-o>");
|
||||
assert_eq!(h.cursor(), "Seeds to order", "a jump: Ctrl-o comes back");
|
||||
h.type_keys("gg");
|
||||
h.type_keys("gd");
|
||||
assert_eq!(h.message(), "this node has no cross-links to follow");
|
||||
// With two links, a list to pick from.
|
||||
h.type_keys(":find compost<CR>:link soil<CR>gd");
|
||||
assert_eq!(h.label(), "MENU");
|
||||
let screen = h.screen();
|
||||
assert!(screen.contains("→ Soil") && screen.contains("← Seeds to order needs"), "{screen}");
|
||||
h.type_keys("<CR>");
|
||||
assert_eq!(h.cursor(), "Soil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_list_and_paste() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("gl\"ayygj\"byy:registers<CR>");
|
||||
let screen = h.screen();
|
||||
assert!(screen.contains("\"a 1 node") && screen.contains("\"b 3 nodes"), "{screen}");
|
||||
assert!(screen.contains("\"+ the system clipboard"));
|
||||
// Enter on "a: pasted after the cursor's node.
|
||||
h.type_keys("j<CR>");
|
||||
assert_eq!(top(&h), ["Soil", "Raised beds", "Soil", "Watering", "Kitchen"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_ended_by_a_click_repeats_without_the_next_key() {
|
||||
let mut h = Harness::new();
|
||||
h.type_keys("glA!");
|
||||
let watering = h.find("Watering");
|
||||
h.click(watering);
|
||||
assert_eq!((h.label(), h.cursor().as_str()), ("NORMAL", "Watering"));
|
||||
h.type_keys("gk.");
|
||||
assert_eq!(h.cursor(), "Raised beds!", "just the ! again, not the gk");
|
||||
}
|
||||
+14
-3
@@ -10,7 +10,7 @@
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
|
||||
use super::commands;
|
||||
use super::registers::Registers;
|
||||
use super::registers::{Kept, Registers};
|
||||
use crate::input::command::{Bound, Command, Operator, Target};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::input::key::Key;
|
||||
@@ -211,11 +211,22 @@ impl TextEdit {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let paste = registers.get(typed.register).cloned();
|
||||
// Only a paste reads the register: the clipboard is read just then.
|
||||
let paste = match typed.command {
|
||||
Command::PasteAfter | Command::PasteBefore => match registers.get(typed.register) {
|
||||
Ok(kept) => kept.map(|kept| kept.text()),
|
||||
Err(problem) => return vec![Action::say(Message::problem(problem))],
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let reply = self.editor.command(typed, paste.as_ref());
|
||||
let mut actions = self.follow(ctx);
|
||||
match reply {
|
||||
Reply::Yanked(yank) => registers.store(typed.register, yank),
|
||||
Reply::Yanked(yank) => {
|
||||
if let Err(problem) = registers.store(typed.register, Kept::Text(yank)) {
|
||||
actions.push(Action::say(Message::problem(problem)));
|
||||
}
|
||||
}
|
||||
Reply::Refused(why) => actions.push(Action::say(Message::info(why))),
|
||||
Reply::Done | Reply::NotMine => {}
|
||||
}
|
||||
|
||||
+54
-8
@@ -7,9 +7,10 @@ use ratatui::layout::Position;
|
||||
|
||||
use super::action::{Action, AppAction, DocAction, DocumentChoice, Message, Tone};
|
||||
use super::documents::Trouble;
|
||||
use super::mode::{Grab, Mode};
|
||||
use super::mode::{Grab, Mode, Visual};
|
||||
use super::panes::Arrange;
|
||||
use super::popup::{DocumentRow, MenuFor, Popup};
|
||||
use super::search::Search;
|
||||
use super::text_edit::{StartEdit, TextEdit};
|
||||
use super::{App, Pane};
|
||||
use crate::ui::command_line::CommandLine;
|
||||
@@ -38,16 +39,20 @@ impl App {
|
||||
}
|
||||
|
||||
/// A document action, on the focused pane's document. A read-only
|
||||
/// document takes cursor moves and marks, but no changes.
|
||||
fn doc_action(&mut self, action: DocAction) -> Option<Message> {
|
||||
/// document takes cursor moves and marks, but no changes. A jump that
|
||||
/// moves the cursor goes in the pane's jump list.
|
||||
pub(super) fn doc_action(&mut self, action: DocAction) -> Option<Message> {
|
||||
if matches!(action, DocAction::Edit { .. } | DocAction::Undo(_) | DocAction::Redo(_))
|
||||
&& let Some(refused) = self.refuse_read_only()
|
||||
{
|
||||
return Some(refused);
|
||||
}
|
||||
let jump = matches!(action, DocAction::MoveCursor { jump: true, .. } | DocAction::JumpToMark(_));
|
||||
let from = self.place_here();
|
||||
let open = self.shared.docs.get_mut(self.buffer());
|
||||
let before = open.buffer.history().state();
|
||||
let message = open.buffer.apply(action);
|
||||
let moved = open.buffer.cursor() != from.node;
|
||||
if open.buffer.history().state() != before {
|
||||
open.file.changed = Some(Instant::now());
|
||||
// A save that failed is tried again after the next change.
|
||||
@@ -55,6 +60,9 @@ impl App {
|
||||
open.file.trouble = None;
|
||||
}
|
||||
}
|
||||
if jump && moved {
|
||||
self.jumping(from);
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
@@ -82,12 +90,29 @@ impl App {
|
||||
edit.editor.set_cursor(at);
|
||||
}
|
||||
}
|
||||
AppAction::EnterMoveMode => {
|
||||
AppAction::EnterMoveMode(nodes) => {
|
||||
if let Some(refused) = self.refuse_read_only() {
|
||||
return Some(refused);
|
||||
}
|
||||
self.shared.mode = Mode::Move(Grab::new());
|
||||
let grab = if nodes.is_empty() { Grab::new() } else { Grab::nodes(nodes) };
|
||||
self.shared.mode = Mode::Move(grab);
|
||||
}
|
||||
AppAction::Visual { branches } => {
|
||||
let cursor = self.current().cursor();
|
||||
match &mut self.shared.mode {
|
||||
Mode::Visual(visual) => visual.branches = branches,
|
||||
mode => *mode = Mode::Visual(Visual::new(cursor, branches)),
|
||||
}
|
||||
}
|
||||
AppAction::SwapEnds => {
|
||||
let cursor = self.current().cursor();
|
||||
if let Mode::Visual(visual) = &mut self.shared.mode {
|
||||
let anchor = std::mem::replace(&mut visual.anchor, cursor);
|
||||
return self.doc_action(DocAction::MoveCursor { to: anchor, jump: false });
|
||||
}
|
||||
}
|
||||
AppAction::ToggleSelected(id) => self.toggle_selected(id),
|
||||
AppAction::SelectAlso(ids) => return self.select_also(ids),
|
||||
AppAction::ToggleWhole => {
|
||||
if let Mode::Move(grab) = &mut self.shared.mode {
|
||||
grab.whole = !grab.whole;
|
||||
@@ -107,6 +132,9 @@ impl App {
|
||||
AppAction::RunLine(text) => self.run_line(&text),
|
||||
AppAction::Run(command) => self.run_command(command),
|
||||
AppAction::Type(keys) => keys.into_iter().for_each(|key| self.key(key)),
|
||||
AppAction::Yank { register, nodes, branches } => return self.yank(register, &nodes, branches),
|
||||
AppAction::Paste { register, at, times } => return self.paste(register, at, times),
|
||||
AppAction::Picked(look) => self.picked(look),
|
||||
AppAction::ToggleFocus => {
|
||||
let focus = &mut self.shared.view.focus;
|
||||
*focus = !*focus;
|
||||
@@ -131,10 +159,26 @@ impl App {
|
||||
self.shared.mode = Mode::Popup(help);
|
||||
}
|
||||
AppAction::OpenCard => self.shared.mode = Mode::Popup(Popup::card(self.current().cursor())),
|
||||
AppAction::OpenSearch => {
|
||||
self.shared.mode = Mode::Popup(Popup::Search(Search::new(self.current().doc())));
|
||||
}
|
||||
AppAction::Searched(query) => self.last_search = Some(query),
|
||||
AppAction::FindAgain { forward, times } => return self.find_again(forward, times),
|
||||
AppAction::Travel { back, times } => return self.travel(back, times),
|
||||
AppAction::SetFileMark(name) => return self.set_file_mark(name),
|
||||
AppAction::JumpToFileMark(name) => return self.jump_to_file_mark(name),
|
||||
AppAction::GoToPlace(place) => return self.jump_to_place(&place),
|
||||
AppAction::OpenMarks => self.open_marks(),
|
||||
AppAction::OpenJumps => self.open_jumps(),
|
||||
AppAction::OpenRegisters => self.open_registers(),
|
||||
AppAction::OpenLinks => self.open_links(),
|
||||
AppAction::Find(query) => return self.find_now(query),
|
||||
AppAction::OpenPicker(kind) => {
|
||||
let documents = self.document_rows();
|
||||
let current = self.shared.docs.index_of(self.buffer());
|
||||
match Popup::picker(kind, &self.shared.ctx(self.buffer()), &documents, current) {
|
||||
// In visual mode, the picker sets every selected node.
|
||||
let nodes = self.selection().unwrap_or_else(|| vec![self.current().cursor()]);
|
||||
match Popup::picker(kind, &self.shared.ctx(self.buffer()), &nodes, &documents, current) {
|
||||
Ok(picker) => self.shared.mode = Mode::Popup(picker),
|
||||
Err(message) => return Some(message),
|
||||
}
|
||||
@@ -179,7 +223,7 @@ impl App {
|
||||
let pane = self.panes.current_mut();
|
||||
if pane.view.kind() != kind {
|
||||
let camera = self.shared.docs.get(pane.buffer).camera;
|
||||
*pane = Pane::new(pane.buffer, kind, camera);
|
||||
pane.show(pane.buffer, kind, camera);
|
||||
}
|
||||
}
|
||||
AppAction::SwitchDocument(choice) => return self.switch_document(choice),
|
||||
@@ -219,7 +263,9 @@ impl App {
|
||||
}
|
||||
let buffer = pane.buffer;
|
||||
let camera = pane.view.camera().or(self.shared.docs.get(buffer).camera);
|
||||
self.panes.split(arrange, Pane::new(buffer, kind, camera));
|
||||
// The new pane starts with a copy of this one's jump list, as in vim.
|
||||
let jumps = pane.jumps.clone();
|
||||
self.panes.split(arrange, Pane { jumps, ..Pane::new(buffer, kind, camera) });
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Visual mode: selecting nodes, then acting on all of them at once.
|
||||
//!
|
||||
//! `v` starts a selection at the cursor's node, and moving the cursor
|
||||
//! takes in what lies between: on the canvas, every node on show that the
|
||||
//! rectangle from one end's box to the other's touches; in the outline and
|
||||
//! the Note view, the nodes in between in reading order. `V` takes each
|
||||
//! node with its whole branch. A Ctrl+click adds or takes out one node, a
|
||||
//! Ctrl+drag on the canvas adds every node its rectangle touches.
|
||||
//!
|
||||
//! The operators take exactly what's selected (the user's pick in part
|
||||
//! 8c): `d` deletes the selected nodes, and with `v` the children that
|
||||
//! weren't selected move up, as `x` does; `y` yanks them, with their shape
|
||||
//! among them; `>` and `<` move them through the tree; `M` moves them on
|
||||
//! the canvas; the pickers set every one of them.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::action::{Action, AppAction, CursorAfter, DocAction, Message};
|
||||
use super::mode::{Mode, Visual};
|
||||
use super::{App, commands};
|
||||
use crate::input::command::{Command, Operator, Register, Target};
|
||||
use crate::input::engine::Typed;
|
||||
use crate::model::{CanvasPos, CanvasRect, CanvasSize, EditOp, NodeId, Visible};
|
||||
use crate::view::{ViewContext, ViewKind};
|
||||
|
||||
/// The nodes selected in a view of this kind, in reading order.
|
||||
pub fn selection(visual: &Visual, ctx: &ViewContext<'_>, kind: ViewKind) -> Vec<NodeId> {
|
||||
let doc = ctx.doc;
|
||||
let visible = Visible::new(doc, ctx.cursor, ctx.settings.focus);
|
||||
let mut chosen: BTreeSet<NodeId> = match kind {
|
||||
ViewKind::Canvas | ViewKind::Board => {
|
||||
let rects = ctx.box_rects();
|
||||
let ends: Vec<CanvasRect> =
|
||||
[visual.anchor, ctx.cursor].iter().filter_map(|id| rects.get(id).copied()).collect();
|
||||
match span(&ends) {
|
||||
Some(area) => visible
|
||||
.order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| rects.get(id).is_some_and(|rect| rect.overlaps(area)))
|
||||
.collect(),
|
||||
None => BTreeSet::from([ctx.cursor]),
|
||||
}
|
||||
}
|
||||
ViewKind::Outline | ViewKind::Note => {
|
||||
let order = doc.outline();
|
||||
let at = |id: NodeId| order.iter().position(|&other| other == id);
|
||||
let (from, to) = match (at(visual.anchor), at(ctx.cursor)) {
|
||||
(Some(a), Some(b)) => (a.min(b), a.max(b)),
|
||||
(None, Some(b)) => (b, b),
|
||||
_ => (0, 0),
|
||||
};
|
||||
order[from..=to.min(order.len().saturating_sub(1))]
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| visible.contains(id) || id == visual.anchor || id == ctx.cursor)
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
chosen.extend(visual.added.iter().copied().filter(|&id| doc.contains(id)));
|
||||
chosen.retain(|id| !visual.removed.contains(id) && doc.contains(*id));
|
||||
if visual.branches {
|
||||
let below: Vec<NodeId> = chosen.iter().flat_map(|&id| doc.descendants(id)).collect();
|
||||
chosen.extend(below);
|
||||
}
|
||||
doc.outline().into_iter().filter(|id| chosen.contains(id)).collect()
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The selected nodes, in visual mode, as the focused view takes them.
|
||||
pub(super) fn selection(&self) -> Option<Vec<NodeId>> {
|
||||
let Mode::Visual(visual) = &self.shared.mode else { return None };
|
||||
let pane = self.panes.current();
|
||||
Some(selection(visual, &self.shared.ctx(pane.buffer), pane.view.kind()))
|
||||
}
|
||||
|
||||
/// A Ctrl+click: the node goes into the selection, or out of it. In
|
||||
/// normal mode, a selection starts with the cursor's node and that one.
|
||||
pub(super) fn toggle_selected(&mut self, id: NodeId) {
|
||||
let cursor = self.current().cursor();
|
||||
let selected = self.selection().is_some_and(|selected| selected.contains(&id));
|
||||
match &mut self.shared.mode {
|
||||
Mode::Visual(visual) if selected => {
|
||||
visual.added.remove(&id);
|
||||
visual.removed.insert(id);
|
||||
}
|
||||
Mode::Visual(visual) => {
|
||||
visual.removed.remove(&id);
|
||||
visual.added.insert(id);
|
||||
}
|
||||
Mode::Normal => {
|
||||
let mut visual = Visual::new(cursor, false);
|
||||
visual.added.insert(id);
|
||||
self.shared.mode = Mode::Visual(visual);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Ctrl+drag's rectangle: its nodes go into the selection. In normal
|
||||
/// mode, a selection of just them starts, the cursor on the first.
|
||||
pub(super) fn select_also(&mut self, ids: Vec<NodeId>) -> Option<Message> {
|
||||
let &first = ids.first()?;
|
||||
if let Mode::Visual(visual) = &mut self.shared.mode {
|
||||
for id in &ids {
|
||||
visual.removed.remove(id);
|
||||
}
|
||||
visual.added.extend(ids);
|
||||
return None;
|
||||
}
|
||||
let said = self.doc_action(DocAction::MoveCursor { to: first, jump: false });
|
||||
let mut visual = Visual::new(first, false);
|
||||
visual.added.extend(ids);
|
||||
self.shared.mode = Mode::Visual(visual);
|
||||
said
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest rectangle around some boxes.
|
||||
fn span(rects: &[CanvasRect]) -> Option<CanvasRect> {
|
||||
let left = rects.iter().map(|rect| rect.left()).min()?;
|
||||
let top = rects.iter().map(|rect| rect.top()).min()?;
|
||||
let right = rects.iter().map(|rect| rect.right()).max()?;
|
||||
let bottom = rects.iter().map(|rect| rect.bottom()).max()?;
|
||||
let size = |from: i32, to: i32| u16::try_from(to - from).unwrap_or(u16::MAX);
|
||||
Some(CanvasRect::new(CanvasPos::new(left, top), CanvasSize::new(size(left, right), size(top, bottom))))
|
||||
}
|
||||
|
||||
/// A command in visual mode, or `None` for the ones that mean the same as
|
||||
/// in normal mode (moving, scrolling, marks…).
|
||||
pub fn command(
|
||||
typed: &Typed,
|
||||
visual: &Visual,
|
||||
selected: &[NodeId],
|
||||
kind: ViewKind,
|
||||
ctx: &ViewContext<'_>,
|
||||
) -> Option<Vec<Action>> {
|
||||
let leave = Action::App(AppAction::LeaveMode);
|
||||
let actions = match typed.command {
|
||||
// `v` in a `V` selection makes it a `v` one, and `v` again stops, as
|
||||
// in vim; the same for `V`.
|
||||
Command::Visual if visual.branches => app(AppAction::Visual { branches: false }),
|
||||
Command::VisualBranches if !visual.branches => app(AppAction::Visual { branches: true }),
|
||||
Command::Visual | Command::VisualBranches => vec![leave],
|
||||
Command::SwapEnds => app(AppAction::SwapEnds),
|
||||
Command::Operate(Operator::Delete, Target::Selection) => {
|
||||
delete(visual, selected, typed.register, ctx)
|
||||
}
|
||||
Command::Operate(Operator::Yank, Target::Selection) => vec![
|
||||
leave,
|
||||
Action::App(AppAction::Yank {
|
||||
register: typed.register,
|
||||
nodes: selected.to_vec(),
|
||||
branches: false,
|
||||
}),
|
||||
],
|
||||
Command::Operate(operator @ (Operator::Indent | Operator::Outdent), Target::Selection) => {
|
||||
let tops = tops(selected, ctx);
|
||||
let parent = tops.first().and_then(|&first| ctx.doc.parent(first));
|
||||
if tops.iter().any(|&top| ctx.doc.parent(top) != parent) {
|
||||
return Some(say("> and < move nodes that share a parent: these don't"));
|
||||
}
|
||||
let mut actions = vec![leave];
|
||||
actions.extend(if operator == Operator::Indent {
|
||||
commands::indent(ctx, &tops, None)
|
||||
} else {
|
||||
commands::outdent(ctx, &tops, None)
|
||||
});
|
||||
actions
|
||||
}
|
||||
Command::MoveMode if kind == ViewKind::Canvas => app(AppAction::EnterMoveMode(selected.to_vec())),
|
||||
Command::MoveMode => {
|
||||
say("several nodes move on the canvas; here, > and < move them through the tree")
|
||||
}
|
||||
Command::ConnectMode => say("connect mode comes in part 8d of the build"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(actions)
|
||||
}
|
||||
|
||||
/// `d` on a selection: exactly the selected nodes go (the root stays). With
|
||||
/// `v`, the children that weren't selected move up, as with `x`; with `V`
|
||||
/// the branches go whole. What's deleted goes in the register.
|
||||
fn delete(
|
||||
visual: &Visual,
|
||||
selected: &[NodeId],
|
||||
register: Option<Register>,
|
||||
ctx: &ViewContext<'_>,
|
||||
) -> Vec<Action> {
|
||||
let doc = ctx.doc;
|
||||
let nodes: Vec<NodeId> = selected.iter().copied().filter(|&id| id != doc.root()).collect();
|
||||
if nodes.is_empty() {
|
||||
return say("the root can't be deleted");
|
||||
}
|
||||
// The cursor goes to the last node before them that stays.
|
||||
let gone: BTreeSet<NodeId> = nodes.iter().copied().collect();
|
||||
let order = doc.outline();
|
||||
let first = order.iter().position(|id| gone.contains(id)).unwrap_or(0);
|
||||
let stays = order[..first].iter().rev().find(|id| !gone.contains(id)).copied();
|
||||
let op = if visual.branches {
|
||||
EditOp::RemoveBranches(nodes.clone())
|
||||
} else {
|
||||
EditOp::RemoveKeepingChildren(nodes.clone())
|
||||
};
|
||||
vec![
|
||||
Action::App(AppAction::LeaveMode),
|
||||
Action::App(AppAction::Yank { register, nodes, branches: false }),
|
||||
Action::edit_then(op, stays.map_or(CursorAfter::Stay, CursorAfter::To)),
|
||||
]
|
||||
}
|
||||
|
||||
/// The selected nodes that aren't under another selected node.
|
||||
fn tops(selected: &[NodeId], ctx: &ViewContext<'_>) -> Vec<NodeId> {
|
||||
let set: BTreeSet<NodeId> = selected.iter().copied().collect();
|
||||
selected
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| !ctx.doc.ancestors(id).any(|ancestor| set.contains(&ancestor)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn app(action: AppAction) -> Vec<Action> {
|
||||
vec![Action::App(action)]
|
||||
}
|
||||
|
||||
fn say(text: &str) -> Vec<Action> {
|
||||
vec![Action::say(Message::info(text))]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::test_support::garden;
|
||||
use crate::view::test_support::Setup;
|
||||
|
||||
#[test]
|
||||
fn the_canvas_takes_the_rectangle_the_outline_the_rows() {
|
||||
let ((doc, g), setup) = (garden(), Setup::default());
|
||||
// Soil (22,0) to Watering (22,8): Raised beds (22,4) is between;
|
||||
// Cedar (40,4) is off to the right.
|
||||
let visual = Visual::new(g.soil, false);
|
||||
let ctx = setup.ctx(&doc, g.watering);
|
||||
assert_eq!(selection(&visual, &ctx, ViewKind::Canvas), [g.soil, g.beds, g.watering]);
|
||||
// In reading order, Cedar is between Raised beds and Watering.
|
||||
assert_eq!(selection(&visual, &ctx, ViewKind::Outline), [g.soil, g.beds, g.cedar, g.watering]);
|
||||
// V: with their branches; Ctrl+click picks: added and taken out.
|
||||
let mut visual = Visual::new(g.soil, true);
|
||||
visual.removed.insert(g.watering);
|
||||
visual.added.insert(g.compost);
|
||||
assert_eq!(selection(&visual, &ctx, ViewKind::Canvas), [g.soil, g.beds, g.cedar, g.compost]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Nodes into registers and back: `yy` and the deletes keep nodes in a
|
||||
//! register, `p` `P` `]p` paste them (or a register's text, made into
|
||||
//! nodes). See [`registers`](super::registers).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::App;
|
||||
use super::action::{self, CursorAfter, DocAction, Message, PasteAt};
|
||||
use super::commands;
|
||||
use super::registers::Kept;
|
||||
use crate::input::command::Register;
|
||||
use crate::model::{Clip, NodeId};
|
||||
|
||||
impl App {
|
||||
/// Keeps nodes of the focused document in a register: exactly `nodes`,
|
||||
/// or (`branches`) each with its whole branch.
|
||||
pub(super) fn yank(
|
||||
&mut self,
|
||||
register: Option<Register>,
|
||||
nodes: &[NodeId],
|
||||
branches: bool,
|
||||
) -> Option<Message> {
|
||||
let doc = self.current().doc();
|
||||
let clip = if branches {
|
||||
Clip::branches(doc, nodes)
|
||||
} else {
|
||||
Clip::take(doc, &nodes.iter().copied().collect::<BTreeSet<NodeId>>())
|
||||
};
|
||||
let count = action::nodes(clip.count());
|
||||
if let Err(problem) = self.registers.store(register, Kept::Nodes(clip)) {
|
||||
return Some(Message::problem(problem));
|
||||
}
|
||||
Some(Message::done(match register {
|
||||
Some(Register::Clipboard) => format!("copied {count} to the clipboard"),
|
||||
Some(Register::Named(name)) => format!("yanked {count} into \"{name}"),
|
||||
Some(Register::BlackHole) => return None,
|
||||
Some(Register::Unnamed) | None => format!("yanked {count}"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Pastes what a register holds around the cursor's node, `times` over.
|
||||
pub(super) fn paste(&mut self, register: Option<Register>, at: PasteAt, times: u32) -> Option<Message> {
|
||||
if let Some(refused) = self.refuse_read_only() {
|
||||
return Some(refused);
|
||||
}
|
||||
let kept = match self.registers.get(register) {
|
||||
Ok(Some(kept)) => kept,
|
||||
Ok(None) => return Some(Message::info("nothing to paste yet: yy or dd keeps nodes to paste")),
|
||||
Err(problem) => return Some(Message::problem(problem)),
|
||||
};
|
||||
let Some(mut clip) = kept.clip() else {
|
||||
return Some(Message::info("the register's text is empty: no nodes to make"));
|
||||
};
|
||||
let ctx = self.shared.ctx(self.buffer());
|
||||
if !clip.placed {
|
||||
clip.lay_out(|node| ctx.box_size(node));
|
||||
}
|
||||
let count = clip.count() * usize::try_from(times.max(1)).unwrap_or(1);
|
||||
let op = commands::paste(&ctx, clip, at, times)?;
|
||||
let done =
|
||||
self.doc_action(DocAction::Edit { op: Box::new(op), cursor: CursorAfter::Added, group: None });
|
||||
done.or_else(|| (count > 1).then(|| Message::done(format!("pasted {}", action::nodes(count)))))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user