on master help sign in
soft-wrap long lines in the reading pane and editor: word wrap with hanging indents, hard-broken code with ↪, screen-row motion per keymap, zw toggle
commit786c670803ba99d11ec330c6645237b44444156a authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 05:54 (1 month ago) parentae13f49
11 files changed+556−56
SPEC.md+14 crates/fold-tui/src/app.rs+10−1 crates/fold-tui/src/app/action.rs+5 crates/fold-tui/src/app/editor/helix.rs+2−1 crates/fold-tui/src/app/editor/mod.rs+92 crates/fold-tui/src/app/editor/normal.rs+2−1 crates/fold-tui/src/app/editor/vim.rs+5 crates/fold-tui/src/app/mouse.rs+9−4 crates/fold-tui/src/app/ui.rs+136−49 crates/fold-tui/src/app/wrap.rs+152 crates/fold-tui/tests/wrap.rs+129
SPEC.md+14
@@ -855,6 +855,15 @@ Four regions, each of them live under the pointer:
855855Split layout, outline on the left at a third of the width (minimum 30 columns); the border
856856between the panes is a handle — drag it to resize. Below 80 columns the panes stack.
857857
858+**Long lines wrap** in the reading pane and the editor. Prose breaks after a space, and a
859+wrapped list item's continuation rows hang under its text, not under its bullet or
860+checkbox; a word longer than the pane breaks where it must. Lines inside fenced code break
861+at the pane's edge instead, one column early, with `↪` marking each break, so code is
862+never reflowed. Every screen row of a line is that line to the pointer: a click on any
863+row selects it or places the cursor where it lands. `zw` (*Wrap lines*) turns wrapping
864+off for the session, and long lines are cut at the edge. The outline never wraps: a long
865+title ends in `…`.
866+
858867**The pointer.** Everything a key does, the pointer does too, and the screen shows where:
859868
860869| Gesture | Where | Does |
@@ -912,6 +921,7 @@ the popup — closes the topmost thing.
912921| `H` / `L` | collapse / expand all under cursor |
913922| `zd` | toggle hiding done nodes (both panes) |
914923| `zr` | toggle raw mode: exact source in the reading pane, frontmatter included |
924+| `zw` | toggle wrapping of long lines in the reading pane and the editor (§10.1) |
915925| `za` | archive: refile subtree under `Archive` (§6.5) |
916926| `-` | go to parent |
917927| `{` / `}` | previous / next sibling |
@@ -998,6 +1008,10 @@ clipboard and undo:
9981008 `% ; Alt-;`, `f t F T`, `gg ge gh gl gs`, `mi` / `ma` + object, `v` to extend; `d c y p P
9991009 R r ~ > < J` act on the selection; `i a I A o O`; `u U`; `/ ? n N *`.
10001010
1011+With wrapping on (§10.1), the normal keymap's `↑`/`↓`/`PgUp`/`PgDn` and Helix's `j`/`k`
1012+move by screen row through a wrapped line, as micro and Helix do; Vim's `j`/`k` move by line,
1013+and `gj`/`gk` by screen row.
1014+
10011015All three take `:` commands — `:w` save, `:q` / `:wq` / `:x` done, `:q!` / `:e!` revert,
10021016`:N` go to line — and in all three a click puts the cursor where it lands, a drag
10031017selects, a double-click selects a word, the wheel scrolls, and text pasted into the
crates/fold-tui/src/app.rs+10−1
@@ -28,6 +28,7 @@ mod highlight;
2828mod markdown;
2929mod mouse;
3030mod ui;
31+mod wrap;
3132
3233pub use action::Action;
3334pub use editor::Keys as EditKeys;
@@ -72,6 +73,8 @@ pub struct App {
7273 folded: Vec<NodeKey>,
7374 hide_done: bool,
7475 raw_mode: bool,
76+ /// Long lines wrap in the reading pane and the editor (§10.1); `zw`.
77+ wrap: bool,
7578 register: String,
7679 status: String,
7780 status_time: Instant,
@@ -160,6 +163,7 @@ impl App {
160163 folded: Vec::new(),
161164 hide_done: false,
162165 raw_mode: false,
166+ wrap: true,
163167 register: String::new(),
164168 status: "click to select · double-click to zoom · right-click for actions · drag to move · ? help".into(),
165169 status_time: Instant::now(),
@@ -1094,6 +1098,7 @@ impl App {
10941098 self.say(if self.raw_mode { "raw" } else { "styled" });
10951099 }
10961100 ('z', KeyCode::Char('a')) => self.act_archive(),
1101+ ('z', KeyCode::Char('w')) => self.run_action(Action::Wrap),
10971102 ('g', KeyCode::Char('g')) => match self.focus {
10981103 Focus::Outline => self.cursor = 0,
10991104 Focus::Reading => self.read_cursor = 0,
@@ -1783,6 +1788,10 @@ impl App {
17831788 self.clamp_cursor();
17841789 self.say(if self.hide_done { "done hidden" } else { "done shown" });
17851790 }
1791+ Action::Wrap => {
1792+ self.wrap = !self.wrap;
1793+ self.say(if self.wrap { "long lines wrap" } else { "long lines are cut at the edge" });
1794+ }
17861795 Action::RawMode => {
17871796 self.raw_mode = !self.raw_mode;
17881797 self.say(if self.raw_mode { "raw" } else { "styled" });
@@ -1915,7 +1924,7 @@ pub fn help_text() -> Vec<Line<'static>> {
19151924 ("n/N x t", "new sibling / child · done · task on/off"),
19161925 ("J/K > < ~", "move · indent · outdent · heading ↔ bullet"),
19171926 ("r y d p/P", "move to… · copy · delete · paste after / before"),
1918− ("s za zd zr", "make block · archive · hide done · raw source"),
1927+ ("s za zd zr zw", "make block · archive · hide done · raw source · wrap lines"),
19191928 ("/ : ?", "filter · command palette · this help"),
19201929 ("u/U q", "undo / redo · quit (everything is always saved)"),
19211930 ("Tab", "switch panes · in the text: [[ ]] headings, / search, o link"),
crates/fold-tui/src/app/action.rs+5
@@ -36,6 +36,7 @@ pub enum Action {
3636 CaptureTask,
3737 HideDone,
3838 RawMode,
39+ Wrap,
3940 GoTo,
4041 ClearDone,
4142 Canonicalize,
@@ -93,6 +94,7 @@ impl Action {
9394 CaptureTask => "Capture task",
9495 HideDone => "Hide / show done",
9596 RawMode => "Raw / styled",
97+ Wrap => "Wrap lines",
9698 GoTo => "Go to…",
9799 ClearDone => "Clear done",
98100 Canonicalize => "Canonicalize",
@@ -170,6 +172,7 @@ impl Action {
170172 CaptureTask => "C",
171173 HideDone => "zd",
172174 RawMode => "zr",
175+ Wrap => "zw",
173176 EditDone => "Esc",
174177 EditRevert => ":q!",
175178 Close => "Esc",
@@ -219,6 +222,7 @@ impl Action {
219222 CaptureTask => "append a task to today's inbox",
220223 HideDone => "hide or show finished tasks",
221224 RawMode => "show the exact source",
225+ Wrap => "wrap long lines, or cut them at the edge",
222226 GoTo => "jump to a node",
223227 ClearDone => "trash finished tasks under the zoom",
224228 Canonicalize => "rewrite the vault in canonical form",
@@ -297,6 +301,7 @@ pub const PALETTE: &[Action] = &[
297301 Action::Redo,
298302 Action::HideDone,
299303 Action::RawMode,
304+ Action::Wrap,
300305 Action::EditorKeys,
301306 Action::ClearDone,
302307 Action::Canonicalize,
crates/fold-tui/src/app/editor/helix.rs+2−1
@@ -159,7 +159,8 @@ fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
159159 } else {
160160 e.anchor = None;
161161 }
162− e.move_vert(if c == 'j' { n as isize } else { -(n as isize) });
162+ // Helix moves by screen row when lines wrap
163+ e.move_visual(if c == 'j' { n as isize } else { -(n as isize) });
163164 }
164165 ('w' | 'W', false) => {
165166 let big = c == 'W';
crates/fold-tui/src/app/editor/mod.rs+92
@@ -10,6 +10,7 @@ mod helix;
1010mod normal;
1111mod vim;
1212
13+use super::wrap::{self, Row};
1314use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1415use fold_core::edit::{EditBuffer, EditLine, Owner};
1516use std::collections::BTreeSet;
@@ -126,6 +127,12 @@ pub struct Editor {
126127 pub copied: Option<String>,
127128 /// Where a mouse drag started.
128129 drag_origin: Option<Pos>,
130+ /// Columns to wrap at (set by the renderer); `None` means no wrapping.
131+ pub wrap_cols: Option<usize>,
132+ /// Screen rows of every line, for the text and width they were made for.
133+ layout: Option<(u64, Option<usize>, Vec<Vec<Row>>, Vec<bool>)>,
134+ /// The screen column vertical moves by screen row aim for.
135+ want_x: Option<usize>,
129136 want_col: Option<usize>,
130137 undo: Vec<Snap>,
131138 redo: Vec<Snap>,
@@ -156,6 +163,9 @@ impl Editor {
156163 page: 20,
157164 copied: None,
158165 drag_origin: None,
166+ wrap_cols: None,
167+ layout: None,
168+ want_x: None,
159169 want_col: None,
160170 undo: Vec::new(),
161171 redo: Vec::new(),
@@ -542,6 +552,88 @@ impl Editor {
542552 pub fn set_cursor(&mut self, p: Pos) {
543553 self.cursor = p;
544554 self.want_col = None;
555+ self.want_x = None;
556+ }
557+
558+ // -------------------------------------------------------------- layout
559+
560+ /// Every line's screen rows (§10.1): prose wraps at spaces, lines inside
561+ /// fenced code break hard. Cached until the text or width changes.
562+ pub fn layout(&mut self) -> &Vec<Vec<Row>> {
563+ let fresh = matches!(&self.layout, Some((c, w, _, _)) if *c == self.changes && *w == self.wrap_cols);
564+ if !fresh {
565+ let mut rows = Vec::with_capacity(self.lines());
566+ let mut codes = Vec::with_capacity(self.lines());
567+ let mut fence: Option<(char, usize)> = None;
568+ for l in 0..self.lines() {
569+ let text = self.line(l);
570+ let t = text.trim_start();
571+ let fc = t.chars().next().filter(|c| *c == '`' || *c == '~');
572+ let n = fc.map(|c| t.chars().take_while(|x| *x == c).count()).unwrap_or(0);
573+ let is_fence = n >= 3;
574+ let code = fence.is_some() && !is_fence;
575+ if is_fence {
576+ fence = match fence {
577+ Some((c, m)) if Some(c) == fc && n >= m => None,
578+ None => Some((fc.unwrap(), n)),
579+ keep => keep,
580+ };
581+ }
582+ codes.push(code);
583+ rows.push(match self.wrap_cols {
584+ Some(cols) => wrap::wrap(text, cols, code),
585+ None => vec![Row { start: 0, end: text.chars().count(), indent: 0 }],
586+ });
587+ }
588+ self.layout = Some((self.changes, self.wrap_cols, rows, codes));
589+ }
590+ &self.layout.as_ref().unwrap().2
591+ }
592+
593+ /// Which lines sit inside fenced code (they break hard, with `↪`).
594+ pub fn code_lines(&mut self) -> Vec<bool> {
595+ self.layout();
596+ self.layout.as_ref().unwrap().3.clone()
597+ }
598+
599+ /// Screen row (counted over the whole buffer) and column of a position.
600+ pub fn pos_to_screen(&mut self, p: Pos) -> (usize, usize) {
601+ let before: usize = self.layout().iter().take(p.line).map(|r| r.len()).sum();
602+ let l = p.line.min(self.lines() - 1);
603+ let rows = self.layout()[l].clone();
604+ let (r, x) = wrap::locate(&rows, self.line(p.line), p.col);
605+ (before + r, x)
606+ }
607+
608+ /// The position under screen row `row` (over the whole buffer), column `x`.
609+ pub fn screen_to_pos(&mut self, row: usize, x: usize) -> Pos {
610+ let mut left = row;
611+ let n = self.lines();
612+ for l in 0..n {
613+ let rows = self.layout()[l].clone();
614+ if left < rows.len() || l + 1 == n {
615+ let r = left.min(rows.len() - 1);
616+ return Pos::new(l, wrap::column_at(&rows, self.line(l), r, x));
617+ }
618+ left -= rows.len();
619+ }
620+ Pos::new(0, 0)
621+ }
622+
623+ /// Up or down by screen rows: a wrapped line is several rows (the
624+ /// normal keymap's arrows, Helix's `j`/`k`, Vim's `gj`/`gk`).
625+ pub fn move_visual(&mut self, delta: isize) {
626+ if self.wrap_cols.is_none() {
627+ return self.move_vert(delta);
628+ }
629+ let (row, x) = self.pos_to_screen(self.cursor);
630+ let want = self.want_x.unwrap_or(x);
631+ let total: usize = self.layout().iter().map(|r| r.len()).sum();
632+ let target = (row as isize + delta).clamp(0, total as isize - 1) as usize;
633+ let p = self.screen_to_pos(target, want);
634+ self.cursor = p;
635+ self.want_col = None;
636+ self.want_x = Some(want);
545637 }
546638
547639 pub fn first_non_blank(&self, l: usize) -> usize {
crates/fold-tui/src/app/editor/normal.rs+2−1
@@ -53,7 +53,8 @@ pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
5353 _ => None,
5454 };
5555 if let Some(d) = vert {
56− select_to(e, shift, |e| e.move_vert(d));
56+ // by screen row: a wrapped line is several rows
57+ select_to(e, shift, |e| e.move_visual(d));
5758 return out;
5859 }
5960
crates/fold-tui/src/app/editor/vim.rs+5
@@ -476,6 +476,11 @@ fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
476476 let to = e.prev(to).unwrap_or(to);
477477 e.set_cursor(to);
478478 }
479+ // gj / gk: by screen row through wrapped lines
480+ 'j' | 'k' => {
481+ let n = count.unwrap_or(1) as isize;
482+ e.move_visual(if c == 'j' { n } else { -n });
483+ }
479484 _ => e.vim.op = None,
480485 },
481486 'Z' => match c {
crates/fold-tui/src/app/mouse.rs+9−4
@@ -371,10 +371,15 @@ impl App {
371371 }
372372 }
373373
374− /// The text position under a screen cell in the editor.
375− fn edit_pos(&self, x: u16, y: u16) -> super::editor::Pos {
374+ /// The text position under a screen cell in the editor, through the
375+ /// wrapped layout.
376+ fn edit_pos(&mut self, x: u16, y: u16) -> super::editor::Pos {
376377 let area = self.ui.edit_area;
377− let line = self.ui.edit_scroll + y.saturating_sub(area.y) as usize;
378− super::editor::Pos::new(line, x.saturating_sub(area.x) as usize)
378+ let row = self.ui.edit_scroll + y.saturating_sub(area.y) as usize;
379+ let col = x.saturating_sub(area.x) as usize;
380+ match self.editor.as_mut() {
381+ Some(ed) => ed.screen_to_pos(row, col),
382+ None => super::editor::Pos::new(row, col),
383+ }
379384 }
380385}
crates/fold-tui/src/app/ui.rs+136−49
@@ -188,6 +188,53 @@ fn fit(s: &str, w: usize) -> String {
188188 out
189189}
190190
191+/// One screen row of the reading pane.
192+struct Drawn {
193+ doc: Option<usize>,
194+ line: Line<'static>,
195+ code: bool,
196+ check: Option<u16>,
197+ link: Option<(u16, u16)>,
198+}
199+
200+/// Break a styled line into screen rows at `cols` columns (§10.1): each row
201+/// its own `Line`, continuation rows indented, and code rows that go on
202+/// ending in `↪`.
203+fn wrap_styled(line: &Line<'static>, cols: usize, hard: bool) -> Vec<(Line<'static>, super::wrap::Row)> {
204+ let cells: Vec<(char, Style)> = line.spans.iter().flat_map(|s| s.content.chars().map(move |c| (c, s.style))).collect();
205+ let text: String = cells.iter().map(|c| c.0).collect();
206+ let rows = super::wrap::wrap(&text, cols, hard);
207+ let n = rows.len();
208+ if n == 1 {
209+ return vec![(line.clone(), rows[0])];
210+ }
211+ rows.iter()
212+ .enumerate()
213+ .map(|(i, r)| {
214+ let mut spans: Vec<Span<'static>> = Vec::new();
215+ if r.indent > 0 {
216+ spans.push(Span::raw(" ".repeat(r.indent)));
217+ }
218+ let mut run = String::new();
219+ let mut style = None;
220+ for &(c, st) in &cells[r.start..r.end] {
221+ if style != Some(st) && !run.is_empty() {
222+ spans.push(Span::styled(std::mem::take(&mut run), style.unwrap_or_default()));
223+ }
224+ style = Some(st);
225+ run.push(c);
226+ }
227+ if !run.is_empty() {
228+ spans.push(Span::styled(run, style.unwrap_or_default()));
229+ }
230+ if hard && i + 1 < n {
231+ spans.push(Span::styled("↪", Style::default().fg(theme::DIM)));
232+ }
233+ (Line::from(spans), *r)
234+ })
235+ .collect()
236+}
237+
191238fn rounded(title: Line<'static>, focused: bool) -> WBlock<'static> {
192239 WBlock::default()
193240 .borders(Borders::ALL)
@@ -619,6 +666,7 @@ impl App {
619666 };
620667 // display lines: (doc line or None for the property header, text)
621668 let mut shown: Vec<(Option<usize>, String)> = Vec::new();
669+ let mut header_at: Option<usize> = None;
622670 self.read_header = false;
623671 if self.raw_mode {
624672 for l in render(&self.vault.tree, r, 1, false).lines() {
@@ -632,6 +680,7 @@ impl App {
632680 if let Some(b) = &self.vault.tree.node(r).block {
633681 let props: Vec<String> = b.props.iter().filter(|(k, _)| k.as_str() != "id").map(|(k, v)| format!("{} {}", k, v)).collect();
634682 if !props.is_empty() {
683+ header_at = Some(1.min(shown.len()));
635684 shown.insert(1.min(shown.len()), (None, format!("⚑ {}", props.join(" · "))));
636685 self.read_header = true;
637686 }
@@ -639,55 +688,75 @@ impl App {
639688 }
640689 let view = inner.height as usize;
641690 self.ui.reading_view = view;
642− self.ui.reading_len = shown.len();
643− let cursor_shown = self.read_cursor + (self.read_header && self.read_cursor >= 1) as usize;
644− let moved = self.ui.last_read_cursor != Some(self.read_cursor);
645− self.ui.last_read_cursor = Some(self.read_cursor);
646− follow(&mut self.scroll_reading, cursor_shown, moved, view, shown.len());
691+ // every line styled, then laid out in screen rows (§10.1)
647692 let texts: Vec<&str> = shown.iter().map(|(_, t)| t.as_str()).collect();
648693 let code = self.code_lines(&texts);
694+ let cols = if self.wrap { inner.width as usize } else { usize::MAX / 2 };
695+ let mut rows: Vec<Drawn> = Vec::new();
649696 for (si, (doc_line, text)) in shown.iter().enumerate() {
650− if si < self.scroll_reading || si >= self.scroll_reading + view {
651− continue;
697+ let styled = if header_at == Some(si) {
698+ super::markdown::Styled {
699+ line: Line::from(Span::styled(text.clone(), Style::default().fg(theme::DIM).add_modifier(Modifier::ITALIC))),
700+ check: None,
701+ link: None,
702+ }
703+ } else {
704+ match &code[si] {
705+ Some(spans) => super::markdown::Styled { line: Line::from(spans.clone()), check: None, link: None },
706+ None => style_line(text, false),
707+ }
708+ };
709+ let parts = wrap_styled(&styled.line, cols, code[si].is_some());
710+ for (line, row) in parts {
711+ let within = |c: u16| (c as usize) >= row.start && (c as usize) < row.end.max(row.start + 1);
712+ let at = |c: u16| (row.indent + c as usize - row.start) as u16;
713+ rows.push(Drawn {
714+ doc: *doc_line,
715+ line,
716+ code: code[si].is_some(),
717+ check: styled.check.filter(|c| within(*c)).map(at),
718+ link: styled.link.filter(|(a, _)| within(*a)).map(|(a, b)| (at(a), at((b as usize).min(row.end) as u16))),
719+ });
652720 }
653− let y = inner.y + (si - self.scroll_reading) as u16;
721+ }
722+ self.ui.reading_len = rows.len();
723+ let cursor_row = rows.iter().position(|d| d.doc == Some(self.read_cursor)).unwrap_or(0);
724+ let moved = self.ui.last_read_cursor != Some(self.read_cursor);
725+ self.ui.last_read_cursor = Some(self.read_cursor);
726+ follow(&mut self.scroll_reading, cursor_row, moved, view, rows.len());
727+ for (ri, d) in rows.iter().enumerate().skip(self.scroll_reading).take(view) {
728+ let y = inner.y + (ri - self.scroll_reading) as u16;
654729 let line_rect = Rect { x: inner.x, y, width: inner.width, height: 1 };
655− let buf = f.buffer_mut();
656− let Some(di) = doc_line else {
657− put(buf, inner.x, y, &fit(text, inner.width as usize), inner.width, Style::default().fg(theme::DIM).add_modifier(Modifier::ITALIC));
658− continue;
659− };
660− let styled = match &code[si] {
661− Some(spans) => super::markdown::Styled { line: Line::from(spans.clone()), check: None, link: None },
662− None => style_line(text, false),
663− };
664− let cur = focused && *di == self.read_cursor;
665− let matched = self.read_matches.contains(di);
730+ let cur = focused && d.doc.is_some() && d.doc == Some(self.read_cursor);
731+ let matched = d.doc.is_some_and(|di| self.read_matches.contains(&di));
732+ let hovered = self.ui.hovered(line_rect) && self.ui.menu.is_none() && d.doc.is_some();
666733 let bg = if cur {
667734 Some(theme::SEL_BLUR)
668735 } else if matched {
669736 Some(ratatui::style::Color::Indexed(58))
670− } else if self.ui.hovered(line_rect) && self.ui.menu.is_none() {
737+ } else if hovered {
671738 Some(theme::HOVER)
672− } else if code[si].is_some() {
739+ } else if d.code {
673740 Some(theme::CODE_BG)
674741 } else {
675742 None
676743 };
744+ let buf = f.buffer_mut();
677745 if let Some(b) = bg {
678746 buf.set_style(line_rect, Style::default().bg(b));
679747 }
680− buf.set_line(inner.x, y, &styled.line, inner.width);
681− self.ui.push(line_rect, Hit::DocLine(*di));
682− if let Some(c) = styled.check {
683− self.ui.push(Rect { x: inner.x + c, y, width: 1, height: 1 }, Hit::DocCheck(*di));
748+ buf.set_line(inner.x, y, &d.line, inner.width);
749+ let Some(di) = d.doc else { continue };
750+ self.ui.push(line_rect, Hit::DocLine(di));
751+ if let Some(c) = d.check {
752+ self.ui.push(Rect { x: inner.x + c, y, width: 1, height: 1 }, Hit::DocCheck(di));
684753 }
685− if let Some((a, b)) = styled.link {
686− self.ui.push(Rect { x: inner.x + a, y, width: b - a, height: 1 }, Hit::Link(*di));
754+ if let Some((a, b)) = d.link {
755+ self.ui.push(Rect { x: inner.x + a, y, width: b.saturating_sub(a).max(1), height: 1 }, Hit::Link(di));
687756 }
688757 }
689− if shown.len() > view {
690− let mut st = ScrollbarState::new(shown.len().saturating_sub(view)).position(self.scroll_reading);
758+ if rows.len() > view {
759+ let mut st = ScrollbarState::new(rows.len().saturating_sub(view)).position(self.scroll_reading);
691760 f.render_stateful_widget(
692761 Scrollbar::new(ScrollbarOrientation::VerticalRight).begin_symbol(None).end_symbol(None),
693762 Rect { y: area.y + 1, height: area.height.saturating_sub(2), ..area },
@@ -810,32 +879,50 @@ impl App {
810879 _ => None,
811880 };
812881 let view = inner.height as usize - bottom.is_some() as usize;
813− let (cursor, sel, block_cur) = (ed.cursor, ed.selection(), ed.block_cursor());
882+ let (sel, block_cur) = (ed.selection(), ed.block_cursor());
883+ let wrap = self.wrap;
884+ let ed = self.editor.as_mut().unwrap();
885+ ed.page = view.max(1);
886+ ed.wrap_cols = wrap.then_some(inner.width as usize);
887+ // every screen row: (line, row, is the line's last row)
888+ let layout = ed.layout().clone();
889+ let codes = ed.code_lines();
890+ let (cur_row, cur_x) = ed.pos_to_screen(ed.cursor);
891+ let cursor = ed.cursor;
814892 let lines: Vec<(String, bool)> = ed.buf.lines.iter().map(|l| (l.text.clone(), l.owner == owner)).collect();
815− let moved = self.ui.last_edit_line != Some(cursor.line);
816− self.ui.last_edit_line = Some(cursor.line);
817− follow(&mut self.ui.edit_scroll, cursor.line, moved, view, lines.len());
818− if let Some(ed) = self.editor.as_mut() {
819− ed.page = view.max(1);
820− }
893+ let screen: Vec<(usize, super::wrap::Row, bool)> = layout
894+ .iter()
895+ .enumerate()
896+ .flat_map(|(l, rows)| rows.iter().enumerate().map(move |(i, r)| (l, *r, i + 1 == rows.len())))
897+ .collect();
898+ let moved = self.ui.last_edit_line != Some(cur_row);
899+ self.ui.last_edit_line = Some(cur_row);
900+ follow(&mut self.ui.edit_scroll, cur_row, moved, view, screen.len());
821901 let buf = f.buffer_mut();
822− for (i, (text, own)) in lines.iter().enumerate().skip(self.ui.edit_scroll).take(view) {
823− let y = inner.y + (i - self.ui.edit_scroll) as u16;
824− if i == cursor.line {
902+ for (si, (l, row, last)) in screen.iter().enumerate().skip(self.ui.edit_scroll).take(view) {
903+ let y = inner.y + (si - self.ui.edit_scroll) as u16;
904+ let (text, own) = &lines[*l];
905+ if *l == cursor.line {
825906 buf.set_style(Rect { x: inner.x, y, width: inner.width, height: 1 }, Style::default().bg(theme::HOVER));
826907 }
827908 // the block being edited is bright, the rest of the subtree is
828909 // context
829910 let style = if *own { Style::default() } else { Style::default().fg(ratatui::style::Color::Gray) };
830− put(buf, inner.x, y, text, inner.width, style);
911+ let chars: Vec<char> = text.chars().collect();
912+ let part: String = chars[row.start..row.end].iter().collect();
913+ let x0 = inner.x + row.indent as u16;
914+ put(buf, x0, y, &part, inner.width.saturating_sub(row.indent as u16), style);
915+ if codes[*l] && !last {
916+ put(buf, inner.x + inner.width - 1, y, "↪", 1, Style::default().fg(theme::DIM));
917+ }
831918 if let Some((s, e)) = sel {
832− if i >= s.line && i <= e.line {
833− let len = text.chars().count();
834− let from = if i == s.line { s.col } else { 0 };
835− // a selected line end shows as one cell
836− let to = if i == e.line { e.col } else { len + 1 };
919+ if *l >= s.line && *l <= e.line {
920+ let from = if *l == s.line { s.col } else { 0 }.max(row.start);
921+ // a selected line end shows as one cell, on the last row
922+ let to = if *l == e.line { e.col } else { chars.len() + 1 };
923+ let to = if *last { to } else { to.min(row.end) };
837924 for col in from..to {
838− let x = inner.x + col as u16;
925+ let x = x0 + (col - row.start) as u16;
839926 if x < inner.x + inner.width {
840927 buf[(x, y)].set_style(Style::default().bg(theme::SEL));
841928 }
@@ -854,9 +941,9 @@ impl App {
854941 return;
855942 }
856943 }
857− if cursor.line >= self.ui.edit_scroll && cursor.line < self.ui.edit_scroll + view {
858− let cx = inner.x + (cursor.col as u16).min(inner.width.saturating_sub(1));
859− let cy = inner.y + (cursor.line - self.ui.edit_scroll) as u16;
944+ if cur_row >= self.ui.edit_scroll && cur_row < self.ui.edit_scroll + view {
945+ let cx = inner.x + (cur_x as u16).min(inner.width.saturating_sub(1));
946+ let cy = inner.y + (cur_row - self.ui.edit_scroll) as u16;
860947 if block_cur {
861948 f.buffer_mut()[(cx, cy)].set_style(Style::default().add_modifier(Modifier::REVERSED));
862949 }
crates/fold-tui/src/app/wrap.rsnew file+152
@@ -0,0 +1,152 @@
1+//! Soft wrapping (§10.1): where a long line breaks into screen rows. Prose
2+//! breaks after a space, with continuation rows hung under the text of a
3+//! list item; code breaks hard, one column early, to leave room for `↪`.
4+
5+use unicode_width::UnicodeWidthChar;
6+
7+/// One screen row of a line: the characters `[start, end)`, drawn after
8+/// `indent` columns (zero on the first row).
9+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10+pub struct Row {
11+ pub start: usize,
12+ pub end: usize,
13+ pub indent: usize,
14+}
15+
16+/// How far continuation rows are indented: under the text, past leading
17+/// spaces, a list marker and a checkbox.
18+pub fn hang(text: &str) -> usize {
19+ let lead = text.chars().take_while(|c| *c == ' ').count();
20+ let rest: String = text.chars().skip(lead).collect();
21+ let mut h = lead;
22+ let marker = ["- ", "* ", "+ "].iter().find(|m| rest.starts_with(**m)).map(|m| m.len()).or_else(|| {
23+ let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
24+ (digits > 0 && rest[digits..].starts_with(". ")).then_some(digits + 2)
25+ });
26+ if let Some(m) = marker {
27+ h += m;
28+ let after = &rest[m..];
29+ for cb in ["[ ] ", "[x] ", "[X] ", "[-] "] {
30+ if after.starts_with(cb) {
31+ h += 4;
32+ }
33+ }
34+ for cb in ["☐ ", "☑ "] {
35+ if after.starts_with(cb) {
36+ h += 2;
37+ }
38+ }
39+ }
40+ h
41+}
42+
43+fn width(c: char) -> usize {
44+ UnicodeWidthChar::width(c).unwrap_or(0)
45+}
46+
47+/// The rows of a line at `cols` columns. `hard` breaks anywhere and keeps
48+/// the last column free for a `↪` (code); otherwise lines break after a
49+/// space when they can. A line always has at least one row.
50+pub fn wrap(text: &str, cols: usize, hard: bool) -> Vec<Row> {
51+ let chars: Vec<char> = text.chars().collect();
52+ if cols < 4 || chars.iter().map(|c| width(*c)).sum::<usize>() <= cols {
53+ return vec![Row { start: 0, end: chars.len(), indent: 0 }];
54+ }
55+ let indent = if hard { 0 } else { hang(text).min(cols / 2) };
56+ let mut rows = Vec::new();
57+ let mut start = 0;
58+ while start < chars.len() {
59+ let ind = if rows.is_empty() { 0 } else { indent };
60+ let avail = cols - ind;
61+ // how many characters fit
62+ let mut w = 0;
63+ let mut i = start;
64+ while i < chars.len() && w + width(chars[i]) <= avail {
65+ w += width(chars[i]);
66+ i += 1;
67+ }
68+ let end = if i >= chars.len() {
69+ chars.len()
70+ } else if hard {
71+ // one column for the ↪
72+ (i - 1).max(start + 1)
73+ } else {
74+ // after the last space that fits, else wherever it fills
75+ match (start + 1..=i).rev().find(|&k| chars[k - 1] == ' ') {
76+ Some(k) => k,
77+ None => i.max(start + 1),
78+ }
79+ };
80+ rows.push(Row { start, end, indent: ind });
81+ start = end;
82+ }
83+ rows
84+}
85+
86+/// The row and screen column of a character column, given a line's rows.
87+/// A column at a row's end belongs to the next row, except at the line's end.
88+pub fn locate(rows: &[Row], text: &str, col: usize) -> (usize, usize) {
89+ let chars: Vec<char> = text.chars().collect();
90+ let r = rows.iter().rposition(|r| col >= r.start).unwrap_or(0);
91+ let row = rows[r];
92+ let x = row.indent + chars[row.start..col.min(chars.len()).max(row.start)].iter().map(|c| width(*c)).sum::<usize>();
93+ (r, x)
94+}
95+
96+/// The character column under screen column `x` of row `r`.
97+pub fn column_at(rows: &[Row], text: &str, r: usize, x: usize) -> usize {
98+ let chars: Vec<char> = text.chars().collect();
99+ let row = rows[r.min(rows.len() - 1)];
100+ let mut w = row.indent;
101+ let mut col = row.start;
102+ while col < row.end && w + width(chars[col]) <= x {
103+ w += width(chars[col]);
104+ col += 1;
105+ }
106+ // the last row reaches the line's end; earlier rows stop before theirs
107+ let last = r + 1 >= rows.len();
108+ if !last && col >= row.end {
109+ col = row.end.saturating_sub(1).max(row.start);
110+ }
111+ col
112+}
113+
114+#[cfg(test)]
115+mod tests {
116+ use super::*;
117+
118+ fn texts(t: &str, cols: usize, hard: bool) -> Vec<String> {
119+ let chars: Vec<char> = t.chars().collect();
120+ wrap(t, cols, hard)
121+ .iter()
122+ .map(|r| format!("{}{}", " ".repeat(r.indent), chars[r.start..r.end].iter().collect::<String>()))
123+ .collect()
124+ }
125+
126+ #[test]
127+ fn prose_breaks_after_spaces_with_a_hanging_indent() {
128+ assert_eq!(texts("short", 20, false), ["short"]);
129+ assert_eq!(
130+ texts("- [ ] buy milk and bread today", 16, false),
131+ ["- [ ] buy milk ", " and bread ", " today"]
132+ );
133+ assert_eq!(texts("averyveryverylongword end", 10, false), ["averyveryv", "erylongwor", "d end"]);
134+ }
135+
136+ #[test]
137+ fn code_breaks_hard_leaving_a_column() {
138+ assert_eq!(texts("let x = compute(a, b);", 10, true), ["let x = c", "ompute(a,", " b);"]);
139+ }
140+
141+ #[test]
142+ fn locate_and_back() {
143+ let t = "- one two three four";
144+ let rows = wrap(t, 10, false);
145+ let (r, x) = locate(&rows, t, 12);
146+ assert_eq!((r, x), (1, 4));
147+ assert_eq!(column_at(&rows, t, r, x), 12);
148+ // the end of the line sits on the last row
149+ let (r, _) = locate(&rows, t, t.len());
150+ assert_eq!(r, rows.len() - 1);
151+ }
152+}
crates/fold-tui/tests/wrap.rsnew file+129
@@ -0,0 +1,129 @@
1+//! Long lines wrap (§10.1): in the reading pane and the editor, with code
2+//! broken hard and `zw` to cut lines at the edge instead.
3+
4+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
5+use fold_tui::app::{App, EditKeys, Hit};
6+use ratatui::{backend::TestBackend, Terminal};
7+
8+const W: u16 = 90;
9+const H: u16 = 20;
10+
11+fn app_with(text: &str) -> (tempfile::TempDir, App) {
12+ let d = tempfile::tempdir().unwrap();
13+ std::fs::write(d.path().join("root.md"), text).unwrap();
14+ let app = App::new(d.path()).unwrap();
15+ (d, app)
16+}
17+
18+fn draw(app: &mut App) -> Vec<String> {
19+ let mut t = Terminal::new(TestBackend::new(W, H)).unwrap();
20+ t.draw(|f| app.draw(f)).unwrap();
21+ let b = t.backend().buffer().clone();
22+ (0..H).map(|y| (0..W).map(|x| b[(x, y)].symbol()).collect::<String>()).collect()
23+}
24+
25+/// Character column of a substring in a screen row.
26+fn col(line: &str, needle: &str) -> u16 {
27+ line[..line.find(needle).unwrap()].chars().count() as u16
28+}
29+
30+fn key(app: &mut App, c: KeyCode) {
31+ app.handle_key(KeyEvent::new(c, KeyModifiers::NONE));
32+}
33+
34+fn keys(app: &mut App, s: &str) {
35+ for c in s.chars() {
36+ key(app, KeyCode::Char(c));
37+ }
38+}
39+
40+const LONG: &str = "Mirrored pairs, no raidz. Snapshots hourly via sanoid and pruned daily, replicated to the offsite box every night.";
41+
42+#[test]
43+fn the_reading_pane_wraps_and_every_row_clicks_through() {
44+ let (_d, mut app) = app_with(&format!("# A\n\n{}\n\nlast\n", LONG));
45+ let s = draw(&mut app);
46+ let first = s.iter().position(|l| l.contains("Mirrored pairs")).unwrap();
47+ assert!(s.iter().any(|l| l.contains("night.")), "{:#?}", s);
48+ // the second row of the paragraph is still the paragraph's line
49+ let second = &s[first + 1];
50+ let x = second.chars().position(|c| c.is_alphabetic()).unwrap_or(40) as u16;
51+ assert_eq!(app.hit_at(x, first as u16 + 1), Some(Hit::DocLine(2)), "{:#?}", s);
52+ // zw: cut at the edge instead
53+ keys(&mut app, "zw");
54+ let s = draw(&mut app);
55+ assert!(!s.iter().any(|l| l.contains("night.")));
56+}
57+
58+#[test]
59+fn list_items_hang_under_their_text() {
60+ let (_d, mut app) = app_with(&format!("# A\n\n- [ ] {}\n", LONG));
61+ let s = draw(&mut app);
62+ // the reading pane: the row with "- ☐ Mirrored" and the one after it
63+ let first = s.iter().position(|l| l.contains("- ☐ Mirrored")).unwrap();
64+ let text_col = col(&s[first], "Mirrored");
65+ let next: Vec<char> = s[first + 1].chars().collect();
66+ let pane = col(&s[first], "- ☐") as usize;
67+ let cont = (pane..next.len()).find(|&i| !next[i].is_whitespace()).unwrap() as u16;
68+ assert_eq!(cont, text_col, "{:#?}", &s[first..first + 2]);
69+}
70+
71+#[test]
72+fn code_breaks_hard_with_a_marker() {
73+ let code = "let replicated = snapshots.iter().filter(|s| s.age() < max_age).map(|s| s.send(offsite)).count();";
74+ let (_d, mut app) = app_with(&format!("# A\n\n```rust\n{}\n```\n", code));
75+ let s = draw(&mut app);
76+ assert!(s.iter().any(|l| l.contains('↪')), "{:#?}", s);
77+}
78+
79+#[test]
80+fn editor_arrows_move_by_screen_row_and_vim_j_by_line() {
81+ let (_d, mut app) = app_with(&format!("# A\n\n{}\nnext\n", LONG));
82+ keys(&mut app, "e");
83+ draw(&mut app);
84+ // normal keymap: Down from the title goes through blank, then the rows
85+ // of the long line, before reaching "next"
86+ for _ in 0..3 {
87+ key(&mut app, KeyCode::Down);
88+ }
89+ draw(&mut app);
90+ keys(&mut app, "Z");
91+ key(&mut app, KeyCode::Esc);
92+ let text = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
93+ assert!(!text.contains("next\nZ") && !text.starts_with("Z"), "Z landed inside the long line: {}", text);
94+ assert!(text.contains("Z"), "{}", text);
95+ let line = text.lines().find(|l| l.contains('Z')).unwrap();
96+ assert!(line.starts_with("Mirrored") && !line.starts_with("Z"), "{}", line);
97+
98+ // vim: j goes to the next line, gj to the next screen row
99+ let (_d, mut app) = app_with(&format!("# A\n\n{}\nnext\n", LONG));
100+ app.set_edit_keys(EditKeys::Vim);
101+ keys(&mut app, "e");
102+ draw(&mut app);
103+ keys(&mut app, "jjjiY");
104+ key(&mut app, KeyCode::Esc);
105+ keys(&mut app, "kgjiG");
106+ key(&mut app, KeyCode::Esc);
107+ keys(&mut app, ":wq");
108+ key(&mut app, KeyCode::Enter);
109+ let text = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
110+ assert!(text.contains("\nYnext\n"), "{}", text);
111+ let long = text.lines().find(|l| l.starts_with("Mirrored")).unwrap();
112+ assert!(long.contains('G') && !long.starts_with('G'), "gj stays inside the long line: {}", long);
113+}
114+
115+#[test]
116+fn clicking_a_wrapped_row_in_the_editor_places_the_cursor_there() {
117+ let (_d, mut app) = app_with(&format!("# A\n\n{}\n", LONG));
118+ keys(&mut app, "e");
119+ let s = draw(&mut app);
120+ let y = s.iter().position(|l| l.contains("offsite")).unwrap() as u16;
121+ let x = col(&s[y as usize], "offsite");
122+ let ev = |kind| MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE };
123+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left)));
124+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left)));
125+ keys(&mut app, "#");
126+ key(&mut app, KeyCode::Esc);
127+ let text = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
128+ assert!(text.contains("the #offsite box"), "{}", text);
129+}