use std::collections::HashSet;
use ratatui::Frame;
use ratatui::layout::{Margin, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::theme;
#[derive(Default)]
pub struct CodeView {
lines: Vec<Line<'static>>,
top: usize,
left: usize,
viewport: (u16, u16),
max_width: usize,
matches: HashSet<usize>,
current_match: Option<usize>,
}
impl CodeView {
pub fn new(lines: Vec<Line<'static>>) -> Self {
let max_width = lines.iter().map(Line::width).max().unwrap_or(0);
Self {
lines,
max_width,
..Default::default()
}
}
pub fn scroll_by(&mut self, delta: i32) {
self.top = clamp_add(self.top, delta, self.max_top());
}
pub fn page(&mut self, dir: i32) {
let step = self.page_rows() as i32 * dir;
self.top = clamp_add(self.top, step, self.max_top());
}
pub fn scroll_h(&mut self, delta: i32) {
self.left = clamp_add(self.left, delta, self.max_left());
}
pub fn goto_top(&mut self) {
self.top = 0;
}
pub fn goto_bottom(&mut self) {
self.top = self.max_top();
}
pub fn visible_text(&self) -> String {
self.lines
.iter()
.skip(self.top)
.take(self.page_rows())
.map(line_to_plain)
.collect::<Vec<_>>()
.join("\n")
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn top(&self) -> usize {
self.top
}
pub fn viewport_rows(&self) -> usize {
self.page_rows()
}
pub fn goto_line(&mut self, one_based: usize) {
let target = one_based
.saturating_sub(1)
.min(self.lines.len().saturating_sub(1));
let half = self.page_rows() / 2;
self.top = target.saturating_sub(half).min(self.max_top());
}
pub fn matching_lines(&self, needle: &str) -> Vec<usize> {
self.lines
.iter()
.enumerate()
.filter(|(_, line)| line_to_plain(line).to_lowercase().contains(needle))
.map(|(i, _)| i)
.collect()
}
pub fn set_matches(&mut self, matches: &[usize], current: Option<usize>) {
self.matches = matches.iter().copied().collect();
self.current_match = current;
}
pub fn set_current_match(&mut self, line: Option<usize>) {
self.current_match = line;
}
pub fn clear_matches(&mut self) {
self.matches.clear();
self.current_match = None;
}
fn page_rows(&self) -> usize {
(self.viewport.1 as usize).max(1)
}
fn max_top(&self) -> usize {
self.lines.len().saturating_sub(self.page_rows())
}
fn gutter_width(&self) -> usize {
let digits = self.lines.len().max(1).to_string().len();
digits.max(2) + 1
}
fn text_width(&self) -> usize {
(self.viewport.0 as usize).saturating_sub(self.gutter_width())
}
fn max_left(&self) -> usize {
self.max_width.saturating_sub(self.text_width())
}
fn clamp(&mut self) {
self.top = self.top.min(self.max_top());
self.left = self.left.min(self.max_left());
}
}
pub fn render(frame: &mut Frame, view: &mut CodeView, area: Rect, title: &str, focused: bool) {
let border = if focused { theme::ACCENT } else { theme::MUTED };
let block = Block::bordered()
.title(title)
.border_style(Style::default().fg(border));
let inner = block.inner(area);
frame.render_widget(block, area);
view.viewport = (inner.width, inner.height);
view.clamp();
let gutter_width = view.gutter_width();
let rows = inner.height as usize;
let mut out: Vec<Line> = Vec::with_capacity(rows);
for (i, line) in view.lines.iter().enumerate().skip(view.top).take(rows) {
let number = Span::styled(
format!("{:>width$} ", i + 1, width = gutter_width - 1),
Style::default().fg(theme::MUTED),
);
let mut spans = vec![number];
spans.extend(shift_line(line, view.left));
let mut out_line = Line::from(spans);
if Some(i) == view.current_match {
out_line.style = Style::default()
.bg(theme::SELECTION_BG)
.add_modifier(Modifier::BOLD);
} else if view.matches.contains(&i) {
out_line.style = Style::default().bg(theme::SELECTION_BG);
}
out.push(out_line);
}
frame.render_widget(Paragraph::new(out), inner);
if view.lines.len() > rows {
let mut state = ScrollbarState::new(view.max_top().max(1)).position(view.top);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None),
area.inner(Margin::new(0, 1)),
&mut state,
);
}
}
fn clamp_add(base: usize, delta: i32, max: usize) -> usize {
(base as i64 + delta as i64).clamp(0, max as i64) as usize
}
fn line_to_plain(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn shift_line(line: &Line<'static>, left: usize) -> Vec<Span<'static>> {
if left == 0 {
return line.spans.clone();
}
let mut remaining = left;
let mut out: Vec<Span<'static>> = Vec::new();
for span in &line.spans {
if out.is_empty() {
let width = span.content.width();
if remaining >= width {
remaining -= width;
continue; }
if remaining > 0 {
let content = drop_cells(span.content.as_ref(), remaining);
out.push(Span::styled(content, span.style));
remaining = 0;
continue;
}
}
out.push(span.clone());
}
out
}
fn drop_cells(s: &str, n: usize) -> String {
let mut acc = 0;
for (i, ch) in s.char_indices() {
if acc >= n {
return s[i..].to_string();
}
acc += UnicodeWidthChar::width(ch).unwrap_or(0);
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn view(lines: usize) -> CodeView {
let lines = (0..lines)
.map(|i| Line::from(format!("line {i}")))
.collect();
let mut v = CodeView::new(lines);
v.viewport = (20, 10); v
}
#[test]
fn scroll_by_clamps_at_top() {
let mut v = view(100);
v.scroll_by(-5);
assert_eq!(v.top, 0);
}
#[test]
fn scroll_by_clamps_at_bottom() {
let mut v = view(100);
v.goto_bottom();
assert_eq!(v.top, 90); v.scroll_by(50);
assert_eq!(v.top, 90);
}
#[test]
fn page_steps_by_viewport_height() {
let mut v = view(100);
v.page(1);
assert_eq!(v.top, 10);
v.page(-1);
assert_eq!(v.top, 0);
}
#[test]
fn scroll_h_clamps_to_widest_line() {
let mut v = CodeView::new(vec![Line::from("x".repeat(100)), Line::from("y")]);
v.viewport = (20, 10); v.scroll_h(1000);
assert_eq!(v.left, 100 - 17);
v.scroll_h(-1000);
assert_eq!(v.left, 0);
}
#[test]
fn visible_text_returns_the_window() {
let mut v = view(100);
v.viewport = (20, 3);
v.scroll_by(5);
assert_eq!(v.visible_text(), "line 5\nline 6\nline 7");
}
#[test]
fn shift_line_drops_leading_cells() {
let line = Line::from("hello world");
let shifted = shift_line(&line, 6);
let text: String = shifted.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "world");
}
#[test]
fn shift_line_drops_across_spans() {
let line = Line::from(vec![Span::raw("abc"), Span::raw("def")]);
let text: String = shift_line(&line, 4)
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(text, "ef");
}
}