use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewerRender {
Raw,
Rendered,
}
#[derive(Debug, Clone)]
pub struct ViewerContent {
pub path: PathBuf,
pub lines: Vec<String>,
}
#[derive(Debug)]
pub struct ViewerPaneState {
active: bool,
render: ViewerRender,
scroll_offset: usize,
cursor: usize,
scroll_x: usize,
wrap_lines: bool,
viewport_height: usize,
viewport_width: usize,
total_lines: usize,
content: Option<ViewerContent>,
}
impl Default for ViewerPaneState {
fn default() -> Self {
Self {
active: false,
render: ViewerRender::Raw,
scroll_offset: 0,
cursor: 0,
scroll_x: 0,
wrap_lines: true,
viewport_height: 0,
viewport_width: 0,
total_lines: 0,
content: None,
}
}
}
impl ViewerPaneState {
const RENDERABLE_EXTENSIONS: &'static [&'static str] = &["md", "markdown"];
pub fn is_active(&self) -> bool {
self.active
}
pub fn activate(&mut self) {
self.active = true;
self.scroll_offset = 0;
self.cursor = 0;
self.scroll_x = 0;
}
pub fn deactivate(&mut self) {
self.active = false;
}
pub fn toggle_active(&mut self) -> bool {
if self.active {
self.deactivate();
} else {
self.activate();
}
self.active
}
pub fn render_mode(&self) -> ViewerRender {
self.render
}
pub fn is_renderable(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.is_some_and(|e| Self::RENDERABLE_EXTENSIONS.contains(&e.as_str()))
}
pub fn set_raw(&mut self) {
self.render = ViewerRender::Raw;
}
pub fn set_rendered(&mut self, path: &Path) -> bool {
if Self::is_renderable(path) {
self.render = ViewerRender::Rendered;
true
} else {
false
}
}
pub fn toggle_render(&mut self, path: &Path) -> Option<ViewerRender> {
match self.render {
ViewerRender::Rendered => {
self.set_raw();
Some(ViewerRender::Raw)
}
ViewerRender::Raw => {
if self.set_rendered(path) {
Some(ViewerRender::Rendered)
} else {
None
}
}
}
}
pub fn coerce_render_for(&mut self, path: &Path) {
if self.render == ViewerRender::Rendered && !Self::is_renderable(path) {
self.render = ViewerRender::Raw;
}
}
pub fn has_content_for(&self, path: &Path) -> bool {
self.content.as_ref().is_some_and(|c| c.path == path)
}
pub fn content(&self) -> Option<&ViewerContent> {
self.content.as_ref()
}
pub fn set_content(&mut self, path: PathBuf, lines: Vec<String>) {
self.total_lines = lines.len();
self.content = Some(ViewerContent { path, lines });
self.scroll_offset = 0;
self.cursor = 0;
self.scroll_x = 0;
}
pub fn invalidate_content(&mut self) {
self.content = None;
self.total_lines = 0;
}
pub fn cursor_line_no(&self) -> u32 {
(self.cursor as u32).saturating_add(1)
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn scroll_x(&self) -> usize {
self.scroll_x
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn wrap_lines(&self) -> bool {
self.wrap_lines
}
pub fn toggle_wrap(&mut self) {
self.wrap_lines = !self.wrap_lines;
if self.wrap_lines {
self.scroll_x = 0;
}
}
pub fn set_viewport(&mut self, height: usize, width: usize) {
self.viewport_height = height;
self.viewport_width = width;
}
fn last_line(&self) -> usize {
self.total_lines.saturating_sub(1)
}
pub fn cursor_down(&mut self, n: usize) {
self.cursor = (self.cursor + n).min(self.last_line());
self.ensure_cursor_visible();
}
pub fn cursor_up(&mut self, n: usize) {
self.cursor = self.cursor.saturating_sub(n);
self.ensure_cursor_visible();
}
pub fn cursor_to_top(&mut self) {
self.cursor = 0;
self.scroll_offset = 0;
}
pub fn cursor_to_bottom(&mut self) {
self.cursor = self.last_line();
self.ensure_cursor_visible();
}
pub fn scroll_right(&mut self, n: usize) {
if !self.wrap_lines {
self.scroll_x = self.scroll_x.saturating_add(n);
}
}
pub fn scroll_left(&mut self, n: usize) {
if !self.wrap_lines {
self.scroll_x = self.scroll_x.saturating_sub(n);
}
}
pub fn ensure_cursor_visible(&mut self) {
if self.viewport_height == 0 {
return;
}
if self.cursor < self.scroll_offset {
self.scroll_offset = self.cursor;
} else if self.cursor >= self.scroll_offset + self.viewport_height {
self.scroll_offset = self.cursor.saturating_sub(self.viewport_height - 1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn lines(n: usize) -> Vec<String> {
(0..n).map(|i| format!("line {i}")).collect()
}
#[test]
fn default_is_inactive_raw() {
let v = ViewerPaneState::default();
assert!(!v.is_active());
assert_eq!(v.render_mode(), ViewerRender::Raw);
}
#[test]
fn toggle_active_round_trips_and_resets_scroll() {
let mut v = ViewerPaneState::default();
v.set_content(PathBuf::from("a.rs"), lines(100));
v.set_viewport(10, 80);
v.cursor_down(50);
assert!(v.scroll_offset() > 0);
assert!(v.toggle_active()); assert_eq!(v.scroll_offset(), 0, "activate resets scroll to top");
assert_eq!(v.cursor(), 0);
assert!(!v.toggle_active()); assert!(!v.is_active());
}
#[test]
fn rendered_only_for_markdown() {
assert!(ViewerPaneState::is_renderable(Path::new("README.md")));
assert!(ViewerPaneState::is_renderable(Path::new("docs/x.markdown")));
assert!(ViewerPaneState::is_renderable(Path::new("X.MD"))); assert!(!ViewerPaneState::is_renderable(Path::new("main.rs")));
assert!(!ViewerPaneState::is_renderable(Path::new("Makefile")));
}
#[test]
fn set_rendered_rejects_non_markdown() {
let mut v = ViewerPaneState::default();
assert!(!v.set_rendered(Path::new("main.rs")));
assert_eq!(
v.render_mode(),
ViewerRender::Raw,
"mode unchanged on reject"
);
assert!(v.set_rendered(Path::new("notes.md")));
assert_eq!(v.render_mode(), ViewerRender::Rendered);
}
#[test]
fn toggle_render_signals_unsupported() {
let mut v = ViewerPaneState::default();
assert_eq!(v.toggle_render(Path::new("main.rs")), None);
assert_eq!(v.render_mode(), ViewerRender::Raw);
assert_eq!(
v.toggle_render(Path::new("a.md")),
Some(ViewerRender::Rendered)
);
assert_eq!(v.toggle_render(Path::new("a.md")), Some(ViewerRender::Raw));
}
#[test]
fn coerce_render_drops_rendered_for_code() {
let mut v = ViewerPaneState::default();
assert!(v.set_rendered(Path::new("a.md")));
v.coerce_render_for(Path::new("main.rs"));
assert_eq!(v.render_mode(), ViewerRender::Raw);
assert!(v.set_rendered(Path::new("b.md")));
v.coerce_render_for(Path::new("c.md"));
assert_eq!(v.render_mode(), ViewerRender::Rendered);
}
#[test]
fn content_cache_keyed_by_path() {
let mut v = ViewerPaneState::default();
assert!(!v.has_content_for(Path::new("a.rs")));
v.set_content(PathBuf::from("a.rs"), lines(3));
assert!(v.has_content_for(Path::new("a.rs")));
assert!(!v.has_content_for(Path::new("b.rs")));
v.invalidate_content();
assert!(!v.has_content_for(Path::new("a.rs")));
}
#[test]
fn cursor_line_no_is_one_based() {
let mut v = ViewerPaneState::default();
v.set_content(PathBuf::from("a.rs"), lines(10));
assert_eq!(v.cursor_line_no(), 1, "cursor at index 0 → line 1");
v.set_viewport(10, 80);
v.cursor_down(4);
assert_eq!(v.cursor_line_no(), 5);
}
#[test]
fn cursor_clamps_at_bounds() {
let mut v = ViewerPaneState::default();
v.set_content(PathBuf::from("a.rs"), lines(5));
v.set_viewport(10, 80);
v.cursor_up(3); assert_eq!(v.cursor(), 0);
v.cursor_down(100); assert_eq!(v.cursor(), 4, "clamps to last line index");
v.cursor_to_top();
assert_eq!(v.cursor(), 0);
v.cursor_to_bottom();
assert_eq!(v.cursor(), 4);
}
#[test]
fn ensure_cursor_visible_scrolls_viewport() {
let mut v = ViewerPaneState::default();
v.set_content(PathBuf::from("a.rs"), lines(100));
v.set_viewport(10, 80);
v.cursor_down(20);
assert!(v.scroll_offset() <= 20);
assert!(v.scroll_offset() + 10 > 20, "cursor within viewport");
v.cursor_up(20);
assert_eq!(v.scroll_offset(), 0, "scrolling back up returns to top");
}
#[test]
fn horizontal_scroll_only_when_unwrapped() {
let mut v = ViewerPaneState::default();
assert!(v.wrap_lines());
v.scroll_right(4);
assert_eq!(v.scroll_x(), 0, "wrapped → horizontal scroll is a no-op");
v.toggle_wrap();
assert!(!v.wrap_lines());
v.scroll_right(4);
assert_eq!(v.scroll_x(), 4);
v.scroll_left(10);
assert_eq!(v.scroll_x(), 0, "clamps at 0");
v.scroll_right(6);
v.toggle_wrap();
assert!(v.wrap_lines());
assert_eq!(v.scroll_x(), 0);
}
}