use ratatui::{backend::TestBackend, Terminal};
use edamame::config::Theme;
use edamame::editor::vim_ops::VisualKind;
use edamame::editor::Mode;
use edamame::ui::status_bar::{StatusBar, StatusBarState};
fn render_status_bar(
mode: Mode,
filename: &str,
line_count: usize,
modified: bool,
width: u16,
) -> String {
let theme = Box::leak(Box::new(Theme::default()));
let backend = TestBackend::new(width, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let bar = StatusBar {
state: StatusBarState {
mode,
filename,
line_count,
scroll_total: line_count,
viewport_rows: 1,
modified,
scroll: 0,
cursor_line: None,
cursor_col: None,
section_path: Vec::new(),
diff_progress: None,
vim_mode_label: None,
},
theme,
};
frame.render_widget(bar, frame.area());
})
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..width)
.map(|x| {
buf.cell((x, 0))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
}
#[test]
fn status_bar_preview_mode() {
let out = render_status_bar(Mode::Preview, "test.md", 42, false, 60);
assert!(out.contains("PREVIEW"), "got: {out:?}");
}
#[test]
fn status_bar_filename() {
let out = render_status_bar(Mode::Preview, "my_notes.md", 10, false, 60);
assert!(out.contains("my_notes.md"), "got: {out:?}");
}
#[test]
fn status_bar_line_count() {
let out = render_status_bar(Mode::Preview, "f.md", 123, false, 80);
assert!(out.contains("123"), "got: {out:?}");
}
#[test]
fn status_bar_modified_flag() {
let out = render_status_bar(Mode::Preview, "f.md", 5, true, 80);
assert!(out.contains("f.md*"), "got: {out:?}");
assert!(!out.contains("[modified]"), "got: {out:?}");
}
#[test]
fn status_bar_clean_no_modified_flag() {
let out = render_status_bar(Mode::Preview, "f.md", 5, false, 80);
assert!(!out.contains("f.md*"), "got: {out:?}");
}
#[test]
fn status_bar_raw_mode_label() {
let out = render_status_bar(Mode::Raw, "f.md", 5, false, 60);
assert!(out.contains("RAW"), "got: {out:?}");
}
#[test]
fn status_bar_edit_mode_label() {
let out = render_status_bar(Mode::Rendered, "f.md", 5, false, 60);
assert!(out.contains("EDIT"), "got: {out:?}");
}
#[test]
fn snapshot_status_bar_preview() {
let out = render_status_bar(Mode::Preview, "readme.md", 100, false, 80);
insta::assert_snapshot!(out);
}
#[test]
fn snapshot_status_bar_modified() {
let out = render_status_bar(Mode::Rendered, "notes.md", 42, true, 80);
insta::assert_snapshot!(out);
}
fn render_status_bar_vim(label: &str, width: u16) -> String {
let theme = Box::leak(Box::new(Theme::default()));
let backend = TestBackend::new(width, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let bar = StatusBar {
state: StatusBarState {
mode: Mode::Rendered,
filename: "notes.md",
line_count: 42,
scroll_total: 42,
viewport_rows: 1,
modified: false,
scroll: 0,
cursor_line: Some(1),
cursor_col: Some(1),
section_path: Vec::new(),
diff_progress: None,
vim_mode_label: Some(label),
},
theme,
};
frame.render_widget(bar, frame.area());
})
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..width)
.map(|x| {
buf.cell((x, 0))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
}
#[test]
fn status_bar_vim_normal_badge_wins_over_view_mode() {
let out = render_status_bar_vim("NORMAL", 60);
assert!(out.contains("NORMAL"), "got: {out:?}");
assert!(!out.contains("EDIT"), "got: {out:?}");
}
#[test]
fn snapshot_status_bar_vim_normal() {
let out = render_status_bar_vim("NORMAL", 80);
insta::assert_snapshot!(out);
}
#[test]
fn snapshot_status_bar_vim_insert() {
let out = render_status_bar_vim("INSERT", 80);
insta::assert_snapshot!(out);
}
#[test]
fn snapshot_status_bar_vim_visual() {
let out = render_status_bar_vim("VISUAL", 80);
insta::assert_snapshot!(out);
}
#[test]
fn yank_flash_paints_the_yanked_span() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::terminal::Capabilities;
use edamame::ui::bottom_region::{HintContent, HintSet};
use edamame::ui::{EditorView, EditorViewState};
let theme = Box::leak(Box::new(Theme::default()));
let mut state = EditorState::new(Buffer::from_str("hello world\n"), theme);
state.mode = Mode::Rendered;
state.flash_yank(0, 5);
let backend = TestBackend::new(40, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = EditorViewState::new();
let caps = Capabilities::default();
terminal
.draw(|frame| {
let view = EditorView {
state: &mut state,
theme,
filename: "test.md",
show_table_buttons: false,
table_drop_indicator: None,
capabilities: &caps,
show_line_numbers: false,
is_scrolling: false,
hint: HintContent::Chords(HintSet::default()),
vim_mode_label: None,
visual_kind: None,
editor_cursor_style: theme.status_mode_rendered,
max_width_enabled: false,
max_width_cols: 0,
scrollbar_active: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
for x in 1..5u16 {
assert_eq!(
buf.cell((x, 0)).unwrap().style().bg,
theme.selection.bg,
"yanked col {x} must carry the flash bg"
);
}
assert_ne!(buf.cell((5, 0)).unwrap().style().bg, theme.selection.bg);
}
#[test]
fn rendered_view_paints_selection_across_multiple_rendered_blocks() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "first para here\n\nsecond para here\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.selection = Some(Selection {
anchor: 6, active: 23, });
state.cursor.offset = 23;
let backend = TestBackend::new(25, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_has_sel_bg = |y: u16| {
(0..25u16).any(|x| {
buf.cell((x, y))
.map(|c| c.style().bg == theme.selection.bg)
.unwrap_or(false)
})
};
assert!(row_has_sel_bg(0), "first paragraph row must show selection");
assert!(
row_has_sel_bg(2),
"second paragraph row must show selection"
);
}
#[test]
fn reflowed_paragraph_reveals_all_raw_source_lines_stacked() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "**bold** words\nsecond line\nthird line\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(40);
state.set_reflow(true);
state.cursor.offset = 0;
let width = 40u16;
let backend = TestBackend::new(width, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..width)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
};
assert!(
row_text(0).contains("**bold**"),
"row 0 must show the raw first source line, got {:?}",
row_text(0)
);
assert!(
row_text(1).contains("second line"),
"row 1 must show the raw second source line, got {:?}",
row_text(1)
);
assert!(
row_text(2).contains("third line"),
"row 2 must show the raw third source line, got {:?}",
row_text(2)
);
}
#[test]
fn nested_reflowed_paragraph_reveals_its_whole_source_lines_stacked() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let width = 40u16;
for (src, needle, expected) in [
(
"> first para\n>\n> alpha bravo\n> charlie delta\n>\n> last\n",
"charlie",
&[
"▎ first para",
"▎",
"> alpha bravo",
"> charlie delta",
"▎",
"▎ last",
][..],
),
(
"- one\n- alpha bravo\n charlie delta\n- three\n",
"charlie",
&["• one", "- alpha bravo", " charlie delta", "• three"],
),
(
"> - a\n> b soft\n> - c\n",
"soft",
&["> - a", "> b soft", "▎ • c"],
),
(
"- a\n - b\n soft word\n- c\n",
"soft",
&["• a", " - b", " soft word", "• c"],
),
(
"ref[^n]\n\n[^n]: note one\n two three\n\n second para\n more\n\nafter\n",
"two",
&[
"ref[n]",
"",
"[^n]: note one",
" two three",
"",
" second para more ↩",
"",
"after",
],
),
(
"ref[^n]\n\n[^n]: note one\n two three\n\n second para\n more\n\nafter\n",
"more",
&[
"ref[n]",
"",
" n. note one two three",
"",
" second para",
" more",
"",
"after",
],
),
] {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(width as usize);
state.set_reflow(true);
state.cursor.offset = src.find(needle).unwrap();
state.update_cursor_block();
state.cursor_block_entered_at = None;
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows: Vec<String> = (0..expected.len() as u16)
.map(|y| {
(0..width)
.map(|x| buf.cell((x, y)).unwrap().symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
assert_eq!(rows, expected, "in {src:?}");
}
}
#[test]
fn cursor_on_phantom_final_line_does_not_swallow_last_block() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "Alpha\n\nBeta\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.chars().count();
let backend = TestBackend::new(25, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..25u16)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect()
};
assert!(
row_text(2).contains("Beta"),
"last text line must stay rendered with cursor on the phantom line, got {:?}",
row_text(2)
);
}
#[test]
fn cursor_in_loose_list_reveals_correct_row_and_keeps_blank() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "1. alpha\n2. beta\n\n1. gamma\n2. delta\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.find("gamma").unwrap();
let backend = TestBackend::new(20, 8);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..20u16)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect::<String>()
.trim_end()
.to_string()
};
assert_eq!(row_text(0), "1. alpha");
assert_eq!(row_text(1), "2. beta");
assert_eq!(row_text(2), "", "separator blank row must be preserved");
assert_eq!(
row_text(3),
"1. gamma",
"cursor line must reveal raw source at its true rendered row"
);
assert_eq!(row_text(4), "4. delta");
}
#[test]
fn selection_on_bold_text_highlights_rendered_positions() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "x\n**bold** text\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.selection = Some(Selection {
anchor: 4,
active: 8,
});
state.cursor.offset = 0;
let width = 40u16;
let backend = TestBackend::new(width, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let cell_has_sel = |x: u16, y: u16| {
buf.cell((x, y))
.map(|c| c.style().bg == theme.selection.bg)
.unwrap_or(false)
};
for x in 0..4 {
assert!(
cell_has_sel(x, 1),
"cell ({x}, 1) should have selection bg (rendered 'bold' range)"
);
}
assert!(
!cell_has_sel(4, 1),
"cell (4, 1) should NOT have selection bg (past 'bold')"
);
}
#[test]
fn visual_line_selection_paints_the_list_marker() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "x\n- alpha\n- beta\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.selection = Some(Selection {
anchor: 4, active: 14, });
state.cursor.offset = 0;
let backend = TestBackend::new(25, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: Some(VisualKind::Line),
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let cell_has_sel = |x: u16, y: u16| {
buf.cell((x, y))
.map(|c| c.style().bg == theme.selection.bg)
.unwrap_or(false)
};
assert!(
cell_has_sel(0, 1),
"VisualLine highlight must cover the list marker cell, not start past it"
);
assert!(cell_has_sel(2, 1), "item content must stay highlighted");
assert!(cell_has_sel(0, 2), "second item marker must be highlighted");
}
#[test]
fn setext_heading_reveals_both_title_and_underline_on_cursor() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "Title\n-----\n\nBody\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.find('-').unwrap();
let backend = TestBackend::new(20, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..20u16)
.map(|x| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
};
assert!(
row_text(0).starts_with("Title"),
"row 0 = {:?}",
row_text(0)
);
assert!(
row_text(1).starts_with("-----"),
"row 1 = {:?}",
row_text(1)
);
}
fn paint_revealed(src: &str, offset: usize) -> (Vec<String>, Option<(u16, u16)>) {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = offset;
state.update_cursor_block();
state.cursor_block_entered_at = None;
let mut terminal = Terminal::new(TestBackend::new(20, 6)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows = (0..6u16)
.map(|y| {
(0..20u16)
.map(|x| buf.cell((x, y)).map_or(" ", |c| c.symbol()))
.collect::<String>()
.trim_end()
.to_owned()
})
.collect();
let cursor_bg = theme.status_mode_rendered.bg.unwrap();
let cursor = (0..6u16)
.flat_map(|y| (0..20u16).map(move |x| (x, y)))
.find(|&cell| buf.cell(cell).is_some_and(|c| c.bg == cursor_bg));
(rows, cursor)
}
fn paint_rows(src: &str, width: u16, cursor: Option<usize>) -> (Vec<String>, Option<(u16, u16)>) {
use std::time::{Duration, Instant};
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
const HEIGHT: u16 = 14;
let theme = Box::leak(Box::new(Theme::default()));
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(usize::from(width));
if let Some(byte) = cursor {
state.cursor.offset = state.buffer.rope().byte_to_char(byte);
state.update_cursor_block();
}
state.cursor_block_entered_at = match cursor {
Some(_) => None,
None => Some(Instant::now() + Duration::from_secs(3600)),
};
let mut terminal = Terminal::new(TestBackend::new(width, HEIGHT)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut rows: Vec<String> = (0..HEIGHT)
.map(|y| {
(0..width)
.map(|x| buf.cell((x, y)).map_or(" ", |c| c.symbol()))
.collect::<String>()
.trim_end()
.to_owned()
})
.collect();
while rows.last().is_some_and(String::is_empty) {
rows.pop();
}
let cursor_bg = theme.status_mode_rendered.bg.unwrap();
let cell = (0..HEIGHT)
.flat_map(|y| (0..width).map(move |x| (x, y)))
.find(|&c| buf.cell(c).is_some_and(|c| c.bg == cursor_bg));
(rows, cell)
}
#[test]
fn a_prefix_before_long_cjk_text_keeps_it_on_its_row() {
let text = "日本語のリスト項目がとても長くてテキストが続きます";
for (src, head) in [
(format!("- {text}\n"), "• 日"),
(format!("> {text}\n"), "▎ 日"),
(format!("Note: {text}\n"), "Note: 日"),
] {
let (rows, _) = paint_rows(&src, 24, None);
assert!(rows[0].starts_with(head), "{rows:?} in {src:?}");
}
}
#[test]
fn a_soft_break_between_cjk_glyphs_renders_as_nothing() {
use unicode_width::UnicodeWidthChar;
for (src, row) in [
("日本語の\nリスト項目が\n", "日本語のリスト項目が"),
("- 日本語の\n リスト項目が\n", "• 日本語のリスト項目が"),
("> 日本語の\n> リスト項目が\n", "▎ 日本語のリスト項目が"),
("日本語の\n、続き\n", "日本語の、続き"),
("**日本**\n*語*\n", "日本語"),
("日本語の\nabc\n", "日本語の abc"),
("한국어\n문장\n", "한국어 문장"),
] {
let row: String = row
.chars()
.flat_map(|c| [Some(c), (c.width() == Some(2)).then_some(' ')])
.flatten()
.collect();
let (rows, _) = paint_rows(src, 40, None);
assert_eq!(
rows.first().map(|r| r.trim_end()),
Some(row.trim_end()),
"{rows:?} in {src:?}"
);
}
}
#[test]
fn a_long_code_line_starts_on_its_first_row() {
for src in [
"```\nabcdefghijklmnopqrstuvwxyz0123456789\n```\n",
"```\n日本語のコードはとても長い行になります\n```\n",
] {
let (rows, _) = paint_rows(src, 20, None);
assert!(
rows.len() > 2 && rows[1..].iter().all(|r| !r.trim().is_empty()),
"{rows:?} in {src:?}"
);
}
}
#[test]
fn wrapped_rows_hang_where_the_renderer_drew_their_text() {
for (src, width, expected) in [
(
"ref[^1]\n\n[^1]: alpha bravo charlie delta echo foxtrot golf\n",
24,
&[
"ref[1]",
"",
" 1. alpha bravo",
" charlie delta",
" echo foxtrot golf",
" ↩",
][..],
),
(
"ref[^note]\n\n[^note]: alpha bravo charlie delta echo foxtrot golf\n",
24,
&[
"ref[note]",
"",
" note. alpha bravo",
" charlie delta",
" echo foxtrot",
" golf ↩",
],
),
(
"1\\. alpha bravo charlie delta echo\n",
16,
&["1. alpha bravo", "charlie delta", "echo"],
),
(
"\\- alpha bravo charlie delta echo\n",
16,
&["- alpha bravo", "charlie delta", "echo"],
),
(
"> 2\\. alpha bravo charlie delta echo\n",
16,
&["▎ 2. alpha", "▎ bravo charlie", "▎ delta echo"],
),
] {
let (rows, _) = paint_rows(src, width, Some(src.len()));
assert_eq!(rows, expected, "in {src:?}");
}
}
#[test]
fn a_revealed_ordered_item_keeps_its_marker_alignment() {
let src = "1. a\n2. b\n3. c\n4. d\n5. e\n6. alpha bravo charlie delta echo\n7. g\n8. h\n\
9. i\n10. j\n";
let at = src.find("lpha").unwrap();
let (rendered, _) = paint_rows(src, 16, None);
let (revealed, cursor) = paint_rows(src, 16, Some(at));
assert_eq!(rendered[5], " 6. alpha bravo");
assert_eq!(revealed[5], " 6. alpha bravo");
assert_eq!(revealed[6..8], [" charlie", " delta echo"]);
assert_eq!(revealed[8], " 7. g");
assert_eq!(cursor, Some((5, 5)), "on the `l` of `alpha`");
}
#[test]
fn vertical_navigation_lands_where_the_cursor_paints() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
const WIDTH: u16 = 12;
let state_at = |src: &str, byte: usize| {
let theme = Box::leak(Box::new(Theme::default()));
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(usize::from(WIDTH));
state.cursor.offset = state.buffer.rope().byte_to_char(byte);
state.update_cursor_block();
state
};
for (src, line, crosses) in [
(" para alpha bravo charlie delta\n\nend\n", 0, true),
("```\n code alpha bravo charlie\n```\n\nend\n", 1, true),
(
"- a\n\n ```\n code alpha bravo\n ```\n\nend\n",
3,
true,
),
("> - alpha bravo charlie delta\n\nend\n", 0, true),
(
"x[^1]\n\n[^1]: alpha bravo charlie delta\n\nend\n",
2,
false,
),
(
"1. a\n2. b\n3. c\n4. d\n5. e\n6. alpha bravo charlie delta\n7. g\n8. h\n9. i\n\
10. j\n",
5,
true,
),
] {
let start: usize = src.split_inclusive('\n').take(line).map(str::len).sum();
let end = start + src[start..].find('\n').unwrap();
for byte in start..end {
let (_, painted) = paint_rows(src, WIDTH, Some(byte));
let (x, y) = painted.unwrap_or_else(|| panic!("no cursor at {byte} in {src:?}"));
let mut state = state_at(src, byte);
let col = state.current_visual_col(usize::from(WIDTH));
assert_eq!(col, usize::from(x), "column at byte {byte} of {src:?}");
state.cursor.preferred_col = col;
state.move_down_visual(usize::from(WIDTH));
let below = state.buffer.rope().char_to_byte(state.cursor.offset);
let (_, moved) = paint_rows(src, WIDTH, Some(below));
let (_, moved_y) = moved.unwrap();
if !crosses && below > end {
continue;
}
assert_eq!(
moved_y,
y + 1,
"down from byte {byte} of {src:?} to {below}"
);
}
}
}
#[test]
fn multi_line_setext_heading_reveals_every_text_line() {
let src = "Multi\nline\n---\n\nBody\n";
for (line, at) in [(0u16, "Multi"), (1, "line"), (2, "---")] {
let offset = src.find(at).unwrap() + 1;
let (rows, cursor) = paint_revealed(src, offset);
assert_eq!(
rows[..3],
["Multi", "line", "---"],
"the cursor on line {line}"
);
assert_eq!(
cursor,
Some((1, line)),
"the cursor on line {line}: {rows:?}"
);
}
}
#[test]
fn a_quotes_hidden_definition_reveals_with_the_line_below() {
let src = "> a\n>\n> [d]: /u\n> b\n\nafter\n";
let (rows, cursor) = paint_revealed(src, src.find("[d]").unwrap() + 1);
assert_eq!(rows[2], "> [d]: /u", "{rows:?}");
assert_eq!(rows[3], "> b", "{rows:?}");
assert_eq!(cursor, Some((3, 2)), "{rows:?}");
}
#[test]
fn a_quotes_hidden_comment_reveals_with_the_line_below() {
let src = "> a\n>\n> <!-- note -->\n> b\n\nafter\n";
let (rows, cursor) = paint_revealed(src, src.find("note").unwrap());
assert_eq!(rows[2], "> <!-- note -->", "{rows:?}");
assert_eq!(rows[3], "> b", "{rows:?}");
assert_eq!(cursor, Some((7, 2)), "{rows:?}");
}
#[test]
fn a_hidden_definition_over_a_marker_row_shows_no_line_twice() {
let src = "> [d]: /u\n> - - a\n\nafter\n";
let (rows, cursor) = paint_revealed(src, src.find("[d]").unwrap() + 1);
assert_eq!(rows[0], "> [d]: /u", "{rows:?}");
assert!(rows[1].ends_with("• a"), "{rows:?}");
assert!(!rows.iter().any(|r| r == "> - - a"), "{rows:?}");
assert_eq!(cursor, Some((3, 0)), "{rows:?}");
}
#[test]
fn blockquote_wash_fills_the_row_and_survives_the_raw_reveal() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "> quoted\n> lines\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = 0;
let backend = TestBackend::new(20, 4);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let wash = theme.blockquote_text.bg;
for x in 0..20u16 {
assert_eq!(
buf.cell((x, 1)).unwrap().style().bg,
wash,
"rendered quote row must be washed to the edge at col {x}"
);
}
for x in 1..20u16 {
assert_eq!(
buf.cell((x, 0)).unwrap().style().bg,
wash,
"revealed quote row must keep the wash at col {x}"
);
}
}
#[test]
fn a_blank_line_after_a_quote_reveals_without_its_wash() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "> quoted\n\nafter\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.find("\n\n").unwrap() + 1;
let backend = TestBackend::new(20, 4);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let wash = theme.blockquote_text.bg;
assert_eq!(
buf.cell((5, 0)).unwrap().style().bg,
wash,
"the quote row keeps its wash"
);
for x in 1..20u16 {
assert_ne!(
buf.cell((x, 1)).unwrap().style().bg,
wash,
"the blank row after the quote must not take its wash at col {x}"
);
}
}
#[test]
fn rendered_view_selection_in_table_cell_does_not_spill_into_borders() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| a | bb | c |\n|---|----|---|\n| x | yy | z |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let yy_start = src.find("yy").unwrap();
state.selection = Some(Selection {
anchor: yy_start,
active: yy_start + 2,
});
state.cursor.offset = 0;
let backend = TestBackend::new(30, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let has_sel_bg = |x: u16, y: u16| {
buf.cell((x, y))
.map(|c| c.style().bg == theme.selection.bg)
.unwrap_or(false)
};
for x in 0..30u16 {
assert!(
!has_sel_bg(x, 0),
"top border col {x} must not carry selection bg"
);
}
for x in 0..30u16 {
assert!(
!has_sel_bg(x, 2),
"separator col {x} must not carry selection bg"
);
}
let data_row_has_sel = (0..30u16).any(|x| has_sel_bg(x, 3));
assert!(
data_row_has_sel,
"data row must show selection bg on the 'yy' cell"
);
}
#[test]
fn rendered_view_selection_inside_cursors_own_cell_survives_cell_overlay() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| aaa | bbb | ccc |\n|---|---|---|\n| 1 | 2 | 3 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = 8;
state.selection = Some(Selection {
anchor: 8,
active: 11,
});
let backend = TestBackend::new(25, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let has_sel_bg = |x: u16, y: u16| {
buf.cell((x, y))
.map(|c| c.style().bg == theme.selection.bg)
.unwrap_or(false)
};
let any_cell_highlighted = (7u16..=11).any(|x| has_sel_bg(x, 1));
assert!(
any_cell_highlighted,
"selection bg must survive the cell overlay for the cursor's own cell"
);
}
#[test]
fn rendered_view_nested_table_highlights_and_reveals_by_cell() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "- item\n\n | a | b |\n |---|---|\n | 1 | 2 |\n";
let draw = |state: &EditorState| {
let mut terminal = Terminal::new(TestBackend::new(30, 8)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
terminal.backend().buffer().clone()
};
let row_text = |buf: &ratatui::buffer::Buffer, y: u16| -> String {
(0..30).map(|x| buf[(x, y)].symbol()).collect()
};
let data_row = |buf: &ratatui::buffer::Buffer| {
(0..8)
.find(|&y| row_text(buf, y).contains('2'))
.expect("the data row is painted")
};
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let two = src.rfind('2').unwrap();
state.selection = Some(Selection {
anchor: two,
active: two + 1,
});
let buf = draw(&state);
let y = data_row(&buf);
let text = row_text(&buf, y);
let highlighted: Vec<u16> = (0..30)
.filter(|&x| buf[(x, y)].style().bg == theme.selection.bg)
.collect();
let two_x = text.chars().position(|c| c == '2').unwrap() as u16;
assert_eq!(highlighted, [two_x], "only the selected `2`: {text:?}");
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.rfind('1').unwrap();
state.update_cursor_block();
state.cursor_block_entered_at = None;
let buf = draw(&state);
let text = row_text(&buf, data_row(&buf));
assert!(
text.contains('│') && !text.contains('|'),
"the cursor's row keeps its rendered pipes: {text:?}"
);
}
#[test]
fn rendered_view_cell_scoped_reveal_keeps_neighbouring_pipes_rendered() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| aaa | bbb | ccc |\n|---|---|---|\n| 1 | 2 | 3 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = 8;
let backend = TestBackend::new(25, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let symbol_at = |x: u16, y: u16| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
};
for &x in &[0u16, 6, 12, 18] {
assert_eq!(symbol_at(x, 1), '│', "expected │ at ({x}, 1)");
}
for (x, ch) in [
(2u16, 'a'),
(3, 'a'),
(4, 'a'),
(14, 'c'),
(15, 'c'),
(16, 'c'),
] {
assert_eq!(
symbol_at(x, 1),
ch,
"neighbouring cell char at ({x}, 1) should still be rendered"
);
}
for x in 8u16..=10 {
assert_eq!(
symbol_at(x, 1),
'b',
"active cell should show raw 'b' at ({x}, 1)"
);
}
let cell_at = |x: u16, y: u16| buf.cell((x, y)).expect("cell in bounds");
let cursor_bg = theme.status_mode_rendered.bg;
assert!(cursor_bg.is_some(), "status_mode_rendered must carry a bg");
assert_eq!(
cell_at(8, 1).style().bg,
cursor_bg,
"cursor cell at (8, 1) should carry the cursor bg"
);
for x in [9u16, 10] {
assert_ne!(
cell_at(x, 1).style().bg,
cursor_bg,
"non-cursor cell at ({x}, 1) must not carry the cursor bg"
);
}
}
#[test]
fn table_view_paints_row_and_column_handles_when_enabled() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| aa | bb |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: true,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let symbol_at = |x: u16, y: u16| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
};
assert_eq!(view_state.table_snapshots.len(), 1);
let snap = &view_state.table_snapshots[0];
assert!(
snap.row_handle_col.is_some(),
"row handle col must be set when handles enabled"
);
assert!(
snap.top_border_row.is_some(),
"top border row must be set when handles enabled"
);
assert!(
snap.header_row.is_some(),
"header row must be set when handles enabled"
);
let handle_col = snap.row_handle_col.unwrap();
let mut row_handle_count = 0;
for y_range in &snap.row_ranges {
if symbol_at(handle_col, y_range.start) == '⠿' {
row_handle_count += 1;
}
}
assert_eq!(
row_handle_count,
snap.row_ranges.len(),
"every data row should show a ⠿ reorder glyph in the gutter"
);
let top_y = snap.top_border_row.unwrap();
let col_reorder_count = snap
.col_ranges
.iter()
.filter(|r| {
let mid = r.start + (r.end - r.start) / 2;
symbol_at(mid, top_y) == '⠿'
})
.count();
assert_eq!(
col_reorder_count,
snap.col_ranges.len(),
"every column should show a ⠿ reorder glyph on the top border"
);
let header_y = snap.header_row.unwrap();
let resize_count = snap
.col_ranges
.iter()
.filter(|r| symbol_at(r.end, header_y) == '⇔')
.count();
assert_eq!(
resize_count,
snap.col_ranges.len(),
"every border (interior + right outer) should show a ⇔ resize glyph"
);
}
#[test]
fn table_view_paints_one_row_handle_per_logical_row() {
use edamame::config::Theme;
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| Name | Notes |\n|---|---|\n| a | This is a very long note that wraps |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let target = src.find("This").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(target);
state.update_cursor_block();
let backend = TestBackend::new(28, 12);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: true,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let symbol_at = |x: u16, y: u16| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
};
assert_eq!(view_state.table_snapshots.len(), 1);
let snap = &view_state.table_snapshots[0];
assert_eq!(snap.row_ranges.len(), 1, "one logical data row");
let y_range = &snap.row_ranges[0];
assert!(
y_range.end - y_range.start > 1,
"test fixture must produce a wrapped row, got {y_range:?}",
);
let handle_col = snap.row_handle_col.expect("row handle col is set");
let mut painted = 0usize;
for y in y_range.start..y_range.end {
if symbol_at(handle_col, y) == '⠿' {
painted += 1;
}
}
assert_eq!(
painted, 1,
"exactly one ⠿ row handle should paint per logical row, got {painted}",
);
}
#[test]
fn rendered_view_wrapped_cell_keeps_table_layout_when_cursor_inside() {
use edamame::config::Theme;
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| Name | Notes |\n|---|---|\n| a | This is a very long note that wraps |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let target = src.find("very").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(target);
state.update_cursor_block();
state.cursor_block_entered_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
let backend = TestBackend::new(28, 12);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..28u16)
.map(|x| {
buf.cell((x, y))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ')
})
.collect()
};
for y in 0u16..8 {
let row = row_text(y);
if row.is_empty() {
continue;
}
let first = row.chars().next().unwrap();
if first == ' ' {
continue; }
assert!(
!row.starts_with('|'),
"row {y} should not collapse to raw markdown ({row:?})",
);
}
let mut found_top = false;
let mut found_bottom = false;
for y in 0u16..12 {
let row = row_text(y);
if row.starts_with('┌') {
found_top = true;
}
if row.starts_with('└') {
found_bottom = true;
}
}
assert!(found_top, "top border must still render");
assert!(found_bottom, "bottom border must still render");
}
#[test]
fn rendered_view_wrapped_cell_shows_raw_markdown_chunk() {
use edamame::config::Theme;
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| x | **_words_** |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let target = src.find("words").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(target);
state.update_cursor_block();
state.cursor_block_entered_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
let backend = TestBackend::new(20, 8);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..20u16)
.map(|x| {
buf.cell((x, y))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ')
})
.collect()
};
let mut data_row_text = None;
for y in 0u16..8 {
let row = row_text(y);
if row.starts_with('│') && !row.contains('━') {
if row.contains('x') || row.contains('*') || row.contains('w') {
data_row_text = Some(row);
break;
}
}
}
let data_row = data_row_text.expect("data row should be on screen");
assert!(
data_row.contains('*') || data_row.contains('_'),
"cursor's cell should show raw markdown chars; got {data_row:?}",
);
}
#[test]
fn rendered_view_wrapped_cell_wipes_stale_tail_when_raw_shrinks() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| x | `breakable_code_name` words |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(24);
let target = src.find("breakable").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(target);
state.update_cursor_block();
state.cursor_block_entered_at =
Some(std::time::Instant::now() - std::time::Duration::from_secs(1));
let backend = TestBackend::new(24, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
let draw = |terminal: &mut Terminal<TestBackend>,
state: &EditorState,
view_state: &mut RenderedViewState| {
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut *view_state);
})
.unwrap();
};
let rows_text = |terminal: &Terminal<TestBackend>| -> Vec<String> {
let buf = terminal.backend().buffer().clone();
(0..10u16)
.map(|y| {
(0..24u16)
.map(|x| {
buf.cell((x, y))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ')
})
.collect()
})
.collect()
};
draw(&mut terminal, &state, &mut view_state);
assert!(
rows_text(&terminal).iter().any(|r| r.contains("code_name")),
"fixture must wrap the code cell across sub-lines",
);
let cut_start = src.find("able_code_name").unwrap();
state
.buffer
.remove(cut_start, cut_start + "able_code_name".len());
draw(&mut terminal, &state, &mut view_state);
let rows = rows_text(&terminal);
assert!(
!rows.iter().any(|r| r.contains("code_name")),
"stale wrap tail must be wiped from the de-rendered cell: {rows:#?}",
);
}
#[test]
fn table_view_handles_only_paint_when_cursor_in_table() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "intro line\n\n| aa | bb |\n|---|---|\n| 1 | 2 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: true,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let symbol_at = |buf: &ratatui::buffer::Buffer, x: u16, y: u16| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
};
assert_eq!(view_state.table_snapshots.len(), 1);
let mut painted = 0usize;
for y in 0u16..10 {
for x in 0u16..30 {
if symbol_at(&buf, x, y) == '⠿' {
painted += 1;
}
}
}
assert_eq!(
painted, 0,
"no handles should paint when cursor is outside the table",
);
let table_start = src.find("| aa").unwrap();
let data_byte = src[table_start..]
.find("| 1")
.map(|off| table_start + off + 2) .unwrap();
let target_char = state.buffer.rope().byte_to_char(data_byte);
state.cursor.offset = target_char;
state.update_cursor_block();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: true,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut painted = 0usize;
for y in 0u16..10 {
for x in 0u16..30 {
if symbol_at(&buf, x, y) == '⠿' {
painted += 1;
}
}
}
assert!(
painted > 0,
"cursor inside the table should reveal `⠿` handle glyphs",
);
}
#[test]
fn table_view_snapshots_empty_when_no_table() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let state = EditorState::new(Buffer::from_str("plain paragraph\n"), theme);
let backend = TestBackend::new(30, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: true,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
assert!(view_state.table_snapshots.is_empty());
}
#[test]
fn table_view_partial_user_widths_honours_auto_underscore_entries() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
let theme = Box::leak(Box::new(Theme::default()));
let src = "| abc | defghi |\n| --- | --- |\n| bar | baz |\n<!-- tui-columns: [5, _] -->\n";
let state = EditorState::new(Buffer::from_str(src), theme);
let header = state
.parsed
.lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains('│')))
.expect("rendered a header row");
let mut pipes = Vec::new();
let mut col = 0usize;
for span in &header.spans {
for ch in span.content.chars() {
if ch == '│' {
pipes.push(col);
}
col += 1;
}
}
assert_eq!(pipes.len(), 3);
assert_eq!(pipes[1] - pipes[0] - 1, 7);
assert_eq!(pipes[2] - pipes[1] - 1, 8);
}
#[test]
fn table_view_persists_user_widths_from_tui_columns_comment() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
let theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n<!-- tui-columns: [10, 20] -->\n";
let state = EditorState::new(Buffer::from_str(src), theme);
let first_line = state
.parsed
.lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains('│')))
.expect("rendered a header row");
let pipes: Vec<usize> = {
let mut positions = Vec::new();
let mut col = 0usize;
for span in &first_line.spans {
for ch in span.content.chars() {
if ch == '│' {
positions.push(col);
}
col += 1;
}
}
positions
};
assert_eq!(pipes.len(), 3, "header has two cells + three pipes");
let col0_width = pipes[1] - pipes[0] - 1;
assert_eq!(col0_width, 12);
let col1_width = pipes[2] - pipes[1] - 1;
assert_eq!(col1_width, 22);
}
#[test]
fn status_bar_shows_cursor_position() {
let theme = Box::leak(Box::new(Theme::default()));
let backend = TestBackend::new(80, 1);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let bar = StatusBar {
state: StatusBarState {
mode: Mode::Rendered,
filename: "f.md",
line_count: 10,
scroll_total: 10,
viewport_rows: 1,
modified: false,
scroll: 0,
cursor_line: Some(5),
cursor_col: Some(12),
section_path: Vec::new(),
diff_progress: None,
vim_mode_label: None,
},
theme,
};
frame.render_widget(bar, frame.area());
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let out: String = (0..80u16)
.map(|x| {
buf.cell((x, 0))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect();
assert!(out.contains("5:12"), "got: {out:?}");
}
#[test]
fn table_row_striping_alternates_bg_per_data_row() {
use edamame::config::Theme;
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
use ratatui::style::{Color, Style};
let theme_owned = Theme {
table_row_even: Style::default().bg(Color::Indexed(238)),
table_row_odd: Style::default().bg(Color::Indexed(237)),
..Theme::default()
};
let theme: &'static Theme = Box::leak(Box::new(theme_owned));
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_row_striping(true);
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let cell_at = |x: u16, y: u16| buf.cell((x, y)).cloned().unwrap_or_default();
let row0_bg = cell_at(2, 3).bg;
let row1_bg = cell_at(2, 5).bg;
assert_eq!(
row0_bg,
Color::Indexed(238),
"row 0 even-stripe bg mismatch"
);
assert_eq!(row1_bg, Color::Indexed(237), "row 1 odd-stripe bg mismatch");
}
#[test]
fn table_row_striping_replaces_thin_rule_with_blank_separator() {
use edamame::config::Theme;
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
use ratatui::style::{Color, Style};
let theme_owned = Theme {
table_row_even: Style::default().bg(Color::Indexed(238)),
table_row_odd: Style::default().bg(Color::Indexed(237)),
..Theme::default()
};
let theme: &'static Theme = Box::leak(Box::new(theme_owned));
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_row_striping(true);
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let symbol_at = |x: u16, y: u16| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
};
let bg_at = |x: u16, y: u16| buf.cell((x, y)).map(|c| c.bg).unwrap_or(Color::Reset);
for x in 0u16..16 {
let g = symbol_at(x, 4);
assert!(
g != '├' && g != '┼' && g != '┤' && g != '─',
"stripe-on separator at ({x}, 4) should be blank, got {g:?}",
);
}
assert_eq!(
bg_at(2, 4),
Color::Indexed(238),
"blank separator below row 0 should carry row 0's bg",
);
}
#[test]
fn table_row_striping_off_leaves_default_bg() {
use edamame::config::Theme;
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
use ratatui::style::{Color, Style};
let theme_owned = Theme {
table_row_even: Style::default().bg(Color::Indexed(238)),
table_row_odd: Style::default().bg(Color::Indexed(237)),
..Theme::default()
};
let theme: &'static Theme = Box::leak(Box::new(theme_owned));
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let bg_at = |x: u16, y: u16| buf.cell((x, y)).map(|c| c.bg).unwrap_or(Color::Reset);
assert_ne!(bg_at(2, 3), Color::Indexed(238));
assert_ne!(bg_at(2, 3), Color::Indexed(237));
assert_eq!(bg_at(2, 3), bg_at(2, 5));
}
#[test]
fn table_view_paints_drop_indicator_on_row_drag() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::table_view::DropIndicator;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let indicator = DropIndicator::Row {
table_byte_start: 0,
src_row_idx: 2,
hover_row_idx: 3,
};
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: Some(indicator),
state: &state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let symbol_at = |x: u16, y: u16| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
};
assert_eq!(view_state.table_snapshots.len(), 1);
let snap = &view_state.table_snapshots[0];
assert_eq!(snap.row_ranges.len(), 2);
let sep_y = snap.row_ranges[1].end;
let first_x = snap.col_ranges.first().unwrap().start;
let last_x = snap.col_ranges.last().unwrap().end;
let mut found = 0usize;
for x in first_x.saturating_sub(1)..=last_x {
if symbol_at(x, sep_y) == '━' {
found += 1;
}
}
assert!(
found > 0,
"drop indicator should paint ━ on separator y={sep_y}, got 0 matches"
);
}
#[test]
fn rendered_view_code_block_only_opening_fence_de_renders() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```rust\nfn main() {}\n```\n";
let row_text = |term: &Terminal<TestBackend>, y: u16, w: u16| -> String {
let buf = term.backend().buffer().clone();
(0..w)
.map(|x| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
};
let render = |state: &EditorState| -> Terminal<TestBackend> {
let backend = TestBackend::new(30, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
terminal
};
{
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = 0;
let term = render(&state);
assert!(
row_text(&term, 0, 30).contains("```rust"),
"row 0 should show raw fence: {:?}",
row_text(&term, 0, 30)
);
assert!(
row_text(&term, 1, 30).contains("fn main() {}"),
"row 1 should show rendered body: {:?}",
row_text(&term, 1, 30)
);
}
{
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.find("fn main").unwrap();
let term = render(&state);
assert!(
!row_text(&term, 0, 30).contains("```"),
"row 0 must remain the styled language label, got: {:?}",
row_text(&term, 0, 30)
);
assert!(
row_text(&term, 1, 30).contains("fn main() {}"),
"row 1 should show rendered body: {:?}",
row_text(&term, 1, 30)
);
}
{
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.rfind("```").unwrap();
let term = render(&state);
assert!(
row_text(&term, 1, 30).contains("fn main() {}"),
"body row must stay rendered when cursor is on closing fence: {:?}",
row_text(&term, 1, 30)
);
}
}
#[test]
fn rendered_view_selection_covers_the_whole_closing_fence_row() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```rust\nlet x = 1;\n```\n";
let width: u16 = 25;
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(width as usize);
state.selection = Some(Selection {
anchor: 0,
active: src.chars().count(),
});
state.cursor.offset = src.chars().count();
let backend = TestBackend::new(width, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let selected = |x: u16, y: u16| {
buf.cell((x, y))
.map(|c| c.style().bg == theme.selection.bg)
.unwrap_or(false)
};
let washed = (0..width).filter(|&x| selected(x, 2)).count();
assert!(
washed > 3,
"closing fence row must be washed whole, only {washed} cells were",
);
}
#[test]
fn rendered_view_code_block_cursor_indicator_sits_on_its_char() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```rust\nlet x = 1;\n```\n";
let width: u16 = 30;
for raw_col in 0..="let x = 1;".len() {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.find("let").unwrap() + raw_col;
state.set_viewport_width(width as usize);
let backend = TestBackend::new(width, 8);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let cursor_x = (0..width).find(|&x| {
buf.cell((x, 1))
.map(|c| {
c.style().bg == theme.status_mode_rendered.bg
&& c.style().fg == theme.status_mode_rendered.fg
})
.unwrap_or(false)
});
assert_eq!(
cursor_x,
Some(raw_col as u16 + 1),
"raw col {raw_col} must paint one cell right of itself (the pad)",
);
let expected = "let x = 1;".chars().nth(raw_col).unwrap_or(' ');
assert_eq!(
buf.cell((raw_col as u16 + 1, 1))
.map(|c| c.symbol().to_string()),
Some(expected.to_string()),
"cursor cell must carry the char at raw col {raw_col}",
);
}
}
#[test]
fn rendered_view_code_nested_in_list_item_does_not_reveal_its_body() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "8. Tag it.\n\n ```bash\n gh run watch\n ```\n";
let width: u16 = 30;
let draw = |cursor: usize| {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = cursor;
state.set_viewport_width(width as usize);
state.update_cursor_block();
state.cursor_block_entered_at = None; let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
terminal.backend().buffer().clone()
};
let resting = draw(0);
let active = draw(src.find("run").unwrap());
let cursor_x = 7u16;
for x in 0..width {
let (r, a) = (resting.cell((x, 3)).unwrap(), active.cell((x, 3)).unwrap());
assert_eq!(a.symbol(), r.symbol(), "row text changed at col {x}");
if x != cursor_x {
assert_eq!(a.style(), r.style(), "row styling changed at col {x}");
}
}
assert_eq!(active.cell((cursor_x, 3)).unwrap().symbol(), "r");
assert_eq!(
active.cell((cursor_x, 3)).unwrap().style().bg,
theme.status_mode_rendered.bg,
"cursor indicator must sit on the `r`",
);
}
#[test]
fn rendered_view_selection_on_code_nested_in_list_item_covers_its_chars() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "8. Tag it.\n\n ```bash\n gh run watch\n ```\n";
let width: u16 = 30;
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(width as usize);
let run = src.find("run").unwrap();
state.selection = Some(Selection {
anchor: run,
active: run + 3,
});
state.cursor.offset = 0;
let mut terminal = Terminal::new(TestBackend::new(width, 6)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let washed = |y: u16| -> Vec<u16> {
(0..width)
.filter(|&x| buf.cell((x, y)).unwrap().style().bg == theme.selection.bg)
.collect()
};
assert_eq!(
washed(3),
[7, 8, 9],
"`run` sits at cols 7..10 of the body row"
);
assert!(washed(2).is_empty(), "the label row must stay unselected");
}
#[test]
fn rendered_view_selection_on_a_last_items_nested_item_covers_it() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "- a\n- - q\n\nafter\n";
let width: u16 = 30;
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(width as usize);
let q = src.find('q').unwrap();
state.selection = Some(Selection {
anchor: q,
active: q + 1,
});
state.cursor.offset = src.find("after").unwrap();
state.update_cursor_block();
let mut terminal = Terminal::new(TestBackend::new(width, 6)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let washed: Vec<&str> = (0..width)
.map(|x| buf.cell((x, 2)).unwrap())
.filter(|c| c.style().bg == theme.selection.bg)
.map(|c| c.symbol())
.collect();
assert_eq!(washed, ["q"], "the nested item's `q` must be washed");
}
#[test]
fn rendered_view_selection_on_a_nested_items_continuation_covers_it() {
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "- a\n - b\n soft *word*\n - c\n deep word\n\nafter\n";
let width: u16 = 30;
for (needle, row, expected) in [("word*", 2u16, "word"), ("word\n", 4, "word")] {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_reflow(false);
state.set_viewport_width(width as usize);
let start = src.find(needle).unwrap();
state.selection = Some(Selection {
anchor: start,
active: start + 4,
});
state.cursor.offset = src.find("after").unwrap();
state.update_cursor_block();
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let washed: String = (0..width)
.map(|x| buf.cell((x, row)).unwrap())
.filter(|c| c.style().bg == theme.selection.bg)
.map(|c| c.symbol())
.collect();
assert_eq!(washed, expected, "row {row}");
}
}
#[test]
fn rendered_view_indented_code_block_cursor_indicator_sits_on_its_char() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "Intro.\n\n let x = 1;\n";
let width: u16 = 30;
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.find('=').unwrap();
state.set_viewport_width(width as usize);
let backend = TestBackend::new(width, 8);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let code_row = (0..8u16)
.find(|&y| (0..width).any(|x| buf.cell((x, y)).map(|c| c.symbol() == "=").unwrap_or(false)))
.expect("indented code row must render");
let cursor_x = (0..width).find(|&x| {
buf.cell((x, code_row))
.map(|c| {
c.style().bg == theme.status_mode_rendered.bg
&& c.style().fg == theme.status_mode_rendered.fg
})
.unwrap_or(false)
});
assert_eq!(cursor_x, Some(7), "stripped indent must be subtracted back");
assert_eq!(
buf.cell((7, code_row)).map(|c| c.symbol().to_string()),
Some("=".to_string()),
);
}
#[test]
fn rendered_view_code_block_blank_body_line_aligns_cursor_indicator() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```rust\nA\n\nB\n```\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = src.find('B').unwrap();
let width: u16 = 30;
state.set_viewport_width(width as usize);
let backend = TestBackend::new(width, 8);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_has_cursor = |y: u16| {
(0..width).any(|x| {
buf.cell((x, y))
.map(|c| {
c.style().bg == theme.status_mode_rendered.bg
&& c.style().fg == theme.status_mode_rendered.fg
})
.unwrap_or(false)
})
};
assert!(
row_has_cursor(3),
"cursor indicator must paint on row 3 (the 'B' line) not above it"
);
assert!(
!row_has_cursor(2),
"cursor indicator must not paint on row 2 (the blank padding line)"
);
}
#[test]
fn rendered_view_bare_code_fence_de_renders_opening_fence() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```\nfn main() {}\n```\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.cursor.offset = 0;
let backend = TestBackend::new(30, 5);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..30u16)
.map(|x| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
};
assert!(
row_text(0).contains("```"),
"opening fence must de-render to show ``` glyphs, got: {:?}",
row_text(0)
);
assert!(
row_text(1).contains("fn main() {}"),
"body line must follow the opening-fence row, got: {:?}",
row_text(1)
);
}
#[test]
fn mermaid_block_reveals_full_raw_source_on_cursor_entry() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```mermaid\nflowchart TD\nA-->B\nB-->C\n```\n";
let mut state = EditorState::new_with_config(Buffer::from_str(src), theme, true, true, 6);
state.mode = Mode::Rendered;
let byte = src.find("flowchart").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(byte);
state.update_cursor_block();
state.cursor_block_entered_at = None;
let backend = TestBackend::new(20, 7);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..20u16)
.map(|x| {
buf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect()
};
assert!(
row_text(0).trim_start().starts_with("mermaid"),
"row 0 must show the ` mermaid ` language label, got: {:?}",
row_text(0)
);
assert!(
row_text(1).starts_with("flowchart TD"),
"row 1 must show first body line, got: {:?}",
row_text(1)
);
assert!(
row_text(2).starts_with("A-->B"),
"row 2 must show second body line, got: {:?}",
row_text(2)
);
assert!(
row_text(3).starts_with("B-->C"),
"row 3 must show third body line, got: {:?}",
row_text(3)
);
for y in 0..5u16 {
assert!(
!row_text(y).contains("[Image"),
"row {y} must not show image placeholder, got: {:?}",
row_text(y)
);
}
}
#[test]
fn mermaid_reveal_pads_rows_past_the_source() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```mermaid\nflowchart TD\nA-->B\n```\n";
let mut state = EditorState::new_with_config(Buffer::from_str(src), theme, true, true, 8);
state.mode = Mode::Rendered;
let byte = src.rfind("```").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(byte);
state.update_cursor_block();
state.cursor_block_entered_at = None;
let backend = TestBackend::new(20, 9);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..20u16)
.map(|x| buf.cell((x, y)).unwrap().symbol())
.collect::<String>()
.trim_end()
.to_string()
};
assert_eq!(
row_text(3),
"```",
"the closing fence shows raw under the cursor"
);
for y in 4..8u16 {
assert_eq!(row_text(y), "", "row {y} past the source must be padding");
}
}
#[test]
fn mermaid_reveal_hides_every_kind_of_closing_fence() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
for (src, closing) in [
("~~~mermaid\nflowchart TD\nA-->B\n~~~\n", Some("~~~")),
("````mermaid\nflowchart TD\nA-->B\n````\n", Some("````")),
("```mermaid\nflowchart TD\nA-->B\n`````\n", Some("`````")),
("```mermaid\nflowchart TD\nA-->B\n```\n", Some("```")),
("```mermaid\nflowchart TD\nA-->B\n", None),
] {
let mut state = EditorState::new_with_config(Buffer::from_str(src), theme, true, true, 8);
state.mode = Mode::Rendered;
let byte = src.find("A-->B").unwrap() + 1;
state.cursor.offset = state.buffer.rope().byte_to_char(byte);
state.update_cursor_block();
state.cursor_block_entered_at = None;
let mut terminal = Terminal::new(TestBackend::new(20, 9)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..20u16)
.map(|x| buf.cell((x, y)).unwrap().symbol())
.collect::<String>()
.trim_end()
.replace('\u{00A0}', " ")
.trim_end()
.to_string()
};
assert_eq!(row_text(2), "A-->B", "in {src:?}");
match closing {
Some(fence) => {
assert_eq!(
row_text(3),
"",
"{fence} must be the placeholder in {src:?}"
);
let at = src.rfind(fence).unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(at);
state.update_cursor_block();
state.cursor_block_entered_at = None;
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row3: String = (0..20u16)
.map(|x| buf.cell((x, 3)).unwrap().symbol())
.collect();
assert_eq!(
row3.trim_end(),
fence,
"{fence} under the cursor in {src:?}"
);
}
None => assert_eq!(row_text(1), "flowchart TD", "in {src:?}"),
}
}
}
#[test]
fn yank_flash_paints_a_revealed_diagrams_source_line() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::terminal::Capabilities;
use edamame::ui::bottom_region::{HintContent, HintSet};
use edamame::ui::{EditorView, EditorViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```mermaid\nflowchart TD\nA-->B\n```\n";
for (line, row) in [("flowchart TD", 1u16), ("```mermaid", 0)] {
let mut state = EditorState::new_with_config(Buffer::from_str(src), theme, true, true, 8);
state.mode = Mode::Rendered;
let cursor = src.find("A-->B").unwrap() + "A-->B".len();
state.cursor.offset = state.buffer.rope().byte_to_char(cursor);
state.update_cursor_block();
state.cursor_block_entered_at = None;
let start = state.buffer.rope().byte_to_char(src.find(line).unwrap());
state.flash_yank(start, start + line.chars().count());
let backend = TestBackend::new(30, 9);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = EditorViewState::new();
let caps = Capabilities::default();
terminal
.draw(|frame| {
let view = EditorView {
state: &mut state,
theme,
filename: "test.md",
show_table_buttons: false,
table_drop_indicator: None,
capabilities: &caps,
show_line_numbers: false,
is_scrolling: false,
hint: HintContent::Chords(HintSet::default()),
vim_mode_label: None,
visual_kind: None,
editor_cursor_style: theme.status_mode_rendered,
max_width_enabled: false,
max_width_cols: 0,
scrollbar_active: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let flashed = |y: u16| -> Vec<u16> {
(0..30u16)
.filter(|&x| buf.cell((x, y)).unwrap().style().bg == theme.selection.bg)
.collect()
};
if row == 1 {
let n = line.chars().count() as u16;
assert_eq!(flashed(1), (0..n).collect::<Vec<_>>(), "{line:?}");
} else {
assert_eq!(flashed(0), Vec::<u16>::new(), "{line:?}");
}
for y in (0..8u16).filter(|&y| y != row) {
assert_eq!(flashed(y), Vec::<u16>::new(), "{line:?}: row {y}");
}
}
}
#[test]
fn yank_flash_paints_revealed_rows_at_their_raw_columns() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::terminal::Capabilities;
use edamame::ui::bottom_region::{HintContent, HintSet};
use edamame::ui::{EditorView, EditorViewState};
let theme = Box::leak(Box::new(Theme::default()));
#[allow(clippy::type_complexity)]
let cases: &[(&str, &str, &str, bool, bool, [std::ops::Range<u16>; 6])] = &[
(
"a **bold** word\n\nnext\n",
"a **",
"word",
false,
false,
[11..15, 0..0, 0..0, 0..0, 0..0, 0..0],
),
(
"- a **b** word\n- c\n",
"a **",
"word",
false,
false,
[10..14, 0..0, 0..0, 0..0, 0..0, 0..0],
),
(
"Title *x* end\n===\n\nnext\n",
"Title",
"end",
false,
false,
[10..13, 0..0, 0..0, 0..0, 0..0, 0..0],
),
(
"Title *x* end\n---\n\nnext\n",
"Title",
"---",
false,
false,
[0..0, 0..3, 0..0, 0..0, 0..0, 0..0],
),
(
"# **T** end\n\nnext\n",
"# **",
"end",
false,
true,
[8..11, 0..0, 0..30, 0..0, 0..0, 0..0],
),
(
"a **b** c\nmore word\n\nnext\n",
"a **",
"word",
true,
false,
[0..0, 5..9, 0..0, 0..0, 0..0, 0..0],
),
(
"a **b** c\nmore word\n\nnext\n",
"a **",
"next",
true,
false,
[0..0, 0..0, 0..0, 0..4, 0..0, 0..0],
),
];
for (src, at, flash, reflow, big_h1, expected) in cases {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(30);
state.set_reflow(!*reflow);
state.set_reflow(*reflow);
state.set_big_h1(*big_h1);
let cursor = src.find(at).unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(cursor);
state.update_cursor_block();
state.cursor_block_entered_at = None;
let start = state.buffer.rope().byte_to_char(src.find(flash).unwrap());
state.flash_yank(start, start + flash.chars().count());
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = EditorViewState::new();
let caps = Capabilities::default();
terminal
.draw(|frame| {
let view = EditorView {
state: &mut state,
theme,
filename: "test.md",
show_table_buttons: false,
table_drop_indicator: None,
capabilities: &caps,
show_line_numbers: false,
is_scrolling: false,
hint: HintContent::Chords(HintSet::default()),
vim_mode_label: None,
visual_kind: None,
editor_cursor_style: theme.status_mode_rendered,
max_width_enabled: false,
max_width_cols: 0,
scrollbar_active: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
for (y, want) in expected.iter().enumerate() {
let y = y as u16;
let got: Vec<u16> = (0..30u16)
.filter(|&x| buf.cell((x, y)).unwrap().style().bg == theme.selection.bg)
.collect();
assert_eq!(
got,
want.clone().collect::<Vec<_>>(),
"{src:?}, flash {flash:?}: row {y}"
);
}
}
}
#[test]
fn table_cursor_keeps_its_column_when_a_plain_cell_reveals() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| a | bb |\n|---|---|\n| 1 | 2 |\n";
for (at, col) in [
(src.find('a').unwrap(), 2u16),
(src.find("b |").unwrap(), 9),
] {
let mut columns = Vec::new();
for revealed in [false, true] {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(30);
state.cursor.offset = state.buffer.rope().byte_to_char(at);
state.update_cursor_block();
state.cursor_block_entered_at = (!revealed).then(std::time::Instant::now);
let backend = TestBackend::new(30, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let cursor: Vec<u16> = (0..30u16)
.filter(|&x| buf.cell((x, 1)).unwrap().style().bg == theme.status_mode_rendered.bg)
.collect();
columns.push(cursor);
}
assert_eq!(
columns[0],
vec![col],
"before the reveal, cursor at byte {at}"
);
assert_eq!(
columns[1],
vec![col],
"after the reveal, cursor at byte {at}"
);
}
}
#[test]
fn table_cursor_in_a_dropped_cell_reveals_the_whole_row() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| 1 | 2 | 3 | 4 |\n";
let draw = |at: usize| {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(30);
state.cursor.offset = state.buffer.rope().byte_to_char(at);
state.update_cursor_block();
state.cursor_block_entered_at = None;
let mut terminal = Terminal::new(TestBackend::new(30, 6)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
terminal.backend().buffer().clone()
};
let row_with = |buf: &ratatui::buffer::Buffer, needle: char| {
(0..6u16)
.map(|y| (0..30u16).map(|x| buf[(x, y)].symbol()).collect::<String>())
.find(|row| row.contains(needle))
};
let cursor_symbols = |buf: &ratatui::buffer::Buffer| -> Vec<String> {
(0..6u16)
.flat_map(|y| (0..30u16).map(move |x| (x, y)))
.filter(|&p| buf[p].style().bg == theme.status_mode_rendered.bg)
.map(|p| buf[p].symbol().to_owned())
.collect()
};
let buf = draw(src.rfind('4').unwrap());
let row = row_with(&buf, '4').expect("the dropped cells show");
assert!(row.starts_with("| 1 | 2 | 3 | 4 |"), "raw row: {row:?}");
assert_eq!(cursor_symbols(&buf), ["4"]);
let buf = draw(src.rfind('2').unwrap());
assert!(row_with(&buf, '4').is_none(), "a drawn cell reveals alone");
assert_eq!(cursor_symbols(&buf), ["2"]);
}
#[test]
fn table_cursor_shows_on_its_glyph_while_the_row_is_formatted() {
use crossterm::event::KeyModifiers;
use edamame::document::Buffer;
use edamame::editor::{mouse_ops, EditorState};
use edamame::input::MouseAction;
use edamame::search::SearchState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let cases = [
(
"| a | **b** x y |\n|---|---|\n| 1 | 2222222222222 |\n",
"x y",
40,
1,
10,
14,
),
(
"| h | k |\n|---|---|\n| a | **alpha** bravo |\n",
"avo",
16,
4,
10,
10,
),
(
"| h | k |\n|---|---|\n| a | **alpha** bravo |\n",
"pha",
16,
3,
10,
12,
),
];
for (src, on, width, row, formatted, revealed) in cases {
let at = src.find(on).unwrap();
let cursor_cols = |phase: &str| -> Vec<u16> {
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(width as usize);
if phase == "search" {
state.enter_search(SearchState::new("zzz".to_owned(), None).unwrap());
}
state.cursor.offset = state.buffer.rope().byte_to_char(at);
state.update_cursor_block();
state.cursor_block_entered_at = (phase == "delay").then(std::time::Instant::now);
let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
(0..width)
.filter(|&x| {
buf.cell((x, row)).unwrap().style().bg == theme.status_mode_rendered.bg
})
.collect()
};
assert_eq!(
cursor_cols("delay"),
vec![formatted],
"{on:?} before the reveal"
);
assert_eq!(
cursor_cols("search"),
vec![formatted],
"{on:?} during search"
);
assert_eq!(cursor_cols("revealed"), vec![revealed], "{on:?} revealed");
let mut state = EditorState::new(Buffer::from_str(&format!("{src}\nafter\n")), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(width as usize);
state.cursor.offset = state.buffer.len_chars() - 1;
state.update_cursor_block();
let click = MouseAction::Click {
col: formatted,
row,
modifiers: KeyModifiers::NONE,
};
mouse_ops::apply(&mut state, click, &mut None, &[], 20, width as usize);
assert_eq!(
state.buffer.rope().char_to_byte(state.cursor.offset),
at,
"{on:?}: a click where the cursor showed"
);
}
}
#[test]
fn rendered_view_drag_selection_paints_wrapped_cell_continuation() {
use edamame::config::Theme;
use edamame::document::{Buffer, Selection};
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme: &'static Theme = Box::leak(Box::new(Theme::default()));
let src = "| Name | Notes |\n|---|---|\n| a | This is a very long note that wraps |\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.set_viewport_width(28);
let sel_start = src.find("This").unwrap();
let sel_end = sel_start + src[sel_start..].find(" |\n").unwrap();
let rope = state.buffer.rope();
state.selection = Some(Selection {
anchor: rope.byte_to_char(sel_start),
active: rope.byte_to_char(sel_end),
});
state.cursor.offset = rope.byte_to_char(sel_end);
state.update_cursor_block();
state.drag_in_progress = true;
let backend = TestBackend::new(28, 12);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
state: &state,
theme,
show_table_buttons: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..28u16)
.map(|x| {
buf.cell((x, y))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ')
})
.collect()
};
let cont_y = (0..12u16)
.find(|&y| row_text(y).contains("wraps"))
.expect("wrapped continuation row visible");
let x = row_text(cont_y).find("wraps").unwrap() as u16;
let cell = buf.cell((x, cont_y)).expect("cell in bounds");
assert_eq!(
cell.style().bg,
theme.selection.bg,
"selection background must paint on the wrapped continuation row mid-drag"
);
}
#[test]
fn gutter_numbers_source_lines_in_rendered_mode() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::terminal::Capabilities;
use edamame::ui::bottom_region::{HintContent, HintSet};
use edamame::ui::{EditorView, EditorViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\nafter\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
let backend = TestBackend::new(40, 14);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = EditorViewState::new();
let caps = Capabilities::default();
terminal
.draw(|frame| {
let view = EditorView {
state: &mut state,
theme,
filename: "test.md",
show_table_buttons: false,
table_drop_indicator: None,
capabilities: &caps,
show_line_numbers: true,
is_scrolling: false,
hint: HintContent::Chords(HintSet::default()),
vim_mode_label: None,
visual_kind: None,
editor_cursor_style: theme.status_mode_rendered,
max_width_enabled: false,
max_width_cols: 0,
scrollbar_active: false,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let gutter: Vec<String> = (0..12u16)
.map(|y| {
(0..3u16)
.map(|x| buf.cell((x, y)).unwrap().symbol())
.collect::<String>()
.trim()
.to_string()
})
.collect();
let numbers: Vec<&str> = gutter
.iter()
.filter(|s| !s.is_empty())
.map(|s| s.as_str())
.collect();
assert_eq!(
numbers,
vec!["1", "2", "3", "4", "5", "6"],
"gutter: {gutter:?}"
);
assert!(
gutter.iter().filter(|s| s.is_empty()).count() >= 4,
"expected blank rows for table borders: {gutter:?}"
);
}
#[test]
fn revealed_diagram_shows_every_source_line() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "```mermaid\nflowchart LR\n A --> B\n B --> C\n```\n\nAfter.\n";
let mut state = EditorState::new_with_config(Buffer::from_str(src), theme, true, true, 2);
state.mode = Mode::Rendered;
let byte = src.find("flowchart").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(byte);
state.update_cursor_block();
state.cursor_block_entered_at = None;
assert!(
state.sync_image_reveal(),
"entering the diagram must re-lay the document out"
);
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows: Vec<String> = (0..10u16)
.map(|y| {
(0..30u16)
.map(|x| buf.cell((x, y)).unwrap().symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
let joined = rows.join("\n");
for line in ["flowchart LR", "A --> B", "B --> C"] {
assert!(
joined.contains(line),
"revealed diagram must show {line:?}: {rows:?}"
);
}
let after = rows
.iter()
.position(|r| r.contains("After."))
.unwrap_or_else(|| panic!("trailer must stay visible: {rows:?}"));
let last_source = rows
.iter()
.position(|r| r.contains("B --> C"))
.expect("last diagram line");
assert!(
after > last_source,
"trailer must follow the fence: {rows:?}"
);
}
#[test]
fn revealed_image_reflows_the_document_around_its_source_line() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "Above.\n\n\n\nBelow.\n";
let mut state = EditorState::new_with_config(Buffer::from_str(src), theme, true, true, 12);
state.mode = Mode::Rendered;
let byte = src.find("![cat]").unwrap();
state.cursor.offset = state.buffer.rope().byte_to_char(byte);
state.update_cursor_block();
state.cursor_block_entered_at = None;
assert!(
state.sync_image_reveal(),
"entering the image must re-lay the document out"
);
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let rows: Vec<String> = (0..10u16)
.map(|y| {
(0..30u16)
.map(|x| buf.cell((x, y)).unwrap().symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect();
let image = rows
.iter()
.position(|r| r.contains(""))
.unwrap_or_else(|| panic!("the raw source line must be painted: {rows:?}"));
let below = rows
.iter()
.position(|r| r.contains("Below."))
.unwrap_or_else(|| panic!("trailer must stay visible: {rows:?}"));
assert_eq!(
below,
image + 2,
"the trailer must follow one blank separator row, not the \
picture's reservation: {rows:?}"
);
}
fn render_raw_rows(state: &edamame::editor::EditorState, width: u16, height: u16) -> Vec<String> {
use edamame::ui::raw_view::{RawView, RawViewState};
use ratatui::widgets::StatefulWidget;
let theme = Box::leak(Box::new(Theme::default()));
let mut view_state = RawViewState::default();
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let view = RawView {
state,
theme,
visual_kind: None,
cursor_style: theme.status_mode_raw,
};
StatefulWidget::render(view, frame.area(), frame.buffer_mut(), &mut view_state);
})
.unwrap();
let tbuf = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
let row: String = (0..width)
.map(|x| {
tbuf.cell((x, y))
.map_or(' ', |c| c.symbol().chars().next().unwrap_or(' '))
})
.collect();
row.trim_end().to_string()
})
.collect()
}
fn raw_state(text: &str) -> edamame::editor::EditorState {
let theme = Box::leak(Box::new(Theme::default()));
let mut state =
edamame::editor::EditorState::new(edamame::document::Buffer::from_str(text), theme);
state.mode = Mode::Raw;
state
}
#[test]
fn raw_view_wraps_a_list_item_without_a_hanging_indent() {
let state = raw_state("- alpha bravo charlie delta echo foxtrot\n");
let rows = render_raw_rows(&state, 16, 6);
assert_eq!(rows[0], "- alpha bravo");
assert!(
!rows[1].starts_with(' '),
"continuation row is indented: {:?}",
rows[1]
);
assert_eq!(rows[1], "charlie delta");
}
#[test]
fn raw_view_wrapped_rows_agree_with_the_click_mapping() {
use edamame::editor::mouse_ops;
const W: u16 = 16;
let src = "- alpha bravo charlie delta echo foxtrot\nplain tail line\n";
let mut state = raw_state(src);
let rows = render_raw_rows(&state, W, 8);
for (y, row) in rows.iter().enumerate() {
for (x, painted) in row.chars().enumerate() {
if painted == ' ' {
continue; }
let mut anchor: Option<mouse_ops::DragTarget> = None;
let action = edamame::input::MouseAction::Click {
col: x as u16,
row: y as u16,
modifiers: crossterm::event::KeyModifiers::NONE,
};
mouse_ops::apply(&mut state, action, &mut anchor, &[], 8, W as usize);
let at_cursor = state
.buffer
.rope()
.chars_at(state.cursor.offset)
.next()
.unwrap_or('\n');
assert_eq!(
at_cursor, painted,
"click at ({x}, {y}) landed on {at_cursor:?}, painted {painted:?}"
);
}
}
}
#[test]
fn empty_document_shows_the_cursor_in_rendered_mode() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let mut state = EditorState::new(Buffer::from_str(""), theme);
state.mode = Mode::Rendered;
state.cursor.offset = 0;
let backend = TestBackend::new(20, 4);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let cell = buf.cell((0, 0)).expect("cell (0,0) must exist");
let style = cell.style();
assert_eq!(
(style.fg, style.bg),
(theme.status_mode_rendered.fg, theme.status_mode_rendered.bg),
"the cursor block must be painted at line 0, column 0 of an empty document"
);
}
#[test]
fn revealed_math_block_is_styled_like_a_code_block() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let mut state = EditorState::new(Buffer::from_str("$$\nE = mc^2\n$$\n"), theme);
state.mode = Mode::Rendered;
state.math_preview = false; state.cursor.offset = "$$\n".chars().count() + 1;
state.update_cursor_block();
state.cursor_block_entered_at = None; assert!(state.cursor_block_revealed(), "the math block must reveal");
assert!(state.sync_image_reveal());
let backend = TestBackend::new(25, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row_text = |y: u16| -> String {
(0..25u16)
.filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
.collect()
};
let row_has_bg = |y: u16, bg| {
(0..25u16).any(|x| {
buf.cell((x, y))
.map(|c| c.style().bg == bg)
.unwrap_or(false)
})
};
assert!(
row_text(0).contains("math"),
"opening row: {:?}",
row_text(0)
);
assert!(
!row_text(0).contains("$$"),
"opening `$$` must be hidden when the cursor is elsewhere: {:?}",
row_text(0)
);
assert!(
row_has_bg(0, theme.code_block_lang.bg),
"header row must carry the code-block language surface"
);
assert!(
row_text(1).contains("E = mc^2"),
"body row: {:?}",
row_text(1)
);
assert!(
row_has_bg(1, theme.code_block_text.bg),
"body row must carry the code-block text surface"
);
assert!(
!row_text(2).contains("$$"),
"closing `$$` must be hidden when the cursor is elsewhere: {:?}",
row_text(2)
);
assert!(
row_has_bg(2, theme.code_block_text.bg),
"closing row must carry the code-block surface"
);
}
#[test]
fn revealed_math_block_shows_literal_delimiter_under_the_cursor() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let mut state = EditorState::new(Buffer::from_str("$$\nE = mc^2\n$$\n"), theme);
state.mode = Mode::Rendered;
state.math_preview = false;
state.cursor.offset = 0; state.update_cursor_block();
state.cursor_block_entered_at = None;
assert!(state.cursor_block_revealed());
assert!(state.sync_image_reveal());
let backend = TestBackend::new(25, 6);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let row0: String = (0..25u16)
.filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol().to_string()))
.collect();
assert!(
row0.contains("$$"),
"opening row must reveal its literal `$$` under the cursor: {row0:?}"
);
assert!(
!row0.contains("math"),
"the ` math ` header must yield to the raw delimiter under the cursor: {row0:?}"
);
}
#[test]
fn revealed_math_block_closing_is_blank_even_with_trailing_content() {
use edamame::document::Buffer;
use edamame::editor::EditorState;
use edamame::ui::{RenderedView, RenderedViewState};
let theme = Box::leak(Box::new(Theme::default()));
let src = "Intro.\n\n$$\nE = mc^2\n$$\n\nAfter.\n";
let mut state = EditorState::new(Buffer::from_str(src), theme);
state.mode = Mode::Rendered;
state.math_preview = false;
state.cursor.offset = "Intro.\n\n$$\n".chars().count() + 1;
state.update_cursor_block();
state.cursor_block_entered_at = None; assert!(state.cursor_block_revealed(), "the math block must reveal");
assert!(state.sync_image_reveal());
let backend = TestBackend::new(25, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut view_state = RenderedViewState::default();
terminal
.draw(|frame| {
let view = RenderedView {
cursor_style: theme.status_mode_rendered,
visual_kind: None,
drop_indicator: None,
show_table_buttons: false,
state: &state,
theme,
};
frame.render_stateful_widget(view, frame.area(), &mut view_state);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut full = String::new();
for y in 0..10u16 {
for x in 0..25u16 {
if let Some(c) = buf.cell((x, y)) {
full.push_str(c.symbol());
}
}
}
assert!(
!full.contains("$$"),
"no literal `$$` should render while the cursor is on the body: {full:?}"
);
}