Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: code line number

RivoLink 55ef664c f7f659c0

+190 -26
+3 -3
src/app/mod.rs
··· 1 1 use crate::{ 2 2 markdown::{ 3 - build_plain_lines, hash_file_contents, hash_str, parse_markdown_with_width, 3 + build_searchable_lines, hash_file_contents, hash_str, parse_markdown_with_width, 4 4 read_file_state, 5 5 toc::{should_hide_single_h1, should_promote_h2_when_no_h1, toc_display_level, TocEntry}, 6 6 }, ··· 179 179 filepath, 180 180 last_file_state, 181 181 } = config; 182 - let plain_lines = build_plain_lines(&lines) 182 + let plain_lines = build_searchable_lines(&lines) 183 183 .into_iter() 184 184 .map(|line| line.to_lowercase()) 185 185 .collect(); ··· 316 316 } 317 317 318 318 pub(crate) fn replace_content(&mut self, lines: Vec<Line<'static>>, toc: Vec<TocEntry>) { 319 - self.plain_lines = build_plain_lines(&lines) 319 + self.plain_lines = build_searchable_lines(&lines) 320 320 .into_iter() 321 321 .map(|line| line.to_lowercase()) 322 322 .collect();
+53 -20
src/markdown/mod.rs
··· 4 4 mod wrapping; 5 5 6 6 use tables::{handle_table_event, start_table, TableBuf}; 7 - pub(crate) use width::{build_plain_lines, display_width, line_plain_text, truncate_display_width}; 7 + pub(crate) use width::{ 8 + build_searchable_lines, display_width, line_plain_text, truncate_display_width, 9 + }; 8 10 9 11 use crate::theme::{app_theme, MarkdownTheme}; 10 12 use pulldown_cmark::{CodeBlockKind, Event as MdEvent, HeadingLevel, Options, Parser, Tag, TagEnd}; ··· 23 25 use toc::{normalize_toc, TocEntry}; 24 26 use unicode_width::UnicodeWidthStr; 25 27 use width::expand_tabs; 26 - use wrapping::push_wrapped_prefixed_lines; 28 + use wrapping::{push_wrapped_code_lines, push_wrapped_prefixed_lines}; 27 29 28 30 #[derive(Clone, Copy)] 29 31 enum ListKind { ··· 168 170 .unwrap_or_else(|| ss.find_syntax_plain_text()) 169 171 } 170 172 173 + struct CodeLine { 174 + content_spans: Vec<Span<'static>>, 175 + } 176 + 171 177 fn highlight_code( 172 178 code: &str, 173 179 lang: &str, 174 180 ss: &SyntaxSet, 175 181 theme: &Theme, 176 182 render_width: usize, 177 - ) -> (Vec<Line<'static>>, usize) { 178 - let theme_colors = &app_theme().markdown; 183 + ) -> (Vec<CodeLine>, usize, usize) { 179 184 let syntax = resolve_syntax(lang, ss); 180 185 let mut hl = HighlightLines::new(syntax, theme); 181 - let gutter = Style::default().fg(theme_colors.code_gutter); 182 186 183 187 let mut raw: Vec<(Vec<Span<'static>>, usize)> = Vec::new(); 184 188 for line_str in LinesWithEndings::from(code) { 185 189 let regions = hl.highlight_line(line_str, ss).unwrap_or_default(); 186 - let mut spans = vec![Span::styled("│ ", gutter)]; 190 + let mut spans = Vec::new(); 187 191 let mut text_width: usize = 0; 188 192 for (st, text) in &regions { 189 193 let t = expand_tabs(text.trim_end_matches('\n'), text_width); ··· 216 220 } 217 221 218 222 let label = if lang.is_empty() { "text" } else { lang }; 223 + let total_lines = raw.len(); 224 + let digit_width = total_lines.max(1).to_string().len(); 225 + let gutter_width = digit_width + 2; 219 226 let max_text = raw.iter().map(|(_, w)| *w).max().unwrap_or(0); 220 227 let max_inner_width = render_width 221 228 .saturating_sub(4) ··· 223 230 let min_inner = (UnicodeWidthStr::width(label) + 3) 224 231 .max(44) 225 232 .min(max_inner_width); 226 - let inner_width = (max_text + 2).max(min_inner); 233 + let inner_width = (max_text + 2 + gutter_width) 234 + .max(min_inner) 235 + .min(max_inner_width); 227 236 228 237 let mut out = Vec::new(); 229 - for (mut spans, text_width) in raw { 230 - let pad = inner_width.saturating_sub(text_width + 1); 231 - spans.push(Span::raw(" ".repeat(pad))); 232 - spans.push(Span::styled("│", gutter)); 233 - out.push(Line::from(spans)); 238 + for (spans, _text_width) in raw { 239 + out.push(CodeLine { 240 + content_spans: spans, 241 + }); 234 242 } 235 - (out, inner_width) 243 + (out, inner_width, digit_width) 236 244 } 237 245 238 246 fn block_prefix(in_bq: bool) -> Vec<Span<'static>> { ··· 416 424 code_lang.clone() 417 425 }; 418 426 let available_width = ctx.render_width.saturating_sub(prefix_width); 419 - let (code_lines, inner_width) = 427 + let (code_lines, inner_width, digit_width) = 420 428 highlight_code(code_buf, code_lang, ctx.ss, ctx.theme, available_width); 429 + let gutter_width = digit_width + 2; 430 + let gutter_style = Style::default().fg(ctx.theme_colors.code_gutter); 431 + let content_width = inner_width.saturating_sub(gutter_width + 1); 432 + 421 433 let header_width = UnicodeWidthStr::width(label.as_str()) + 3; 422 434 let top_bar = "─".repeat(inner_width.saturating_sub(header_width)); 423 435 let mut header = prefix.clone(); ··· 436 448 ), 437 449 ]); 438 450 lines.push(Line::from(header)); 439 - lines.extend(code_lines.into_iter().map(|line| { 440 - let mut spans = prefix.clone(); 441 - spans.extend(line.spans); 442 - Line::from(spans) 443 - })); 451 + 452 + for (i, code_line) in code_lines.into_iter().enumerate() { 453 + let line_num = i + 1; 454 + let num_gutter = Span::styled(format!("│{:>w$}│", line_num, w = digit_width), gutter_style); 455 + let blank_gutter = Span::styled(format!("│{:>w$}│", "", w = digit_width), gutter_style); 456 + 457 + let mut first_prefix = prefix.clone(); 458 + first_prefix.push(num_gutter); 459 + 460 + let mut cont_prefix = prefix.clone(); 461 + cont_prefix.push(blank_gutter); 462 + 463 + push_wrapped_code_lines( 464 + lines, 465 + code_line.content_spans, 466 + first_prefix, 467 + cont_prefix, 468 + gutter_style, 469 + content_width, 470 + ); 471 + } 472 + 444 473 let mut footer = prefix; 445 474 footer.push(Span::styled( 446 - format!("└{}┘", "─".repeat(inner_width)), 475 + format!( 476 + "└{}┴{}┘", 477 + "─".repeat(gutter_width - 2), 478 + "─".repeat(inner_width.saturating_sub(gutter_width - 1)) 479 + ), 447 480 Style::default().fg(ctx.theme_colors.code_frame), 448 481 )); 449 482 lines.push(Line::from(footer));
+52 -2
src/markdown/width.rs
··· 7 7 line.spans.iter().map(|s| s.content.as_ref()).collect() 8 8 } 9 9 10 - pub(crate) fn build_plain_lines(lines: &[Line<'_>]) -> Vec<String> { 11 - lines.iter().map(line_plain_text).collect() 10 + fn is_code_gutter_span(content: &str) -> bool { 11 + let inner = content.strip_prefix('│').and_then(|s| s.strip_suffix('│')); 12 + match inner { 13 + Some(s) if !s.is_empty() => { 14 + let mut has_digit = false; 15 + for b in s.bytes() { 16 + if b.is_ascii_digit() { 17 + has_digit = true; 18 + } else if b != b' ' { 19 + return false; 20 + } 21 + } 22 + has_digit 23 + } 24 + _ => false, 25 + } 26 + } 27 + 28 + pub(crate) fn line_searchable_text(line: &Line<'_>) -> String { 29 + let spans = &line.spans; 30 + let has_pipe = spans.iter().take(4).any(|s| s.content.contains('│')); 31 + if !has_pipe { 32 + return line_plain_text(line); 33 + } 34 + let mut gutter_end = None; 35 + for (i, span) in spans.iter().enumerate() { 36 + if is_code_gutter_span(span.content.as_ref()) { 37 + gutter_end = Some(i); 38 + break; 39 + } 40 + } 41 + let Some(ge) = gutter_end else { 42 + return line_plain_text(line); 43 + }; 44 + let last = spans.len().saturating_sub(1); 45 + let start = ge + 1; 46 + if start > last { 47 + return String::new(); 48 + } 49 + let end = if last > start && spans[last].content.as_ref() == "│" { 50 + last 51 + } else { 52 + spans.len() 53 + }; 54 + spans[start..end] 55 + .iter() 56 + .map(|s| s.content.as_ref()) 57 + .collect() 58 + } 59 + 60 + pub(crate) fn build_searchable_lines(lines: &[Line<'_>]) -> Vec<String> { 61 + lines.iter().map(line_searchable_text).collect() 12 62 } 13 63 14 64 pub(crate) fn truncate_display_width(text: &str, max_width: usize) -> String {
+82 -1
src/markdown/wrapping.rs
··· 1 1 use super::width::display_width; 2 - use ratatui::text::{Line, Span}; 2 + use ratatui::{ 3 + style::Style, 4 + text::{Line, Span}, 5 + }; 3 6 use unicode_width::UnicodeWidthChar; 4 7 5 8 pub(super) fn push_wrapped_prefixed_lines( ··· 158 161 lines.push(Line::from(current_prefix)); 159 162 } 160 163 } 164 + 165 + pub(super) fn push_wrapped_code_lines( 166 + lines: &mut Vec<Line<'static>>, 167 + content_spans: Vec<Span<'static>>, 168 + first_prefix: Vec<Span<'static>>, 169 + continuation_prefix: Vec<Span<'static>>, 170 + suffix_style: Style, 171 + available_content_width: usize, 172 + ) { 173 + let mut chars: Vec<(char, Style)> = Vec::new(); 174 + for span in &content_spans { 175 + let style = span.style; 176 + for ch in span.content.chars() { 177 + chars.push((ch, style)); 178 + } 179 + } 180 + 181 + if chars.is_empty() { 182 + let pad = " ".repeat(available_content_width + 1); 183 + let mut row = first_prefix; 184 + row.push(Span::raw(format!(" {pad}"))); 185 + row.push(Span::styled("│", suffix_style)); 186 + lines.push(Line::from(row)); 187 + return; 188 + } 189 + 190 + let max_w = available_content_width.max(1); 191 + let mut pos = 0; 192 + let mut first_prefix = Some(first_prefix); 193 + 194 + while pos < chars.len() { 195 + let prefix = first_prefix 196 + .take() 197 + .unwrap_or_else(|| continuation_prefix.clone()); 198 + 199 + let mut row_chars: Vec<(char, Style)> = Vec::new(); 200 + let mut row_width = 0; 201 + 202 + while pos < chars.len() { 203 + let (ch, st) = chars[pos]; 204 + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); 205 + if row_width + ch_w > max_w && row_width > 0 { 206 + break; 207 + } 208 + row_chars.push((ch, st)); 209 + row_width += ch_w; 210 + pos += 1; 211 + } 212 + 213 + let mut row = prefix; 214 + row.push(Span::raw(" ")); 215 + 216 + let mut current_style: Option<Style> = None; 217 + let mut current_text = String::new(); 218 + for (ch, st) in &row_chars { 219 + if current_style == Some(*st) { 220 + current_text.push(*ch); 221 + } else { 222 + if !current_text.is_empty() { 223 + row.push(Span::styled( 224 + std::mem::take(&mut current_text), 225 + current_style.unwrap(), 226 + )); 227 + } 228 + current_style = Some(*st); 229 + current_text.push(*ch); 230 + } 231 + } 232 + if !current_text.is_empty() { 233 + row.push(Span::styled(current_text, current_style.unwrap())); 234 + } 235 + 236 + let pad = max_w.saturating_sub(row_width); 237 + row.push(Span::raw(" ".repeat(pad + 1))); 238 + row.push(Span::styled("│", suffix_style)); 239 + lines.push(Line::from(row)); 240 + } 241 + }