@@ -1,9 +1,12 @@
11//! The TUI application (§10).
22
3−use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
3+use crossterm::event::{
4+ self, Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
5+};
46use crossterm::terminal::{
57 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
68};
9+use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
710use crossterm::ExecutableCommand;
811use fold_core::ops;
912use fold_core::parse::{Kind, TaskState};
@@ -88,6 +91,12 @@ pub struct App {
8891 last_watch_event: Instant,
8992 self_write_until: Instant,
9093 pending_reload: bool,
94+ // mouse support
95+ mouse_enabled: bool,
96+ pane_outline: Rect,
97+ pane_reading: Rect,
98+ outline_scroll: usize,
99+ last_click: Option<(u16, u16, Instant)>,
91100}
92101
93102struct Prompt {
@@ -146,9 +155,22 @@ impl App {
146155 last_watch_event: Instant::now(),
147156 self_write_until: Instant::now() - Duration::from_secs(1),
148157 pending_reload: false,
158+ mouse_enabled: false,
159+ pane_outline: Rect::default(),
160+ pane_reading: Rect::default(),
161+ outline_scroll: 0,
162+ last_click: None,
149163 })
150164 }
151165
166+ pub fn pane_outline_pub(&self) -> Rect { self.pane_outline }
167+ pub fn pane_reading_pub(&self) -> Rect { self.pane_reading }
168+
169+ /// Turn mouse support on (called by run(); off in tests).
170+ pub fn enable_mouse(&mut self) {
171+ self.mouse_enabled = true;
172+ }
173+
152174 /// Start the vault watcher (§11.2): recursive, events on a channel.
153175 pub fn start_watcher(&mut self) {
154176 use notify::{RecursiveMode, Watcher};
@@ -997,6 +1019,162 @@ impl App {
9971019 let _ = block;
9981020 }
9991021
1022+ // -------------------------------------------------------- mouse
1023+
1024+ /// Handle a mouse event. Layout: outline clicks select/fold/zoom, wheel
1025+ /// scrolls the pane under the pointer, reading clicks move its cursor,
1026+ /// editor clicks place the text cursor. Double-click zooms/follows.
1027+ pub fn handle_mouse(&mut self, m: MouseEvent) {
1028+ if !self.mouse_enabled {
1029+ return;
1030+ }
1031+ let (x, y) = (m.column, m.row);
1032+ match m.kind {
1033+ MouseEventKind::ScrollDown => self.mouse_scroll(x, y, 3),
1034+ MouseEventKind::ScrollUp => self.mouse_scroll(x, y, -3),
1035+ MouseEventKind::Down(MouseButton::Left) => self.mouse_click(x, y),
1036+ _ => {}
1037+ }
1038+ }
1039+
1040+ fn mouse_scroll(&mut self, x: u16, y: u16, delta: i32) {
1041+ if self.point_in(self.pane_outline, x, y) {
1042+ let len = self.rows().len() as i32;
1043+ let mut c = self.cursor as i32 + delta;
1044+ c = c.clamp(0, (len - 1).max(0));
1045+ self.cursor = c as usize;
1046+ self.focus = Focus::Outline;
1047+ } else if self.point_in(self.pane_reading, x, y) {
1048+ let doc = self.reading_doc();
1049+ let max = doc.lines.len().saturating_sub(1) as i32;
1050+ let mut c = self.read_cursor as i32 + delta;
1051+ c = c.clamp(0, max.max(0));
1052+ self.read_cursor = c as usize;
1053+ self.scroll_reading = self.read_cursor;
1054+ if self.mode != Mode::Edit {
1055+ self.focus = Focus::Reading;
1056+ } else {
1057+ // scroll the editor buffer instead
1058+ let el = self.edit_cursor.0 as i32 + delta;
1059+ self.edit_cursor.0 = el.clamp(
1060+ 0,
1061+ self.edit_buf
1062+ .as_ref()
1063+ .map(|b| b.lines.len().saturating_sub(1))
1064+ .unwrap_or(0) as i32,
1065+ ) as usize;
1066+ }
1067+ }
1068+ }
1069+
1070+ fn mouse_click(&mut self, x: u16, y: u16) {
1071+ let double = self
1072+ .last_click
1073+ .map(|(lx, ly, t)| lx == x && ly == y && t.elapsed() < Duration::from_millis(500))
1074+ .unwrap_or(false);
1075+ self.last_click = Some((x, y, Instant::now()));
1076+
1077+ if self.mode == Mode::Edit && self.point_in(self.pane_reading, x, y) {
1078+ // place the text cursor
1079+ let inner_top = self.pane_reading.y + 1;
1080+ let inner_left = self.pane_reading.x + 1;
1081+ if y >= inner_top && x >= inner_left {
1082+ let inner_height = self.pane_reading.height.saturating_sub(2) as usize;
1083+ let scroll = if self.edit_cursor.0 >= inner_height {
1084+ self.edit_cursor.0 + 1 - inner_height
1085+ } else {
1086+ 0
1087+ };
1088+ let line = scroll + (y - inner_top) as usize;
1089+ if let Some(buf) = self.edit_buf.as_mut() {
1090+ if line < buf.lines.len() {
1091+ self.edit_cursor.0 = line;
1092+ let col_target = (x - inner_left) as usize;
1093+ let len = buf.lines[line].text.chars().count();
1094+ self.edit_cursor.1 = col_target.min(len);
1095+ self.edit_last_key = Instant::now();
1096+ }
1097+ }
1098+ }
1099+ return;
1100+ }
1101+ if self.point_in(self.pane_outline, x, y) {
1102+ self.focus = Focus::Outline;
1103+ let inner_top = self.pane_outline.y + 1;
1104+ if y < inner_top {
1105+ return;
1106+ }
1107+ let idx = self.outline_scroll + (y - inner_top) as usize;
1108+ let rows = self.rows();
1109+ let Some(row) = rows.get(idx) else { return };
1110+ self.cursor = idx;
1111+ // click on the fold marker toggles the fold
1112+ let inner_left = self.pane_outline.x + 1;
1113+ let marker_x = inner_left + (row.depth * 2) as u16;
1114+ if x >= marker_x && x <= marker_x + 1 && !self.vault.tree.resolved_children(row.nref).is_empty()
1115+ {
1116+ self.toggle_fold(row.nref);
1117+ return;
1118+ }
1119+ if double {
1120+ // double-click: zoom into the node (like Enter)
1121+ if self.vault.tree.node(row.nref).kind != Kind::Root {
1122+ self.zoom_root = Some(row.nref);
1123+ self.cursor = 0;
1124+ self.read_cursor = 0;
1125+ self.scroll_reading = 0;
1126+ self.focus = Focus::Reading;
1127+ }
1128+ }
1129+ } else if self.point_in(self.pane_reading, x, y) {
1130+ self.focus = Focus::Reading;
1131+ let inner_top = self.pane_reading.y + 1;
1132+ if y < inner_top {
1133+ return;
1134+ }
1135+ let doc = self.reading_doc();
1136+ let inner_height = self.pane_reading.height.saturating_sub(2) as usize;
1137+ let skip = if self.read_cursor >= inner_height && inner_height > 0 {
1138+ self.read_cursor + 1 - inner_height
1139+ } else {
1140+ self.scroll_reading.min(doc.lines.len().saturating_sub(1))
1141+ };
1142+ let line = skip + (y - inner_top) as usize;
1143+ if line < doc.lines.len() {
1144+ self.read_cursor = line;
1145+ self.scroll_reading = self.scroll_reading.min(self.read_cursor);
1146+ if double {
1147+ use fold_core::reading::LineRef;
1148+ match fold_core::reading::node_at(&doc, line) {
1149+ Some(LineRef::Title(r)) => {
1150+ let n = self.vault.tree.node(r);
1151+ if n.task.is_some() {
1152+ let _ = fold_core::ops::toggle_task(&mut self.vault, r);
1153+ } else if n.kind == Kind::Section {
1154+ self.zoom_root = Some(r);
1155+ self.read_cursor = 0;
1156+ self.scroll_reading = 0;
1157+ }
1158+ }
1159+ Some(LineRef::Embed(e)) => {
1160+ let t = self.vault.tree.resolved_child(e);
1161+ if t != e {
1162+ self.zoom_root = Some(t);
1163+ self.read_cursor = 0;
1164+ self.scroll_reading = 0;
1165+ }
1166+ }
1167+ _ => {}
1168+ }
1169+ }
1170+ }
1171+ }
1172+ }
1173+
1174+ fn point_in(&self, r: Rect, x: u16, y: u16) -> bool {
1175+ x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
1176+ }
1177+
10001178 pub fn key_normal(&mut self, key: KeyEvent) {
10011179 let rows = self.rows();
10021180 match self.focus {
@@ -1621,6 +1799,8 @@ impl App {
16211799 self.draw_status(f, chunks[1]);
16221800 return;
16231801 }
1802+ self.pane_outline = panes[0];
1803+ self.pane_reading = panes[1];
16241804 self.draw_outline(f, panes[0]);
16251805 match self.mode {
16261806 Mode::Edit => self.draw_editor(f, panes[1]),
@@ -1677,6 +1857,7 @@ impl App {
16771857 } else {
16781858 0
16791859 };
1860+ self.outline_scroll = scroll;
16801861 let mut lines: Vec<Line> = Vec::new();
16811862 for (i, row) in rows.iter().enumerate().skip(scroll).take(inner_height) {
16821863 let n = self.vault.tree.node(row.nref);
@@ -2118,6 +2299,10 @@ pub fn help_text() -> Vec<Line<'static>> {
21182299 ("x e a o", "toggle · edit · properties · open link"),
21192300 ("[[ / ]]", "previous / next heading · / search, n/N next"),
21202301 ("", ""),
2302+ ("MOUSE", ""),
2303+ ("click", "select a node · click ▾/▸ to fold · double-click zooms"),
2304+ ("wheel", "scroll the pane under the pointer"),
2305+ ("", ""),
21212306 ("CONFLICTS & QUIT", ""),
21222307 (":merge", "fold sync-conflict files in, then resolve:"),
21232308 ("o t b", "keep ours / theirs / both · n/N pairs · Enter done"),
@@ -2216,6 +2401,7 @@ fn palette_actions() -> Vec<PaletteAction> {
22162401
22172402pub fn run(dir: &Path) -> anyhow::Result<()> {
22182403 let mut app = App::new(dir)?;
2404+ app.enable_mouse();
22192405 app.start_watcher();
22202406 // a sync-conflict file present at startup starts the merge flow (§12.2)
22212407 if let Ok(files) = app.vault.conflict_files() {
@@ -2231,10 +2417,12 @@ pub fn run(dir: &Path) -> anyhow::Result<()> {
22312417 }
22322418 enable_raw_mode()?;
22332419 std::io::stdout().execute(EnterAlternateScreen)?;
2420+ std::io::stdout().execute(EnableMouseCapture)?;
22342421 let backend = CrosstermBackend::new(std::io::stdout());
22352422 let mut terminal = Terminal::new(backend)?;
22362423 let res = run_loop(&mut terminal, &mut app);
22372424 disable_raw_mode()?;
2425+ std::io::stdout().execute(DisableMouseCapture)?;
22382426 std::io::stdout().execute(LeaveAlternateScreen)?;
22392427 res
22402428}
@@ -2266,7 +2454,9 @@ fn run_loop(
22662454 app.reload_external();
22672455 }
22682456 if event::poll(Duration::from_millis(200))? {
2269− if let Event::Key(key) = event::read()? {
2457+ match event::read()? {
2458+ Event::Mouse(m) => app.handle_mouse(m),
2459+ Event::Key(key) => {
22702460 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c')
22712461 {
22722462 return Ok(());
@@ -2334,6 +2524,8 @@ fn run_loop(
23342524 }
23352525 Mode::Conflict => app.key_conflict(key),
23362526 }
2527+ }
2528+ _ => {}
23372529 }
23382530 }
23392531 }