Files
cumulus/src/app/popup.rs
T
skkeyeandClaude Opus 5.5 c587c42115 Add the outline and Note views, splits, popups and the bars
The outline shows the tree as an indented list: a row per node with its
fold marker, tree lines in the parent's color, tags, a ¶ for a body and
+N for what a fold hides. zb shows every body under its title, to read a
document like a page. j/k go row by row, h/l to the parent and the first
child, a click on a marker folds, and titles are edited in their row,
rapid entry included. In move mode, j/k swap the node with its siblings,
h takes it out a level and l puts it under the one above.

The Note view shows one node at full size, as parts: the title, the
tags, the body, each link and each child. j/k step through them, Enter
or i edits the part (or goes to that link or child), and h/l turn the
page to the sibling before or after. Bodies and tags are edited with the
same editor as titles: Enter starts a line in a body, Tab types spaces,
and tags that don't parse come back as they were. cc, C and s now start
typing on an empty line too, as in vim.

Panes split the screen the way vim's windows do (Ctrl-w v s h j k l w c
o =, :split, :vsplit, :only), each in a box with its view and document,
and any pane can show any open document (gt, gT, :b, the documents
picker, a click on the document bar). The status bar has two lines: the
mode, the view, the breadcrumb or a message, the keys typed; then key
hints for what you're doing, which <Space>w hides with which-key.

Popups are a mode of their own, built on one list: ? lists every key and
command, / searches it and Enter runs what you pick; the right-click menu
lists what you can do with a node (with the keys that do the same) or
adds a new idea on the empty canvas; the pickers set a node's color,
border, style or tags. K opens the body in a card beside the node, where
i edits it in place.

The example now opens two documents (the garden, and the kitchen its
portal leads to) and its tests drive all of the above end to end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 19:10:09 -04:00

774 lines
31 KiB
Rust

//! Popups that take the keys for a while: the help (`?`), the right-click
//! menu, the pickers (`<Space>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<Run>,
}
/// 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<Key>),
/// 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<Pick>,
/// The node's tags as they are now, in the tags picker.
tags: Tags,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Pick {
Color(Option<ColorName>),
Border(Option<Border>),
Style(Option<StyleName>),
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<TextEdit>,
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<Entry> = Vec::new();
let mut seen: Vec<Vec<Key>> = 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!("{:<width$} ", entry.keys), key_style);
let search = format!("{} {}", entry.keys, entry.help);
rows.push(ListRow::new(vec![keys, Span::raw(entry.help.clone())], search));
runs.push(entry.run.clone());
}
}
let footer = "j/k move · / search · Enter run · Esc close";
let list =
ListPopup::new(format!("help · {view}"), footer, rows, Place::Center { width: HELP_WIDTH });
Popup::Help(Runs { list: list.with_search(search), runs })
}
/// The right-click menu at a cell, for a node or a spot on the canvas,
/// with the keys that do the same (from `contexts`).
pub fn menu(
what: MenuFor,
title: &str,
at: Position,
keymap: &Keymap,
contexts: &'static [Context],
theme: &Theme,
) -> 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<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 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);
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<Pick>, Vec<ListRow>) = 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<Tag> = doc
.outline()
.into_iter()
.filter_map(|id| doc.node(id))
.flat_map(|node| node.tags.iter().cloned().collect::<Vec<_>>())
.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<Action> {
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<Action> {
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<Action> {
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<Rect>, 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<Action> {
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<String> {
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<Action> {
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<Action> {
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<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() },
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<Action> {
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<Action> {
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<Action> {
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<CardText<'a>> {
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;