Skip to content

Commit 52811c6

Browse files
skibitskyclaude
andcommitted
Fix bottom cutoff when long lines wrap in the TUI
max_scroll used Text::height() (raw Line count), but Paragraph is rendered with Wrap { trim: false }, so long lines wrap to multiple visual rows at display time. Result: documents with prose that exceeded the viewport width silently lost rows off the bottom — ~10 rows for README.md at 60 cols, ~48 rows for typical plan docs at 80 cols. Compute wrapped row count ourselves via unicode_width per span, summing ceil(display_width / render_width) per Line. Thread the render width onto App so max_scroll and clamp_scroll can use it. Also split the crate into lib + thin bin: App and the scroll math now live in src/scroll.rs with tests inline, alongside the existing render / highlight / watch modules. src/main.rs is now just CLI, event loop, and --dump output. Bump to 0.1.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2d06cb5 commit 52811c6

5 files changed

Lines changed: 146 additions & 40 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mdview"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
edition = "2024"
55
description = "A terminal markdown viewer"
66
license = "MIT"

src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
pub mod highlight;
2+
pub mod render;
3+
pub mod scroll;
4+
pub mod watch;

src/main.rs

Lines changed: 10 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
mod highlight;
2-
mod render;
3-
mod watch;
4-
51
use std::io::{self, Write as _};
62
use std::path::PathBuf;
73
use std::sync::mpsc;
@@ -17,9 +13,10 @@ use ratatui::backend::CrosstermBackend;
1713
use ratatui::layout::Rect;
1814
use ratatui::widgets::{Paragraph, Wrap};
1915
use ratatui::Terminal;
20-
use ratatui::text::Text;
2116

22-
use render::render_markdown;
17+
use mdview::render::render_markdown;
18+
use mdview::scroll::App;
19+
use mdview::watch;
2320

2421
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
2522

@@ -31,31 +28,6 @@ impl Drop for TerminalGuard {
3128
}
3229
}
3330

34-
struct App {
35-
text: Text<'static>,
36-
scroll: u16,
37-
viewport_height: u16,
38-
}
39-
40-
impl App {
41-
fn max_scroll(&self) -> u16 {
42-
let content_height = (self.text.height() as u32).min(u16::MAX as u32) as u16;
43-
content_height.saturating_sub(self.viewport_height)
44-
}
45-
46-
fn scroll_down(&mut self, n: u16) {
47-
self.scroll = self.scroll.saturating_add(n).min(self.max_scroll());
48-
}
49-
50-
fn scroll_up(&mut self, n: u16) {
51-
self.scroll = self.scroll.saturating_sub(n);
52-
}
53-
54-
fn clamp_scroll(&mut self) {
55-
self.scroll = self.scroll.min(self.max_scroll());
56-
}
57-
}
58-
5931
fn main() -> Result<()> {
6032
let args: Vec<String> = std::env::args().collect();
6133

@@ -110,11 +82,11 @@ fn main() -> Result<()> {
11082
let mut terminal = Terminal::new(backend)?;
11183

11284
let size = terminal.size()?;
113-
let mut render_width = size.width;
11485
let mut app = App {
115-
text: render_markdown(&content, render_width),
86+
text: render_markdown(&content, size.width),
11687
scroll: 0,
11788
viewport_height: size.height,
89+
render_width: size.width,
11890
};
11991

12092
let (tx, rx) = mpsc::channel();
@@ -146,8 +118,8 @@ fn main() -> Result<()> {
146118
if size_ok {
147119
if let Ok(new_content) = std::fs::read_to_string(&path) {
148120
content = new_content;
149-
render_width = terminal.size()?.width;
150-
app.text = render_markdown(&content, render_width);
121+
app.render_width = terminal.size()?.width;
122+
app.text = render_markdown(&content, app.render_width);
151123
app.clamp_scroll();
152124
}
153125
}
@@ -176,9 +148,9 @@ fn main() -> Result<()> {
176148
},
177149
Event::Resize(w, h) => {
178150
app.viewport_height = h;
179-
if w != render_width {
180-
render_width = w;
181-
app.text = render_markdown(&content, render_width);
151+
if w != app.render_width {
152+
app.render_width = w;
153+
app.text = render_markdown(&content, app.render_width);
182154
}
183155
app.clamp_scroll();
184156
}

src/scroll.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use ratatui::text::Text;
2+
use unicode_width::UnicodeWidthStr;
3+
4+
pub struct App {
5+
pub text: Text<'static>,
6+
pub scroll: u16,
7+
pub viewport_height: u16,
8+
pub render_width: u16,
9+
}
10+
11+
pub fn wrapped_row_count(text: &Text<'_>, width: u16) -> u32 {
12+
if width == 0 {
13+
return text.lines.len() as u32;
14+
}
15+
let w = width as u32;
16+
text.lines
17+
.iter()
18+
.map(|line| {
19+
let display: u32 = line
20+
.spans
21+
.iter()
22+
.map(|s| UnicodeWidthStr::width(s.content.as_ref()) as u32)
23+
.sum();
24+
if display == 0 { 1 } else { display.div_ceil(w) }
25+
})
26+
.sum()
27+
}
28+
29+
impl App {
30+
pub fn max_scroll(&self) -> u16 {
31+
let content_height = wrapped_row_count(&self.text, self.render_width)
32+
.min(u16::MAX as u32) as u16;
33+
content_height.saturating_sub(self.viewport_height)
34+
}
35+
36+
pub fn scroll_down(&mut self, n: u16) {
37+
self.scroll = self.scroll.saturating_add(n).min(self.max_scroll());
38+
}
39+
40+
pub fn scroll_up(&mut self, n: u16) {
41+
self.scroll = self.scroll.saturating_sub(n);
42+
}
43+
44+
pub fn clamp_scroll(&mut self) {
45+
self.scroll = self.scroll.min(self.max_scroll());
46+
}
47+
}
48+
49+
#[cfg(test)]
50+
mod tests {
51+
use super::*;
52+
use ratatui::text::{Line, Span};
53+
54+
fn line_of(s: &str) -> Line<'static> {
55+
Line::from(Span::raw(s.to_string()))
56+
}
57+
58+
#[test]
59+
fn wrapped_row_count_short_line_is_one_row() {
60+
let text = Text::from(vec![line_of("short")]);
61+
assert_eq!(wrapped_row_count(&text, 80), 1);
62+
}
63+
64+
#[test]
65+
fn wrapped_row_count_empty_line_is_one_row() {
66+
let text = Text::from(vec![Line::default()]);
67+
assert_eq!(wrapped_row_count(&text, 80), 1);
68+
}
69+
70+
#[test]
71+
fn wrapped_row_count_wraps_long_line() {
72+
let text = Text::from(vec![line_of(&"a".repeat(150))]);
73+
assert_eq!(wrapped_row_count(&text, 60), 3);
74+
}
75+
76+
#[test]
77+
fn wrapped_row_count_sums_across_lines() {
78+
let text = Text::from(vec![
79+
line_of("aaa"),
80+
line_of(&"b".repeat(9)),
81+
line_of("ccc"),
82+
]);
83+
assert_eq!(wrapped_row_count(&text, 4), 5);
84+
}
85+
86+
#[test]
87+
fn wrapped_row_count_uses_display_width_for_cjk() {
88+
let text = Text::from(vec![line_of("你好世界")]);
89+
assert_eq!(wrapped_row_count(&text, 4), 2);
90+
}
91+
92+
#[test]
93+
fn wrapped_row_count_sums_span_widths_within_line() {
94+
let text = Text::from(vec![Line::from(vec![
95+
Span::raw("hello "),
96+
Span::raw("world"),
97+
])]);
98+
assert_eq!(wrapped_row_count(&text, 4), 3);
99+
}
100+
101+
#[test]
102+
fn wrapped_row_count_width_zero_falls_back_to_line_count() {
103+
let text = Text::from(vec![line_of("anything"), line_of("here")]);
104+
assert_eq!(wrapped_row_count(&text, 0), 2);
105+
}
106+
107+
#[test]
108+
fn max_scroll_accounts_for_line_wrapping() {
109+
let text = Text::from(vec![line_of(&"a".repeat(150))]);
110+
let app = App {
111+
text,
112+
scroll: 0,
113+
viewport_height: 2,
114+
render_width: 50,
115+
};
116+
assert_eq!(app.max_scroll(), 1);
117+
}
118+
119+
#[test]
120+
fn max_scroll_is_zero_when_content_fits() {
121+
let text = Text::from(vec![line_of("hello")]);
122+
let app = App {
123+
text,
124+
scroll: 0,
125+
viewport_height: 10,
126+
render_width: 80,
127+
};
128+
assert_eq!(app.max_scroll(), 0);
129+
}
130+
}

0 commit comments

Comments
 (0)