use leaf_core::{
Alignment, BlockKind, Capabilities, Doc, InlineKind, InlineMarks, LineFlow, MarkColor,
MarkupMode, View,
};
use leaf_ratatui::Outcome;
#[derive(Clone, Copy)]
pub struct Ctx {
pub caps: Capabilities,
pub in_table: bool,
pub in_code: bool,
pub in_mark: bool,
pub mark_color: Option<MarkColor>,
pub marks: InlineMarks,
pub heading: Option<u32>,
pub markup: MarkupMode,
pub flow: LineFlow,
pub has_selection: bool,
pub view: View,
pub read_only: bool,
}
impl Ctx {
pub fn read(doc: &mut Doc) -> Self {
Ctx {
caps: doc.capabilities(),
in_table: doc.caret_in_table(),
in_code: doc.caret_in_fenced_code(),
in_mark: doc.caret_in_mark(),
mark_color: doc.mark_color_at_caret(),
marks: doc.active_inline_marks(),
heading: doc.current_heading_level(),
markup: doc.markup_mode(),
flow: doc.line_flow(),
has_selection: doc.selection().is_some(),
view: doc.view,
read_only: doc.read_only(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Command {
Undo,
Redo,
Cut,
Copy,
Paste,
PastePlain,
SelectAll,
Paragraph,
Heading(u32),
BulletList,
NumberedList,
Quote,
TaskItem,
TaskChecked,
CycleAlign,
Inline(InlineKind),
HighlightColor(Option<MarkColor>),
Link,
Image,
Video,
Audio,
Footnote,
ThematicBreak,
PageBreak,
CodeLanguage,
RowAbove,
RowBelow,
DeleteRow,
ColumnLeft,
ColumnRight,
DeleteColumn,
MoveRowUp,
MoveRowDown,
MoveColumnLeft,
MoveColumnRight,
Align(Alignment),
ToggleView,
CycleMarkup,
Markup(MarkupMode),
ToggleFlow,
Flow(LineFlow),
Follow,
Find,
Replace,
Save,
SaveAs,
New,
Quit,
Help,
}
impl Command {
pub fn label(self) -> &'static str {
use Command::*;
match self {
Undo => "Undo",
Redo => "Redo",
Cut => "Cut",
Copy => "Copy",
Paste => "Paste",
PastePlain => "Paste as Plain Text",
SelectAll => "Select All",
Paragraph => "Paragraph",
Heading(1) => "Heading 1",
Heading(2) => "Heading 2",
Heading(3) => "Heading 3",
Heading(4) => "Heading 4",
Heading(5) => "Heading 5",
Heading(_) => "Heading 6",
BulletList => "Bulleted List",
NumberedList => "Numbered List",
Quote => "Quote",
TaskItem => "Checklist Item",
TaskChecked => "Tick Checkbox",
CycleAlign => "Cycle Text Alignment",
Inline(InlineKind::Strong) => "Bold",
Inline(InlineKind::Emph) => "Italic",
Inline(InlineKind::Verbatim) => "Code",
Inline(InlineKind::Mark) => "Highlight",
Inline(InlineKind::Delete) => "Strikethrough",
Inline(_) => "Underline",
HighlightColor(Some(MarkColor::Red)) => "Highlight: Red",
HighlightColor(Some(MarkColor::Orange)) => "Highlight: Orange",
HighlightColor(Some(MarkColor::Yellow)) => "Highlight: Yellow",
HighlightColor(Some(MarkColor::Green)) => "Highlight: Green",
HighlightColor(Some(MarkColor::Blue)) => "Highlight: Blue",
HighlightColor(Some(MarkColor::Purple)) => "Highlight: Purple",
HighlightColor(Some(MarkColor::Brown)) => "Highlight: Brown",
HighlightColor(None) => "Highlight: No Colour",
Link => "Link…",
Image => "Image…",
Video => "Video…",
Audio => "Audio…",
Footnote => "Footnote",
ThematicBreak => "Horizontal Rule",
PageBreak => "Page Break",
CodeLanguage => "Code Language…",
RowAbove => "Insert Row Above",
RowBelow => "Insert Row Below",
DeleteRow => "Delete Row",
ColumnLeft => "Insert Column Left",
ColumnRight => "Insert Column Right",
DeleteColumn => "Delete Column",
MoveRowUp => "Move Row Up",
MoveRowDown => "Move Row Down",
MoveColumnLeft => "Move Column Left",
MoveColumnRight => "Move Column Right",
Align(Alignment::Left) => "Align Column Left",
Align(Alignment::Center) => "Align Column Center",
Align(Alignment::Right) => "Align Column Right",
Align(_) => "Align Column Default",
ToggleView => "Toggle Rich View",
CycleMarkup => "Cycle Markup Mode",
ToggleFlow => "Toggle Line Flow",
Markup(MarkupMode::None) => "Markup: Hidden",
Markup(MarkupMode::Shortcuts) => "Markup: Shortcuts",
Markup(MarkupMode::Full) => "Markup: Full",
Flow(LineFlow::Fold) => "Line Flow: Fold",
Flow(LineFlow::Preserve) => "Line Flow: Preserve",
Follow => "Follow Link or Footnote",
Find => "Find…",
Replace => "Find and Replace…",
Save => "Save",
SaveAs => "Save As…",
New => "New Document",
Quit => "Quit",
Help => "Keyboard Reference",
}
}
pub fn hint(self) -> &'static str {
use Command::*;
match self {
Undo => "^z",
Redo => "^⇧z",
Cut => "^x",
Copy => "^c",
Paste => "^v",
PastePlain => "⌥v",
SelectAll => "^a",
Paragraph => "⌥0",
Heading(1) => "⌥1",
Heading(2) => "⌥2",
Heading(3) => "⌥3",
Heading(4) => "⌥4",
Heading(5) => "⌥5",
Heading(6) => "⌥6",
Heading(_) => "",
NumberedList => "⌥7",
BulletList => "⌥8",
Quote => "⌥9",
TaskItem => "⌥t",
TaskChecked => "⌥x",
CycleAlign => "⌥a",
Inline(InlineKind::Strong) => "⌥b",
Inline(InlineKind::Emph) => "⌥i",
Inline(InlineKind::Verbatim) => "⌥c",
Inline(InlineKind::Mark) => "⌥m",
Inline(InlineKind::Delete) => "⌥d",
Inline(InlineKind::Insert) => "⌥u",
Inline(_) => "",
Link => "⌥k",
Image => "⌥e",
Footnote => "⌥f",
ThematicBreak => "⌥r",
PageBreak => "⌥⇧r",
CodeLanguage => "⌥l",
ToggleView => "⌥w",
CycleMarkup => "⌥⇧w",
ToggleFlow => "⌥⇧f",
Follow => "⌥g",
Find => "^f",
Replace => "^h",
Save => "^s",
SaveAs => "⌥s",
New => "⌥n",
Quit => "^q",
Help => "⌥h",
_ => "",
}
}
pub fn enabled(self, ctx: &Ctx) -> bool {
use Command::*;
if ctx.read_only && self.writes() {
return false;
}
let c = &ctx.caps;
match self {
Cut | Copy => ctx.has_selection,
Paragraph | Heading(_) => c.heading,
BulletList => c.bullet_list,
NumberedList => c.ordered_list,
Quote => c.blockquote,
TaskItem | TaskChecked => c.task,
CycleAlign => c.alignment,
Inline(InlineKind::Strong) => c.bold,
Inline(InlineKind::Emph) => c.italic,
Inline(InlineKind::Verbatim) => c.code,
Inline(InlineKind::Mark) => c.mark,
Inline(InlineKind::Delete) => c.strike,
Inline(InlineKind::Insert) => c.underline,
Inline(_) => false,
HighlightColor(_) => c.mark_color && (ctx.in_mark || ctx.has_selection),
Link => c.link,
Image | Video | Audio => c.image,
Footnote => c.footnote,
ThematicBreak => c.thematic_break,
PageBreak => c.page_break,
CodeLanguage => c.code_language && ctx.in_code,
RowAbove | RowBelow | DeleteRow | ColumnLeft | ColumnRight | DeleteColumn
| MoveRowUp | MoveRowDown | MoveColumnLeft | MoveColumnRight | Align(_) => {
c.table && ctx.in_table
}
Markup(_) | Flow(_) | CycleMarkup | ToggleFlow => ctx.view == View::Wysiwyg,
_ => true,
}
}
fn writes(self) -> bool {
use Command::*;
!matches!(
self,
Copy | SelectAll
| ToggleView
| CycleMarkup
| Markup(_)
| ToggleFlow
| Flow(_)
| Follow
| Find
| Quit
| Help
)
}
pub fn active(self, ctx: &Ctx) -> bool {
use Command::*;
match self {
Inline(k) => ctx.marks.contains(k),
HighlightColor(Some(c)) => ctx.mark_color == Some(c),
HighlightColor(None) => ctx.in_mark && ctx.mark_color.is_none(),
Heading(n) => ctx.heading == Some(n),
Markup(m) => ctx.markup == m,
Flow(f) => ctx.flow == f,
ToggleView => ctx.view == View::Wysiwyg,
_ => false,
}
}
pub fn run(self, doc: &mut Doc) -> Outcome {
use Command::*;
match self {
Undo => doc.undo(),
Redo => doc.redo(),
Cut => return Outcome::Cut,
Copy => return Outcome::Copy,
Paste => return Outcome::Paste,
PastePlain => return Outcome::PastePlain,
SelectAll => doc.select_all(),
Paragraph => doc.set_block(BlockKind::Paragraph),
Heading(n) => doc.toggle_heading(n),
BulletList => doc.toggle_list(false),
NumberedList => doc.toggle_list(true),
Quote => doc.toggle_blockquote(),
TaskItem => doc.toggle_task_item(),
TaskChecked => doc.toggle_task_checked(),
CycleAlign => leaf_ratatui::cycle_alignment(doc),
Inline(k) => doc.toggle(k),
HighlightColor(c) => doc.highlight(c),
Link => return Outcome::LinkPrompt,
Image => return Outcome::ImagePrompt,
Video => return Outcome::VideoPrompt,
Audio => return Outcome::AudioPrompt,
Footnote => doc.insert_footnote(),
ThematicBreak => doc.insert_thematic_break(),
PageBreak => doc.insert_page_break(),
CodeLanguage => return Outcome::LanguagePrompt,
RowAbove => doc.table_insert_row(false),
RowBelow => doc.table_insert_row(true),
DeleteRow => doc.table_delete_row(),
ColumnLeft => doc.table_insert_column(false),
ColumnRight => doc.table_insert_column(true),
DeleteColumn => doc.table_delete_column(),
MoveRowUp => doc.table_move_row(false),
MoveRowDown => doc.table_move_row(true),
MoveColumnLeft => doc.table_move_column(false),
MoveColumnRight => doc.table_move_column(true),
Align(a) => doc.table_set_alignment(a),
ToggleView => doc.toggle_view(),
CycleMarkup => leaf_ratatui::cycle_markup_mode(doc),
ToggleFlow => leaf_ratatui::toggle_line_flow(doc),
Markup(m) => {
doc.set_markup_mode(m);
doc.status = Some(format!("markup: {}", leaf_ratatui::markup_mode_name(m)));
}
Flow(f) => {
doc.set_line_flow(f);
doc.status = Some(format!("line flow: {}", leaf_ratatui::line_flow_name(f)));
}
Follow => leaf_ratatui::follow(doc),
Find => return Outcome::Find,
Replace => return Outcome::Replace,
Save => return Outcome::Save,
SaveAs => return Outcome::SaveAs,
New => return Outcome::New,
Quit => return Outcome::Quit,
Help => return Outcome::Help,
}
Outcome::Continue
}
}
pub const GROUPS: &[(&str, &[Command])] = &[
("Edit", EDIT),
("Block", BLOCK),
("Inline", INLINE),
("Insert", INSERT),
("Table", TABLE),
("View", VIEW),
("Navigate", NAVIGATE),
("Find", FIND),
("File", FILE),
];
const EDIT: &[Command] = &[
Command::Undo,
Command::Redo,
Command::Cut,
Command::Copy,
Command::Paste,
Command::PastePlain,
Command::SelectAll,
];
const BLOCK: &[Command] = &[
Command::Paragraph,
Command::Heading(1),
Command::Heading(2),
Command::Heading(3),
Command::Heading(4),
Command::Heading(5),
Command::Heading(6),
Command::BulletList,
Command::NumberedList,
Command::Quote,
Command::TaskItem,
Command::TaskChecked,
Command::CycleAlign,
];
const INLINE: &[Command] = &[
Command::Inline(InlineKind::Strong),
Command::Inline(InlineKind::Emph),
Command::Inline(InlineKind::Verbatim),
Command::Inline(InlineKind::Mark),
Command::HighlightColor(Some(MarkColor::Red)),
Command::HighlightColor(Some(MarkColor::Orange)),
Command::HighlightColor(Some(MarkColor::Yellow)),
Command::HighlightColor(Some(MarkColor::Green)),
Command::HighlightColor(Some(MarkColor::Blue)),
Command::HighlightColor(Some(MarkColor::Purple)),
Command::HighlightColor(Some(MarkColor::Brown)),
Command::HighlightColor(None),
Command::Inline(InlineKind::Delete),
Command::Inline(InlineKind::Insert),
];
const INSERT: &[Command] = &[
Command::Link,
Command::Image,
Command::Video,
Command::Audio,
Command::Footnote,
Command::ThematicBreak,
Command::PageBreak,
Command::CodeLanguage,
];
const TABLE: &[Command] = &[
Command::RowAbove,
Command::RowBelow,
Command::DeleteRow,
Command::ColumnLeft,
Command::ColumnRight,
Command::DeleteColumn,
Command::MoveRowUp,
Command::MoveRowDown,
Command::MoveColumnLeft,
Command::MoveColumnRight,
Command::Align(Alignment::Left),
Command::Align(Alignment::Center),
Command::Align(Alignment::Right),
Command::Align(Alignment::Default),
];
const VIEW: &[Command] = &[
Command::ToggleView,
Command::CycleMarkup,
Command::ToggleFlow,
Command::Markup(MarkupMode::None),
Command::Markup(MarkupMode::Shortcuts),
Command::Markup(MarkupMode::Full),
Command::Flow(LineFlow::Fold),
Command::Flow(LineFlow::Preserve),
];
const NAVIGATE: &[Command] = &[Command::Follow];
const FIND: &[Command] = &[Command::Find, Command::Replace];
const FILE: &[Command] = &[
Command::Save,
Command::SaveAs,
Command::New,
Command::Quit,
Command::Help,
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_command_has_a_label_and_a_unique_key() {
let mut keys: Vec<&str> = Vec::new();
for (_, group) in GROUPS {
for cmd in *group {
assert!(!cmd.label().is_empty());
let hint = cmd.hint();
if !hint.is_empty() {
assert!(
!keys.contains(&hint),
"{hint} is claimed by more than one command ({})",
cmd.label()
);
keys.push(hint);
}
}
}
}
#[test]
fn underline_is_offered_in_djot_and_dimmed_in_markdown() {
let underline = Command::Inline(InlineKind::Insert);
let mut dj = Doc::from_source("hello\n".into(), leaf_core::Format::Djot).unwrap();
assert!(underline.enabled(&Ctx::read(&mut dj)));
let mut md = Doc::from_source("hello\n".into(), leaf_core::Format::Markdown).unwrap();
assert!(!underline.enabled(&Ctx::read(&mut md)));
}
#[test]
fn highlight_and_strikethrough_are_offered_in_both_markdown_and_djot() {
for fmt in [leaf_core::Format::Markdown, leaf_core::Format::Djot] {
let mut doc = Doc::from_source("hello\n".into(), fmt).unwrap();
let ctx = Ctx::read(&mut doc);
assert!(Command::Inline(InlineKind::Mark).enabled(&ctx), "{fmt:?}");
assert!(Command::Inline(InlineKind::Delete).enabled(&ctx), "{fmt:?}");
}
}
#[test]
fn highlight_colours_need_a_highlight_or_a_selection() {
let red = Command::HighlightColor(Some(MarkColor::Red));
let mut md =
Doc::from_source("a ==word== b\n".into(), leaf_core::Format::Markdown).unwrap();
md.caret = 0;
assert!(
!red.enabled(&Ctx::read(&mut md)),
"no highlight at the caret"
);
md.caret = md.source.find("word").unwrap();
assert!(red.enabled(&Ctx::read(&mut md)), "inside the highlight");
md.caret = 1;
md.anchor = Some(0);
assert!(
red.enabled(&Ctx::read(&mut md)),
"a selection is a highlight this press would make"
);
let mut dj = Doc::from_source("a {=word=} b\n".into(), leaf_core::Format::Djot).unwrap();
dj.caret = dj.source.find("word").unwrap();
let ctx = Ctx::read(&mut dj);
assert!(dj.caret_in_mark());
assert!(!red.enabled(&ctx));
}
#[test]
fn the_ticked_colour_is_the_caret_s_own() {
let mut doc = Doc::from_source(
"a ==\u{1F534} red== and ==plain== b\n".into(),
leaf_core::Format::Markdown,
)
.unwrap();
doc.caret = doc.source.find("red=").unwrap();
let ctx = Ctx::read(&mut doc);
assert!(Command::HighlightColor(Some(MarkColor::Red)).active(&ctx));
assert!(!Command::HighlightColor(Some(MarkColor::Blue)).active(&ctx));
assert!(!Command::HighlightColor(None).active(&ctx));
doc.caret = doc.source.find("plain").unwrap();
let ctx = Ctx::read(&mut doc);
assert!(Command::HighlightColor(None).active(&ctx));
assert!(!Command::HighlightColor(Some(MarkColor::Red)).active(&ctx));
doc.caret = 0;
let ctx = Ctx::read(&mut doc);
assert!(
!Command::HighlightColor(None).active(&ctx),
"outside a highlight there is no colour to be none of"
);
}
#[test]
fn a_colour_row_highlights_and_colours_in_one_press() {
let mut doc = Doc::from_source("a word b\n".into(), leaf_core::Format::Markdown).unwrap();
doc.anchor = Some(2);
doc.caret = 6;
Command::HighlightColor(Some(MarkColor::Green)).run(&mut doc);
assert_eq!(doc.source, "a ==\u{1F7E2} word== b\n");
doc.undo();
assert_eq!(doc.source, "a word b\n");
}
#[test]
fn table_commands_need_a_caret_in_a_table() {
let mut doc = Doc::from_source(
"para\n\n| a | b |\n| - | - |\n| c | d |\n".into(),
leaf_core::Format::Markdown,
)
.unwrap();
doc.caret = 1; assert!(!Command::DeleteRow.enabled(&Ctx::read(&mut doc)));
doc.caret = doc.source.find("c |").unwrap();
assert!(Command::DeleteRow.enabled(&Ctx::read(&mut doc)));
}
#[test]
fn code_language_needs_a_fence() {
let mut doc = Doc::from_source(
"para\n\n```\ncode\n```\n".into(),
leaf_core::Format::Markdown,
)
.unwrap();
doc.caret = 1;
assert!(!Command::CodeLanguage.enabled(&Ctx::read(&mut doc)));
doc.caret = doc.source.find("code").unwrap();
assert!(Command::CodeLanguage.enabled(&Ctx::read(&mut doc)));
}
#[test]
fn a_read_only_document_dims_what_would_change_it_and_nothing_else() {
let mut doc = Doc::from_source("hello world\n".into(), leaf_core::Format::Djot).unwrap();
doc.set_read_only(true);
doc.anchor = Some(0);
doc.caret = 5; let ctx = Ctx::read(&mut doc);
for cmd in [
Command::Undo,
Command::Cut,
Command::Paste,
Command::Heading(1),
Command::Inline(InlineKind::Strong),
Command::Link,
Command::Footnote,
Command::Save,
Command::SaveAs,
Command::New,
] {
assert!(
!cmd.enabled(&ctx),
"{} should be dimmed in a read-only document",
cmd.label()
);
}
for cmd in [
Command::Copy,
Command::SelectAll,
Command::ToggleView,
Command::CycleMarkup,
Command::Follow,
Command::Quit,
Command::Help,
] {
assert!(
cmd.enabled(&ctx),
"{} is what reading a document is made of",
cmd.label()
);
}
}
#[test]
fn every_command_is_classified_for_read_only() {
for (_, group) in GROUPS {
for cmd in *group {
let reads = !cmd.writes();
assert_eq!(
reads,
matches!(
cmd,
Command::Copy
| Command::SelectAll
| Command::ToggleView
| Command::CycleMarkup
| Command::Markup(_)
| Command::ToggleFlow
| Command::Flow(_)
| Command::Follow
| Command::Find
| Command::Quit
| Command::Help
),
"{} is on the wrong side of the read-only line",
cmd.label()
);
}
}
}
#[test]
fn host_owned_commands_name_themselves_in_the_outcome() {
let mut doc = Doc::from_source("hello\n".into(), leaf_core::Format::Markdown).unwrap();
assert_eq!(Command::Heading(2).run(&mut doc), Outcome::Continue);
assert_eq!(Command::Link.run(&mut doc), Outcome::LinkPrompt);
assert_eq!(Command::Video.run(&mut doc), Outcome::VideoPrompt);
assert_eq!(Command::Quit.run(&mut doc), Outcome::Quit);
}
}