use std::cell::RefCell;
use std::time::{Duration, Instant};
use crate::config::Theme;
use crate::diff::DiffState;
use crate::document::{Buffer, Cursor, EditDelta, History, ParsedDoc, Selection, VisualSelection};
use crate::editor::state_viewport::RawVisualRowCache;
use crate::editor::vim_ops::SubstitutePreview;
use crate::editor::Mode;
use crate::editor::YankFlash;
use crate::image::ImageCache;
use crate::markdown::RenderCache;
use crate::search::SearchState;
const BLINK_INTERVAL: Duration = Duration::from_millis(530);
#[derive(Debug, Clone)]
pub struct CursorBlink {
blinking: bool,
visible: bool,
interval: Duration,
last_toggle: Instant,
}
impl Default for CursorBlink {
fn default() -> Self {
Self {
blinking: true,
visible: true,
interval: BLINK_INTERVAL,
last_toggle: Instant::now(),
}
}
}
impl CursorBlink {
pub fn from_config(blinking: bool, interval_ms: u64) -> Self {
let interval = if interval_ms == 0 {
BLINK_INTERVAL
} else {
Duration::from_millis(interval_ms)
};
Self {
blinking,
visible: true,
interval,
last_toggle: Instant::now(),
}
}
pub fn apply_config(&mut self, blinking: bool, interval_ms: u64) {
self.blinking = blinking;
if interval_ms != 0 {
self.interval = Duration::from_millis(interval_ms);
}
self.reset();
}
pub fn is_blinking(&self) -> bool {
self.blinking
}
pub fn is_visible(&self) -> bool {
!self.blinking || self.visible
}
pub fn reset(&mut self) {
self.visible = true;
self.last_toggle = Instant::now();
}
pub fn tick(&mut self) -> bool {
if !self.blinking {
return false;
}
if self.last_toggle.elapsed() >= self.interval {
self.visible = !self.visible;
self.last_toggle = Instant::now();
true
} else {
false
}
}
pub fn next_toggle(&self) -> Option<Instant> {
if self.blinking {
Some(self.last_toggle + self.interval)
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ImageReveal {
pub(crate) ordinal: usize,
pub(crate) url: String,
pub(crate) rows: usize,
}
pub struct EditorState {
pub buffer: Buffer,
pub cursor: Cursor,
pub selection: Option<Selection>,
pub visual_selection: Option<VisualSelection>,
pub history: History,
pub mode: Mode,
pub parsed: ParsedDoc,
pub dirty: bool,
pub kill_ring: String,
pub scroll: usize,
pub cursor_block_idx: Option<usize>,
pub cursor_line_idx: Option<usize>,
pub cursor_block_entered_at: Option<Instant>,
pub last_table_delete_at: Option<Instant>,
pub drag_in_progress: bool,
theme: &'static Theme,
preserve_blank_lines: bool,
pub visual_line_nav: bool,
pub image_max_height: usize,
pub image_max_width: usize,
pub image_font_size: (u16, u16),
pub images: ImageCache,
pub images_enabled: bool,
pub diagrams_enabled: bool,
pub parsed_version: u64,
pub live_table_widths: Option<(usize, Vec<Option<usize>>)>,
pub row_striping: bool,
pub big_h1: bool,
pub viewport_width: usize,
pub pending_column_widths_commit: Option<usize>,
pub pending_link_follow: Option<crate::editor::link::LinkTarget>,
pub parsed_dirty: bool,
pub cursor_block_line_range: Option<std::ops::Range<usize>>,
pub cursor_blink: CursorBlink,
pub modal_open: bool,
pub terminal_focused: bool,
pub(crate) raw_visual_rows: RefCell<Vec<RawVisualRowCache>>,
pub diff: Option<DiffState>,
pub pre_diff_scroll: usize,
pub pending_focus_scroll: bool,
pub search: Option<SearchState>,
pub yank_flash: Option<YankFlash>,
pub substitute_preview: Option<SubstitutePreview>,
pub(crate) image_reveal: Option<ImageReveal>,
render_cache: RenderCache,
}
pub const RAW_REVEAL_DELAY: std::time::Duration = std::time::Duration::from_millis(120);
impl EditorState {
#[allow(dead_code)]
pub fn new(buffer: Buffer, theme: &'static Theme) -> Self {
Self::new_with_config(buffer, theme, true, true, 24)
}
#[allow(dead_code)]
pub fn new_with_config(
buffer: Buffer,
theme: &'static Theme,
preserve_blank_lines: bool,
visual_line_nav: bool,
image_max_height: usize,
) -> Self {
Self::new_with_image_config(
buffer,
theme,
preserve_blank_lines,
visual_line_nav,
image_max_height,
80, (10, 20), )
}
pub fn new_with_image_config(
buffer: Buffer,
theme: &'static Theme,
preserve_blank_lines: bool,
visual_line_nav: bool,
image_max_height: usize,
image_max_width: usize,
image_font_size: (u16, u16),
) -> Self {
let content = buffer.contents();
let parsed = ParsedDoc::build(&content, theme, preserve_blank_lines, image_max_height);
let mut state = Self {
buffer,
cursor: Cursor::new(),
selection: None,
visual_selection: None,
history: History::new(),
mode: Mode::Preview,
parsed,
dirty: false,
kill_ring: String::new(),
scroll: 0,
cursor_block_idx: None,
cursor_line_idx: None,
cursor_block_entered_at: None,
last_table_delete_at: None,
drag_in_progress: false,
theme,
preserve_blank_lines,
visual_line_nav,
image_max_height,
image_max_width,
image_font_size,
images: ImageCache::new(),
images_enabled: true,
diagrams_enabled: true,
parsed_version: 0,
live_table_widths: None,
row_striping: false,
big_h1: false,
viewport_width: 80,
pending_column_widths_commit: None,
pending_link_follow: None,
parsed_dirty: false,
cursor_block_line_range: None,
cursor_blink: CursorBlink::default(),
modal_open: false,
terminal_focused: true,
raw_visual_rows: RefCell::new(Vec::new()),
diff: None,
pre_diff_scroll: 0,
pending_focus_scroll: false,
search: None,
yank_flash: None,
substitute_preview: None,
image_reveal: None,
render_cache: RenderCache::default(),
};
state.update_cursor_block();
state.cursor_block_entered_at = None;
state.cursor_line_idx = None;
state
}
#[allow(dead_code)]
pub fn contents(&self) -> String {
self.buffer.contents()
}
pub fn selection_size(&self) -> Option<(usize, usize)> {
if self.mode == Mode::Preview {
let vs = self.visual_selection?;
if vs.is_empty() {
return None;
}
let ((sr, sc), (er, ec)) = vs.range();
let mut chars = 0usize;
let mut lines = 0usize;
for row in sr..=er {
let Some(line) = self.parsed.lines.get(row) else {
break;
};
let full: String = line.spans.iter().flat_map(|s| s.content.chars()).collect();
let row_len = full.chars().count();
let mut col_start = if row == sr { sc } else { 0 };
let mut col_end = if row == er { ec.min(row_len) } else { row_len };
if let Some(band) = vs.band {
col_start = col_start.max(band.cols.0);
col_end = col_end.min(band.cols.1.min(row_len));
}
chars += col_end.saturating_sub(col_start);
lines += 1;
}
Some((chars, lines.max(1)))
} else {
let sel = self.selection?;
if sel.is_empty() {
return None;
}
let (start, end) = sel.range();
let end = end.min(self.buffer.len_chars());
if start >= end {
return None;
}
let chars = end - start;
let start_line = self.buffer.char_to_line(start);
let end_inclusive = end.saturating_sub(1);
let end_line = self.buffer.char_to_line(end_inclusive.max(start));
Some((chars, end_line - start_line + 1))
}
}
pub fn theme(&self) -> &'static Theme {
self.theme
}
pub fn set_theme(&mut self, theme: &'static Theme) {
if std::ptr::eq(self.theme, theme) {
return;
}
self.theme = theme;
self.refresh_parsed();
}
pub fn replace_buffer(&mut self, new_buffer: Buffer) {
self.search = None;
self.substitute_preview = None;
self.image_reveal = None;
let new_len = new_buffer.len_chars();
self.buffer = new_buffer;
self.dirty = false;
self.history = History::new();
self.selection = None;
self.visual_selection = None;
self.cursor.offset = self.cursor.offset.min(new_len);
self.refresh_parsed();
self.update_cursor_block();
}
pub fn enter_diff_mode(&mut self, diff_state: DiffState) {
self.pre_diff_scroll = self.scroll;
self.scroll = 0;
self.diff = Some(diff_state);
self.mode = Mode::Diff;
self.selection = None;
self.visual_selection = None;
self.pending_focus_scroll = true;
}
pub fn scroll_focused_hunk_into_view(&mut self, viewport_height: usize, viewport_width: usize) {
if viewport_height == 0 {
return;
}
let Some(diff) = self.diff.as_ref() else {
return;
};
let row = diff.focused_hunk_visual_row(viewport_width);
let total = diff.total_visual_rows(viewport_width);
self.scroll_row_comfortably_into_view(row, total, viewport_height);
}
fn scroll_row_comfortably_into_view(
&mut self,
row: usize,
total: usize,
viewport_height: usize,
) {
const TOP_MARGIN: usize = 3;
let max_scroll = total.saturating_sub(1);
let comfortably_visible =
row >= self.scroll + TOP_MARGIN && row < self.scroll + viewport_height;
if !comfortably_visible {
self.scroll = row.saturating_sub(TOP_MARGIN).min(max_scroll);
}
}
pub fn scroll_cursor_comfortably_into_view(
&mut self,
viewport_height: usize,
viewport_width: usize,
) {
if viewport_height == 0 || viewport_width == 0 {
return;
}
let row = self.cursor_visual_row(viewport_width);
let total = self.total_visual_rows_for_mode(viewport_width);
self.scroll_row_comfortably_into_view(row, total, viewport_height);
}
pub fn place_cursor(&mut self, offset: usize) {
self.cursor.offset = offset.min(self.buffer.len_chars());
self.cursor.preferred_col = self.cursor.cell_col(&self.buffer);
self.update_cursor_block();
}
pub fn restore_view(&mut self, cursor: usize, scroll: Option<usize>) {
self.place_cursor(cursor);
if let Some(scroll) = scroll {
self.scroll = scroll;
}
}
pub fn exit_diff_mode(&mut self) {
self.diff = None;
self.scroll = self.pre_diff_scroll;
self.pre_diff_scroll = 0;
if self.mode == Mode::Diff {
self.mode = Mode::Rendered;
}
}
pub fn enter_search(&mut self, search_state: SearchState) {
self.search = Some(search_state);
self.selection = None;
self.visual_selection = None;
self.pending_focus_scroll = true;
self.ensure_search_fresh();
}
pub fn exit_search(&mut self) {
self.search = None;
}
pub fn inline_map_for(
&self,
buffer_line_idx: usize,
raw_line: &str,
) -> std::borrow::Cow<'_, crate::markdown::InlineColMap> {
let canonical = self
.buffer
.line(buffer_line_idx)
.is_some_and(|s| s.trim_end_matches('\n') == raw_line);
if canonical {
std::borrow::Cow::Borrowed(self.parsed.inline_map(buffer_line_idx, raw_line))
} else {
std::borrow::Cow::Owned(crate::markdown::InlineColMap::build(raw_line))
}
}
pub fn ensure_search_fresh(&mut self) {
let version = self.buffer.version();
let Some(s) = self.search.as_mut() else {
return;
};
if s.is_fresh(version) {
return;
}
let source = self.buffer.contents();
s.ensure_fresh(&source, version);
}
pub fn sync_cursor_to_search_focus(&mut self) {
let Some(range) = self.search.as_ref().and_then(|s| s.focused_range()) else {
return;
};
let total_bytes = self.buffer.rope().len_bytes();
let byte = range.start.min(total_bytes);
let offset = self.buffer.rope().byte_to_char(byte);
self.place_cursor(offset);
}
pub fn scroll_focused_match_into_view(
&mut self,
viewport_height: usize,
viewport_width: usize,
) {
if self.search.is_none() {
return;
}
self.scroll_cursor_comfortably_into_view(viewport_height, viewport_width);
}
pub fn set_row_striping(&mut self, on: bool) {
if self.row_striping == on {
return;
}
self.row_striping = on;
self.refresh_parsed();
}
pub fn set_big_h1(&mut self, on: bool) {
if self.big_h1 == on {
return;
}
self.big_h1 = on;
self.refresh_parsed();
}
pub fn set_viewport_width(&mut self, width: usize) {
let width = width.max(1);
if self.viewport_width == width {
return;
}
self.viewport_width = width;
self.refresh_parsed();
}
pub fn commit_pending_column_widths(&mut self) {
let Some(table_byte_start) = self.pending_column_widths_commit.take() else {
return;
};
let live_widths = self
.live_table_widths
.as_ref()
.filter(|(start, _)| *start == table_byte_start)
.map(|(_, w)| w.clone());
self.live_table_widths = None;
let Some(widths) = live_widths else {
self.refresh_parsed();
return;
};
let source = self.buffer.contents();
let Some(info) = crate::editor::table_edit::find_table_at(&source, table_byte_start) else {
self.refresh_parsed();
return;
};
let byte_delta = crate::editor::table_edit::write_column_widths(&source, &info, &widths);
let rope = self.buffer.rope();
let char_delta = EditDelta {
offset: rope.byte_to_char(byte_delta.offset),
removed: byte_delta.removed,
inserted: byte_delta.inserted,
};
self.apply_delta(char_delta);
}
pub fn cancel_pending_column_widths(&mut self) {
self.pending_column_widths_commit = None;
self.live_table_widths = None;
self.refresh_parsed();
}
#[allow(dead_code)]
pub fn has_pending_column_widths(&self) -> bool {
self.pending_column_widths_commit.is_some()
}
pub fn table_has_tui_columns_comment(&self, table_byte_start: usize) -> bool {
let source = self.buffer.contents();
let Some(info) = crate::editor::table_edit::find_table_at(&source, table_byte_start) else {
return false;
};
if info.end >= source.len() {
return false;
}
let comment_line_end = source[info.end..]
.find('\n')
.map(|i| info.end + i)
.unwrap_or(source.len());
let comment_line = &source[info.end..comment_line_end];
crate::markdown::table_layout::parse_column_widths_comment(comment_line).is_some()
}
pub fn cursor_in_table(&self) -> bool {
crate::editor::table_edit_ops::cursor_in_table(self)
}
pub(crate) fn refresh_parsed(&mut self) {
let content = self.buffer.contents();
let images = &self.images;
let max_w = self.image_max_width as u16;
let max_h = self.image_max_height as u16;
let font_size = self.image_font_size;
let images_enabled = self.images_enabled;
let image_reveal = self.image_reveal.as_ref();
let override_fn = |url: &str, ordinal: usize| {
if let Some(reveal) = image_reveal {
if reveal.ordinal == ordinal && reveal.url == url {
return Some(reveal.rows);
}
}
if !images_enabled {
return Some(1);
}
images.reserved_rows(url, max_w, max_h, font_size)
};
self.parsed = ParsedDoc::build_with_overrides(
&content,
self.theme,
self.preserve_blank_lines,
self.image_max_height,
self.live_table_widths.as_ref(),
Some(&override_fn),
self.row_striping,
self.viewport_width,
self.big_h1,
self.diagrams_enabled,
Some(&mut self.render_cache),
);
self.parsed_version = self.parsed_version.wrapping_add(1);
self.parsed_dirty = false;
let live: std::collections::HashSet<String> = self
.parsed
.image_blocks
.iter()
.map(|i| i.url.clone())
.collect();
self.images.gc(&live);
}
pub fn flush_parsed_if_dirty(&mut self) -> bool {
if self.parsed_dirty {
self.refresh_parsed();
true
} else {
false
}
}
pub(crate) fn apply_delta(&mut self, delta: EditDelta) {
let crosses_line = delta.inserted.contains('\n') || delta.removed.contains('\n');
let new_cursor = delta.redo_cursor();
let end = delta.offset + delta.removed.chars().count();
if !delta.removed.is_empty() {
self.buffer
.remove(delta.offset, end.min(self.buffer.len_chars()));
}
if !delta.inserted.is_empty() {
self.buffer.insert(delta.offset, &delta.inserted);
}
self.history.record(delta);
self.cursor.offset = new_cursor.min(self.buffer.len_chars());
self.dirty = true;
if crosses_line {
self.refresh_parsed();
self.update_cursor_block();
} else {
self.parsed_dirty = true;
self.parsed_version = self.parsed_version.wrapping_add(1);
self.cursor_blink.reset();
}
}
pub fn cursor_screen_row(&self, viewport_width: usize) -> usize {
if viewport_width == 0 {
return 0;
}
match self.mode {
crate::editor::Mode::Raw => raw_cursor_screen_row(self, viewport_width),
_ => rendered_cursor_screen_row(self, viewport_width),
}
}
pub fn set_scroll_for_cursor_screen_row(&mut self, target_row: usize, viewport_width: usize) {
if viewport_width == 0 {
return;
}
match self.mode {
crate::editor::Mode::Raw => {
set_raw_scroll_for_screen_row(self, target_row, viewport_width)
}
_ => set_rendered_scroll_for_screen_row(self, target_row, viewport_width),
}
}
}
pub(super) fn line_text_trimmed(buf: &crate::document::Buffer, line: usize) -> String {
buf.line(line)
.map(|s| s.trim_end_matches('\n').to_owned())
.unwrap_or_default()
}
fn raw_cursor_screen_row(state: &EditorState, width: usize) -> usize {
raw_cursor_visual_row(state, width).saturating_sub(state.scroll)
}
pub(super) fn raw_cursor_visual_row(state: &EditorState, width: usize) -> usize {
let (cursor_line, cursor_col) = state.cursor.line_col(&state.buffer);
let rows = state.visual_rows_before_raw_line(cursor_line, width);
let cursor_text = line_text_trimmed(&state.buffer, cursor_line);
let cursor_rows = crate::ui::line_render::visual_rows_of_str(&cursor_text, width);
let (sub, _) = crate::ui::line_render::sub_line_of_col(&cursor_rows, cursor_col);
rows + sub
}
fn set_raw_scroll_for_screen_row(state: &mut EditorState, target_row: usize, width: usize) {
state.scroll = raw_cursor_visual_row(state, width).saturating_sub(target_row);
}
fn rendered_cursor_screen_row(state: &EditorState, width: usize) -> usize {
rendered_cursor_visual_row(state, width).saturating_sub(state.scroll)
}
pub(super) fn rendered_cursor_visual_row(state: &EditorState, width: usize) -> usize {
let cursor_rendered = cursor_rendered_line_idx(state);
let rows_before = state.parsed.visual_rows_before(cursor_rendered, width);
rows_before + cursor_sub_line_in_rendered(state, cursor_rendered, width)
}
fn set_rendered_scroll_for_screen_row(state: &mut EditorState, target_row: usize, width: usize) {
state.scroll = rendered_cursor_visual_row(state, width).saturating_sub(target_row);
}
fn cursor_sub_line_in_rendered(
state: &EditorState,
_cursor_rendered: usize,
width: usize,
) -> usize {
let (cursor_buf_line, cursor_col) = state.cursor.line_col(&state.buffer);
let line_text = line_text_trimmed(&state.buffer, cursor_buf_line);
let rows = crate::ui::line_render::visual_rows_of_str(&line_text, width);
let (sub, _) = crate::ui::line_render::sub_line_of_col(&rows, cursor_col);
sub
}
pub(crate) fn cursor_rendered_line_idx(state: &EditorState) -> usize {
let cursor_offset = state.cursor.offset;
let cursor_byte = state.buffer.rope().char_to_byte(cursor_offset);
let cursor_block_idx = state
.parsed
.source_map
.block_for_byte(cursor_byte)
.unwrap_or(0);
let cursor_block_lines = state
.parsed
.source_map
.rendered_lines_for_block(cursor_block_idx);
if cursor_block_lines.is_empty() {
return state.scroll;
}
let cursor_block_own = state.parsed.block_own_line_count(cursor_block_idx);
let raw = crate::ui::rendered_view::raw_block_cursor(state, cursor_byte);
let raw_lines: Vec<&str> = crate::ui::rendered_view::raw_source_lines(&raw.source);
let cursor_in_block = cursor_sub_line_in_block(
&state.parsed,
cursor_byte,
cursor_block_idx,
cursor_block_own,
&raw.source,
&raw_lines,
raw.raw_line,
);
cursor_block_lines.start + cursor_in_block
}
pub(crate) fn cursor_sub_line_in_block(
parsed: &ParsedDoc,
cursor_byte: usize,
cursor_block_idx: usize,
cursor_block_own: usize,
raw_block_source: &str,
raw_lines: &[&str],
cursor_raw_line: usize,
) -> usize {
let subs = sub_lines_in_block(
parsed,
cursor_byte,
cursor_block_idx,
cursor_block_own,
raw_block_source,
raw_lines,
);
subs.get(cursor_raw_line)
.or_else(|| subs.last())
.copied()
.unwrap_or(0)
}
pub(crate) fn sub_lines_in_block(
parsed: &ParsedDoc,
classify_byte: usize,
block_idx: usize,
block_own: usize,
raw_block_source: &str,
raw_lines: &[&str],
) -> Vec<usize> {
use crate::markdown::list_layout::raw_list_marker_char_width;
use crate::ui::table_view::TableSubLineKind;
let n = raw_lines.len();
let is_table = crate::editor::table_edit::is_table_block(raw_block_source);
if is_table && block_own >= 3 {
let rendered = parsed.source_map.rendered_lines_for_block(block_idx);
let block_lines = parsed.lines.get(rendered).unwrap_or(&[]);
let kinds = crate::ui::table_view::classify_table_sub_lines(block_lines);
let last_replaceable = block_own.saturating_sub(2);
let mut header: Option<usize> = None;
let mut thick: Option<usize> = None;
let mut data: Vec<Option<usize>> = Vec::new();
for (i, kind) in kinds.iter().enumerate() {
match *kind {
TableSubLineKind::Header { sub: 0 } => header.get_or_insert(i),
TableSubLineKind::ThickSeparator => thick.get_or_insert(i),
TableSubLineKind::DataRow { row, sub: 0 } => {
if data.len() <= row {
data.resize(row + 1, None);
}
data[row].get_or_insert(i)
}
_ => continue,
};
}
return (0..=n)
.map(|r| {
let sub = match r {
0 => header.unwrap_or(1),
1 => thick.unwrap_or(2),
r => data
.get(r - 2)
.copied()
.flatten()
.unwrap_or_else(|| 2 * r - 1),
};
sub.min(last_replaceable)
})
.collect();
}
let is_mermaid = parsed.is_mermaid_block(block_idx);
let is_code_block = matches!(
parsed.real_block_for_byte(classify_byte),
Some(crate::markdown::Block::CodeBlock { .. })
);
if is_mermaid || is_code_block {
let last = block_own.saturating_sub(1);
return (0..=n).map(|r| r.min(last)).collect();
}
let base_indent = raw_lines
.first()
.map(|l| l.len() - l.trim_start().len())
.unwrap_or(0);
let is_top_level_marker = |line: &str| {
let indent = line.len() - line.trim_start().len();
indent == base_indent && raw_list_marker_char_width(line).is_some()
};
let mut renders = vec![false; n];
let mut run_ends_at_marker = false;
for i in (0..n).rev() {
if raw_lines[i].trim().is_empty() {
renders[i] = run_ends_at_marker;
} else {
renders[i] = true;
run_ends_at_marker = is_top_level_marker(raw_lines[i]);
}
}
let last = block_own.saturating_sub(1);
let mut subs = Vec::with_capacity(n + 1);
let mut rendered_before = 0usize;
for renders_row in renders {
subs.push(rendered_before.min(last));
rendered_before += usize::from(renders_row);
}
subs.push(rendered_before.min(last));
subs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::Buffer;
fn theme() -> &'static Theme {
Box::leak(Box::new(Theme::default()))
}
#[test]
fn inline_map_for_does_not_poison_cache_with_noncanonical_text() {
let state = EditorState::new(Buffer::from_str("hello **world**\nsecond line\n"), theme());
let wrong = state.inline_map_for(1, "hello **world**");
assert_eq!(wrong.raw_len(), 15);
let right = state.inline_map_for(1, "second line");
assert_eq!(right.raw_len(), 11);
let oob = state.inline_map_for(99, "anything");
assert_eq!(oob.raw_len(), 8);
}
#[test]
fn images_enabled_false_collapses_blocks_to_placeholder() {
let src = "\n\n\n";
let mut state = EditorState::new_with_config(
Buffer::from_str(src),
theme(),
true,
true,
10, );
let expanded = state.parsed.line_count();
assert!(
expanded >= 20,
"expected ≥ 20 rendered lines with images expanded, got {expanded}",
);
state.images_enabled = false;
state.refresh_parsed();
assert_eq!(state.parsed.line_count(), 4);
}
#[test]
fn move_down_visual_honours_word_wrap_boundaries() {
let text = "hello world foo bar baz quux wibble wobble";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.cursor.offset = 3;
state.cursor.preferred_col = 3;
state.move_down_visual(20);
let rows = crate::ui::line_render::visual_rows_of_str(text, 20);
assert!(rows.len() >= 2, "expected wrap into at least 2 rows");
let (row1_start, _, _) = rows[1];
assert_eq!(state.cursor.offset, row1_start + 3);
}
#[test]
fn move_up_visual_crosses_to_last_subline_of_previous_line() {
let long = "aaaaa bbbbb ccccc ddddd eeeee fffff ggggg hhhhh";
let text = format!("{}\nshort\n", long);
let mut state = EditorState::new(Buffer::from_str(&text), theme());
let line1_start = state.buffer.line_to_char(1);
state.cursor.offset = line1_start + 3;
state.cursor.preferred_col = 3;
state.move_up_visual(20);
let rows = crate::ui::line_render::visual_rows_of_str(long, 20);
let last = *rows.last().unwrap();
let expected_raw_col = last.0 + 3;
assert_eq!(
state.cursor.offset,
state.buffer.line_to_char(0) + expected_raw_col
);
}
#[test]
fn move_up_visual_clamps_within_target_subrow_not_on_wrap_boundary() {
let text = format!("Super long line of inline code ` {}`", "a".repeat(150));
let mut state = EditorState::new(Buffer::from_str(&text), theme());
let width = 40;
let rows = crate::ui::line_render::visual_rows_of_str(&text, width);
assert!(rows.len() >= 2);
let (row0_s, row0_e, _) = rows[0];
let row0_width = row0_e - row0_s;
let (row1_start, _, _) = rows[1];
let visual_col_on_row1 = row0_width + 3;
state.cursor.offset = row1_start + visual_col_on_row1;
state.cursor.preferred_col = visual_col_on_row1;
state.move_up_visual(width);
let (sub_idx, _) = crate::ui::line_render::sub_line_of_col(&rows, state.cursor.offset);
assert_eq!(
sub_idx, 0,
"cursor at offset {} should be visually on row 0, not row {}",
state.cursor.offset, sub_idx
);
let before = state.cursor.offset;
state.move_up_visual(width);
assert_ne!(
state.cursor.offset, before,
"Up from row 0 must not stall at offset {before}",
);
}
#[test]
fn move_down_visual_preserves_screen_cell_across_indent_boundary() {
let text = "- list item content that wraps to a second row\nplain paragraph here";
let mut state = EditorState::new(Buffer::from_str(text), theme());
let width = 30;
let chars: Vec<(char, ratatui::style::Style)> = text
.lines()
.next()
.unwrap()
.chars()
.map(|c| (c, ratatui::style::Style::default()))
.collect();
let rows = crate::ui::line_render::visual_rows_of_chars(&chars, width, 2);
assert!(rows.len() >= 2, "list item must wrap");
let (row1_start, row1_end, _) = rows[1];
state.cursor.offset = row1_start + 3.min(row1_end - row1_start);
state.cursor.preferred_col = state.current_visual_col(width);
assert_eq!(state.cursor.preferred_col, 5);
state.move_down_visual(width);
let line1_start = state.buffer.line_to_char(1);
assert_eq!(state.cursor.offset, line1_start + 5);
}
#[test]
fn click_on_wrapped_continuation_row_seeds_preferred_col_from_screen() {
let text = "the quick brown fox jumps over the lazy dog one more time";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = crate::editor::Mode::Rendered;
let viewport_w: usize = 20;
let rows = crate::ui::line_render::visual_rows_of_str(text, viewport_w);
assert!(rows.len() >= 3);
let (row2_start, row2_end, _) = rows[2];
let target_offset = row2_start + 4.min(row2_end - row2_start);
let click_action = crate::input::MouseAction::Click {
col: 4,
row: 2,
modifiers: crossterm::event::KeyModifiers::NONE,
};
let mut anchor: Option<crate::editor::mouse_ops::DragTarget> = None;
crate::editor::mouse_ops::apply(&mut state, click_action, &mut anchor, &[], 24, viewport_w);
assert_eq!(state.cursor.offset, target_offset);
assert_eq!(state.cursor.preferred_col, 4);
}
#[test]
fn move_down_visual_on_list_item_without_offset_bug() {
let text = "- hello world foo bar baz quux wibble";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.cursor.offset = 5;
state.cursor.preferred_col = 5;
state.move_down_visual(20);
let rows = crate::ui::line_render::visual_rows_of_chars(
&text
.chars()
.map(|c| (c, ratatui::style::Style::default()))
.collect::<Vec<_>>(),
20,
2,
);
assert!(rows.len() >= 2);
let (row1_start, _, _) = rows[1];
assert_eq!(state.cursor.offset, row1_start + 3);
}
#[test]
fn move_down_visual_in_raw_mode_wraps_flat() {
let text = "- hello world foo bar baz quux wibble";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = crate::editor::Mode::Raw;
state.cursor.offset = 5;
state.cursor.preferred_col = 5;
state.move_down_visual(20);
let rows = crate::ui::line_render::visual_rows_of_str(text, 20);
assert!(rows.len() >= 2);
let (row1_start, _, _) = rows[1];
assert_eq!(state.cursor.offset, row1_start + 5);
}
#[test]
fn current_visual_col_in_raw_mode_takes_no_hanging_indent() {
let text = "- hello world foo bar baz quux wibble";
let mut state = EditorState::new(Buffer::from_str(text), theme());
state.mode = crate::editor::Mode::Raw;
let rows = crate::ui::line_render::visual_rows_of_str(text, 20);
assert!(rows.len() >= 2);
let (row1_start, row1_end, _) = rows[1];
state.cursor.offset = row1_start + 3.min(row1_end - row1_start);
assert_eq!(state.current_visual_col(20), 3);
state.mode = crate::editor::Mode::Rendered;
assert_eq!(state.current_visual_col(20), 5);
}
#[test]
fn scroll_to_bottom_accounts_for_wrapped_lines() {
let long = "a".repeat(100); let src = format!("{long}\n\nfinal line.\n");
let mut state = EditorState::new(Buffer::from_str(&src), theme());
state.mode = crate::editor::Mode::Rendered;
state.cursor.move_doc_end(&state.buffer);
state.update_cursor_block();
let vp_h = 5;
let vp_w = 20;
state.scroll_to_bottom(vp_h, vp_w);
state.ensure_cursor_visible(vp_h, vp_w);
let total = state.parsed.lines.len();
let last = total - 1;
let used = state.visual_rows_between(state.scroll, last, vp_w);
assert!(
used <= vp_h,
"scroll {} leaves {} visual rows between scroll and last rendered line (viewport is {})",
state.scroll,
used,
vp_h
);
}
#[test]
fn cursor_screen_row_round_trips_across_mode_switch() {
let mut src = String::new();
for i in 0..20 {
src.push_str(&format!("line {i}\n"));
}
let vp_w = 40;
let mut state = EditorState::new(Buffer::from_str(&src), theme());
state.mode = crate::editor::Mode::Rendered;
state.cursor.offset = state.buffer.line_to_char(12);
state.update_cursor_block();
state.scroll = 9;
let row_before = state.cursor_screen_row(vp_w);
assert_eq!(row_before, 3);
state.mode = crate::editor::Mode::Raw;
state.scroll = 0;
state.set_scroll_for_cursor_screen_row(row_before, vp_w);
let row_after = state.cursor_screen_row(vp_w);
assert_eq!(row_after, row_before);
}
#[test]
fn set_theme_swaps_reference_and_refreshes_parsed() {
let original: &'static Theme = theme();
let mut state = EditorState::new(Buffer::from_str("# heading\n"), original);
let v_before = state.parsed_version;
state.set_theme(original);
assert_eq!(state.parsed_version, v_before);
let other: &'static Theme = Box::leak(Box::new(Theme::default()));
state.set_theme(other);
assert!(std::ptr::eq(state.theme, other));
assert_ne!(state.parsed_version, v_before);
}
#[test]
fn set_scroll_for_cursor_screen_row_clamps_at_top() {
let src = "line 0\nline 1\nline 2\n";
let mut state = EditorState::new(Buffer::from_str(src), theme());
state.mode = crate::editor::Mode::Raw;
state.cursor.offset = state.buffer.line_to_char(1);
state.scroll = 0;
state.set_scroll_for_cursor_screen_row(50, 40);
assert_eq!(state.scroll, 0);
assert_eq!(state.cursor_screen_row(40), 1);
}
#[test]
fn scroll_focused_hunk_into_view_scrolls_offscreen_hunk_up() {
let mut old = String::new();
for i in 0..20 {
old.push_str(&format!("ctx{i}\n"));
}
let mut new = old.clone();
old.push_str("before\n");
new.push_str("AFTER\n");
let diff = crate::diff::DiffState::new(&old, &new).unwrap();
let mut state = EditorState::new(Buffer::from_str(&old), theme());
state.scroll = 0;
state.enter_diff_mode(diff);
assert!(state.pending_focus_scroll);
assert_eq!(state.scroll, 0);
state.scroll_focused_hunk_into_view(5, 80);
assert_eq!(state.scroll, 17);
state.scroll_focused_hunk_into_view(5, 80);
assert_eq!(state.scroll, 17);
}
#[test]
fn scroll_focused_hunk_into_view_noop_when_already_visible() {
let diff = crate::diff::DiffState::new("a\nB\nc\n", "a\nBB\nc\n").unwrap();
let mut state = EditorState::new(Buffer::from_str("a\nB\nc\n"), theme());
state.enter_diff_mode(diff);
state.scroll_focused_hunk_into_view(20, 80);
assert_eq!(state.scroll, 0);
}
}