@@ -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 }