mod commands;
mod find;
mod palette;
mod ui;
use std::io::stdout;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};
use anyhow::{Result, anyhow};
use leaf_core::{Alignment, DiskState, Doc, InlineKind, LineFlow, MarkupMode, MediaKind, View};
use leaf_ratatui::{MouseOutcome, Outcome};
use commands::{Command, Ctx};
use find::{Find, FindField, find_matches, is_visible};
use palette::Palette;
use ratatui::{
crossterm::{
event::{
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste,
EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent,
MouseEventKind,
},
execute,
},
layout::Rect,
};
const USAGE: &str = "usage: leaf [-r|--read-only] [-w|--width columns] <file.md|file.dj|file.html|file.xml> \
(a missing file is created on save)";
const HELP: &str = "\
leaf — a caret-based rich-text editor for documents
usage: leaf [-r|--read-only] [-w|--width columns] <file>
(a missing file is created on save)
<file> .md, .dj, .html or .xml — the extension picks the format
-r, --read-only open the document for reading: caret motion, selection,
copy, follow, and the view controls all work, and nothing
can be changed or written
-w, --width N center the document in a column at most N cells wide
(default: 80; use 0 to fill the terminal)
-h, --help this
-V, --version the version leaf was built at
Once inside, F1 or ⌥h is the key reference and ^p is the command palette.";
#[cfg_attr(test, derive(Debug))]
struct Args {
path: PathBuf,
read_only: bool,
line_width: u16,
}
#[cfg_attr(test, derive(Debug))]
enum Parsed {
Run(Args),
Print(&'static str),
}
fn parse_args(argv: &[std::ffi::OsString]) -> Result<Parsed> {
let mut path: Option<PathBuf> = None;
let mut read_only = false;
let mut line_width = 80;
let mut args = argv.iter();
while let Some(arg) = args.next() {
match arg.to_str() {
Some("--version" | "-V") => {
return Ok(Parsed::Print(concat!("leaf ", env!("CARGO_PKG_VERSION"))));
}
Some("--help" | "-h") => return Ok(Parsed::Print(HELP)),
Some("--read-only" | "-r") => read_only = true,
Some("--width" | "-w") => {
let value = args
.next()
.ok_or_else(|| anyhow!("--width needs a column count\n{USAGE}"))?;
line_width = parse_line_width(value)?;
}
Some(value) if value.starts_with("--width=") => {
line_width = parse_line_width(std::ffi::OsStr::new(&value[8..]))?;
}
Some(flag) if flag.starts_with('-') && flag != "-" => {
return Err(anyhow!("unknown option `{flag}`\n{USAGE}"));
}
_ if path.is_some() => {
return Err(anyhow!("leaf opens one document at a time\n{USAGE}"));
}
_ => path = Some(PathBuf::from(arg)),
}
}
match path {
Some(path) => Ok(Parsed::Run(Args {
path,
read_only,
line_width,
})),
None => Err(anyhow!("{USAGE}")),
}
}
fn parse_line_width(value: &std::ffi::OsStr) -> Result<u16> {
let value = value
.to_str()
.ok_or_else(|| anyhow!("line width must be a number\n{USAGE}"))?;
value
.parse()
.map_err(|_| anyhow!("invalid line width `{value}`\n{USAGE}"))
}
fn main() -> Result<()> {
let argv: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
let args = match parse_args(&argv)? {
Parsed::Print(text) => {
println!("{text}");
return Ok(());
}
Parsed::Run(args) => args,
};
let existed = args.path.exists();
let mut doc = Doc::open_or_create(args.path)?;
if args.read_only {
doc.set_read_only(true);
}
if !existed {
doc.status = Some(if args.read_only {
format!("{} — no such file, and read-only", doc.file_name())
} else {
format!("{} — new file", doc.file_name())
});
}
let mut terminal = ratatui::init();
execute!(stdout(), EnableMouseCapture, EnableBracketedPaste)?;
let result = run(&mut terminal, &mut doc, args.line_width);
let _ = execute!(stdout(), DisableBracketedPaste, DisableMouseCapture);
ratatui::restore();
result
}
#[derive(Default)]
struct App {
dirty_prompt: Option<DirtyPrompt>,
pending_action: Option<DirtyAction>,
conflict: Option<ConflictPrompt>,
context_menu: Option<ContextMenu>,
text_prompt: Option<TextPrompt>,
palette: Option<Palette>,
find: Option<Find>,
help: bool,
editor: leaf_ratatui::EditorState,
body_width: u16,
line_width: u16,
disk_stamp: Option<(SystemTime, u64)>,
disk_ahead: bool,
disk_settling: bool,
last_disk_check: Option<Instant>,
}
#[derive(Clone, Copy)]
pub enum MenuEntry {
Action(Command),
Submenu(&'static str, &'static [MenuEntry]),
Header(&'static str),
}
impl MenuEntry {
pub fn label(self) -> &'static str {
match self {
MenuEntry::Action(c) => c.label(),
MenuEntry::Submenu(l, _) | MenuEntry::Header(l) => l,
}
}
pub fn hint(self) -> &'static str {
match self {
MenuEntry::Action(c) => c.hint(),
_ => "",
}
}
fn selectable(self, ctx: &Ctx) -> bool {
match self {
MenuEntry::Header(_) => false,
MenuEntry::Action(c) => c.enabled(ctx),
MenuEntry::Submenu(_, items) => items.iter().any(|e| e.selectable(ctx)),
}
}
}
pub const ROOT_MENU: &[MenuEntry] = &[
MenuEntry::Action(Command::Cut),
MenuEntry::Action(Command::Copy),
MenuEntry::Action(Command::Paste),
MenuEntry::Action(Command::SelectAll),
MenuEntry::Submenu("Format", FORMAT_MENU),
MenuEntry::Submenu("Insert", INSERT_MENU),
MenuEntry::Submenu("Table", TABLE_MENU),
MenuEntry::Submenu("View", VIEW_MENU),
MenuEntry::Action(Command::Follow),
MenuEntry::Action(Command::Find),
MenuEntry::Action(Command::Replace),
];
pub const FORMAT_MENU: &[MenuEntry] = &[
MenuEntry::Header("Block"),
MenuEntry::Action(Command::Paragraph),
MenuEntry::Action(Command::Heading(1)),
MenuEntry::Action(Command::Heading(2)),
MenuEntry::Action(Command::Heading(3)),
MenuEntry::Action(Command::BulletList),
MenuEntry::Action(Command::NumberedList),
MenuEntry::Action(Command::Quote),
MenuEntry::Action(Command::TaskItem),
MenuEntry::Action(Command::TaskChecked),
MenuEntry::Header("Inline"),
MenuEntry::Action(Command::Inline(InlineKind::Strong)),
MenuEntry::Action(Command::Inline(InlineKind::Emph)),
MenuEntry::Action(Command::Inline(InlineKind::Verbatim)),
MenuEntry::Action(Command::Inline(InlineKind::Mark)),
MenuEntry::Action(Command::Inline(InlineKind::Delete)),
MenuEntry::Action(Command::Inline(InlineKind::Insert)),
];
pub const INSERT_MENU: &[MenuEntry] = &[
MenuEntry::Action(Command::Link),
MenuEntry::Action(Command::Image),
MenuEntry::Action(Command::Video),
MenuEntry::Action(Command::Audio),
MenuEntry::Action(Command::Footnote),
MenuEntry::Action(Command::ThematicBreak),
MenuEntry::Action(Command::CodeLanguage),
];
pub const TABLE_MENU: &[MenuEntry] = &[
MenuEntry::Header("Rows"),
MenuEntry::Action(Command::RowAbove),
MenuEntry::Action(Command::RowBelow),
MenuEntry::Action(Command::DeleteRow),
MenuEntry::Action(Command::MoveRowUp),
MenuEntry::Action(Command::MoveRowDown),
MenuEntry::Header("Columns"),
MenuEntry::Action(Command::ColumnLeft),
MenuEntry::Action(Command::ColumnRight),
MenuEntry::Action(Command::DeleteColumn),
MenuEntry::Action(Command::MoveColumnLeft),
MenuEntry::Action(Command::MoveColumnRight),
MenuEntry::Header("Alignment"),
MenuEntry::Action(Command::Align(Alignment::Left)),
MenuEntry::Action(Command::Align(Alignment::Center)),
MenuEntry::Action(Command::Align(Alignment::Right)),
MenuEntry::Action(Command::Align(Alignment::Default)),
];
pub const VIEW_MENU: &[MenuEntry] = &[
MenuEntry::Action(Command::ToggleView),
MenuEntry::Header("Markup"),
MenuEntry::Action(Command::CycleMarkup),
MenuEntry::Action(Command::Markup(MarkupMode::None)),
MenuEntry::Action(Command::Markup(MarkupMode::Shortcuts)),
MenuEntry::Action(Command::Markup(MarkupMode::Full)),
MenuEntry::Header("Line flow"),
MenuEntry::Action(Command::ToggleFlow),
MenuEntry::Action(Command::Flow(LineFlow::Fold)),
MenuEntry::Action(Command::Flow(LineFlow::Preserve)),
];
pub struct ContextMenu {
anchor: (u16, u16),
levels: Vec<MenuLevel>,
}
pub struct MenuLevel {
items: &'static [MenuEntry],
selected: usize,
rect: Option<Rect>,
}
impl MenuLevel {
fn new(items: &'static [MenuEntry], ctx: &Ctx) -> Self {
let selected = items.iter().position(|e| e.selectable(ctx)).unwrap_or(0);
MenuLevel {
items,
selected,
rect: None,
}
}
fn step(&mut self, delta: isize, ctx: &Ctx) {
let n = self.items.len() as isize;
if n == 0 {
return;
}
let mut i = self.selected as isize;
for _ in 0..n {
i = (i + delta).rem_euclid(n);
if self.items[i as usize].selectable(ctx) {
self.selected = i as usize;
return;
}
}
}
}
impl ContextMenu {
fn new(anchor: (u16, u16), ctx: &Ctx) -> Self {
ContextMenu {
anchor,
levels: vec![MenuLevel::new(ROOT_MENU, ctx)],
}
}
fn active_level(&self) -> usize {
self.levels.len() - 1
}
fn open_submenu(&mut self, parent: usize, items: &'static [MenuEntry], ctx: &Ctx) {
if self
.levels
.get(parent + 1)
.is_some_and(|l| l.items.as_ptr() == items.as_ptr())
{
return;
}
self.levels.truncate(parent + 1);
self.levels.push(MenuLevel::new(items, ctx));
}
fn hit(&self, row: u16, col: u16) -> Option<(usize, usize)> {
for (i, level) in self.levels.iter().enumerate().rev() {
if let Some(rect) = level.rect
&& row >= rect.y
&& row < rect.y + rect.height
&& col >= rect.x
&& col < rect.x + rect.width
{
let idx = (row - rect.y) as usize;
if idx < level.items.len() {
return Some((i, idx));
}
}
}
None
}
}
struct TextPrompt {
label: &'static str,
value: String,
cursor: usize,
on_confirm: fn(&mut Doc, &str),
}
impl TextPrompt {
fn new(
label: &'static str,
initial: impl Into<String>,
on_confirm: fn(&mut Doc, &str),
) -> Self {
let value = initial.into();
let cursor = value.len();
TextPrompt {
label,
value,
cursor,
on_confirm,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DirtyAction {
Quit,
New,
}
struct DirtyPrompt {
action: DirtyAction,
selected: usize,
}
struct ConflictPrompt {
selected: usize,
}
fn run(terminal: &mut ratatui::DefaultTerminal, doc: &mut Doc, line_width: u16) -> Result<()> {
let mut app = App {
line_width,
..App::default()
};
app.editor.query_graphics();
app.editor.query_color_scheme();
if app.editor.supports_graphics() {
let plain = app.editor.theme().with_plain_headings();
app.editor.set_theme(plain);
}
terminal.clear()?;
app.disk_stamp = disk_stamp(doc);
let mut redraw = true;
loop {
if redraw {
terminal.draw(|f| ui::render(f, doc, &mut app))?;
redraw = false;
}
if event::poll(DISK_POLL)? {
redraw = true;
match event::read()? {
Event::Key(key)
if key.kind == KeyEventKind::Press
&& handle_key(doc, key, &mut app) == Flow::Quit =>
{
return Ok(());
}
Event::Mouse(m) => handle_mouse(doc, m, &mut app),
Event::Paste(text) => handle_paste(doc, &text, &mut app),
_ => {}
}
}
if app
.last_disk_check
.is_none_or(|at| at.elapsed() >= DISK_POLL)
{
app.last_disk_check = Some(Instant::now());
redraw |= tick_disk(doc, &mut app);
}
}
}
#[derive(PartialEq, Eq, Debug)]
enum Flow {
Continue,
Quit,
}
fn handle_key(doc: &mut Doc, key: KeyEvent, app: &mut App) -> Flow {
if let Some(prompt) = &mut app.dirty_prompt {
match key.code {
KeyCode::Up => prompt.selected = (prompt.selected + 3 - 1) % 3,
KeyCode::Down => prompt.selected = (prompt.selected + 1) % 3,
KeyCode::Char('s') | KeyCode::Char('S') => return resolve_dirty_prompt(doc, app, 0),
KeyCode::Char('d') | KeyCode::Char('D') => return resolve_dirty_prompt(doc, app, 1),
KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Esc => app.dirty_prompt = None,
KeyCode::Enter => {
let selected = prompt.selected;
return resolve_dirty_prompt(doc, app, selected);
}
_ => {} }
return Flow::Continue;
}
if let Some(prompt) = &mut app.conflict {
match key.code {
KeyCode::Up => prompt.selected = (prompt.selected + 3 - 1) % 3,
KeyCode::Down => prompt.selected = (prompt.selected + 1) % 3,
KeyCode::Char('o') | KeyCode::Char('O') => return resolve_conflict(doc, app, 0),
KeyCode::Char('r') | KeyCode::Char('R') => return resolve_conflict(doc, app, 1),
KeyCode::Char('c') | KeyCode::Char('C') | KeyCode::Esc => {
app.conflict = None;
app.pending_action = None;
}
KeyCode::Enter => {
let selected = prompt.selected;
return resolve_conflict(doc, app, selected);
}
_ => {}
}
return Flow::Continue;
}
if app.context_menu.is_some() {
let ctx = Ctx::read(doc);
let menu = app.context_menu.as_mut().unwrap();
let lvl = menu.active_level();
let entry = menu.levels[lvl].items[menu.levels[lvl].selected];
match key.code {
KeyCode::Up => menu.levels[lvl].step(-1, &ctx),
KeyCode::Down => menu.levels[lvl].step(1, &ctx),
KeyCode::Right => {
if let MenuEntry::Submenu(_, items) = entry {
menu.open_submenu(lvl, items, &ctx);
}
}
KeyCode::Enter => match entry {
MenuEntry::Action(cmd) => {
app.context_menu = None;
let outcome = cmd.run(doc);
return apply_over_find(doc, app, outcome);
}
MenuEntry::Submenu(_, items) => menu.open_submenu(lvl, items, &ctx),
MenuEntry::Header(_) => {}
},
KeyCode::Left | KeyCode::Esc => {
if lvl > 0 {
menu.levels.pop();
} else {
app.context_menu = None;
}
}
_ => app.context_menu = None,
}
return Flow::Continue;
}
if app.help {
app.help = false;
return Flow::Continue;
}
if app.palette.is_some() {
return handle_palette_key(doc, key, app);
}
if let Some(prompt) = &mut app.text_prompt {
match key.code {
KeyCode::Enter => {
let value = std::mem::take(&mut prompt.value);
let on_confirm = prompt.on_confirm;
app.text_prompt = None;
on_confirm(doc, &value);
return resolve_pending(doc, app);
}
KeyCode::Esc => {
app.text_prompt = None;
app.pending_action = None;
}
code => {
edit_field(&mut prompt.value, &mut prompt.cursor, code);
}
}
return Flow::Continue;
}
if app.find.is_some() {
return handle_find_key(doc, key, app);
}
let outcome = leaf_ratatui::handle_key(doc, key, &mut app.editor);
apply_outcome(doc, app, outcome)
}
fn writes(outcome: Outcome) -> bool {
!matches!(
outcome,
Outcome::Continue
| Outcome::Quit
| Outcome::Copy
| Outcome::Palette
| Outcome::Help
| Outcome::Find
)
}
fn refused_read_only(doc: &mut Doc) -> bool {
if doc.read_only() {
doc.status = Some("read-only".into());
return true;
}
false
}
fn apply_outcome(doc: &mut Doc, app: &mut App, outcome: Outcome) -> Flow {
if writes(outcome) && refused_read_only(doc) {
return Flow::Continue;
}
match outcome {
Outcome::Continue => Flow::Continue,
Outcome::Quit => {
if doc.dirty {
app.dirty_prompt = Some(DirtyPrompt {
action: DirtyAction::Quit,
selected: 0,
});
Flow::Continue
} else {
Flow::Quit
}
}
Outcome::Save => {
attempt_save(doc, app, None);
Flow::Continue
}
Outcome::SaveAs => {
open_save_as_prompt(doc, app);
Flow::Continue
}
Outcome::New => {
if doc.dirty {
app.dirty_prompt = Some(DirtyPrompt {
action: DirtyAction::New,
selected: 0,
});
} else {
replace_with_blank(doc);
}
Flow::Continue
}
Outcome::Copy => {
clipboard_copy(doc);
Flow::Continue
}
Outcome::Cut => {
clipboard_cut(doc);
Flow::Continue
}
Outcome::Paste => {
clipboard_paste(doc);
Flow::Continue
}
Outcome::PastePlain => {
clipboard_paste_plain(doc);
Flow::Continue
}
Outcome::LinkPrompt => {
open_link_prompt(doc, app);
Flow::Continue
}
Outcome::LanguagePrompt => {
open_language_prompt(doc, app);
Flow::Continue
}
Outcome::ImagePrompt => {
open_media_prompt(app, MediaKind::Image);
Flow::Continue
}
Outcome::VideoPrompt => {
open_media_prompt(app, MediaKind::Video);
Flow::Continue
}
Outcome::AudioPrompt => {
open_media_prompt(app, MediaKind::Audio);
Flow::Continue
}
Outcome::Palette => {
app.palette = Some(Palette::new(&Ctx::read(doc)));
Flow::Continue
}
Outcome::Help => {
app.help = true;
Flow::Continue
}
Outcome::Find => {
open_find(doc, app, false);
Flow::Continue
}
Outcome::Replace => {
open_find(doc, app, true);
Flow::Continue
}
}
}
fn apply_over_find(doc: &mut Doc, app: &mut App, outcome: Outcome) -> Flow {
let before = doc.revision();
let flow = apply_outcome(doc, app, outcome);
if app.find.is_some() && doc.revision() != before {
refresh_find_after_edit(doc, app);
}
flow
}
fn handle_palette_key(doc: &mut Doc, key: KeyEvent, app: &mut App) -> Flow {
let ctx = Ctx::read(doc);
let palette = app.palette.as_mut().unwrap();
match key.code {
KeyCode::Up => palette.step(-1),
KeyCode::Down => palette.step(1),
KeyCode::Esc => app.palette = None,
KeyCode::Enter => {
let chosen = palette.chosen();
app.palette = None;
if let Some(cmd) = chosen {
let outcome = cmd.run(doc);
return apply_over_find(doc, app, outcome);
}
}
code => {
if edit_field(&mut palette.query, &mut palette.cursor, code) {
palette.refilter(&ctx);
}
}
}
Flow::Continue
}
fn open_find(doc: &mut Doc, app: &mut App, replacing: bool) {
if replacing && refused_read_only(doc) {
return;
}
if let Some(find) = &mut app.find {
if replacing {
find.replacement.get_or_insert_default();
find.field = FindField::Replacement;
} else {
find.field = FindField::Query;
}
return;
}
let seed = doc
.selected_text()
.filter(|text| !text.contains('\n'))
.unwrap_or_default()
.to_string();
let origin = doc.selection().map_or(doc.caret, |(start, _)| start);
app.find = Some(Find::new(replacing, seed));
refresh_find(doc, app, origin);
}
fn close_find(doc: &mut Doc, app: &mut App) {
app.find = None;
doc.set_highlights(Vec::new());
}
fn refresh_find(doc: &mut Doc, app: &mut App, anchor: usize) {
if app.find.is_none() {
return;
}
if doc.view == View::Wysiwyg {
let width = if app.body_width > 0 {
app.body_width as usize
} else {
NOMINAL_WIDTH
};
doc.build_visual(width);
}
let find = app.find.as_mut().expect("checked above");
find.origin = anchor;
let all = find_matches(&doc.source, &find.query);
let hidden = all.len();
let matches: Vec<(usize, usize)> = all
.into_iter()
.filter(|&(start, end)| is_visible(doc, start, end))
.collect();
let hidden = hidden - matches.len();
let find = app.find.as_mut().expect("checked above");
find.set_matches(matches, hidden, anchor);
let highlights = find.highlights();
let current = find.current_match();
doc.set_highlights(highlights);
match current {
Some((start, end)) => select_match(doc, start, end),
None => doc.select_range(doc.caret, doc.caret),
}
}
fn refresh_find_after_edit(doc: &mut Doc, app: &mut App) {
let anchor = app.find.as_ref().map_or(doc.caret, |find| {
find.current_match().map_or(find.origin, |(start, _)| start)
});
let said = doc.status.take();
refresh_find(doc, app, anchor);
if said.is_some() {
doc.status = said;
}
}
fn step_find(doc: &mut Doc, app: &mut App, delta: isize) {
let Some(find) = &mut app.find else {
return;
};
let Some((start, end)) = find.step(delta) else {
return;
};
find.origin = start;
select_match(doc, start, end);
}
fn select_match(doc: &mut Doc, start: usize, end: usize) {
doc.select_range(start, end);
}
fn toggle_find_field(app: &mut App) {
if let Some(find) = &mut app.find
&& find.replacement.is_some()
{
find.field = match find.field {
FindField::Query => FindField::Replacement,
FindField::Replacement => FindField::Query,
};
}
}
fn replace_current(doc: &mut Doc, app: &mut App) {
if refused_read_only(doc) {
return;
}
let Some(find) = &app.find else {
return;
};
let Some(replacement) = find.replacement.clone() else {
return;
};
let Some((start, end)) = find.current_match() else {
doc.status = Some("no matches".into());
return;
};
doc.edit(start, end, &replacement);
let anchor = doc.caret;
refresh_find(doc, app, anchor);
}
fn replace_all(doc: &mut Doc, app: &mut App) {
if refused_read_only(doc) {
return;
}
let Some(find) = &app.find else {
return;
};
let Some(replacement) = find.replacement.clone() else {
return;
};
if find.matches.is_empty() {
doc.status = Some("no matches".into());
return;
}
let matches = find.matches.clone();
let start = matches[0].0;
let end = matches[matches.len() - 1].1;
let mut rebuilt = String::new();
let mut at = start;
for (hit_start, hit_end) in &matches {
rebuilt.push_str(&doc.source[at..*hit_start]);
rebuilt.push_str(&replacement);
at = *hit_end;
}
doc.edit(start, end, &rebuilt);
let n = matches.len();
let anchor = doc.caret;
refresh_find(doc, app, anchor);
doc.status = Some(format!(
"replaced {n} {}",
if n == 1 { "match" } else { "matches" }
));
}
fn handle_find_key(doc: &mut Doc, key: KeyEvent, app: &mut App) -> Flow {
if key.modifiers.contains(KeyModifiers::ALT) {
return match key.code {
KeyCode::Char('h') | KeyCode::Char('w') => fall_through(doc, key, app, BarAfter::Keep),
_ => Flow::Continue,
};
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('r') => replace_all(doc, app),
KeyCode::Char('g') => step_find(doc, app, 1),
KeyCode::Char('f') => open_find(doc, app, false),
KeyCode::Char('h') => open_find(doc, app, true),
KeyCode::Char('q') | KeyCode::Char('s') | KeyCode::Char('c') => {
return fall_through(doc, key, app, BarAfter::Keep);
}
KeyCode::Char('a') | KeyCode::Char('p') => {
return fall_through(doc, key, app, BarAfter::Close);
}
_ => {}
}
return Flow::Continue;
}
match key.code {
KeyCode::Esc => close_find(doc, app),
KeyCode::F(1) => return fall_through(doc, key, app, BarAfter::Keep),
KeyCode::Down => step_find(doc, app, 1),
KeyCode::Up => step_find(doc, app, -1),
KeyCode::Tab | KeyCode::BackTab => toggle_find_field(app),
KeyCode::Enter => match app.find.as_ref().map(|find| find.field) {
Some(FindField::Replacement) => replace_current(doc, app),
_ => step_find(doc, app, 1),
},
_ => edit_find_field(doc, key, app),
}
Flow::Continue
}
#[derive(PartialEq, Eq)]
enum BarAfter {
Keep,
Close,
}
fn fall_through(doc: &mut Doc, key: KeyEvent, app: &mut App, after: BarAfter) -> Flow {
if after == BarAfter::Close {
close_find(doc, app);
}
let outcome = leaf_ratatui::handle_key(doc, key, &mut app.editor);
let flow = apply_outcome(doc, app, outcome);
if app.find.is_some() {
refresh_find_after_edit(doc, app);
}
flow
}
fn edit_find_field(doc: &mut Doc, key: KeyEvent, app: &mut App) {
let Some(find) = &mut app.find else {
return;
};
let searching = find.field == FindField::Query;
let origin = find.origin;
let (value, cursor) = find.field_mut();
if edit_field(value, cursor, key.code) && searching {
refresh_find(doc, app, origin);
}
}
fn handle_paste(doc: &mut Doc, text: &str, app: &mut App) {
if app.dirty_prompt.is_some()
|| app.conflict.is_some()
|| app.context_menu.is_some()
|| app.help
{
return;
}
if let Some(palette) = &mut app.palette {
insert_into_field(&mut palette.query, &mut palette.cursor, text);
palette.refilter(&Ctx::read(doc));
return;
}
if let Some(prompt) = &mut app.text_prompt {
insert_into_field(&mut prompt.value, &mut prompt.cursor, text);
return;
}
if let Some(find) = &mut app.find {
let searching = find.field == FindField::Query;
let origin = find.origin;
let (value, cursor) = find.field_mut();
insert_into_field(value, cursor, text);
if searching {
refresh_find(doc, app, origin);
}
return;
}
if refused_read_only(doc) {
return;
}
doc.paste(&normalize_newlines(text));
doc.status = Some("pasted".into());
}
fn edit_field(value: &mut String, cursor: &mut usize, key: KeyCode) -> bool {
match key {
KeyCode::Backspace => {
if let Some((i, _)) = value[..*cursor].char_indices().next_back() {
value.drain(i..*cursor);
*cursor = i;
return true;
}
}
KeyCode::Left => {
if let Some((i, _)) = value[..*cursor].char_indices().next_back() {
*cursor = i;
}
}
KeyCode::Right => {
if let Some(c) = value[*cursor..].chars().next() {
*cursor += c.len_utf8();
}
}
KeyCode::Char(c) => {
value.insert(*cursor, c);
*cursor += c.len_utf8();
return true;
}
_ => {}
}
false
}
fn insert_into_field(value: &mut String, cursor: &mut usize, text: &str) {
let flat = flatten(text);
value.insert_str(*cursor, &flat);
*cursor += flat.len();
}
fn flatten(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut last_was_break = false;
for c in normalize_newlines(text).chars() {
if c == '\n' {
if !last_was_break {
out.push(' ');
}
last_was_break = true;
} else if !c.is_control() {
out.push(c);
last_was_break = false;
}
}
out
}
fn normalize_newlines(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn handle_mouse(doc: &mut Doc, m: MouseEvent, app: &mut App) {
let before = doc.revision();
dispatch_mouse(doc, m, app);
if app.find.is_some() && doc.revision() != before {
refresh_find_after_edit(doc, app);
}
}
fn dispatch_mouse(doc: &mut Doc, m: MouseEvent, app: &mut App) {
if app.context_menu.is_some() {
let ctx = Ctx::read(doc);
let menu = app.context_menu.as_mut().unwrap();
match m.kind {
MouseEventKind::Moved | MouseEventKind::Drag(_) => {
if let Some((lvl, idx)) = menu.hit(m.row, m.column)
&& menu.levels[lvl].items[idx].selectable(&ctx)
{
menu.levels.truncate(lvl + 1);
menu.levels[lvl].selected = idx;
if let MenuEntry::Submenu(_, items) = menu.levels[lvl].items[idx] {
menu.open_submenu(lvl, items, &ctx);
}
}
}
MouseEventKind::Down(_) => match menu.hit(m.row, m.column) {
Some((lvl, idx)) => match menu.levels[lvl].items[idx] {
MenuEntry::Action(cmd) if cmd.enabled(&ctx) => {
app.context_menu = None;
let outcome = cmd.run(doc);
apply_outcome(doc, app, outcome);
}
MenuEntry::Submenu(_, items) if !items.is_empty() => {
menu.open_submenu(lvl, items, &ctx)
}
_ => {}
},
None => app.context_menu = None,
},
_ => {}
}
return;
}
if app.help {
if matches!(m.kind, MouseEventKind::Down(_)) {
app.help = false;
}
return;
}
if app.palette.is_some() {
if matches!(m.kind, MouseEventKind::Down(_)) {
let palette = app.palette.as_ref().unwrap();
let chosen = palette
.hit(m.row, m.column)
.filter(|row| row.enabled)
.map(|row| row.command);
match chosen {
Some(cmd) => {
app.palette = None;
let outcome = cmd.run(doc);
apply_outcome(doc, app, outcome);
}
None if palette.covers(m.row, m.column) => {}
None => app.palette = None,
}
}
return;
}
match leaf_ratatui::handle_mouse(doc, m, &mut app.editor) {
MouseOutcome::Continue => {}
MouseOutcome::ContextMenu { x, y } => {
let ctx = Ctx::read(doc);
app.context_menu = Some(ContextMenu::new((x, y), &ctx));
}
}
}
fn open_link_prompt(doc: &mut Doc, app: &mut App) {
let initial = doc.link_destination_at_caret().unwrap_or_default();
app.text_prompt = Some(TextPrompt::new("Link destination", initial, |doc, dest| {
doc.insert_link(dest);
}));
}
fn open_language_prompt(doc: &mut Doc, app: &mut App) {
if !doc.caret_in_fenced_code() {
return;
}
let initial = doc.code_language_at_caret().unwrap_or_default();
app.text_prompt = Some(TextPrompt::new("Code language", initial, |doc, lang| {
doc.set_code_language(lang);
}));
}
fn open_media_prompt(app: &mut App, kind: MediaKind) {
let (label, confirm): (&'static str, fn(&mut Doc, &str)) = match kind {
MediaKind::Image => ("Image source", |doc, dest| doc.insert_image(dest, "")),
MediaKind::Video => ("Video source", |doc, dest| {
doc.insert_media(MediaKind::Video, dest, "")
}),
MediaKind::Audio => ("Audio source", |doc, dest| {
doc.insert_media(MediaKind::Audio, dest, "")
}),
};
app.text_prompt = Some(TextPrompt::new(label, String::new(), confirm));
}
fn open_save_as_prompt(doc: &mut Doc, app: &mut App) {
let initial = doc.path.to_string_lossy().into_owned();
app.text_prompt = Some(TextPrompt::new("Save as", initial, |doc, path| {
doc.save_as(PathBuf::from(path));
}));
}
fn replace_with_blank(doc: &mut Doc) {
match Doc::blank() {
Ok(fresh) => *doc = fresh,
Err(e) => doc.status = Some(format!("new document failed: {e}")),
}
}
fn attempt_save(doc: &mut Doc, app: &mut App, then: Option<DirtyAction>) -> Flow {
if refused_read_only(doc) {
return Flow::Continue;
}
if doc.is_untitled() {
app.pending_action = then;
open_save_as_prompt(doc, app);
return Flow::Continue;
}
if doc.dirty && doc.disk_state() == DiskState::Changed {
app.pending_action = then;
app.conflict = Some(ConflictPrompt { selected: 2 }); return Flow::Continue;
}
doc.save();
app.pending_action = then;
resolve_pending(doc, app)
}
fn resolve_pending(doc: &mut Doc, app: &mut App) -> Flow {
match app.pending_action.take() {
None => Flow::Continue,
Some(_) if doc.dirty => Flow::Continue,
Some(DirtyAction::Quit) => Flow::Quit,
Some(DirtyAction::New) => {
replace_with_blank(doc);
Flow::Continue
}
}
}
fn resolve_dirty_prompt(doc: &mut Doc, app: &mut App, choice: usize) -> Flow {
let action = app.dirty_prompt.take().unwrap().action;
match choice {
0 => attempt_save(doc, app, Some(action)),
1 => match action {
DirtyAction::Quit => Flow::Quit,
DirtyAction::New => {
replace_with_blank(doc);
Flow::Continue
}
},
_ => Flow::Continue,
}
}
fn resolve_conflict(doc: &mut Doc, app: &mut App, choice: usize) -> Flow {
app.conflict = None;
match choice {
0 => {
doc.save();
resolve_pending(doc, app)
}
1 => {
reload_keeping_view(doc, app);
app.pending_action = None;
Flow::Continue
}
_ => {
app.pending_action = None;
Flow::Continue
}
}
}
const NOMINAL_WIDTH: usize = 80;
const DISK_POLL: Duration = Duration::from_millis(500);
fn disk_stamp(doc: &Doc) -> Option<(SystemTime, u64)> {
let meta = std::fs::metadata(&doc.path).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
fn tick_disk(doc: &mut Doc, app: &mut App) -> bool {
if doc.is_untitled() {
return false;
}
let stamp = disk_stamp(doc);
let moved = stamp != app.disk_stamp;
app.disk_stamp = stamp;
let worth_asking = moved || app.disk_settling || (app.disk_ahead && !doc.dirty);
if !worth_asking {
return false;
}
match doc.disk_state() {
DiskState::Changed if doc.dirty => {
app.disk_ahead = true;
app.disk_settling = false;
if moved {
doc.status = Some("file changed on disk — ^S will ask".into());
return true;
}
false
}
DiskState::Changed if moved => {
app.disk_settling = true;
false
}
DiskState::Changed => {
app.disk_settling = false;
app.disk_ahead = false;
reload_keeping_view(doc, app)
}
DiskState::Missing => {
app.disk_ahead = false;
app.disk_settling = false;
if moved {
doc.status = Some(format!("{} is no longer on disk", doc.file_name()));
return true;
}
false
}
_ => {
app.disk_ahead = false;
app.disk_settling = false;
false
}
}
}
fn reload_keeping_view(doc: &mut Doc, app: &mut App) -> bool {
let scroll = doc.scroll;
let before = doc.revision();
doc.reload();
if doc.revision() == before {
return false;
}
doc.scroll = scroll;
doc.status = Some("reloaded from disk".into());
if app.find.is_some() {
refresh_find_after_edit(doc, app);
}
true
}
fn clipboard_copy(doc: &mut Doc) {
let Some(text) = doc.selected_text().map(str::to_string) else {
doc.status = Some("nothing selected".into());
return;
};
let html = doc.selection_html();
doc.status = Some(match set_clipboard(text, html) {
Ok(()) => "copied".into(),
Err(_) => "clipboard unavailable".into(),
});
}
fn clipboard_cut(doc: &mut Doc) {
let Some(text) = doc.selected_text().map(str::to_string) else {
doc.status = Some("nothing selected".into());
return;
};
let html = doc.selection_html();
match set_clipboard(text, html) {
Ok(()) => {
doc.insert(""); doc.status = Some("cut".into());
}
Err(_) => doc.status = Some("clipboard unavailable".into()),
}
}
fn clipboard_paste(doc: &mut Doc) {
if let Ok(html) = get_clipboard_html()
&& doc.paste_html(&html)
{
doc.status = Some("pasted".into());
return;
}
clipboard_paste_plain(doc);
}
fn clipboard_paste_plain(doc: &mut Doc) {
match get_clipboard_text() {
Ok(text) => {
doc.paste(&text);
doc.status = Some("pasted".into());
}
Err(_) => doc.status = Some("clipboard unavailable".into()),
}
}
fn set_clipboard(plain: String, html: Option<String>) -> Result<(), arboard::Error> {
let mut clipboard = arboard::Clipboard::new()?;
match html {
Some(html) => clipboard.set().html(html, Some(plain)),
None => clipboard.set_text(plain),
}
}
fn get_clipboard_text() -> Result<String, arboard::Error> {
arboard::Clipboard::new()?.get_text()
}
fn get_clipboard_html() -> Result<String, arboard::Error> {
arboard::Clipboard::new()?.get().html()
}
#[cfg(test)]
mod tests {
use super::*;
use leaf_core::{InlineKind, View};
use ratatui::crossterm::event::MouseButton;
fn doc_with(name: &str, body: &str) -> Doc {
let mut p = std::env::temp_dir();
p.push(format!("leaf_tui_test_{name}.md"));
std::fs::write(&p, body).unwrap();
let mut doc = Doc::open(p).unwrap();
doc.build_visual(80);
doc.body_origin = (0, 1);
doc.body_height = 10;
doc
}
fn left_down(row: u16, col: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn shift_left_down(row: u16, col: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::SHIFT,
}
}
fn right_down(row: u16, col: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn moved(row: u16, col: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Moved,
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
fn keyp(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn clicking_a_checkbox_ticks_it_and_leaves_the_caret_alone() {
let mut doc = doc_with("task_click", "- [ ] one\n- [ ] two\n");
let mut app = App::default();
doc.caret = doc.source.find("two").unwrap();
let before = doc.caret;
handle_mouse(&mut doc, left_down(1, 0), &mut app);
assert_eq!(doc.source, "- [x] one\n- [ ] two\n");
assert_eq!(
doc.caret, before,
"ticking a box elsewhere must not move the caret"
);
handle_mouse(&mut doc, left_down(1, 4), &mut app);
assert_eq!(
doc.source, "- [x] one\n- [ ] two\n",
"the text is not a box"
);
assert_ne!(doc.caret, before, "the caret moved to the click");
}
#[test]
fn triple_click_selects_the_paragraph_not_the_source_line() {
let mut doc = doc_with("triple", "one two\nthree four\n\nnext\n");
let mut app = App::default();
for _ in 0..3 {
handle_mouse(&mut doc, left_down(1, 1), &mut app);
}
assert_eq!(doc.selected_text(), Some("one two\nthree four"));
}
#[test]
fn double_click_still_takes_only_the_word() {
let mut doc = doc_with("double", "one two\nthree four\n\nnext\n");
let mut app = App::default();
for _ in 0..2 {
handle_mouse(&mut doc, left_down(1, 1), &mut app);
}
assert_eq!(doc.selected_text(), Some("one"));
}
#[test]
fn shift_click_extends_the_selection_from_the_first_click() {
let mut doc = doc_with("shift", "one two three\n");
let mut app = App::default();
handle_mouse(&mut doc, left_down(1, 0), &mut app); handle_mouse(&mut doc, shift_left_down(1, 9), &mut app); assert_eq!(doc.selected_text(), Some("one two t"));
}
#[test]
fn right_click_places_the_caret_and_opens_the_menu() {
let mut doc = doc_with("right_place", "one two three\n");
let mut app = App::default();
handle_mouse(&mut doc, right_down(1, 4), &mut app);
assert_eq!(doc.caret, 4);
assert!(app.context_menu.is_some());
}
#[test]
fn right_click_on_a_selection_leaves_it_intact() {
let mut doc = doc_with("right_sel", "one two three\n");
let mut app = App::default();
for _ in 0..2 {
handle_mouse(&mut doc, left_down(1, 5), &mut app); }
let before = doc.selected_text().map(str::to_string);
assert_eq!(before.as_deref(), Some("two"));
handle_mouse(&mut doc, right_down(1, 5), &mut app);
assert_eq!(doc.selected_text().map(str::to_string), before);
}
#[test]
fn context_menu_esc_dismisses_without_acting() {
let mut doc = doc_with("menu_esc", "one two three\n");
let mut app = App::default();
handle_mouse(&mut doc, right_down(1, 4), &mut app);
assert!(app.context_menu.is_some());
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&mut app,
);
assert!(app.context_menu.is_none());
assert_eq!(doc.selection(), None);
}
#[test]
fn context_menu_arrows_and_enter_run_the_highlighted_action() {
let mut doc = doc_with("menu_nav", "one two three\n");
doc.anchor = Some(0);
doc.caret = 3;
let mut app = App::default();
handle_mouse(&mut doc, right_down(1, 4), &mut app);
for _ in 0..3 {
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
&mut app,
);
}
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&mut app,
);
assert!(app.context_menu.is_none());
assert_eq!(doc.selected_text(), Some("one two three\n"));
}
#[test]
fn menu_click_on_an_item_runs_it_and_a_click_elsewhere_just_dismisses() {
let mut doc = doc_with("menu_click", "one two three\n");
let mut app = App::default();
handle_mouse(&mut doc, right_down(1, 4), &mut app);
assert!(app.context_menu.as_ref().unwrap().levels[0].rect.is_none());
handle_mouse(&mut doc, left_down(5, 5), &mut app);
assert!(app.context_menu.is_none());
}
#[test]
fn alt_1_toggles_a_heading_back_to_a_paragraph_and_forth_again() {
let mut doc = doc_with("heading_toggle", "# Title\n\nbody text\n");
let mut app = App::default();
doc.caret = 3; let alt_1 = KeyEvent::new(KeyCode::Char('1'), KeyModifiers::ALT);
handle_key(&mut doc, alt_1, &mut app);
assert_eq!(
&doc.source[..7],
"Title\n\n",
"first ⌥1 should strip the heading marker"
);
handle_key(&mut doc, alt_1, &mut app);
assert!(
doc.source.starts_with("# Title"),
"second ⌥1 should re-apply H1"
);
}
fn alt(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)
}
fn plain(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn doc_with_dj(name: &str, body: &str) -> Doc {
let mut p = std::env::temp_dir();
p.push(format!("leaf_tui_test_{name}.dj"));
std::fs::write(&p, body).unwrap();
let mut doc = Doc::open(p).unwrap();
doc.build_visual(80);
doc.body_origin = (0, 1);
doc.body_height = 10;
doc
}
fn drag(row: u16, col: u16) -> MouseEvent {
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn alt_k_opens_the_link_prompt_empty() {
let mut doc = doc_with("link_open", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
let prompt = app.text_prompt.as_ref().expect("⌥k should open the prompt");
assert_eq!(prompt.label, "Link destination");
assert_eq!(prompt.value, "");
}
#[test]
fn alt_k_prefills_the_existing_link_s_destination() {
let mut doc = doc_with("link_prefill", "see [t](https://x.dev) ok\n");
doc.caret = 5; let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
let prompt = app.text_prompt.as_ref().expect("⌥k should open the prompt");
assert_eq!(prompt.value, "https://x.dev");
}
#[test]
fn link_prompt_enter_links_the_selection_to_the_typed_destination() {
let mut doc = doc_with("link_confirm", "hello\n");
doc.anchor = Some(0);
doc.caret = 5; let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
for c in "https://example.com".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&mut app,
);
assert!(app.text_prompt.is_none(), "Enter should close the prompt");
assert_eq!(doc.source, "[hello](https://example.com)\n");
}
#[test]
fn link_prompt_esc_cancels_without_touching_the_document() {
let mut doc = doc_with("link_cancel", "hello\n");
doc.anchor = Some(0);
doc.caret = 5;
let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
for c in "http://x".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&mut app,
);
assert!(app.text_prompt.is_none());
assert_eq!(doc.source, "hello\n");
}
#[test]
fn link_prompt_backspace_deletes_the_last_character_typed() {
let mut doc = doc_with("link_backspace", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
for c in "abc".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
&mut app,
);
assert_eq!(app.text_prompt.as_ref().unwrap().value, "ab");
}
#[test]
fn text_prompt_owns_the_keyboard_document_keys_dont_leak_through() {
let mut doc = doc_with("prompt_isolation", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
&mut app,
);
assert_eq!(doc.selection(), None, "^A must not have reached select_all");
handle_key(&mut doc, alt('b'), &mut app);
assert_eq!(
doc.source, "hello\n",
"⌥b must not have reached the document"
);
assert!(app.text_prompt.is_some(), "the prompt should still be open");
assert_eq!(app.text_prompt.unwrap().value, "ab");
}
#[test]
fn alt_8_toggles_a_bulleted_list_at_the_caret() {
let mut doc = doc_with("list8", "item\n");
let mut app = App::default();
doc.caret = 0;
handle_key(&mut doc, alt('8'), &mut app);
assert_eq!(doc.source, "- item\n");
}
#[test]
fn alt_7_toggles_a_numbered_list_at_the_caret() {
let mut doc = doc_with("list7", "item\n");
let mut app = App::default();
doc.caret = 0;
handle_key(&mut doc, alt('7'), &mut app);
assert_eq!(doc.source, "1. item\n");
}
#[test]
fn alt_9_toggles_a_blockquote_at_the_caret() {
let mut doc = doc_with("quote9", "item\n");
let mut app = App::default();
doc.caret = 0;
handle_key(&mut doc, alt('9'), &mut app);
assert_eq!(doc.source, "> item\n");
}
#[test]
fn alt_8_with_a_full_selection_removes_the_list_without_a_nest_message() {
let mut doc = doc_with("list_unwrap", "- item\n");
let mut app = App::default();
doc.anchor = Some(0);
doc.caret = doc.source.len();
handle_key(&mut doc, alt('8'), &mut app);
assert_eq!(doc.source, "item\n");
assert_eq!(doc.status, None);
}
#[test]
fn alt_8_on_a_bare_caret_in_a_multi_item_list_nests_and_says_so() {
let mut doc = doc_with("list_nest", "- a\n- b\n");
let mut app = App::default();
doc.caret = doc.source.find('b').unwrap();
handle_key(&mut doc, alt('8'), &mut app);
assert!(
doc.source.contains("- - b"),
"the second item should have nested: {:?}",
doc.source
);
assert!(
doc.status.as_deref().unwrap_or("").contains("nested"),
"status should explain the nest: {:?}",
doc.status
);
}
#[test]
fn ctrl_q_on_a_clean_document_quits_immediately() {
let mut doc = doc_with("quit_clean", "hello\n");
let mut app = App::default();
assert!(!doc.dirty);
assert!(handle_key(&mut doc, ctrl('q'), &mut app) == Flow::Quit);
}
#[test]
fn ctrl_q_on_a_dirty_document_opens_a_save_discard_cancel_prompt() {
let mut doc = doc_with("quit_dirty", "hello\n");
doc.caret = 5;
doc.insert(" world");
let mut app = App::default();
assert!(handle_key(&mut doc, ctrl('q'), &mut app) == Flow::Continue);
let prompt = app
.dirty_prompt
.as_ref()
.expect("a dirty ^Q should open the prompt");
assert!(prompt.action == DirtyAction::Quit);
assert_eq!(prompt.selected, 0);
}
#[test]
fn dirty_prompt_cancel_leaves_the_document_untouched() {
let mut doc = doc_with("quit_cancel", "hello\n");
doc.caret = 5;
doc.insert(" world");
let mut app = App::default();
handle_key(&mut doc, ctrl('q'), &mut app);
assert!(handle_key(&mut doc, plain('c'), &mut app) == Flow::Continue);
assert!(app.dirty_prompt.is_none());
assert_eq!(doc.source, "hello world\n");
assert!(doc.dirty);
}
#[test]
fn dirty_prompt_discard_quits_without_writing_the_file() {
let mut doc = doc_with("quit_discard", "hello\n");
doc.caret = 5;
doc.insert(" world");
let mut app = App::default();
handle_key(&mut doc, ctrl('q'), &mut app);
assert!(handle_key(&mut doc, plain('d'), &mut app) == Flow::Quit);
assert_eq!(std::fs::read_to_string(&doc.path).unwrap(), "hello\n");
}
#[test]
fn dirty_prompt_save_writes_the_file_and_then_quits() {
let mut doc = doc_with("quit_save", "hello\n");
doc.caret = 5;
doc.insert(" world");
let mut app = App::default();
handle_key(&mut doc, ctrl('q'), &mut app);
assert!(handle_key(&mut doc, plain('s'), &mut app) == Flow::Quit);
assert_eq!(std::fs::read_to_string(&doc.path).unwrap(), "hello world\n");
}
fn missing_tui_path(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("leaf_tui_test_new_{name}.md"));
let _ = std::fs::remove_file(&p);
p
}
#[test]
fn ctrl_s_on_a_file_that_didnt_exist_writes_it_with_no_save_as_prompt() {
let p = missing_tui_path("ctrl_s");
let mut doc = Doc::open_or_create(p.clone()).unwrap();
doc.insert("typed into a file that didn't exist\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
assert!(app.text_prompt.is_none(), "no Save As detour");
assert!(app.conflict.is_none(), "and nothing to conflict with");
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"typed into a file that didn't exist\n"
);
assert!(!doc.dirty);
let _ = std::fs::remove_file(&p);
}
#[test]
fn quitting_a_new_file_untouched_leaves_the_disk_alone() {
let p = missing_tui_path("quit_untouched");
let mut doc = Doc::open_or_create(p.clone()).unwrap();
let mut app = App::default();
assert!(handle_key(&mut doc, ctrl('q'), &mut app) == Flow::Quit);
assert!(app.dirty_prompt.is_none());
assert!(!p.exists(), "quitting must not create the file");
}
#[test]
fn a_new_file_created_underneath_us_still_gets_the_overwrite_prompt() {
let p = missing_tui_path("conflict");
let mut doc = Doc::open_or_create(p.clone()).unwrap();
doc.insert("ours\n");
std::fs::write(&p, "theirs\n").unwrap();
let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
assert!(
app.conflict.is_some(),
"a save about to overwrite someone's file has to ask"
);
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"theirs\n",
"and must not have written anything yet"
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn ctrl_s_on_an_untitled_document_routes_to_save_as_instead_of_failing() {
let mut doc = Doc::blank().unwrap();
doc.insert("hello");
let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
let prompt = app
.text_prompt
.as_ref()
.expect("^S on an untitled doc should open Save As");
assert_eq!(prompt.label, "Save as");
assert_eq!(prompt.value, "");
assert!(doc.is_untitled(), "no path should have been invented");
}
#[test]
fn save_as_confirm_writes_the_file_and_adopts_the_path() {
let mut doc = Doc::blank().unwrap();
doc.insert("hello");
let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
let mut p = std::env::temp_dir();
p.push("leaf_tui_test_saveas_confirm.md");
let _ = std::fs::remove_file(&p);
for c in p.to_string_lossy().chars() {
handle_key(&mut doc, plain(c), &mut app);
}
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&mut app,
);
assert!(app.text_prompt.is_none());
assert!(!doc.is_untitled());
assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello");
}
#[test]
fn quitting_an_untitled_dirty_document_quits_only_after_the_save_as_lands() {
let mut doc = Doc::blank().unwrap();
doc.insert("hello");
let mut app = App::default();
handle_key(&mut doc, ctrl('q'), &mut app);
assert!(handle_key(&mut doc, plain('s'), &mut app) == Flow::Continue);
assert!(
app.text_prompt.is_some(),
"Save should have detoured to Save As"
);
assert!(app.dirty_prompt.is_none());
let mut p = std::env::temp_dir();
p.push("leaf_tui_test_quit_via_saveas.md");
let _ = std::fs::remove_file(&p);
for c in p.to_string_lossy().chars() {
handle_key(&mut doc, plain(c), &mut app);
}
let flow = handle_key(
&mut doc,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
&mut app,
);
assert!(
flow == Flow::Quit,
"the pending quit should fire once the save-as lands"
);
assert_eq!(std::fs::read_to_string(&p).unwrap(), "hello");
}
#[test]
fn escaping_the_save_as_detour_abandons_the_quit_too() {
let mut doc = Doc::blank().unwrap();
doc.insert("hello");
let mut app = App::default();
handle_key(&mut doc, ctrl('q'), &mut app);
handle_key(&mut doc, plain('s'), &mut app);
assert!(app.text_prompt.is_some());
assert!(
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
&mut app
) == Flow::Continue
);
assert!(app.text_prompt.is_none());
assert!(
app.dirty_prompt.is_none(),
"canceling the destination shouldn't resurrect the quit prompt"
);
assert!(doc.dirty);
}
#[test]
fn alt_n_on_a_clean_document_replaces_it_immediately() {
let mut doc = doc_with("new_clean", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('n'), &mut app);
assert!(doc.is_untitled());
assert_eq!(doc.source, "");
}
#[test]
fn alt_n_on_a_dirty_document_asks_first() {
let mut doc = doc_with("new_dirty", "hello\n");
doc.caret = 5;
doc.insert(" world");
let mut app = App::default();
handle_key(&mut doc, alt('n'), &mut app);
let prompt = app
.dirty_prompt
.as_ref()
.expect("⌥n on a dirty doc should ask first");
assert!(prompt.action == DirtyAction::New);
assert_eq!(
doc.source, "hello world\n",
"nothing should change before the choice is made"
);
}
#[test]
fn alt_n_dirty_prompt_discard_replaces_the_document() {
let mut doc = doc_with("new_discard", "hello\n");
doc.caret = 5;
doc.insert(" world");
let mut app = App::default();
handle_key(&mut doc, alt('n'), &mut app);
assert!(handle_key(&mut doc, plain('d'), &mut app) == Flow::Continue);
assert!(doc.is_untitled());
assert_eq!(doc.source, "");
}
#[test]
fn ctrl_s_stops_for_a_file_changed_on_disk_instead_of_clobbering_it() {
let mut doc = doc_with("conflict", "hello\n");
doc.caret = 5;
doc.insert(" world");
std::fs::write(&doc.path, "someone else's edit\n").unwrap(); let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
let prompt = app
.conflict
.as_ref()
.expect("a changed file should stop the save");
assert_eq!(
prompt.selected, 2,
"the safe default is Cancel, not Overwrite"
);
assert_eq!(
std::fs::read_to_string(&doc.path).unwrap(),
"someone else's edit\n"
);
}
#[test]
fn conflict_reload_takes_the_disk_version_and_drops_the_local_edits() {
let mut doc = doc_with("conflict_reload", "hello\n");
doc.caret = 5;
doc.insert(" world");
std::fs::write(&doc.path, "someone else's edit\n").unwrap();
let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
assert!(handle_key(&mut doc, plain('r'), &mut app) == Flow::Continue);
assert!(app.conflict.is_none());
assert_eq!(doc.source, "someone else's edit\n");
assert!(!doc.dirty);
}
#[test]
fn conflict_overwrite_writes_over_the_external_change() {
let mut doc = doc_with("conflict_overwrite", "hello\n");
doc.caret = 5;
doc.insert(" world");
std::fs::write(&doc.path, "someone else's edit\n").unwrap();
let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
assert!(handle_key(&mut doc, plain('o'), &mut app) == Flow::Continue);
assert_eq!(std::fs::read_to_string(&doc.path).unwrap(), "hello world\n");
}
#[test]
fn tab_indents_two_spaces_not_the_four_space_code_block_marker() {
let mut doc = doc_with("indent", "line one\n");
let mut app = App::default();
doc.caret = 0;
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
&mut app,
);
assert_eq!(doc.source, " line one\n");
}
#[test]
fn shift_tab_outdents_one_level() {
let mut doc = doc_with("outdent", " line one\n");
let mut app = App::default();
doc.caret = 0;
handle_key(
&mut doc,
KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
&mut app,
);
assert_eq!(doc.source, " line one\n");
}
#[test]
fn tab_in_a_table_hops_cells_instead_of_indenting() {
let mut doc = doc_with("table_tab", "| a | b |\n| - | - |\n| c | d |\n");
let mut app = App::default();
doc.caret = doc.source.find('a').unwrap();
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
&mut app,
);
assert_eq!(
doc.source, "| a | b |\n| - | - |\n| c | d |\n",
"a table hop must not indent"
);
assert_eq!(
doc.selected_text(),
Some("b"),
"the hopped-to cell comes up selected"
);
assert_eq!(doc.caret, doc.source.find('b').unwrap() + 1);
}
#[test]
fn alt_enter_in_a_markdown_table_inserts_an_in_cell_line_break() {
let mut doc = doc_with("table_break", "| a | b |\n| - | - |\n| c | d |\n");
let mut app = App::default();
doc.caret = doc.source.find('a').unwrap() + 1; handle_key(
&mut doc,
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
&mut app,
);
assert_eq!(doc.source, "| a<br> | b |\n| - | - |\n| c | d |\n");
assert!(
doc.caret_in_table(),
"still editing the cell, past the break"
);
}
#[test]
fn alt_enter_off_a_table_is_an_ordinary_newline() {
let mut doc = doc_with("break_newline", "hello world\n");
let mut app = App::default();
doc.caret = 5; let breaks = doc.source.matches('\n').count();
handle_key(
&mut doc,
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
&mut app,
);
assert!(
doc.source.matches('\n').count() > breaks,
"a newline off a table"
);
assert!(!doc.source.contains("<br>"), "no in-cell break off a table");
}
#[test]
fn ctrl_u_kills_back_to_the_line_start() {
let mut doc = doc_with("kill_start", "hello world\n");
let mut app = App::default();
doc.caret = 5; handle_key(&mut doc, ctrl('u'), &mut app);
assert_eq!(doc.source, " world\n");
}
#[test]
fn ctrl_k_kills_forward_to_the_line_end() {
let mut doc = doc_with("kill_end", "hello world\n");
let mut app = App::default();
doc.caret = 5; handle_key(&mut doc, ctrl('k'), &mut app);
assert_eq!(doc.source, "hello\n");
}
#[test]
fn alt_d_toggles_strikethrough_on_the_selection() {
let mut doc = doc_with_dj("strike", "hello world\n");
let mut app = App::default();
doc.anchor = Some(0);
doc.caret = 5; handle_key(&mut doc, alt('d'), &mut app);
assert!(
doc.active_inline_marks().contains(InlineKind::Delete),
"status: {:?}",
doc.status
);
}
#[test]
fn alt_u_toggles_underline_on_the_selection() {
let mut doc = doc_with_dj("underline", "hello world\n");
let mut app = App::default();
doc.anchor = Some(0);
doc.caret = 5; handle_key(&mut doc, alt('u'), &mut app);
assert!(
doc.active_inline_marks().contains(InlineKind::Insert),
"status: {:?}",
doc.status
);
}
#[test]
fn clipboard_paste_uses_doc_paste_not_doc_insert() {
let _clip = clipboard_lock();
let mut doc = doc_with("paste_coalesce", "");
doc.insert("a"); set_clipboard("b".into(), None).ok(); clipboard_paste(&mut doc);
if doc.status.as_deref() == Some("clipboard unavailable") {
return; }
assert_eq!(doc.source, "ab");
doc.undo();
assert_eq!(doc.source, "a", "undo should peel off only the pasted 'b'");
}
fn seed_clipboard(plain: &str, html: &str) -> bool {
set_clipboard(plain.into(), Some(html.into())).is_ok()
}
static CLIPBOARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clipboard_lock() -> std::sync::MutexGuard<'static, ()> {
CLIPBOARD.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn paste_prefers_the_html_flavor_and_converts_it() {
let _clip = clipboard_lock();
let mut doc = doc_with("paste_rich", "");
if !seed_clipboard("bold", "<p>a <strong>b</strong> c</p>") {
return; }
clipboard_paste(&mut doc);
if doc.status.as_deref() == Some("clipboard unavailable") {
return;
}
assert_eq!(doc.source, "a **b** c", "the rich flavor, as Markdown");
}
#[test]
fn paste_falls_back_to_plain_when_the_html_will_not_convert() {
let _clip = clipboard_lock();
let mut doc = doc_with("paste_fallback", "");
if !seed_clipboard("a\tb", "<table><tr><td>a</td><td>b</td></tr></table>") {
return;
}
clipboard_paste(&mut doc);
if doc.status.as_deref() == Some("clipboard unavailable") {
return;
}
assert_eq!(doc.source, "a\tb");
}
#[test]
fn alt_v_pastes_the_plain_flavor_even_when_html_is_there() {
let _clip = clipboard_lock();
let mut doc = doc_with("paste_plain", "");
let mut app = App::default();
if !seed_clipboard("a **b** c", "<p>a <strong>b</strong> c</p>") {
return;
}
handle_key(&mut doc, alt('v'), &mut app);
if doc.status.as_deref() == Some("clipboard unavailable") {
return;
}
assert_eq!(doc.source, "a **b** c", "the source, not the rich flavor");
}
#[test]
fn copy_publishes_both_flavors() {
let _clip = clipboard_lock();
let mut doc = doc_with("copy_both", "a **bold** c\n");
doc.anchor = Some(2);
doc.caret = 10; clipboard_copy(&mut doc);
if doc.status.as_deref() == Some("clipboard unavailable") {
return;
}
assert_eq!(
get_clipboard_text().ok().as_deref(),
Some("**bold**"),
"the source"
);
let html = get_clipboard_html().expect("html flavor");
assert!(html.contains("<strong>bold</strong>"), "{html:?}");
}
fn type_chars(doc: &mut Doc, app: &mut App, text: &str) {
for c in text.chars() {
handle_key(doc, plain(c), app);
}
}
#[test]
fn ctrl_f_opens_the_find_bar_and_typing_selects_the_first_match() {
let mut doc = doc_with("find_open", "one two three two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
assert!(app.find.is_some(), "^f should open the bar");
type_chars(&mut doc, &mut app, "two");
let find = app.find.as_ref().unwrap();
assert_eq!(find.matches.len(), 2);
assert_eq!(find.current, 0);
assert_eq!(find.caption(), "1 of 2");
assert_eq!(
doc.selected_text(),
Some("two"),
"the current match comes up selected"
);
}
#[test]
fn matching_is_case_insensitive_and_painted_as_highlights() {
let mut doc = doc_with("find_highlights", "Two two TWO\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "two");
assert_eq!(app.find.as_ref().unwrap().matches.len(), 3);
assert_eq!(doc.highlights().len(), 3);
assert_eq!(doc.highlights()[0].start, 0);
}
#[test]
fn enter_and_the_arrows_walk_the_matches_and_wrap() {
let mut doc = doc_with("find_walk", "a one b one c one d\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "one");
let at = |app: &App| app.find.as_ref().unwrap().current;
assert_eq!(at(&app), 0);
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(at(&app), 1);
handle_key(&mut doc, keyp(KeyCode::Down), &mut app);
assert_eq!(at(&app), 2);
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(at(&app), 0);
handle_key(&mut doc, keyp(KeyCode::Up), &mut app);
assert_eq!(at(&app), 2, "and back off zero the other way");
handle_key(&mut doc, ctrl('g'), &mut app);
assert_eq!(at(&app), 0);
}
#[test]
fn esc_closes_the_bar_leaving_the_caret_and_clearing_the_wash() {
let mut doc = doc_with("find_esc", "one two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "two");
let landed = doc.caret;
handle_key(&mut doc, keyp(KeyCode::Esc), &mut app);
assert!(app.find.is_none());
assert_eq!(
doc.caret, landed,
"the caret stays where the search left it"
);
assert!(doc.highlights().is_empty(), "the wash goes with the bar");
}
#[test]
fn the_find_bar_owns_the_keyboard_document_keys_dont_leak_through() {
let mut doc = doc_with("find_isolation", "hello world\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
handle_key(&mut doc, alt('b'), &mut app);
type_chars(&mut doc, &mut app, "wor");
assert_eq!(doc.source, "hello world\n", "nothing reached the document");
assert_eq!(app.find.as_ref().unwrap().query, "wor");
}
#[test]
fn a_match_inside_bold_selects_the_word_the_view_actually_draws() {
let mut doc = doc_with("find_bold", "a **needle** in the haystack\n");
let mut app = App::default();
assert_eq!(doc.view, View::Wysiwyg, "the view that hides the `**`");
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "needle");
let (start, end) = app.find.as_ref().unwrap().current_match().unwrap();
assert_eq!(&doc.source[start..end], "needle");
assert_eq!(
doc.selected_text(),
Some("needle"),
"the selection is the word, not the delimiters around it"
);
let (row, _) = doc.caret_pos();
assert!(row < doc.vmap.rows.len(), "caret row {row} is off the map");
}
#[test]
fn a_match_below_the_fold_scrolls_into_view_above_the_bar() {
let mut doc = doc_with(
"find_scroll",
&format!("{}needle\n", "filler\n\n".repeat(40)),
);
let mut app = App::default();
let _ = frame(&mut doc, &mut app, 40, 10);
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "needle");
let lines = frame(&mut doc, &mut app, 40, 10);
assert!(
lines.iter().any(|l| l.contains("needle")),
"the match should have been scrolled to:\n{}",
lines.join("\n")
);
}
#[test]
fn ctrl_h_opens_the_bar_with_a_replacement_field() {
let mut doc = doc_with("replace_open", "one two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
let find = app.find.as_ref().expect("^h should open the bar");
assert!(find.replacement.is_some(), "with a field to replace into");
assert_eq!(find.field, FindField::Query, "typing starts in the query");
assert_eq!(find.rows(), 3);
}
#[test]
fn ctrl_h_on_an_open_find_bar_keeps_the_query_already_typed() {
let mut doc = doc_with("replace_grow", "one two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "one");
handle_key(&mut doc, ctrl('h'), &mut app);
let find = app.find.as_ref().unwrap();
assert_eq!(find.query, "one", "the query survives");
assert!(find.replacement.is_some());
assert_eq!(find.field, FindField::Replacement, "and the focus moves");
}
#[test]
fn enter_in_the_replacement_field_replaces_one_match_and_moves_on() {
let mut doc = doc_with("replace_one", "one two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "one");
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
type_chars(&mut doc, &mut app, "ONE");
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(doc.source, "ONE two one\n", "only the current match");
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(doc.source, "ONE two ONE\n");
}
#[test]
fn each_replacement_is_its_own_undo_step() {
let mut doc = doc_with("replace_undo", "one two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "one");
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
type_chars(&mut doc, &mut app, "ONE");
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(doc.source, "ONE two ONE\n");
doc.undo();
assert_eq!(doc.source, "ONE two one\n", "one replacement at a time");
}
#[test]
fn replace_all_is_one_undo_step_for_the_whole_operation() {
let mut doc = doc_with("replace_all", "one two one three one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "one");
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
type_chars(&mut doc, &mut app, "ONE");
handle_key(&mut doc, ctrl('r'), &mut app);
assert_eq!(doc.source, "ONE two ONE three ONE\n");
assert_eq!(doc.status.as_deref(), Some("replaced 3 matches"));
doc.undo();
assert_eq!(
doc.source, "one two one three one\n",
"the whole replace-all comes off in one step"
);
}
#[test]
fn a_replacement_containing_the_needle_does_not_loop() {
let mut doc = doc_with("replace_loop", "one and one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "one");
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
type_chars(&mut doc, &mut app, "one two");
handle_key(&mut doc, ctrl('r'), &mut app);
assert_eq!(doc.source, "one two and one two\n");
}
#[test]
fn replacing_with_nothing_deletes_the_matches() {
let mut doc = doc_with("replace_empty", "a BAD b BAD c\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "bad ");
handle_key(&mut doc, ctrl('r'), &mut app);
assert_eq!(doc.source, "a b c\n");
}
#[test]
fn find_seeds_itself_from_the_selection() {
let mut doc = doc_with("find_seed", "alpha beta alpha\n");
doc.anchor = Some(0);
doc.caret = 5; let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
let find = app.find.as_ref().unwrap();
assert_eq!(find.query, "alpha");
assert_eq!(find.matches.len(), 2);
assert_eq!(find.current, 0, "the selected one, not the next one");
}
#[test]
fn a_paste_into_the_find_bar_searches_for_what_was_pasted() {
let mut doc = doc_with("find_paste", "one two three\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
handle_paste(&mut doc, "three", &mut app);
assert_eq!(app.find.as_ref().unwrap().query, "three");
assert_eq!(doc.selected_text(), Some("three"));
}
#[test]
fn a_hit_in_hidden_frontmatter_never_parks_the_caret_inside_it() {
let fm = "---\ntitle: foo\n---\n\n";
let mut doc = doc_with("find_frontmatter", &format!("{fm}body foo here\n"));
let mut app = App::default();
assert_eq!(doc.view, View::Wysiwyg, "the view that hides frontmatter");
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "foo");
let floor = doc.vmap.content_start;
assert!(floor > 0, "there is frontmatter to be floored past");
assert!(
doc.caret >= floor && doc.anchor.is_none_or(|a| a >= floor),
"caret {} / anchor {:?} is inside the hidden frontmatter (floor {floor})",
doc.caret,
doc.anchor,
);
handle_key(&mut doc, keyp(KeyCode::Esc), &mut app);
type_chars(&mut doc, &mut app, "X");
assert!(
doc.source.starts_with(fm),
"the frontmatter was edited: {:?}",
doc.source
);
}
#[test]
fn find_is_offered_in_a_read_only_document_and_replace_is_not() {
let mut doc = doc_with("find_readonly", "one two one\n");
doc.set_read_only(true);
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
assert!(
app.find.is_some(),
"reading a document includes searching it"
);
type_chars(&mut doc, &mut app, "one");
assert_eq!(app.find.as_ref().unwrap().matches.len(), 2);
handle_key(&mut doc, ctrl('h'), &mut app);
let find = app.find.as_ref().expect("the find bar stays up");
assert!(find.replacement.is_none(), "no field to replace into");
assert_eq!(doc.status.as_deref(), Some("read-only"));
let lines = frame(&mut doc, &mut app, 60, 8);
assert!(
!lines.join("\n").contains("^h"),
"the hint row must not offer a key that refuses:\n{}",
lines.join("\n")
);
handle_key(&mut doc, keyp(KeyCode::Esc), &mut app);
handle_key(&mut doc, ctrl('h'), &mut app);
assert!(app.find.is_none(), "replace is not");
assert_eq!(doc.status.as_deref(), Some("read-only"));
}
#[test]
fn a_context_menu_edit_under_the_find_bar_re_runs_the_search() {
let _clip = clipboard_lock();
let mut doc = doc_with("find_menu_edit", "alpha beta alpha gamma\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "gamma");
assert_eq!(app.find.as_ref().unwrap().matches.len(), 1);
doc.select_range(0, 5);
handle_mouse(&mut doc, right_down(1, 2), &mut app);
step_to(&mut doc, &mut app, "Cut");
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
if doc.status.as_deref() == Some("clipboard unavailable") {
return; }
assert_eq!(doc.source, " beta alpha gamma\n", "the menu ran the cut");
let (start, end) = app.find.as_ref().unwrap().current_match().unwrap();
assert_eq!(&doc.source[start..end], "gamma");
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
type_chars(&mut doc, &mut app, "DELTA");
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(doc.source, " beta alpha DELTA\n");
}
#[test]
fn a_query_with_no_matches_disarms_the_selection() {
let mut doc = doc_with("find_none", "hello world\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "hel");
assert_eq!(doc.selected_text(), Some("hel"));
type_chars(&mut doc, &mut app, "x");
assert_eq!(app.find.as_ref().unwrap().caption(), "no matches");
assert_eq!(doc.selection(), None, "and nothing is left armed");
handle_key(&mut doc, keyp(KeyCode::Esc), &mut app);
type_chars(&mut doc, &mut app, "X");
assert_eq!(doc.source, "helXlo world\n", "no word was overwritten");
}
#[test]
fn the_bar_hands_the_host_s_own_chords_back_to_the_host() {
let _lock = clipboard_lock();
let mut doc = doc_with("find_chords", "alpha needle omega\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "needle");
assert_eq!(doc.selected_text(), Some("needle"));
handle_key(&mut doc, ctrl('c'), &mut app);
assert!(app.find.is_some(), "the bar stays up over a copy");
assert_eq!(
doc.status.as_deref().map(|s| s == "copied"),
Some(true),
"^c must reach the host's clipboard verb, not be eaten by the bar"
);
handle_key(&mut doc, ctrl('p'), &mut app);
assert!(app.palette.is_some(), "^p reaches the palette");
assert!(app.find.is_none(), "and takes the bar down with it");
handle_key(&mut doc, keyp(KeyCode::Esc), &mut app);
handle_key(&mut doc, ctrl('f'), &mut app);
assert_eq!(
handle_key(&mut doc, ctrl('q'), &mut app),
Flow::Quit,
"^q must reach the quit path"
);
}
#[test]
fn a_hit_the_rich_view_hides_is_counted_but_never_replaced() {
let fm = "---\ntitle: foo\n---\n\n";
let mut doc = doc_with("find_hidden", &format!("{fm}body foo here\n"));
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "foo");
let find = app.find.as_ref().unwrap();
assert_eq!(find.matches.len(), 1, "only the one in the body");
assert_eq!(find.hidden, 1, "and the frontmatter's is counted");
assert_eq!(find.caption(), "1 of 1, 1 hidden");
assert_eq!(doc.highlights().len(), 1, "the hidden one gets no wash");
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
type_chars(&mut doc, &mut app, "BAR");
handle_key(&mut doc, ctrl('r'), &mut app);
assert_eq!(doc.source, format!("{fm}body BAR here\n"));
doc.toggle_view();
assert_eq!(doc.view, View::Source);
refresh_find_after_edit(&mut doc, &mut app);
let find = app.find.as_ref().unwrap();
assert_eq!(find.hidden, 0);
assert_eq!(find.matches.len(), 1, "only `foo` is left in the metadata");
}
#[test]
fn the_find_bar_draws_at_the_foot_of_the_screen_with_its_count() {
let mut doc = doc_with("find_render", "one two one two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "two");
let lines = frame(&mut doc, &mut app, 60, 8);
let joined = lines.join("\n");
let query_row = &lines[lines.len() - 2];
assert!(query_row.contains("find"), "no find row:\n{joined}");
assert!(query_row.contains("1 of 2"), "no count:\n{joined}");
assert!(
lines.last().unwrap().contains("esc"),
"no hint row:\n{joined}"
);
}
#[test]
fn the_replace_bar_draws_both_fields() {
let mut doc = doc_with("replace_render", "one two one\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('h'), &mut app);
type_chars(&mut doc, &mut app, "one");
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
type_chars(&mut doc, &mut app, "ONE");
let lines = frame(&mut doc, &mut app, 60, 8);
let joined = lines.join("\n");
assert!(lines[lines.len() - 3].contains("one"), "query:\n{joined}");
assert!(
lines[lines.len() - 2].contains("ONE"),
"replacement:\n{joined}"
);
assert!(joined.contains("replace all"), "hints:\n{joined}");
}
#[test]
fn a_query_longer_than_the_field_scrolls_under_the_cursor() {
let mut doc = doc_with("find_long", "needle at the end\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "abcdefghijklmnopqrstuvwxyz0123456789");
let lines = frame(&mut doc, &mut app, 40, 8);
let joined = lines.join("\n");
assert!(
joined.contains("6789"),
"the field should have scrolled to the tail being typed:\n{joined}"
);
assert!(
!joined.contains("abcdef"),
"…and away from the head:\n{joined}"
);
for _ in 0..36 {
handle_key(&mut doc, keyp(KeyCode::Left), &mut app);
}
let joined = frame(&mut doc, &mut app, 40, 8).join("\n");
assert!(joined.contains("abcdef"), "back to the head:\n{joined}");
}
#[test]
fn a_wide_character_query_keeps_the_bar_one_row_wide() {
let mut doc = doc_with("find_cjk", "你好世界 hello\n");
let mut app = App::default();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "你好");
let lines = frame(&mut doc, &mut app, 40, 8);
let bar = &lines[lines.len() - 2];
assert!(bar.contains('你') && bar.contains('好'), "query: {bar:?}");
assert!(bar.trim_end().ends_with("1 of 1"), "count: {bar:?}");
assert_eq!(app.find.as_ref().unwrap().matches.len(), 1);
}
#[test]
fn the_key_reference_dims_what_a_read_only_document_cannot_run() {
let mut doc = doc_with("help_dim", "hello\n");
let mut app = App {
help: true,
..Default::default()
};
let editable = dimmed_labels(&mut doc, &mut app, 120, 40);
assert!(!editable.contains("Bold"), "an editable document can bold");
doc.set_read_only(true);
let card = frame(&mut doc, &mut app, 120, 40).join("\n");
let reading = dimmed_labels(&mut doc, &mut app, 120, 40);
for row in ["Bold", "Save", "Quit", "Paste"] {
assert!(card.contains(row), "{row} was dropped from the card");
}
assert!(
!reading.contains("Quit"),
"quit is not withheld:\n{reading}"
);
assert!(!reading.contains("Find…"), "nor is find:\n{reading}");
for row in ["Bold", "Save", "Paste", "New Document"] {
assert!(reading.contains(row), "{row} should be dimmed:\n{reading}");
}
}
fn dimmed_labels(doc: &mut Doc, app: &mut App, w: u16, h: u16) -> String {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| ui::render(f, doc, app)).unwrap();
let buf = term.backend().buffer().clone();
let theme = app.editor.theme();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| {
let cell = &buf[(x, y)];
if cell.fg == theme.panel_dim {
cell.symbol()
} else {
" "
}
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn a_reload_under_an_open_find_bar_re_runs_the_search() {
let (mut doc, mut app) = watched("find_reload", "one two one\n");
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "one");
assert_eq!(app.find.as_ref().unwrap().matches.len(), 2);
std::fs::write(&doc.path, "one one one and one more\n").unwrap();
settle(&mut doc, &mut app);
assert_eq!(doc.source, "one one one and one more\n");
assert_eq!(
app.find.as_ref().unwrap().matches.len(),
4,
"the search should have been run again over the new text"
);
assert_eq!(doc.highlights().len(), 4);
}
fn argv(args: &[&str]) -> Vec<std::ffi::OsString> {
args.iter().map(std::ffi::OsString::from).collect()
}
fn parsed(args: &[&str]) -> Args {
match parse_args(&argv(args)).unwrap() {
Parsed::Run(args) => args,
Parsed::Print(_) => panic!("expected arguments, got a message to print"),
}
}
#[test]
fn the_read_only_flag_is_taken_on_either_side_of_the_path() {
for args in [&["-r", "notes.md"], &["notes.md", "-r"]] {
let parsed = parsed(args);
assert!(parsed.read_only, "{args:?}");
assert_eq!(parsed.path, PathBuf::from("notes.md"), "{args:?}");
}
assert!(parsed(&["--read-only", "notes.md"]).read_only);
assert!(!parsed(&["notes.md"]).read_only);
}
#[test]
fn line_width_defaults_to_eighty_and_accepts_both_spellings() {
assert_eq!(parsed(&["notes.md"]).line_width, 80);
assert_eq!(parsed(&["-w", "100", "notes.md"]).line_width, 100);
assert_eq!(parsed(&["notes.md", "--width=72"]).line_width, 72);
assert_eq!(parsed(&["--width", "0", "notes.md"]).line_width, 0);
assert!(parse_args(&argv(&["--width", "wide", "notes.md"])).is_err());
assert!(parse_args(&argv(&["--width", "notes.md"])).is_err());
}
#[test]
fn an_unknown_flag_is_an_error_rather_than_a_filename() {
let err = parse_args(&argv(&["--raed-only", "notes.md"])).unwrap_err();
assert!(err.to_string().contains("unknown option"), "{err}");
assert!(parse_args(&argv(&["a.md", "b.md"])).is_err());
assert!(parse_args(&argv(&[])).is_err());
}
#[test]
fn version_and_help_are_answered_before_anything_is_opened() {
for flag in ["--version", "-V"] {
match parse_args(&argv(&[flag])).unwrap() {
Parsed::Print(text) => assert!(text.starts_with("leaf "), "{text}"),
Parsed::Run(_) => panic!("{flag} should not open a document"),
}
}
for flag in ["--help", "-h"] {
match parse_args(&argv(&[flag])).unwrap() {
Parsed::Print(text) => assert!(text.contains("--read-only"), "{text}"),
Parsed::Run(_) => panic!("{flag} should not open a document"),
}
}
}
#[test]
fn typing_into_a_read_only_document_changes_nothing_and_says_why() {
let mut doc = doc_with("ro_typing", "hello\n");
doc.set_read_only(true);
let mut app = App::default();
handle_key(&mut doc, plain('x'), &mut app);
assert_eq!(doc.source, "hello\n");
assert_eq!(doc.status.as_deref(), Some("read-only"));
assert!(!doc.dirty);
}
#[test]
fn ctrl_s_on_a_read_only_document_says_so_and_writes_nothing() {
let mut doc = doc_with("ro_save", "hello\n");
doc.set_read_only(true);
let mut app = App::default();
handle_key(&mut doc, ctrl('s'), &mut app);
assert_eq!(doc.status.as_deref(), Some("read-only"));
assert!(app.text_prompt.is_none(), "and opens no Save As box");
assert_eq!(std::fs::read_to_string(&doc.path).unwrap(), "hello\n");
}
#[test]
fn save_as_and_new_are_refused_in_a_read_only_session_too() {
let mut doc = doc_with("ro_saveas", "hello\n");
doc.set_read_only(true);
let mut app = App::default();
handle_key(&mut doc, alt('s'), &mut app);
assert!(app.text_prompt.is_none(), "no Save As destination box");
handle_key(&mut doc, alt('n'), &mut app);
assert_eq!(doc.source, "hello\n", "and no blank document");
assert!(!doc.is_untitled());
}
#[test]
fn a_paste_into_a_read_only_document_is_refused() {
let mut doc = doc_with("ro_paste", "hello\n");
doc.set_read_only(true);
let mut app = App::default();
handle_paste(&mut doc, "pasted text", &mut app);
assert_eq!(doc.source, "hello\n");
assert_eq!(doc.status.as_deref(), Some("read-only"));
}
#[test]
fn a_read_only_document_still_navigates_selects_and_copies() {
let mut doc = doc_with("ro_reading", "hello world\n");
doc.set_read_only(true);
let mut app = App::default();
handle_mouse(&mut doc, left_down(1, 0), &mut app);
handle_mouse(&mut doc, shift_left_down(1, 5), &mut app);
assert_eq!(doc.selected_text(), Some("hello"));
handle_key(&mut doc, ctrl('a'), &mut app);
assert_eq!(doc.selected_text(), Some("hello world\n"));
handle_key(&mut doc, alt('p'), &mut app);
assert!(app.palette.is_some(), "the palette still opens");
}
#[test]
fn clicking_a_checkbox_in_a_read_only_document_places_the_caret_instead() {
let mut doc = doc_with("ro_checkbox", "- [ ] one\n- [ ] two\n");
doc.set_read_only(true);
doc.caret = doc.source.find("two").unwrap();
let mut app = App::default();
handle_mouse(&mut doc, left_down(1, 0), &mut app);
assert_eq!(doc.source, "- [ ] one\n- [ ] two\n", "nothing was ticked");
assert_eq!(
doc.caret,
doc.source.find("one").unwrap(),
"the caret moved to the click"
);
}
#[test]
fn the_read_only_badge_shows_in_the_corner_and_yields_to_a_status() {
let mut doc = doc_with("ro_badge", "hello\n");
doc.set_read_only(true);
let mut app = App::default();
let lines = frame(&mut doc, &mut app, 40, 6);
assert!(
lines.last().unwrap().ends_with("read-only "),
"badge missing from the bottom-right:\n{}",
lines.join("\n")
);
doc.status = Some("copied".into());
let lines = frame(&mut doc, &mut app, 40, 6);
let bottom = lines.last().unwrap();
assert!(bottom.ends_with("copied "), "{bottom:?}");
assert!(!bottom.contains("read-only"), "{bottom:?}");
}
fn settle(doc: &mut Doc, app: &mut App) -> bool {
let a = tick_disk(doc, app);
let b = tick_disk(doc, app);
a || b
}
fn watched(name: &str, body: &str) -> (Doc, App) {
let doc = doc_with(name, body);
let app = App {
disk_stamp: disk_stamp(&doc),
..Default::default()
};
(doc, app)
}
#[test]
fn a_clean_document_reloads_itself_when_the_file_changes_underneath_it() {
let (mut doc, mut app) = watched("watch_clean", "hello\n");
doc.caret = 3;
doc.scroll = 0;
std::fs::write(&doc.path, "someone else's edit\n").unwrap();
assert!(!tick_disk(&mut doc, &mut app), "not on the first look");
assert_eq!(doc.source, "hello\n", "and nothing was taken yet");
assert!(tick_disk(&mut doc, &mut app), "taken once it stands still");
assert_eq!(doc.source, "someone else's edit\n");
assert_eq!(doc.status.as_deref(), Some("reloaded from disk"));
assert_eq!(doc.caret, 3, "the caret keeps the offset it had");
assert!(!doc.dirty);
assert!(!app.disk_ahead);
}
#[test]
fn a_dirty_document_is_warned_about_once_rather_than_reloaded() {
let (mut doc, mut app) = watched("watch_dirty", "hello\n");
doc.caret = 5;
doc.insert(" world");
std::fs::write(&doc.path, "someone else's edit\n").unwrap();
tick_disk(&mut doc, &mut app);
assert_eq!(doc.source, "hello world\n", "unsaved work survives");
assert_eq!(
doc.status.as_deref(),
Some("file changed on disk — ^S will ask")
);
assert!(app.disk_ahead);
doc.status = None;
tick_disk(&mut doc, &mut app);
assert_eq!(doc.status, None, "the warning is said once, not repeatedly");
}
#[test]
fn the_watch_catches_up_once_the_unsaved_work_goes_away() {
let (mut doc, mut app) = watched("watch_catchup", "hello\n");
doc.caret = 5;
doc.insert(" world");
std::fs::write(&doc.path, "someone else's edit\n").unwrap();
tick_disk(&mut doc, &mut app);
assert!(app.disk_ahead);
doc.undo();
assert!(!doc.dirty, "undone back to what was saved");
tick_disk(&mut doc, &mut app);
assert_eq!(doc.source, "someone else's edit\n");
assert!(!app.disk_ahead);
}
#[test]
fn a_write_still_in_progress_is_not_taken_half_finished() {
let (mut doc, mut app) = watched("watch_partial", "the whole document\n");
std::fs::write(&doc.path, "").unwrap();
assert!(!tick_disk(&mut doc, &mut app));
assert_eq!(doc.source, "the whole document\n", "not the empty half");
std::fs::write(&doc.path, "the whole replacement\n").unwrap();
assert!(
!tick_disk(&mut doc, &mut app),
"it moved again — wait again"
);
assert!(tick_disk(&mut doc, &mut app));
assert_eq!(doc.source, "the whole replacement\n");
}
#[test]
fn a_silent_reload_can_be_undone() {
let (mut doc, mut app) = watched("watch_undo", "mine\n");
doc.caret = 4;
doc.insert(" own");
doc.save();
assert_eq!(doc.source, "mine own\n");
std::fs::write(&doc.path, "the formatter's idea\n").unwrap();
settle(&mut doc, &mut app);
assert_eq!(doc.source, "the formatter's idea\n");
assert_eq!(doc.status.as_deref(), Some("reloaded from disk"));
doc.undo();
assert_eq!(doc.source, "mine own\n", "^z puts the reader back");
doc.undo();
assert_eq!(doc.source, "mine\n", "and the history under it is intact");
}
#[test]
fn the_conflict_prompt_s_reload_keeps_the_view_the_way_the_watch_does() {
let mut doc = doc_with("conflict_reload_view", &"line\n".repeat(60));
let mut app = App::default();
doc.caret = doc.source.len() - 5;
doc.insert("X");
doc.scroll = 40;
std::fs::write(&doc.path, "other\n".repeat(60)).unwrap();
handle_key(&mut doc, ctrl('f'), &mut app);
type_chars(&mut doc, &mut app, "other");
assert_eq!(app.find.as_ref().unwrap().matches.len(), 0);
close_find(&mut doc, &mut app);
doc.scroll = 40;
handle_key(&mut doc, ctrl('s'), &mut app);
assert!(app.conflict.is_some());
handle_key(&mut doc, plain('r'), &mut app);
assert_eq!(doc.source, "other\n".repeat(60));
assert_eq!(doc.scroll, 40, "a reader halfway down stays there");
assert_eq!(doc.status.as_deref(), Some("reloaded from disk"));
}
#[test]
fn our_own_save_is_not_mistaken_for_someone_else_s_write() {
let (mut doc, mut app) = watched("watch_own_save", "hello\n");
doc.caret = 5;
doc.insert(" world");
doc.save();
let after_save = doc.status.clone();
tick_disk(&mut doc, &mut app);
assert_eq!(doc.status, after_save, "our own write is not news");
assert!(!app.disk_ahead);
assert_eq!(doc.source, "hello world\n");
}
#[test]
fn a_file_deleted_underneath_the_document_is_reported_not_reloaded() {
let (mut doc, mut app) = watched("watch_gone", "hello\n");
std::fs::remove_file(&doc.path).unwrap();
tick_disk(&mut doc, &mut app);
assert_eq!(doc.source, "hello\n", "the buffer is what's left of it");
assert!(
doc.status
.as_deref()
.unwrap_or("")
.contains("no longer on disk"),
"status: {:?}",
doc.status
);
}
#[test]
fn an_untitled_document_is_not_watched_at_all() {
let mut doc = Doc::blank().unwrap();
doc.insert("typed");
let mut app = App::default();
tick_disk(&mut doc, &mut app);
assert!(app.disk_stamp.is_none());
assert_eq!(doc.source, "typed");
assert_eq!(doc.status, None);
}
#[test]
fn an_unchanged_file_is_left_entirely_alone() {
let (mut doc, mut app) = watched("watch_quiet", "hello\n");
doc.caret = 2;
tick_disk(&mut doc, &mut app);
assert_eq!(doc.source, "hello\n");
assert_eq!(doc.caret, 2);
assert_eq!(doc.status, None);
assert!(!app.disk_ahead);
}
#[test]
fn a_bracketed_paste_lands_as_one_undo_step_not_a_run_of_keystrokes() {
let mut doc = doc_with("bracketed_undo", "");
let mut app = App::default();
doc.insert("a"); handle_paste(&mut doc, "one\ntwo", &mut app);
assert_eq!(doc.source, "aone\ntwo");
doc.undo();
assert_eq!(doc.source, "a", "the whole paste comes off in one step");
}
#[test]
fn a_pasted_list_marker_is_not_read_as_typing_a_list() {
let mut doc = doc_with("bracketed_literal", "");
let mut app = App::default();
handle_paste(&mut doc, "- one\n- two\n", &mut app);
assert_eq!(doc.source, "- one\n- two\n");
}
#[test]
fn a_pasted_crlf_run_lands_as_plain_newlines() {
let mut doc = doc_with("bracketed_crlf", "");
let mut app = App::default();
handle_paste(&mut doc, "one\r\ntwo\rthree", &mut app);
assert_eq!(
doc.source, "one\ntwo\nthree",
"no carriage return should reach the source"
);
}
#[test]
fn a_paste_goes_into_the_text_prompt_rather_than_the_document() {
let mut doc = doc_with("bracketed_prompt", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
handle_paste(&mut doc, "https://example.com", &mut app);
assert_eq!(
app.text_prompt.as_ref().unwrap().value,
"https://example.com"
);
assert_eq!(doc.source, "hello\n", "and not into the document");
}
#[test]
fn a_multi_line_paste_into_a_one_row_field_flattens_to_spaces() {
let mut doc = doc_with("bracketed_flatten", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('k'), &mut app);
handle_paste(&mut doc, "one\r\n\ttwo\nthree", &mut app);
assert_eq!(app.text_prompt.as_ref().unwrap().value, "one two three");
}
#[test]
fn a_paste_into_the_palette_narrows_the_query() {
let mut doc = doc_with("bracketed_palette", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('p'), &mut app);
handle_paste(&mut doc, "bold", &mut app);
let palette = app
.palette
.as_ref()
.expect("the palette should still be up");
assert_eq!(palette.query, "bold");
assert_eq!(palette.rows[palette.selected].command.label(), "Bold");
}
#[test]
fn a_paste_while_a_modal_prompt_is_up_is_ignored() {
let mut doc = doc_with("bracketed_modal", "hello\n");
doc.caret = 5;
doc.insert(" world");
let mut app = App::default();
handle_key(&mut doc, ctrl('q'), &mut app);
handle_paste(&mut doc, "nope", &mut app);
assert_eq!(doc.source, "hello world\n");
assert!(app.dirty_prompt.is_some(), "and the prompt stays up");
}
#[test]
fn dragging_past_the_bottom_edge_scrolls_down_and_keeps_selecting() {
let mut doc = doc_with("drag_down", &"line\n".repeat(30));
doc.view = View::Source;
let mut app = App::default();
handle_mouse(&mut doc, left_down(1, 0), &mut app); let before = doc.scroll;
handle_mouse(&mut doc, drag(11, 0), &mut app); assert!(
doc.scroll > before,
"dragging past the bottom edge should scroll down"
);
assert!(
doc.selection().is_some(),
"the drag should still be extending a selection"
);
}
#[test]
fn dragging_past_the_top_edge_scrolls_up() {
let mut doc = doc_with("drag_up", &"line\n".repeat(30));
doc.view = View::Source;
doc.scroll = 5;
let mut app = App::default();
handle_mouse(&mut doc, left_down(2, 0), &mut app);
handle_mouse(&mut doc, drag(0, 0), &mut app); assert_eq!(doc.scroll, 4);
}
use ratatui::{Terminal, backend::TestBackend};
fn frame(doc: &mut Doc, app: &mut App, w: u16, h: u16) -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| ui::render(f, doc, app)).unwrap();
let buf = term.backend().buffer().clone();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[test]
fn the_body_starts_on_the_top_row_now_that_there_is_no_header() {
let mut doc = doc_with("no_header", "hello world\n");
let mut app = App::default();
let lines = frame(&mut doc, &mut app, 40, 6);
assert!(
lines[0].starts_with("hello world"),
"body not on row 0:\n{}",
lines.join("\n")
);
}
#[test]
fn a_configured_document_column_is_centered_at_its_exact_line_width() {
let mut doc = doc_with("centered_body", "hello world\n");
let mut app = App {
line_width: 20,
..Default::default()
};
let lines = frame(&mut doc, &mut app, 41, 6);
assert_eq!(doc.body_width, 20, "the scrollbar is outside the measure");
assert_eq!(doc.body_origin.0, 10);
assert!(
lines[0].starts_with(" hello world"),
"{:?}",
lines[0]
);
assert!(
lines.iter().any(|line| line.chars().nth(40) != Some(' ')),
"scrollbar should occupy the terminal's last column: {lines:?}"
);
assert!(
lines.iter().all(|line| line.chars().nth(30) == Some(' ')),
"the old scrollbar column beside the text should be empty: {lines:?}"
);
}
#[test]
fn a_dirty_prompt_floats_as_a_centered_overlay() {
let mut doc = doc_with("prompt_overlay", "hello\n");
let mut app = App {
dirty_prompt: Some(DirtyPrompt {
action: DirtyAction::Quit,
selected: 0,
}),
..Default::default()
};
let lines = frame(&mut doc, &mut app, 50, 10);
let joined = lines.join("\n");
assert!(joined.contains("Unsaved changes"), "no dialog:\n{joined}");
assert!(
joined.contains("Save") && joined.contains("Discard"),
"no choices:\n{joined}"
);
let row = lines
.iter()
.position(|l| l.contains("Unsaved changes"))
.unwrap();
assert!(
row > 0 && row < 9,
"dialog should be centered, got row {row}"
);
}
#[test]
fn a_status_message_shows_as_a_bottom_right_toast() {
let mut doc = doc_with("toast", "hello\n");
doc.status = Some("copied".into());
let mut app = App::default();
let lines = frame(&mut doc, &mut app, 40, 6);
let bottom = lines.last().unwrap();
assert!(
bottom.contains("copied"),
"toast missing from bottom row:\n{}",
lines.join("\n")
);
assert!(
bottom.ends_with("copied "),
"toast should hug the right edge: {bottom:?}"
);
assert!(
bottom.starts_with(" "),
"toast should not stretch across the row: {bottom:?}"
);
}
#[test]
fn a_dirty_prompt_suppresses_the_status_toast() {
let mut doc = doc_with("no_toast_with_prompt", "hello\n");
doc.status = Some("copied".into());
let mut app = App {
dirty_prompt: Some(DirtyPrompt {
action: DirtyAction::Quit,
selected: 0,
}),
..Default::default()
};
let lines = frame(&mut doc, &mut app, 40, 6);
assert!(
!lines.join("\n").contains("copied"),
"toast should be suppressed:\n{}",
lines.join("\n")
);
}
fn step_to(doc: &mut Doc, app: &mut App, label: &str) {
let level = |app: &App| {
let menu = app.context_menu.as_ref().expect("a menu should be open");
let lvl = menu.active_level();
(menu.levels[lvl].items, menu.levels[lvl].selected)
};
let (items, _) = level(app);
for _ in 0..items.len() {
let (items, selected) = level(app);
if items[selected].label() == label {
return;
}
handle_key(doc, keyp(KeyCode::Down), app);
}
let (items, selected) = level(app);
panic!(
"no selectable row called {label:?}; stopped on {:?}",
items[selected].label()
);
}
fn open_format(doc: &mut Doc, app: &mut App) {
handle_mouse(doc, right_down(1, 2), app);
step_to(doc, app, "Format");
handle_key(doc, keyp(KeyCode::Right), app); }
#[test]
fn right_arrow_on_format_opens_the_styling_submenu() {
let mut doc = doc_with("submenu_open", "hello\n");
let mut app = App::default();
open_format(&mut doc, &mut app);
let menu = app.context_menu.as_ref().unwrap();
assert_eq!(menu.levels.len(), 2, "Format should push a second level");
assert_eq!(
menu.levels[1].items[menu.levels[1].selected].label(),
"Paragraph"
);
}
#[test]
fn submenu_arrows_skip_section_headers() {
let mut doc = doc_with("submenu_headers", "hello\n");
let mut app = App::default();
open_format(&mut doc, &mut app);
let row = |app: &App| {
let lvl = &app.context_menu.as_ref().unwrap().levels[1];
lvl.items[lvl.selected].label()
};
handle_key(&mut doc, keyp(KeyCode::Up), &mut app);
assert_eq!(row(&app), "Code");
handle_key(&mut doc, keyp(KeyCode::Down), &mut app);
assert_eq!(row(&app), "Paragraph");
}
#[test]
fn choosing_bold_from_the_submenu_toggles_the_selection() {
let mut doc = doc_with("submenu_bold", "hello world\n");
doc.anchor = Some(0);
doc.caret = 5; let mut app = App::default();
open_format(&mut doc, &mut app);
step_to(&mut doc, &mut app, "Bold");
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert!(
app.context_menu.is_none(),
"running an action closes the menu"
);
assert_eq!(doc.source, "**hello** world\n");
}
#[test]
fn choosing_heading_from_the_submenu_sets_the_block() {
let mut doc = doc_with("submenu_heading", "hello\n");
let mut app = App::default();
open_format(&mut doc, &mut app);
step_to(&mut doc, &mut app, "Heading 1");
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(doc.source, "# hello\n");
}
#[test]
fn left_backs_out_of_the_submenu_without_closing_the_menu() {
let mut doc = doc_with("submenu_back", "hello\n");
let mut app = App::default();
open_format(&mut doc, &mut app);
assert_eq!(app.context_menu.as_ref().unwrap().levels.len(), 2);
handle_key(&mut doc, keyp(KeyCode::Left), &mut app);
let menu = app
.context_menu
.as_ref()
.expect("Left in a submenu backs out, not closes");
assert_eq!(menu.levels.len(), 1);
handle_key(&mut doc, keyp(KeyCode::Left), &mut app);
assert!(app.context_menu.is_none());
}
#[test]
fn hovering_a_row_highlights_it_and_hovering_format_opens_the_submenu() {
let mut doc = doc_with("hover", "hello\n");
doc.anchor = Some(0);
doc.caret = 5;
let mut app = App::default();
handle_mouse(&mut doc, right_down(1, 2), &mut app);
let _ = frame(&mut doc, &mut app, 40, 20);
let root = app.context_menu.as_ref().unwrap().levels[0].rect.unwrap();
handle_mouse(&mut doc, moved(root.y + 1, root.x + 1), &mut app);
assert_eq!(app.context_menu.as_ref().unwrap().levels[0].selected, 1);
handle_mouse(&mut doc, moved(root.y + 4, root.x + 1), &mut app);
assert_eq!(
app.context_menu.as_ref().unwrap().levels.len(),
2,
"hover opens the submenu"
);
handle_mouse(&mut doc, moved(root.y, root.x + 1), &mut app);
assert_eq!(
app.context_menu.as_ref().unwrap().levels.len(),
1,
"hovering off Format closes it"
);
assert_eq!(app.context_menu.as_ref().unwrap().levels[0].selected, 0);
}
#[test]
fn the_format_submenu_renders_its_sections_and_flies_out() {
let mut doc = doc_with("submenu_render", "hello\n");
let mut app = App::default();
open_format(&mut doc, &mut app);
let lines = frame(&mut doc, &mut app, 60, 20);
let joined = lines.join("\n");
assert!(
joined.contains("Format"),
"the root stays visible beside the flyout:\n{joined}"
);
assert!(
joined.contains('▸'),
"the submenu arrow is drawn:\n{joined}"
);
assert!(
joined.contains("Block") && joined.contains("Inline"),
"section headers:\n{joined}"
);
assert!(
joined.contains("Bold") && joined.contains("Strikethrough"),
"inline options listed:\n{joined}"
);
}
#[test]
fn an_active_inline_style_shows_a_check_in_the_submenu() {
let mut doc = doc_with("submenu_active", "**hello** world\n");
doc.anchor = Some(2);
doc.caret = 7; let mut app = App::default();
open_format(&mut doc, &mut app);
let joined = frame(&mut doc, &mut app, 60, 20).join("\n");
assert!(
joined.contains("✓ Bold"),
"active Bold should be checked:\n{joined}"
);
assert!(
joined.contains(" Italic"),
"inactive Italic should not:\n{joined}"
);
}
fn alt_shift(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT | KeyModifiers::SHIFT)
}
#[test]
fn alt_f_inserts_a_footnote_pair() {
let mut doc = doc_with("footnote_insert", "a claim\n");
let mut app = App::default();
doc.caret = 7; handle_key(&mut doc, alt('f'), &mut app);
assert!(
doc.source.contains("[^1]") && doc.source.contains("[^1]:"),
"⌥f should write both the reference and its definition:\n{}",
doc.source
);
}
#[test]
fn alt_g_walks_a_footnote_round_trip() {
let mut doc = doc_with("footnote_follow", "a claim[^1]\n\n[^1]: the note\n");
let mut app = App::default();
doc.caret = doc.source.find("[^1]").unwrap() + 2;
handle_key(&mut doc, alt('g'), &mut app);
let note_body = doc.source.find("the note").unwrap();
assert_eq!(doc.caret, note_body, "⌥g should land in the note's body");
handle_key(&mut doc, alt('g'), &mut app);
assert!(
doc.caret < note_body,
"⌥g in the note should go back to the reference, not deeper"
);
}
#[test]
fn alt_g_on_a_fragment_link_lands_on_the_heading_it_names() {
let mut doc = doc_with(
"fragment_follow",
"see [below](#the-target)\n\n## The Target\n\nbody\n",
);
let mut app = App::default();
doc.caret = doc.source.find("below").unwrap();
handle_key(&mut doc, alt('g'), &mut app);
assert!(
doc.caret >= doc.source.find("## The Target").unwrap(),
"⌥g should jump forward to the heading the fragment slugs to, not sit still"
);
}
#[test]
fn alt_g_reports_an_external_destination_rather_than_opening_it() {
let mut doc = doc_with("external_follow", "see [docs](https://example.com)\n");
let mut app = App::default();
let before = doc.caret;
doc.caret = doc.source.find("docs").unwrap();
handle_key(&mut doc, alt('g'), &mut app);
assert_eq!(
doc.status.as_deref(),
Some("→ https://example.com"),
"an external link is named, not followed"
);
assert_ne!(doc.caret, before + 1000); }
#[test]
fn alt_r_inserts_a_horizontal_rule() {
let mut doc = doc_with("rule", "before\n");
let mut app = App::default();
doc.caret = 6;
handle_key(&mut doc, alt('r'), &mut app);
assert!(
doc.source.contains("---") || doc.source.contains("***"),
"⌥r should write a thematic break:\n{}",
doc.source
);
}
#[test]
fn alt_e_opens_the_image_prompt_and_confirming_embeds_the_picture() {
let mut doc = doc_with("image_prompt", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('e'), &mut app);
assert_eq!(
app.text_prompt.as_ref().map(|p| p.label),
Some("Image source")
);
for c in "pic.png".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert!(
doc.source.contains("pic.png"),
"confirming should embed the image:\n{}",
doc.source
);
}
#[test]
fn alt_shift_w_cycles_the_markup_mode_and_alt_shift_f_flips_line_flow() {
let mut doc = doc_with("modes", "hello\n");
let mut app = App::default();
assert_eq!(doc.markup_mode(), MarkupMode::None);
handle_key(&mut doc, alt_shift('W'), &mut app);
assert_eq!(doc.markup_mode(), MarkupMode::Shortcuts);
handle_key(&mut doc, alt_shift('W'), &mut app);
assert_eq!(doc.markup_mode(), MarkupMode::Full);
handle_key(&mut doc, alt_shift('W'), &mut app);
assert_eq!(
doc.markup_mode(),
MarkupMode::None,
"three notches, then round"
);
assert_eq!(doc.line_flow(), LineFlow::Fold);
handle_key(&mut doc, alt_shift('F'), &mut app);
assert_eq!(doc.line_flow(), LineFlow::Preserve);
}
#[test]
fn tab_off_the_last_cell_grows_the_table_by_a_row() {
let mut doc = doc_with("table_grow", "| a | b |\n| - | - |\n| c | d |\n");
let mut app = App::default();
doc.caret = doc.source.rfind('d').unwrap(); let rows_before = doc.source.lines().count();
handle_key(&mut doc, keyp(KeyCode::Tab), &mut app);
assert!(
doc.source.lines().count() > rows_before,
"Tab off the last cell should append a row:\n{}",
doc.source
);
}
#[test]
fn return_in_a_table_drops_a_row_instead_of_splitting_the_cell() {
let mut doc = doc_with("table_return", "| a | b |\n| - | - |\n| c | d |\n");
let mut app = App::default();
doc.caret = doc.source.find('c').unwrap();
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert!(
doc.source.starts_with("| a | b |"),
"Return in a cell must not break the table apart:\n{}",
doc.source
);
}
#[test]
fn the_table_commands_edit_the_grid() {
let mut doc = doc_with("table_ops", "| a | b |\n| - | - |\n| c | d |\n");
doc.caret = doc.source.find('c').unwrap();
Command::RowBelow.run(&mut doc);
assert_eq!(
doc.source.lines().count(),
4,
"a row was inserted:\n{}",
doc.source
);
Command::ColumnRight.run(&mut doc);
assert_eq!(
doc.source.lines().next().unwrap().matches('|').count(),
4,
"a column was inserted:\n{}",
doc.source
);
Command::DeleteColumn.run(&mut doc);
assert_eq!(
doc.source.lines().next().unwrap().matches('|').count(),
3,
"and removed again:\n{}",
doc.source
);
}
#[test]
fn alt_p_opens_the_palette_and_typing_narrows_it() {
let mut doc = doc_with("palette_open", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('p'), &mut app);
let all = app.palette.as_ref().unwrap().rows.len();
for c in "rule".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
let narrowed = app.palette.as_ref().unwrap();
assert!(narrowed.rows.len() < all, "typing should filter");
assert_eq!(narrowed.chosen(), Some(Command::ThematicBreak));
}
#[test]
fn the_palette_runs_the_chosen_command_and_closes() {
let mut doc = doc_with("palette_run", "| a | b |\n| - | - |\n| c | d |\n");
let mut app = App::default();
doc.caret = doc.source.find('c').unwrap();
handle_key(&mut doc, ctrl('p'), &mut app); for c in "insert row below".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert!(
app.palette.is_none(),
"running a command closes the palette"
);
assert_eq!(
doc.source.lines().count(),
4,
"the row landed:\n{}",
doc.source
);
}
#[test]
fn the_palette_swallows_document_keys_while_it_is_open() {
let mut doc = doc_with("palette_capture", "hello\n");
let before = doc.source.clone();
let mut app = App::default();
handle_key(&mut doc, alt('p'), &mut app);
for c in "bold".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
handle_key(&mut doc, ctrl('s'), &mut app);
assert_eq!(
doc.source, before,
"nothing should have reached the document"
);
handle_key(&mut doc, keyp(KeyCode::Esc), &mut app);
assert!(app.palette.is_none());
}
#[test]
fn the_palette_will_not_run_a_command_this_format_cannot_spell() {
let mut doc = doc_with("palette_gated", "hello\n"); let mut app = App::default();
let before = doc.source.clone();
handle_key(&mut doc, alt('p'), &mut app);
for c in "highlight".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
let palette = app.palette.as_ref().unwrap();
assert!(
palette
.rows
.iter()
.any(|r| r.command == Command::Inline(InlineKind::Mark) && !r.enabled),
"the djot-only highlight should be listed and dimmed in Markdown"
);
handle_key(&mut doc, keyp(KeyCode::Enter), &mut app);
assert_eq!(doc.source, before);
}
#[test]
fn the_palette_renders_its_query_and_rows() {
let mut doc = doc_with("palette_render", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('p'), &mut app);
for c in "foot".chars() {
handle_key(&mut doc, plain(c), &mut app);
}
let joined = frame(&mut doc, &mut app, 70, 20).join("\n");
assert!(joined.contains("foot"), "the query line:\n{joined}");
assert!(joined.contains("Footnote"), "the matching row:\n{joined}");
assert!(joined.contains("⌥f"), "its key:\n{joined}");
}
#[test]
fn alt_h_opens_the_key_reference_and_any_key_closes_it() {
let mut doc = doc_with("help", "hello\n");
let mut app = App::default();
handle_key(&mut doc, alt('h'), &mut app);
assert!(app.help);
let joined = frame(&mut doc, &mut app, 100, 30).join("\n");
assert!(joined.contains("⌥b"), "it lists keys:\n{joined}");
assert!(
joined.contains("palette"),
"and names the palette:\n{joined}"
);
assert!(
joined.contains("⌥⇧w"),
"including the shifted ones:\n{joined}"
);
handle_key(&mut doc, keyp(KeyCode::Esc), &mut app);
assert!(!app.help);
}
#[test]
fn f1_opens_the_key_reference_too() {
let mut doc = doc_with("help_f1", "hello\n");
let mut app = App::default();
handle_key(&mut doc, keyp(KeyCode::F(1)), &mut app);
assert!(app.help);
}
#[test]
fn the_table_flyout_is_dimmed_off_a_table_and_live_inside_one() {
let mut doc = doc_with(
"menu_table_gate",
"para\n\n| a | b |\n| - | - |\n| c | d |\n",
);
let mut app = App::default();
let open_at_caret = |doc: &mut Doc, app: &mut App| {
let ctx = Ctx::read(doc);
app.context_menu = Some(ContextMenu::new((1, 2), &ctx));
};
doc.caret = 1; open_at_caret(&mut doc, &mut app);
for _ in 0..ROOT_MENU.len() {
let menu = app.context_menu.as_ref().unwrap();
assert_ne!(
menu.levels[0].items[menu.levels[0].selected].label(),
"Table",
"the Table flyout should be unreachable off a table"
);
handle_key(&mut doc, keyp(KeyCode::Down), &mut app);
}
doc.caret = doc.source.find('c').unwrap();
open_at_caret(&mut doc, &mut app);
step_to(&mut doc, &mut app, "Table");
handle_key(&mut doc, keyp(KeyCode::Right), &mut app);
let menu = app.context_menu.as_ref().unwrap();
assert_eq!(menu.levels.len(), 2, "Table should push a second level");
assert_eq!(
menu.levels[1].items[menu.levels[1].selected].label(),
"Insert Row Above",
"past the \"Rows\" header, onto the first real row"
);
}
#[test]
fn a_dimmed_row_is_drawn_but_not_run_by_a_click() {
let mut doc = doc_with("menu_dimmed_click", "hello\n");
doc.anchor = Some(0);
doc.caret = 5;
let mut app = App::default();
let before = doc.source.clone();
open_format(&mut doc, &mut app);
let _ = frame(&mut doc, &mut app, 70, 30);
let rect = app.context_menu.as_ref().unwrap().levels[1].rect.unwrap();
let row = FORMAT_MENU
.iter()
.position(|e| e.label() == "Highlight")
.unwrap() as u16;
handle_mouse(&mut doc, left_down(rect.y + row, rect.x + 1), &mut app);
assert!(
app.context_menu.is_some(),
"a click on a dimmed row holds the menu open"
);
assert_eq!(doc.source, before);
}
#[test]
fn hovering_a_footnote_reference_shows_the_note_without_moving_the_caret() {
let mut doc = doc_with("peek_footnote", "a claim[^1]\n\n[^1]: the note itself\n");
doc.view = leaf_core::View::Wysiwyg;
doc.build_visual(80);
let mut app = App::default();
doc.caret = 0;
let off = doc.source.find("[^1]").unwrap() + 2;
let (row, col) = doc.vmap.pos_of_offset(off);
handle_mouse(&mut doc, moved(row as u16 + 1, col as u16), &mut app);
assert_eq!(doc.caret, 0, "a peek must not move the caret");
let status = doc.status.clone().unwrap_or_default();
assert!(
status.contains("the note itself"),
"hovering should preview the note, got {status:?}"
);
}
#[test]
fn a_peek_clears_itself_and_leaves_other_statuses_alone() {
let mut doc = doc_with(
"peek_clear",
"a claim[^1] and more text here\n\n[^1]: note\n",
);
doc.view = leaf_core::View::Wysiwyg;
doc.build_visual(80);
let mut app = App::default();
let off = doc.source.find("[^1]").unwrap() + 2;
let (row, col) = doc.vmap.pos_of_offset(off);
handle_mouse(&mut doc, moved(row as u16 + 1, col as u16), &mut app);
assert!(doc.status.is_some());
let plain_off = doc.source.find("more").unwrap();
let (prow, pcol) = doc.vmap.pos_of_offset(plain_off);
handle_mouse(&mut doc, moved(prow as u16 + 1, pcol as u16), &mut app);
assert_eq!(doc.status, None, "the peek should come back down");
doc.status = Some("saved".into());
handle_mouse(&mut doc, moved(prow as u16 + 1, pcol as u16 + 1), &mut app);
assert_eq!(doc.status.as_deref(), Some("saved"));
}
#[test]
fn hovering_a_link_shows_where_it_points() {
let mut doc = doc_with("peek_link", "see [docs](https://example.com) here\n");
doc.view = leaf_core::View::Wysiwyg;
doc.build_visual(80);
let mut app = App::default();
let off = doc.source.find("docs").unwrap();
let (row, col) = doc.vmap.pos_of_offset(off);
handle_mouse(&mut doc, moved(row as u16 + 1, col as u16), &mut app);
assert_eq!(doc.status.as_deref(), Some("→ https://example.com"));
}
use ratatui::style::Color;
fn chrome_cells(
scheme: leaf_core::ColorScheme,
overlay: impl Fn(&mut Doc, &mut App),
) -> ratatui::buffer::Buffer {
let mut doc = doc_with("chrome", "hello\n");
let mut app = App::default();
app.editor.set_color_scheme(scheme);
overlay(&mut doc, &mut app);
let mut term = Terminal::new(TestBackend::new(70, 24)).unwrap();
term.draw(|f| ui::render(f, &mut doc, &mut app)).unwrap();
term.backend().buffer().clone()
}
#[test]
fn the_chrome_follows_the_terminal_s_light_or_dark_scheme() {
let open_palette = |doc: &mut Doc, app: &mut App| {
handle_key(doc, alt('p'), app);
};
let light = chrome_cells(leaf_core::ColorScheme::Light, open_palette);
let dark = chrome_cells(leaf_core::ColorScheme::Dark, open_palette);
let panel = |buf: &ratatui::buffer::Buffer| {
(0..buf.area.height)
.map(|y| buf[(12, y)].clone())
.find(|c| c.bg != ratatui::style::Color::Reset)
.expect("the palette should paint a panel")
};
let (l, d) = (panel(&light), panel(&dark));
assert_ne!(l.bg, d.bg, "the panel fill must differ between schemes");
assert_ne!(l.fg, d.fg, "and so must the text on it");
}
#[test]
fn no_overlay_paints_invisible_text() {
type Overlay = (&'static str, fn(&mut Doc, &mut App));
let overlays: [Overlay; 4] = [
("palette", |doc, app| {
handle_key(doc, alt('p'), app);
}),
("key reference", |doc, app| {
handle_key(doc, alt('h'), app);
}),
("context menu", |doc, app| {
handle_mouse(doc, right_down(1, 2), app);
}),
("unsaved-changes dialog", |doc, app| {
doc.insert("x"); handle_key(doc, ctrl('q'), app);
}),
];
for scheme in [leaf_core::ColorScheme::Light, leaf_core::ColorScheme::Dark] {
for (name, overlay) in overlays {
let buf = chrome_cells(scheme, overlay);
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
if cell.bg == Color::Reset || cell.symbol().trim().is_empty() {
continue;
}
assert_ne!(
cell.fg,
cell.bg,
"{name} in {scheme:?} paints {:?} invisibly at ({x}, {y})",
cell.symbol()
);
assert_ne!(
cell.fg,
Color::Reset,
"{name} in {scheme:?} leaves {:?} at ({x}, {y}) on the \
terminal's foreground over a panel fill",
cell.symbol()
);
}
}
}
}
}
}