use crate::markdown::{DocBlock, TableBlockId};
use crate::theme::Palette;
use ratatui::text::Text;
use std::collections::HashMap;
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;
#[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 links: Vec<AbsoluteLink>,
pub heading_anchors: Vec<AbsoluteAnchor>,
pub visual_mode: Option<VisualRange>,
}
impl MarkdownViewState {
pub fn recompute_positions(&mut self, content_width: u16) {
use super::visual_rows::line_visual_rows;
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 {
text,
links,
heading_anchors,
..
} = block
{
let mut visual_offset_of_logical: Vec<u32> =
Vec::with_capacity(text.lines.len() + 1);
let mut acc = 0u32;
visual_offset_of_logical.push(acc);
for line in &text.lines {
acc = acc.saturating_add(line_visual_rows(line, content_width));
visual_offset_of_logical.push(acc);
}
for link in links {
let visual_row = visual_offset_of_logical
.get(link.line as usize)
.copied()
.unwrap_or(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_offset_of_logical
.get(ha.line as usize)
.copied()
.unwrap_or(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(0);
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();
}
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);
}
}
pub fn current_line_width(&self) -> u16 {
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;
let width = match block {
DocBlock::Text { text, .. } => {
let logical_idx = super::visual_rows::visual_row_to_logical_in_block(
text,
self.layout_width,
local_visual,
) as usize;
text.lines.get(logical_idx).map_or(0, |l| {
l.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum::<usize>()
})
}
DocBlock::Mermaid { .. } | DocBlock::Table(_) => 0,
};
return crate::cast::u16_sat(width);
}
offset += h;
}
0
}
fn current_line_text(&self) -> Option<String> {
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;
return match block {
DocBlock::Text { text, .. } => {
let logical_idx = super::visual_rows::visual_row_to_logical_in_block(
text,
self.layout_width,
local_visual,
) as usize;
text.lines.get(logical_idx).map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
}
DocBlock::Mermaid { .. } | DocBlock::Table(_) => None,
};
}
offset += h;
}
None
}
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>,
}
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);
}
}