//! Popups that take the keys for a while: the help (`?`), the right-click //! menu, the pickers (`c` and the others) and the `K` card. An open //! popup is a mode ([`Mode::Popup`](super::mode::Mode::Popup)): keys and //! clicks go to it, and what it does comes out as actions, like everything //! else. //! //! The help and the menu are lists of things to run: running one closes the //! popup and does it as if its keys were typed. A picker sets something on //! the node it opened on. The card shows the cursor's body, and edits it in //! place. use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::Span; use unicode_width::UnicodeWidthStr; use super::action::{Action, AppAction, DocumentChoice, Message}; use super::commands; use super::registers::Registers; use super::text_edit::{EditAt, StartEdit, TextEdit}; use crate::command::parse::border; use crate::command::registry::{BORDERS, SPECS}; use crate::input::bindings::Context; use crate::input::command::{Bound, Command, Operator, Target, TargetBound, help_for}; use crate::input::engine::Typed; use crate::input::key::{Key, notation}; use crate::input::keymap::{Keymap, Tables}; use crate::input::mouse::{Cell, Gesture}; use crate::model::{Border, CanvasPos, ColorName, Content, Direction, EditOp, NodeId, StyleName, Tag, Tags}; use crate::ui::card::{CardText, CardView}; use crate::ui::list::{ListOutcome, ListPopup, ListRow, Place}; use crate::ui::theme::Theme; use crate::view::ViewContext; /// How wide the help is, at most. const HELP_WIDTH: u16 = 76; /// How wide a picker is, at most. const PICKER_WIDTH: u16 = 44; /// The widest the keys column of the help gets. const KEYS_WIDTH: usize = 12; /// A popup, with the keys. #[derive(Debug, Clone)] pub enum Popup { /// Every key and command, to search and run. Help(Runs), /// The right-click menu. Menu(Runs), Pick(Picker), Card(Card), } /// What the menu is for: a node, or a spot on the empty canvas. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MenuFor { Node(NodeId), Canvas(CanvasPos), } /// What a picker picks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PickKind { Color, Border, Style, Tags, /// The document the pane shows. Document, } /// A list whose rows run something: the help and the menu. #[derive(Debug, Clone)] pub struct Runs { pub list: ListPopup, /// What each row runs. runs: Vec, } /// What running a row of the help or the menu does. #[derive(Debug, Clone, PartialEq, Eq)] enum Run { /// Nothing: a heading. Nothing, /// A command, as if its keys were typed. Command(Command), /// Keys, as if typed, for a key that waits for more. Keys(Vec), /// A `:` command, run at once. Line(String), /// The command line with this typed: a `:` command that needs more. Ask(String), /// A new loose idea, here on the canvas. NewIdeaAt(CanvasPos), } /// A picker: the node it sets something on, and each row's value. #[derive(Debug, Clone)] pub struct Picker { pub list: ListPopup, node: NodeId, values: Vec, /// The node's tags as they are now, in the tags picker. tags: Tags, } #[derive(Debug, Clone, PartialEq, Eq)] enum Pick { Color(Option), Border(Option), Style(Option), Tag(Tag), Document(usize), } /// The `K` card: the node it shows, where it is, and its body while it's /// edited there. #[derive(Debug, Clone)] pub struct Card { pub node: NodeId, pub edit: Option, pub view: CardView, } impl Popup { /// The help for the keys of `contexts` (a view's, in normal mode), then /// every `:` command, with `search` already typed. pub fn help( keymap: &Keymap, contexts: &'static [Context], view: &str, search: &str, theme: &Theme, ) -> Popup { let mut entries: Vec = Vec::new(); let mut seen: Vec> = Vec::new(); for context in contexts { let tables = keymap.tables(std::slice::from_ref(context)); for binding in tables.bindings() { if seen.contains(&binding.keys) { continue; } seen.push(binding.keys.clone()); let shown = notation(&binding.keys); let section = section(&tables, *context, &binding.keys); match binding.bound { Bound::Command(command) => { entries.push(Entry::new(section, shown, binding.help.clone(), Run::Command(command))); } Bound::WithChar(_) => { let help = format!("{} (then a letter)", binding.help); entries.push(Entry::new( section, format!("{shown}…"), help, Run::Keys(binding.keys.clone()), )); } Bound::Operator(operator) => { // The operator with each target: its own key again, // then the others. let mut targets = vec![(binding.keys.clone(), Target::Current)]; for target in tables.targets_starting_with(&[]) { if let TargetBound::Target(kind) = target.bound { targets.push((target.keys, kind)); } } for (keys, target) in targets { let keys = notation(&[binding.keys.clone(), keys].concat()); let run = Run::Command(Command::Operate(operator, target)); entries.push(Entry::new( "operators".into(), keys, operator_help(operator, target), run, )); } } } } } for spec in SPECS { let needs_more = spec.args.iter().any(|arg| arg.required); let run = if needs_more { Run::Ask(format!("{} ", spec.name)) } else { Run::Line(spec.name.to_string()) }; entries.push(Entry::new( "commands".into(), format!(":{}", spec.name), spec.help.to_string(), run, )); } // The entries under their headings, the headings in the order they // come. let key_style = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD); let heading = Style::new().fg(theme.muted).add_modifier(Modifier::BOLD); let width = entries.iter().map(|entry| entry.keys.width()).max().unwrap_or(0).min(KEYS_WIDTH); let mut sections: Vec<&str> = Vec::new(); for entry in &entries { if !sections.contains(&entry.section.as_str()) { sections.push(&entry.section); } } let (mut rows, mut runs) = (Vec::new(), Vec::new()); for section in sections { rows.push(ListRow::heading(section, heading)); runs.push(Run::Nothing); for entry in entries.iter().filter(|entry| entry.section == section) { let keys = Span::styled(format!("{: Popup { let tables = keymap.tables(contexts); let items: Vec<(&str, Run)> = match what { MenuFor::Node(_) => { NODE_MENU.iter().map(|&(label, command)| (label, Run::Command(command))).collect() } MenuFor::Canvas(spot) => vec![ ("new idea here", Run::NewIdeaAt(spot)), ("zoom in", Run::Command(Command::ZoomIn)), ("zoom out", Run::Command(Command::ZoomOut)), ], }; let key_style = Style::new().fg(theme.muted); let (mut rows, mut runs) = (Vec::new(), Vec::new()); for (label, run) in items { let mut row = ListRow::new(vec![Span::raw(label)], label); if let Run::Command(command) = run && let Some(keys) = keys_for(&tables, command) { row = row.with_note(Span::styled(keys, key_style)); } rows.push(row); runs.push(run); } let list = ListPopup::new(title, "Enter or a click: do it", rows, Place::At(at)); Popup::Menu(Runs { list, runs }) } /// A picker for the cursor's node (or, for documents, for the pane). /// `documents` are the open documents' names, and `current` the one the /// pane shows. pub fn picker( kind: PickKind, ctx: &ViewContext<'_>, documents: &[String], current: usize, ) -> Result { let (doc, theme, glyphs) = (ctx.doc, ctx.theme, ctx.theme.glyphs); let node = doc.node(ctx.cursor).ok_or_else(|| Message::problem("that node is gone"))?; let check = |on: bool| on.then(|| Span::styled("✓", Style::new().fg(theme.accent))); let row = |spans: Vec>, search: &str, on: bool| { let row = ListRow::new(spans, search); match check(on) { Some(mark) => row.with_note(mark), None => row, } }; let muted = Style::new().fg(theme.muted); let mut tags = Tags::default(); let (title, values, rows): (String, Vec, Vec) = match kind { PickKind::Color => { let mut values = vec![Pick::Color(None)]; values.extend(theme.palette.keys().cloned().map(|name| Pick::Color(Some(name)))); let rows = values .iter() .map(|value| { let Pick::Color(color) = value else { return ListRow::new(Vec::new(), "") }; let on = node.look.color == *color; match color { None => row(vec![Span::styled("theme default", muted)], "theme default none", on), Some(name) => { let swatch = Span::styled( format!("{} ", glyphs.bullet()), Style::new().fg(theme.palette.get(name).copied().unwrap_or_default()), ); row(vec![swatch, Span::raw(name.to_string())], name.as_str(), on) } } }) .collect(); (format!("color · {}", node.title()), values, rows) } PickKind::Border => { let mut values = vec![Pick::Border(None)]; let mut rows = vec![row( vec![Span::styled("theme default", muted)], "theme default", node.look.border.is_none(), )]; for choice in BORDERS { let Ok(kind) = border(choice.word) else { continue }; let on = node.look.border == Some(kind); let spans = vec![Span::raw(format!("{:<8}", choice.word)), Span::styled(choice.help, muted)]; rows.push(row(spans, choice.word, on)); values.push(Pick::Border(Some(kind))); } (format!("border · {}", node.title()), values, rows) } PickKind::Style => { if theme.node_styles.is_empty() { return Err(Message::info( "this theme has no named styles yet: theme files come in stage 8", )); } let mut values = vec![Pick::Style(None)]; values.extend(theme.node_styles.keys().cloned().map(|name| Pick::Style(Some(name)))); let rows = values .iter() .map(|value| { let Pick::Style(style) = value else { return ListRow::new(Vec::new(), "") }; let on = node.look.style == *style; match style { None => row(vec![Span::styled("no style", muted)], "no style none", on), Some(name) => row(vec![Span::raw(name.to_string())], name.as_str(), on), } }) .collect(); (format!("style · {}", node.title()), values, rows) } PickKind::Tags => { tags = node.tags.clone(); // Every tag in the document, and the theme's. let mut all: Vec = doc .outline() .into_iter() .filter_map(|id| doc.node(id)) .flat_map(|node| node.tags.iter().cloned().collect::>()) .collect(); all.extend(theme.tag_styles.iter().map(|(tag, _)| tag.clone())); all.sort(); all.dedup(); let rows = all .iter() .map(|tag| row(vec![Span::raw(tag.to_string())], tag.name(), tags.contains(tag))) .collect(); (format!("tags · {}", node.title()), all.into_iter().map(Pick::Tag).collect(), rows) } PickKind::Document => { let rows = documents .iter() .enumerate() .map(|(index, name)| row(vec![Span::raw(name.clone())], name, index == current)) .collect(); ("documents".to_string(), (0..documents.len()).map(Pick::Document).collect(), rows) } }; let footer = match kind { PickKind::Tags => "Enter: on or off · / then a new one: add it", PickKind::Document => "Enter: show it here · Esc: close", _ => "Enter: pick · Esc: close", }; let mut list = ListPopup::new(title, footer, rows, Place::Center { width: PICKER_WIDTH }); // Start on the value the node has now. if let Some(index) = values.iter().position(|value| picked(value, node, &tags, current)) { list.select(index); } Ok(Popup::Pick(Picker { list, node: ctx.cursor, values, tags })) } /// The `K` card for a node. pub fn card(node: NodeId) -> Popup { Popup::Card(Card { node, edit: None, view: CardView::default() }) } /// The mode's name for the status bar. pub fn label(&self) -> &'static str { match self { Popup::Help(_) => "HELP", Popup::Menu(_) => "MENU", Popup::Pick(_) => "PICK", Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => "INSERT", Popup::Card(Card { edit: Some(_), .. }) => "TEXT", Popup::Card(_) => "CARD", } } /// The key tables while it's open. pub fn contexts(&self) -> &'static [Context] { match self { Popup::Help(_) | Popup::Menu(_) | Popup::Pick(_) => &[Context::List], Popup::Card(Card { edit: Some(edit), .. }) if edit.editor.is_typing() => &[Context::Insert], Popup::Card(Card { edit: Some(_), .. }) => &[Context::Text], Popup::Card(_) => &[Context::Card], } } /// Whether keys go straight to it rather than through the key engine: /// while a search or a body is typed. pub fn takes_typing(&self) -> bool { match self { Popup::Help(runs) | Popup::Menu(runs) => runs.list.is_searching(), Popup::Pick(picker) => picker.list.is_searching(), Popup::Card(card) => card.edit.as_ref().is_some_and(|edit| edit.editor.is_typing()), } } /// The body being edited in the card. pub fn edit(&self) -> Option<&TextEdit> { match self { Popup::Card(card) => card.edit.as_ref(), _ => None, } } pub fn edit_mut(&mut self) -> Option<&mut TextEdit> { match self { Popup::Card(card) => card.edit.as_mut(), _ => None, } } /// A key typed straight in (see [`Popup::takes_typing`]). pub fn typing(&mut self, key: Key, keymap: &Keymap, ctx: &ViewContext<'_>) -> Vec { let outcome = match self { Popup::Help(runs) | Popup::Menu(runs) => runs.list.search_key(key), Popup::Pick(picker) => picker.list.search_key(key), Popup::Card(card) => { let bound = keymap.tables(&[Context::Insert]).bound(&[key]); return card.edit.as_mut().map(|edit| edit.typing(key, bound, ctx)).unwrap_or_default(); } }; self.outcome(outcome, ctx) } /// A command from its key tables. pub fn command( &mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>, ) -> Vec { let outcome = match self { Popup::Help(runs) | Popup::Menu(runs) => runs.list.command(typed), Popup::Pick(picker) => picker.list.command(typed), Popup::Card(card) => return card.command(typed, registers, ctx), }; self.outcome(outcome, ctx) } /// A mouse gesture, anywhere on the screen. pub fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec { let list = match self { Popup::Help(runs) | Popup::Menu(runs) => &mut runs.list, Popup::Pick(picker) => &mut picker.list, Popup::Card(card) => return card.mouse(gesture, ctx), }; let outcome = match gesture { Gesture::Press { at, .. } => list.click(position(at)), Gesture::Scroll { direction, .. } => { list.wheel(direction == Direction::Down); ListOutcome::Stay } Gesture::RightPress { at } if list.click(position(at)) == ListOutcome::Close => { ListOutcome::Close } _ => ListOutcome::Stay, }; self.outcome(outcome, ctx) } /// Works out where it goes on `screen`. `anchor` is where the cursor's /// node is on screen, for the card. pub fn layout(&mut self, screen: Rect, anchor: Option, ctx: &ViewContext<'_>) { match self { Popup::Help(runs) | Popup::Menu(runs) => runs.list.layout(screen), Popup::Pick(picker) => picker.list.layout(screen), Popup::Card(card) => { if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) { card.view.layout(screen, anchor, &text); } } } } pub fn render(&self, frame: &mut Frame, ctx: &ViewContext<'_>) { match self { Popup::Help(runs) | Popup::Menu(runs) => runs.list.render(frame, ctx.theme), Popup::Pick(picker) => picker.list.render(frame, ctx.theme), Popup::Card(card) => { if let Some(text) = card_text(card.node, card.edit.as_ref(), ctx) { card.view.render(frame, &text, ctx.theme); } } } } /// What a list's outcome means for this popup. fn outcome(&mut self, outcome: ListOutcome, ctx: &ViewContext<'_>) -> Vec { match (outcome, self) { (ListOutcome::Stay, _) => Vec::new(), (ListOutcome::Close, _) => leave(), (ListOutcome::Picked(index), Popup::Help(runs) | Popup::Menu(runs)) => runs.run(index, ctx), (ListOutcome::Picked(index), Popup::Pick(picker)) => picker.pick(index, ctx), (ListOutcome::NoMatch(text), Popup::Pick(picker)) => picker.add_tag(&text, ctx), (ListOutcome::Picked(_) | ListOutcome::NoMatch(_), _) => Vec::new(), } } } /// The node menu: what you can do with a node, each with its keys. const NODE_MENU: &[(&str, Command)] = &[ ("edit the title", Command::EditTitle), ("add a child", Command::AddChild), ("add a sibling below", Command::AddBelow), ("show the body", Command::ShowBody), ("fold or unfold", Command::ToggleFold), ("color…", Command::PickColor), ("border…", Command::PickBorder), ("style…", Command::PickStyle), ("tags…", Command::EditTags), ("move it", Command::MoveMode), ("open it in the Note view", Command::ShowNote), ("delete just this node", Command::DeleteNode), ("delete it with its branch", Command::Operate(Operator::Delete, Target::Current)), ]; /// A row of the help, before it's laid out. struct Entry { section: String, keys: String, help: String, run: Run, } impl Entry { fn new(section: String, keys: String, help: String, run: Run) -> Entry { Entry { section, keys, help, run } } } /// The heading a key goes under in the help: its group (`g`: go to) when it /// has more than one key, or else where it works. fn section(tables: &Tables<'_>, context: Context, keys: &[Key]) -> String { if keys.len() > 1 && let Some(group) = tables.group_name(&keys[..1]) { return group.to_string(); } match context { Context::Canvas => "on the canvas", Context::Outline => "in the outline", Context::Note => "in the Note view", Context::Normal => "nodes", Context::Motions => "moving around", other => other.name(), } .to_string() } /// What an operator with a target does, in words. fn operator_help(operator: Operator, target: Target) -> String { let named = help_for(Bound::Command(Command::Operate(operator, target))); if !named.is_empty() { return named.to_string(); } let verb = help_for(Bound::Operator(operator)).trim_end_matches('…'); format!("{verb} this node, with its branch") } /// The keys that run `command` in these tables, for the menu. fn keys_for(tables: &Tables<'_>, command: Command) -> Option { let bindings = tables.bindings(); if let Some(binding) = bindings.iter().find(|binding| binding.bound == Bound::Command(command)) { return Some(notation(&binding.keys)); } let Command::Operate(operator, target) = command else { return None }; let keys = &bindings.iter().find(|binding| binding.bound == Bound::Operator(operator))?.keys; let target_keys = match target { Target::Current => keys.clone(), _ => { tables .targets_starting_with(&[]) .into_iter() .find(|bound| bound.bound == TargetBound::Target(target))? .keys } }; Some(notation(&[keys.clone(), target_keys].concat())) } /// Whether a picker's value is the one the node has now. fn picked(value: &Pick, node: &crate::model::Node, tags: &Tags, current: usize) -> bool { match value { Pick::Color(color) => node.look.color == *color, Pick::Border(border) => node.look.border == *border, Pick::Style(style) => node.look.style == *style, Pick::Tag(tag) => tags.contains(tag), Pick::Document(index) => *index == current, } } fn leave() -> Vec { vec![Action::App(AppAction::LeaveMode)] } fn position(cell: Cell) -> Position { Position::new(cell.col, cell.row) } impl Runs { /// Runs a row: the popup closes, and it's done as if its keys were /// typed. fn run(&self, index: usize, ctx: &ViewContext<'_>) -> Vec { let Some(run) = self.runs.get(index) else { return Vec::new() }; let then = match run { Run::Nothing => return Vec::new(), Run::Command(command) => vec![Action::App(AppAction::Run(*command))], Run::Keys(keys) => vec![Action::App(AppAction::Type(keys.clone()))], Run::Line(line) => vec![Action::App(AppAction::RunLine(line.clone()))], Run::Ask(text) => vec![Action::App(AppAction::CommandLineWith(text.clone()))], Run::NewIdeaAt(spot) => commands::add_loose(ctx, *spot), }; let mut actions = leave(); actions.extend(then); actions } } impl Picker { /// Picks a row. A tag goes on or off and the picker stays open; the /// rest close it. fn pick(&mut self, index: usize, ctx: &ViewContext<'_>) -> Vec { let id = self.node; let op = match self.values.get(index) { None => return Vec::new(), Some(Pick::Color(color)) => EditOp::SetColor { id, color: color.clone() }, Some(Pick::Border(border)) => EditOp::SetBorder { id, border: *border }, Some(Pick::Style(style)) => EditOp::SetStyle { id, style: style.clone() }, Some(Pick::Document(at)) => { let mut actions = leave(); actions.push(Action::App(AppAction::SwitchDocument(DocumentChoice::At(*at)))); return actions; } Some(Pick::Tag(tag)) => { let tag = tag.clone(); let on = !self.tags.contains(&tag); if on { self.tags.insert(tag.clone()); } else { self.tags.remove(&tag); } self.list.set_row(index, tag_row(&tag, on, ctx.theme)); let (add, remove) = if on { (vec![tag], Vec::new()) } else { (Vec::new(), vec![tag]) }; return vec![Action::edit(EditOp::Tag { id, add, remove })]; } }; let mut actions = leave(); actions.push(Action::edit(op)); actions } /// Enter on a search that matches no tag: a new tag, put on the node. fn add_tag(&mut self, text: &str, ctx: &ViewContext<'_>) -> Vec { if !self.values.iter().all(|value| matches!(value, Pick::Tag(_))) { return Vec::new(); } match Tag::parse(text) { Ok(tag) if self.tags.contains(&tag) => Vec::new(), Ok(tag) => { self.tags.insert(tag.clone()); self.list.push(tag_row(&tag, true, ctx.theme)); self.values.push(Pick::Tag(tag.clone())); vec![Action::edit(EditOp::Tag { id: self.node, add: vec![tag], remove: Vec::new() })] } Err(error) => vec![Action::say(Message::problem(error.to_string()))], } } } /// A row of the tags picker, checked when the node has the tag. fn tag_row(tag: &Tag, on: bool, theme: &Theme) -> ListRow { let row = ListRow::new(vec![Span::raw(tag.to_string())], tag.name()); if on { row.with_note(Span::styled("✓", Style::new().fg(theme.accent))) } else { row } } impl Card { fn command(&mut self, typed: &Typed, registers: &mut Registers, ctx: &ViewContext<'_>) -> Vec { if let Some(edit) = &mut self.edit { return edit.vim(typed, registers, ctx); } let times = isize::try_from(typed.times()).unwrap_or(1); let start = |at| vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))]; match typed.command { Command::ScrollDown => self.view.scroll_by(times), Command::ScrollUp => self.view.scroll_by(-times), Command::HalfPageDown => self.view.scroll_by(self.view.half() * times), Command::HalfPageUp => self.view.scroll_by(-self.view.half() * times), Command::EditBody => return start(EditAt::End), Command::EditBodyAtStart => return start(EditAt::Start), Command::Escape => return leave(), _ => {} } Vec::new() } /// While the body is edited, a click in it moves the text cursor and a /// click outside is done editing; otherwise a click outside closes the /// card, and a double-click in the body edits it from there. fn mouse(&mut self, gesture: Gesture, ctx: &ViewContext<'_>) -> Vec { let offset_at = |card: &Card, at: Position| { card_text(card.node, card.edit.as_ref(), ctx).and_then(|text| card.view.offset_at(at, &text)) }; match gesture { Gesture::Press { at, .. } => { let at = position(at); match (&self.edit, offset_at(self, at)) { (Some(_), Some(offset)) => vec![Action::App(AppAction::TextCursor(offset))], (_, _) if self.view.contains(at) => Vec::new(), (Some(edit), None) => edit.finish(ctx), (None, _) => leave(), } } Gesture::DoublePress { at } if self.edit.is_none() => { let at = match offset_at(self, position(at)) { Some(offset) => EditAt::Offset(offset), None if self.view.contains(position(at)) => EditAt::End, None => return Vec::new(), }; vec![Action::App(AppAction::StartEdit(StartEdit::Body { at, in_card: true }))] } Gesture::Scroll { direction, .. } => { self.view.scroll_by(if direction == Direction::Down { 3 } else { -3 }); Vec::new() } _ => Vec::new(), } } } /// What the card shows for a node: its body, or the body as typed. fn card_text<'a>(node: NodeId, edit: Option<&'a TextEdit>, ctx: &ViewContext<'a>) -> Option> { let found = ctx.doc.node(node)?; let body = match (edit, &found.content) { (Some(edit), _) => edit.editor.text(), (None, Content::Idea(idea)) => idea.body.as_str(), (None, Content::Portal(_)) => "", }; Some(CardText { title: found.title().as_str(), color: ctx.theme.node_style(found, node == ctx.doc.root()).color, tags: found.tags.to_string(), body, cursor: edit.map(|edit| edit.editor.cursor()), typing: edit.is_some_and(|edit| edit.editor.is_typing()), }) } #[cfg(test)] mod tests;