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

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

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

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

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

262 lines
11 KiB
Rust

//! Keys and the mouse, routed: to the command line while it's open, to the
//! text being edited, to an open popup, or through the key engine to the
//! focused view and then the shared commands.
use ratatui::layout::Position;
use super::action::{Action, AppAction, DocumentChoice, Message};
use super::mode::Mode;
use super::{App, calls, commands, connect, visual};
use crate::command::parse::{Sources, parse};
use crate::config::settings::Setting;
use crate::config::theme_file;
use crate::input::command::{Bound, Command, help_for};
use crate::input::engine::{Outcome, Typed};
use crate::input::key::Key;
use crate::input::mouse::{Cell, Gesture};
use crate::ui::command_line::LineOutcome;
use crate::ui::document_bar::DocumentBar;
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(_) => self.command_line_key(key),
Mode::Text(_) => self.text_key(key),
Mode::Popup(_) => self.popup_key(key),
Mode::Normal | Mode::Visual(_) | Mode::Move(_) | Mode::Connect(_) => {
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();
// Portals show the documents they lead to as they are now.
self.refresh_portals(false);
}
/// A command: the focused view has the first say, then visual or
/// connect mode's commands (in those modes), 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(|| match &self.shared.mode {
Mode::Visual(visual) => visual::command(&typed, visual, &selected, kind, &ctx),
Mode::Connect(connect) => connect::command(&typed, connect, &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))]
});
self.update(actions);
}
/// Runs a command as if its keys were typed (the help and the menu).
pub(super) fn run_command(&mut self, command: Command) {
self.dispatch(Typed { command, count: None, register: None });
}
/// A key while a text is edited in a view: typed into it, or a vim key
/// inside it.
fn text_key(&mut self, key: Key) {
let tables = self.keymap.tables(self.contexts());
// The edit comes out of the mode while it works, so it can look at
// the document; it goes back in before its actions run.
let Mode::Text(mut edit) = std::mem::take(&mut self.shared.mode) else { return };
let ctx = self.shared.ctx(self.buffer());
let actions = if edit.editor.is_typing() {
// Typing: a key the table doesn't bind types itself.
edit.typing(key, tables.bound(&[key]), &ctx)
} else {
match self.keys.press(key, &tables) {
Outcome::Run(typed) => edit.vim(&typed, &mut self.registers, &ctx),
Outcome::Unknown(keys) => {
vec![Action::say(Message::info(format!("{keys} does nothing here")))]
}
Outcome::Waiting | Outcome::Cancelled => Vec::new(),
}
};
self.shared.mode = Mode::Text(edit);
self.update(actions);
}
/// A key while a popup is open: typed into its search (or the card's
/// body), or one of its keys.
fn popup_key(&mut self, key: Key) {
let tables = self.keymap.tables(self.contexts());
let Mode::Popup(mut popup) = std::mem::take(&mut self.shared.mode) else { return };
let ctx = self.shared.ctx(self.buffer());
let actions = if popup.takes_typing() {
popup.typing(key, &self.keymap, &ctx)
} else {
match self.keys.press(key, &tables) {
Outcome::Run(typed) => popup.command(&typed, &mut self.registers, &ctx),
Outcome::Unknown(keys) => {
vec![Action::say(Message::info(format!("{keys} does nothing here")))]
}
Outcome::Waiting | Outcome::Cancelled => Vec::new(),
}
};
self.shared.mode = Mode::Popup(popup);
self.update(actions);
}
/// A key while the command line is open.
fn command_line_key(&mut self, key: Key) {
let sources = self.sources();
let Mode::Command(line) = &mut self.shared.mode else { return };
match line.key(key, &sources, &self.command_history) {
LineOutcome::Typing => {}
LineOutcome::Close => self.shared.mode = Mode::Normal,
LineOutcome::Run(text) => {
self.shared.mode = Mode::Normal;
if text.trim().is_empty() {
return;
}
self.command_history.add(&text);
self.run_line(&text);
}
}
}
/// Runs a command line (without its `:`).
pub(super) fn run_line(&mut self, text: &str) {
let sources = self.sources();
let actions = match parse(text, &sources) {
Ok(call) => calls::actions_for(call, &self.shared.ctx(self.buffer())),
Err(error) => vec![Action::say(Message::problem(error.to_string()))],
};
self.update(actions);
}
/// What the command line can name: the open documents, the focused
/// document's nodes and tags, the palette's colors, the settings.
pub(super) fn sources(&self) -> Sources {
let doc = self.current().doc();
let nodes = doc.outline().into_iter().filter_map(|id| Some((id, doc.node(id)?.title().to_string())));
let mut tags: Vec<String> = doc
.outline()
.into_iter()
.filter_map(|id| doc.node(id))
.flat_map(|node| node.tags.iter().map(|tag| tag.name().to_string()).collect::<Vec<_>>())
.collect();
tags.sort();
tags.dedup();
let theme = &self.shared.theme;
Sources {
documents: self.shared.names(),
nodes: nodes.collect(),
colors: theme.palette.keys().map(ToString::to_string).collect(),
styles: theme.node_styles.keys().map(ToString::to_string).collect(),
themes: theme_file::names(&self.paths),
settings: Setting::ALL.iter().map(|setting| setting.name().to_string()).collect(),
tags,
folder: self.folder(),
}
}
/// A mouse gesture: a popup has the mouse while it's open; otherwise it
/// goes to the document bar or to the pane under it (a drag stays with
/// the pane it started in), which takes the focus.
pub fn gesture(&mut self, gesture: Gesture) {
self.route_gesture(gesture);
self.refresh_portals(false);
}
fn route_gesture(&mut self, gesture: Gesture) {
let at = |cell: Cell| Position::new(cell.col, cell.row);
// In connect mode a click only aims: no editing, no menu, no
// selecting.
let gesture = match (&self.shared.mode, gesture) {
(Mode::Connect(_), Gesture::RightPress { .. }) => return,
(Mode::Connect(_), Gesture::Press { at, .. } | Gesture::DoublePress { at }) => {
Gesture::Press { at, ctrl: false }
}
_ => gesture,
};
// A click drops a half-typed key sequence: a `d` typed before
// clicking somewhere else mustn't wait to delete what comes next.
if matches!(gesture, Gesture::Press { .. } | Gesture::DoublePress { .. } | Gesture::RightPress { .. })
{
self.keys.reset();
if let Mode::Command(_) = self.shared.mode {
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()));
self.shared.mode = Mode::Popup(popup);
return self.update(actions);
}
if let Gesture::Press { at: cell, .. } = gesture
&& cell.row == self.screen.bar.y
{
let names = self.bar_names();
let current = self.shared.docs.index_of(self.buffer());
let bar = DocumentBar { names: &names, current, theme: &self.shared.theme };
if let Some(index) = bar.at(cell.col.saturating_sub(self.screen.bar.x)) {
self.update(vec![Action::App(AppAction::SwitchDocument(DocumentChoice::At(index)))]);
}
return;
}
let target = match gesture {
Gesture::Press { at: cell, .. }
| Gesture::DoublePress { at: cell }
| Gesture::RightPress { at: cell }
| Gesture::Scroll { at: cell, .. } => self.panes.at(self.screen.panes, at(cell)),
Gesture::Drag { .. } | Gesture::Release { .. } => self.dragging,
};
let Some(target) = target else { return };
if let Gesture::Press { .. } = gesture {
self.dragging = Some(target);
}
if target != self.panes.focused() {
// Another pane: a text edited in this one is done first, move or
// visual mode ends, and connect mode is cancelled.
let ctx = self.shared.ctx(self.buffer());
let mut actions = self.shared.mode.edit().map(|edit| edit.finish(&ctx)).unwrap_or_default();
match &self.shared.mode {
Mode::Move(_) | Mode::Visual(_) => actions.push(Action::App(AppAction::LeaveMode)),
Mode::Connect(connecting) => actions.extend(connect::cancel(connecting)),
_ => {}
}
self.update(actions);
self.panes.focus(target);
}
let pane = self.panes.current_mut();
let actions = pane.view.mouse(gesture, &self.shared.ctx(pane.buffer));
if let Gesture::Release { .. } = gesture {
self.dragging = None;
}
self.update(actions);
}
}
/// What a command does, in words: its own help, or its operator's.
fn describe(command: Command) -> &'static str {
match (help_for(Bound::Command(command)), command) {
("", Command::Operate(operator, _)) => help_for(Bound::Operator(operator)),
(help, _) => help,
}
}