use crate::markdown::{DocBlock, TableBlockId, TextBlockId};
use crate::theme::Palette;
use ratatui::text::Text;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisualMode {
Char,
Line,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisualRange {
pub mode: VisualMode,
pub anchor_line: u32,
pub anchor_col: u16,
pub cursor_line: u32,
pub cursor_col: u16,
}
impl VisualRange {
pub fn top_line(&self) -> u32 {
self.anchor_line.min(self.cursor_line)
}
pub fn bottom_line(&self) -> u32 {
self.anchor_line.max(self.cursor_line)
}
pub fn contains(&self, line: u32) -> bool {
line >= self.top_line() && line <= self.bottom_line()
}
pub fn char_range_on_line(&self, line: u32, line_width: u16) -> Option<(u16, u16)> {
if line < self.top_line() || line > self.bottom_line() {
return None;
}
match self.mode {
VisualMode::Line => Some((0, line_width)),
VisualMode::Char => {
let (start_line, start_col, end_line, end_col) = self.ordered();
if self.top_line() == self.bottom_line() {
Some((start_col, end_col + 1))
} else if line == start_line {
Some((start_col, line_width))
} else if line == end_line {
Some((0, end_col + 1))
} else {
Some((0, line_width)) }
}
}
}
fn ordered(&self) -> (u32, u16, u32, u16) {
let anchor_before = self.anchor_line < self.cursor_line
|| (self.anchor_line == self.cursor_line && self.anchor_col <= self.cursor_col);
if anchor_before {
(
self.anchor_line,
self.anchor_col,
self.cursor_line,
self.cursor_col,
)
} else {
(
self.cursor_line,
self.cursor_col,
self.anchor_line,
self.anchor_col,
)
}
}
}
#[derive(Debug, Clone)]
pub struct AbsoluteLink {
pub line: u32,
pub col_start: u16,
pub col_end: u16,
pub url: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct AbsoluteAnchor {
pub anchor: String,
pub line: u32,
}
#[derive(Debug, Default)]
pub struct MarkdownViewState {
pub content: String,
pub rendered: Vec<DocBlock>,
pub scroll_offset: u32,
pub cursor_line: u32,
pub cursor_col: u16,
pub file_name: String,
pub current_path: Option<PathBuf>,
pub total_lines: u32,
pub layout_width: u16,
pub table_layouts: HashMap<TableBlockId, TableLayout>,
pub text_layouts: HashMap<TextBlockId, WrappedTextLayout>,
pub links: Vec<AbsoluteLink>,
pub heading_anchors: Vec<AbsoluteAnchor>,
pub visual_mode: Option<VisualRange>,
}
impl MarkdownViewState {
pub fn recompute_positions(&mut self) {
let mut abs_links: Vec<AbsoluteLink> = Vec::new();
let mut abs_anchors: Vec<AbsoluteAnchor> = Vec::new();
let mut block_offset = 0u32;
for block in &self.rendered {
if let DocBlock::Text {
id,
links,
heading_anchors,
..
} = block
{
let visual_row_of_logical = |logical: u32| -> u32 {
if let Some(layout) = self.text_layouts.get(id) {
layout
.physical_to_logical
.iter()
.position(|&li| li == logical)
.map_or(logical, crate::cast::u32_sat)
} else {
logical
}
};
for link in links {
let visual_row = visual_row_of_logical(link.line);
abs_links.push(AbsoluteLink {
line: block_offset + visual_row,
col_start: link.col_start,
col_end: link.col_end,
url: link.url.clone(),
text: link.text.clone(),
});
}
for ha in heading_anchors {
let visual_row = visual_row_of_logical(ha.line);
abs_anchors.push(AbsoluteAnchor {
anchor: ha.anchor.clone(),
line: block_offset + visual_row,
});
}
}
block_offset += block.height();
}
self.links = abs_links;
self.heading_anchors = abs_anchors;
}
pub fn load(
&mut self,
path: PathBuf,
file_name: String,
content: String,
palette: &Palette,
theme: crate::theme::Theme,
) {
let blocks = crate::markdown::renderer::render_markdown(&content, palette, theme);
self.total_lines = blocks.iter().map(crate::markdown::DocBlock::height).sum();
self.rendered = blocks;
self.recompute_positions();
self.content = content;
self.file_name = file_name;
self.current_path = Some(path);
self.scroll_offset = 0;
self.cursor_line = 0;
self.cursor_col = 0;
self.visual_mode = None;
self.layout_width = 0;
self.table_layouts.clear();
self.text_layouts.clear();
}
pub fn cursor_down(&mut self, n: u32) {
let max = self.total_lines.saturating_sub(1);
self.cursor_line = self.cursor_line.saturating_add(n).min(max);
self.clamp_cursor_col();
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_line = self.cursor_line;
range.cursor_col = self.cursor_col;
}
}
pub fn cursor_up(&mut self, n: u32) {
self.cursor_line = self.cursor_line.saturating_sub(n);
self.clamp_cursor_col();
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_line = self.cursor_line;
range.cursor_col = self.cursor_col;
}
}
pub fn cursor_to_top(&mut self) {
self.cursor_line = 0;
self.scroll_offset = 0;
self.clamp_cursor_col();
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_line = 0;
range.cursor_col = self.cursor_col;
}
}
pub fn cursor_to_bottom(&mut self, view_height: u32) {
self.cursor_line = self.total_lines.saturating_sub(1);
self.scroll_to_cursor(view_height);
self.clamp_cursor_col();
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_line = self.cursor_line;
range.cursor_col = self.cursor_col;
}
}
pub fn clamp_cursor_col(&mut self) {
let width = self.current_line_width();
if width == 0 {
self.cursor_col = 0;
} else {
self.cursor_col = self.cursor_col.min(width - 1);
}
}
fn cursor_block_and_local_visual(&self) -> Option<(TextBlockId, usize)> {
let mut offset = 0u32;
for block in &self.rendered {
let h = block.height();
if self.cursor_line < offset + h {
let local_visual = (self.cursor_line - offset) as usize;
return match block {
DocBlock::Text { id, .. } => Some((*id, local_visual)),
DocBlock::Mermaid { .. } | DocBlock::Table(_) => None,
};
}
offset += h;
}
None
}
pub fn current_line_width(&self) -> u16 {
let Some((id, local_visual)) = self.cursor_block_and_local_visual() else {
return 0;
};
self.text_layouts
.get(&id)
.and_then(|layout| layout.wrapped.get(local_visual))
.map_or(0, |w| w.width)
}
fn current_line_text(&self) -> Option<String> {
let (id, local_visual) = self.cursor_block_and_local_visual()?;
let layout = self.text_layouts.get(&id)?;
let row = layout.wrapped.get(local_visual)?;
Some(row.spans.iter().map(|s| s.content.as_str()).collect())
}
pub fn cursor_word_forward(&mut self) {
let Some(text) = self.current_line_text() else {
return;
};
self.cursor_col = next_word_col(&text, self.cursor_col);
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_col = self.cursor_col;
}
}
pub fn cursor_word_backward(&mut self) {
let Some(text) = self.current_line_text() else {
return;
};
self.cursor_col = prev_word_col(&text, self.cursor_col);
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_col = self.cursor_col;
}
}
pub fn cursor_line_start(&mut self) {
self.cursor_col = 0;
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_col = 0;
}
}
pub fn cursor_line_end(&mut self) {
let max = self.current_line_width().saturating_sub(1);
self.cursor_col = max;
if let Some(range) = self.visual_mode.as_mut() {
range.cursor_col = max;
}
}
pub fn scroll_to_cursor_centered(&mut self, view_height: u32) {
let vh = view_height.max(1);
let half = vh / 2;
self.scroll_offset = self.cursor_line.saturating_sub(half);
let max = self.total_lines.saturating_sub(vh / 2);
self.scroll_offset = self.scroll_offset.min(max);
}
pub fn scroll_to_cursor(&mut self, view_height: u32) {
let vh = view_height.max(1);
if self.cursor_line < self.scroll_offset {
self.scroll_offset = self.cursor_line;
} else if self.cursor_line >= self.scroll_offset + vh {
self.scroll_offset = self.cursor_line.saturating_sub(vh - 1);
}
let max = self.total_lines.saturating_sub(vh / 2);
self.scroll_offset = self.scroll_offset.min(max);
}
}
#[derive(Debug)]
pub struct TableLayout {
pub text: Text<'static>,
pub physical_to_source: Vec<u32>,
}
#[derive(Debug)]
pub struct WrappedTextLayout {
pub wrapped: Vec<crate::text_layout::WrappedLine>,
pub physical_to_logical: Vec<u32>,
}
fn next_word_col(text: &str, current_col: u16) -> u16 {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut col = current_col as usize;
if col >= len {
return crate::cast::u16_sat(len.saturating_sub(1));
}
while col < len && !chars[col].is_whitespace() {
col += 1;
}
while col < len && chars[col].is_whitespace() {
col += 1;
}
if col >= len {
crate::cast::u16_sat(len.saturating_sub(1))
} else {
crate::cast::u16_sat(col)
}
}
fn prev_word_col(text: &str, current_col: u16) -> u16 {
let chars: Vec<char> = text.chars().collect();
if current_col == 0 || chars.is_empty() {
return 0;
}
let mut col = (current_col as usize).min(chars.len()).saturating_sub(1);
while col > 0 && chars[col].is_whitespace() {
col -= 1;
}
if col > 0 && !chars[col - 1].is_whitespace() {
while col > 0 && !chars[col - 1].is_whitespace() {
col -= 1;
}
} else {
while col > 0 && chars[col - 1].is_whitespace() {
col -= 1;
}
while col > 0 && !chars[col - 1].is_whitespace() {
col -= 1;
}
}
crate::cast::u16_sat(col)
}
#[cfg(test)]
mod word_jump_tests {
use super::{next_word_col, prev_word_col};
#[test]
fn next_word_from_inside_word() {
assert_eq!(next_word_col("hello world foo", 4), 6);
}
#[test]
fn next_word_from_whitespace() {
assert_eq!(next_word_col("abc def", 3), 6);
}
#[test]
fn next_word_from_last_word() {
assert_eq!(next_word_col("abc def", 5), 6);
}
#[test]
fn prev_word_from_mid_word() {
assert_eq!(prev_word_col("hello world foo", 8), 6);
}
#[test]
fn prev_word_from_word_start() {
assert_eq!(prev_word_col("hello world foo", 6), 0);
}
#[test]
fn prev_word_at_start_stays() {
assert_eq!(prev_word_col("hello world", 0), 0);
}
#[test]
fn prev_word_from_whitespace() {
assert_eq!(prev_word_col("abc def", 4), 0);
}
}