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]>
262 lines
11 KiB
Rust
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,
|
|
}
|
|
}
|