on master help sign in
full mouse operability: clickable toolbar with every action, on-screen keyboard for editor/prompts/filter/palette, clickable palette/filter/props rows, help closes on click
commit3d54c76c0208b23785b7e78cbcc04a0b98cb28dd authoriko <ilyakooo0@gmail.com> dateSat 12 Sep 2026, 16:05 (1 month ago) parent4d715cd
4 files changed+573−8
.vix/access_stats.db .vix/history.txt+6 crates/fold-tui/src/app.rs+393−8 crates/fold-tui/tests/mouse_all.rs+174
.vix/access_stats.db

Binary file not shown.

.vix/history.txt+6
@@ -98,3 +98,9 @@
9898
9999# 2026-09-12 15:50:33.36588831 +0000 UTC
100100+Implement mouse support
101+
102+# 2026-09-12 15:58:01.495707142 +0000 UTC
103++How do I edit the text?
104+
105+# 2026-09-12 15:59:01.601032173 +0000 UTC
106++Make sure EVERYTHING can be done with mouse alone.
crates/fold-tui/src/app.rs+393−8
@@ -97,6 +97,13 @@ pub struct App {
9797 pane_reading: Rect,
9898 outline_scroll: usize,
9999 last_click: Option<(u16, u16, Instant)>,
100+ toolbar_rect: Rect,
101+ toolbar: Vec<(Rect, &'static str)>,
102+ kb_rects: Vec<(Rect, char)>,
103+ kb_special: Vec<(Rect, &'static str)>,
104+ palette_rows: Vec<(Rect, &'static str)>,
105+ filter_rows_rects: Vec<(Rect, NRef)>,
106+ props_row_rects: Vec<Rect>,
100107}
101108
102109struct Prompt {
@@ -160,10 +167,50 @@ impl App {
160167 pane_reading: Rect::default(),
161168 outline_scroll: 0,
162169 last_click: None,
170+ toolbar_rect: Rect::default(),
171+ toolbar: Vec::new(),
172+ kb_rects: Vec::new(),
173+ kb_special: Vec::new(),
174+ palette_rows: Vec::new(),
175+ filter_rows_rects: Vec::new(),
176+ props_row_rects: Vec::new(),
163177 })
164178 }
165179
166180 pub fn pane_outline_pub(&self) -> Rect { self.pane_outline }
181+ pub fn toolbar_labels(&self) -> Vec<String> {
182+ self.toolbar.iter().map(|(_, a)| a.to_string()).collect()
183+ }
184+ pub fn toolbar_pos(&self, label: &str) -> Option<(u16, u16)> {
185+ self.toolbar
186+ .iter()
187+ .find(|(_, a)| *a == label)
188+ .map(|(r, _)| (r.x, r.y))
189+ }
190+ pub fn kb_pos(&self, c: char) -> Option<(u16, u16)> {
191+ self.kb_rects
192+ .iter()
193+ .find(|(_, k)| *k == c)
194+ .map(|(r, _)| (r.x, r.y))
195+ }
196+ pub fn kb_special_pos(&self, s: &str) -> Option<(u16, u16)> {
197+ self.kb_special
198+ .iter()
199+ .find(|(_, k)| *k == s)
200+ .map(|(r, _)| (r.x, r.y))
201+ }
202+ pub fn palette_row_pos(&self, name: &str) -> Option<(u16, u16)> {
203+ self.palette_rows
204+ .iter()
205+ .find(|(_, n)| *n == name)
206+ .map(|(r, _)| (r.x, r.y))
207+ }
208+ pub fn filter_row_pos(&self, i: usize) -> Option<(u16, u16)> {
209+ self.filter_rows_rects.get(i).map(|(r, _)| (r.x, r.y))
210+ }
211+ pub fn props_row_pos(&self, i: usize) -> Option<(u16, u16)> {
212+ self.props_row_rects.get(i).map(|r| (r.x, r.y))
213+ }
167214 pub fn pane_reading_pub(&self) -> Rect { self.pane_reading }
168215
169216 /// Turn mouse support on (called by run(); off in tests).
@@ -1074,6 +1121,70 @@ impl App {
10741121 .unwrap_or(false);
10751122 self.last_click = Some((x, y, Instant::now()));
10761123
1124+ // the action bar wins over everything
1125+ if self.mouse_enabled && self.toolbar_click(x, y) {
1126+ return;
1127+ }
1128+ // on-screen keyboard (edit, prompts, filter, palette)
1129+ if self.kb_click(x, y) {
1130+ return;
1131+ }
1132+ // Help closes on any click outside the toolbar
1133+ if self.mode == Mode::Help {
1134+ self.mode = Mode::Normal;
1135+ return;
1136+ }
1137+ // palette rows are clickable
1138+ if self.mode == Mode::Picker {
1139+ if let Some((_, name)) = self
1140+ .palette_rows
1141+ .iter()
1142+ .find(|(r, _)| self.point_in(*r, x, y))
1143+ {
1144+ let name = *name;
1145+ self.mode = Mode::Normal;
1146+ self.palette.clear();
1147+ self.run_palette(name);
1148+ return;
1149+ }
1150+ }
1151+ // filter results are clickable
1152+ if self.mode == Mode::Filter {
1153+ if let Some((_, r)) = self
1154+ .filter_rows_rects
1155+ .iter()
1156+ .find(|(r, _)| self.point_in(*r, x, y))
1157+ {
1158+ let r = *r;
1159+ let path = self.vault.tree.ancestors(r);
1160+ for a in &path {
1161+ let k = self.vault.key_of(*a);
1162+ self.folded.retain(|f| f != &k);
1163+ }
1164+ self.zoom_root = None;
1165+ self.move_cursor_to(r);
1166+ self.mode = Mode::Normal;
1167+ self.filter.clear();
1168+ self.filter_rows.clear();
1169+ return;
1170+ }
1171+ }
1172+ // props rows are clickable (select, then toolbar Edit/Del acts)
1173+ if self.mode == Mode::Props {
1174+ if let Some(i) = self
1175+ .props_row_rects
1176+ .iter()
1177+ .position(|r| self.point_in(*r, x, y))
1178+ {
1179+ self.props_sel = i;
1180+ return;
1181+ }
1182+ }
1183+ // prompt: Enter on click outside the keyboard acts as accept
1184+ if self.prompt.is_some() {
1185+ self.text_input(KeyCode::Enter);
1186+ return;
1187+ }
10771188 if self.mode == Mode::Edit && self.point_in(self.pane_reading, x, y) {
10781189 // place the text cursor
10791190 let inner_top = self.pane_reading.y + 1;
@@ -1171,6 +1282,116 @@ impl App {
11711282 }
11721283 }
11731284
1285+ /// Handle a toolbar button click. Returns true if a button was hit.
1286+ fn toolbar_click(&mut self, x: u16, y: u16) -> bool {
1287+ let hit = self
1288+ .toolbar
1289+ .iter()
1290+ .find(|(r, _)| self.point_in(*r, x, y))
1291+ .map(|(_, a)| *a);
1292+ let Some(action) = hit else { return false };
1293+ match action {
1294+ "New" => self.act_new_node(false),
1295+ "Edit" => self.act_edit(),
1296+ "Props" => self.act_props(),
1297+ "Done" => self.act_toggle_task(),
1298+ "Task" => self.act_toggle_taskness(),
1299+ "Block" => self.act_make_block(),
1300+ "Yank" => self.act_yank(),
1301+ "Trash" => self.act_delete(),
1302+ "Paste" => self.act_paste(true),
1303+ "Capture" => {
1304+ self.prompt = Some(Prompt {
1305+ label: "capture".into(),
1306+ text: String::new(),
1307+ action: PromptAction::CaptureText(false),
1308+ });
1309+ }
1310+ "Archive" => self.act_archive(),
1311+ "Refile" => {
1312+ self.prompt = Some(Prompt {
1313+ label: "refile to".into(),
1314+ text: String::new(),
1315+ action: PromptAction::Refile,
1316+ });
1317+ }
1318+ "Undo" => self.act_undo(),
1319+ "Redo" => self.act_redo(),
1320+ "Filter" => {
1321+ self.mode = Mode::Filter;
1322+ self.filter.clear();
1323+ }
1324+ "Menu" => {
1325+ self.mode = Mode::Picker;
1326+ self.palette.clear();
1327+ }
1328+ "Help" => self.mode = Mode::Help,
1329+ "Quit" => self.quit = true,
1330+ "Save" => self.save_editor("toolbar"),
1331+ "Close" => match self.mode {
1332+ Mode::Edit => self.close_editor(),
1333+ Mode::Props | Mode::Filter | Mode::Picker | Mode::Help => {
1334+ self.mode = Mode::Normal
1335+ }
1336+ _ => {}
1337+ },
1338+ "Discard" => {
1339+ self.edit_buf = None;
1340+ self.mode = Mode::Normal;
1341+ let _ = self.vault.reload();
1342+ self.say("changes discarded");
1343+ }
1344+ "Ours" => self.key_conflict(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)),
1345+ "Theirs" => self.key_conflict(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)),
1346+ "Both" => self.key_conflict(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)),
1347+ "Prev" => self.key_conflict(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE)),
1348+ "Next" => self.key_conflict(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)),
1349+ "Del" => self.key_props(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)),
1350+ _ => {}
1351+ }
1352+ true
1353+ }
1354+
1355+ /// Handle a click on the on-screen keyboard. Routes to whichever input
1356+ /// is active: the editor, a prompt, the filter box or the palette.
1357+ fn kb_click(&mut self, x: u16, y: u16) -> bool {
1358+ if let Some((_, c)) = self.kb_rects.iter().find(|(r, _)| self.point_in(*r, x, y)) {
1359+ let c = *c;
1360+ self.text_input(KeyCode::Char(c));
1361+ return true;
1362+ }
1363+ if let Some((_, s)) = self.kb_special.iter().find(|(r, _)| self.point_in(*r, x, y)) {
1364+ let s = *s;
1365+ match s {
1366+ "Space" => self.text_input(KeyCode::Char(' ')),
1367+ "Enter" => self.text_input(KeyCode::Enter),
1368+ "Bksp" => self.text_input(KeyCode::Backspace),
1369+ "↑" => self.text_input(KeyCode::Up),
1370+ "↓" => self.text_input(KeyCode::Down),
1371+ "←" => self.text_input(KeyCode::Left),
1372+ "→" => self.text_input(KeyCode::Right),
1373+ _ => {}
1374+ }
1375+ return true;
1376+ }
1377+ false
1378+ }
1379+
1380+ /// Route a synthesized key to the active text input.
1381+ fn text_input(&mut self, code: KeyCode) {
1382+ let key = KeyEvent::new(code, KeyModifiers::NONE);
1383+ if self.prompt.is_some() {
1384+ self.key_prompt(key);
1385+ } else {
1386+ match self.mode {
1387+ Mode::Edit => self.key_edit(key),
1388+ Mode::Filter => self.key_filter(key),
1389+ Mode::Picker => self.key_palette(key),
1390+ _ => {}
1391+ }
1392+ }
1393+ }
1394+
11741395 fn point_in(&self, r: Rect, x: u16, y: u16) -> bool {
11751396 x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
11761397 }
@@ -1780,7 +2001,11 @@ impl App {
17802001 let size = f.area();
17812002 let chunks = Layout::default()
17822003 .direction(Direction::Vertical)
1783− .constraints([Constraint::Min(3), Constraint::Length(1)])
2004+ .constraints([
2005+ Constraint::Min(3),
2006+ Constraint::Length(1),
2007+ Constraint::Length(1),
2008+ ])
17842009 .split(size);
17852010 let narrow = size.width < 80;
17862011 let panes = if narrow {
@@ -1796,7 +2021,8 @@ impl App {
17962021 };
17972022 if self.mode == Mode::Conflict {
17982023 self.draw_conflict(f, chunks[0]);
1799− self.draw_status(f, chunks[1]);
2024+ self.draw_status(f, chunks[2]);
2025+ self.draw_toolbar(f, chunks[1]);
18002026 return;
18012027 }
18022028 self.pane_outline = panes[0];
@@ -1806,7 +2032,20 @@ impl App {
18062032 Mode::Edit => self.draw_editor(f, panes[1]),
18072033 _ => self.draw_reading(f, panes[1]),
18082034 }
1809− self.draw_status(f, chunks[1]);
2035+ let needs_keyboard = self.mouse_enabled
2036+ && (self.mode == Mode::Edit
2037+ || self.prompt.is_some()
2038+ || self.mode == Mode::Filter
2039+ || self.mode == Mode::Picker);
2040+ if needs_keyboard {
2041+ let kb = Layout::default()
2042+ .direction(Direction::Vertical)
2043+ .constraints([Constraint::Min(0), Constraint::Length(4)])
2044+ .split(chunks[0]);
2045+ self.draw_keyboard(f, kb[1]);
2046+ }
2047+ self.draw_status(f, chunks[2]);
2048+ self.draw_toolbar(f, chunks[1]);
18102049 match self.mode {
18112050 Mode::Filter => self.draw_filter(f, size),
18122051 Mode::Picker => self.draw_palette(f, size),
@@ -2044,7 +2283,14 @@ impl App {
20442283 Style::default().fg(Color::DarkGray),
20452284 )));
20462285 }
2286+ self.props_row_rects.clear();
20472287 for (i, (k, v, editable)) in self.props_rows.iter().enumerate() {
2288+ self.props_row_rects.push(Rect {
2289+ x: rect.x + 1,
2290+ y: rect.y + 2 + i as u16,
2291+ width: rect.width.saturating_sub(2),
2292+ height: 1,
2293+ });
20482294 let style = if i == self.props_sel {
20492295 Style::default().bg(Color::DarkGray)
20502296 } else if !editable {
@@ -2081,8 +2327,125 @@ impl App {
20812327 f.render_widget(Paragraph::new(line).block(block), rect);
20822328 }
20832329
2084− fn draw_status(&mut self, f: &mut ratatui::Frame, area: Rect) {
2085− let file = self
2330+ /// The clickable action bar above the status line (mouse support).
2331+ fn draw_toolbar(&mut self, f: &mut ratatui::Frame, area: Rect) {
2332+ if !self.mouse_enabled {
2333+ self.toolbar.clear();
2334+ return;
2335+ }
2336+ self.toolbar_rect = area;
2337+ let actions: &[&str] = match self.mode {
2338+ Mode::Normal => &[
2339+ "New", "Edit", "Props", "Done", "Task", "Block", "Yank", "Trash", "Paste",
2340+ "Capture", "Archive", "Refile", "Undo", "Redo", "Filter", "Menu", "Help", "Quit",
2341+ ],
2342+ Mode::Edit => &["Save", "Close", "Discard"],
2343+ Mode::Conflict => &["Ours", "Theirs", "Both", "Edit", "Prev", "Next", "Done"],
2344+ Mode::Props => &["New", "Edit", "Del", "Close"],
2345+ Mode::Filter | Mode::Picker | Mode::Help => &["Close"],
2346+ };
2347+ self.toolbar.clear();
2348+ let mut spans: Vec<TSpan> = Vec::new();
2349+ let mut x = area.x + 1;
2350+ for a in actions {
2351+ let label = format!(" {} ", a);
2352+ let w = label.chars().count() as u16;
2353+ if x + w + 1 > area.x + area.width {
2354+ break;
2355+ }
2356+ self.toolbar.push((
2357+ Rect {
2358+ x,
2359+ y: area.y,
2360+ width: w,
2361+ height: 1,
2362+ },
2363+ a,
2364+ ));
2365+ spans.push(TSpan::styled(
2366+ label,
2367+ Style::default()
2368+ .fg(Color::Black)
2369+ .bg(Color::DarkGray),
2370+ ));
2371+ spans.push(TSpan::raw(" "));
2372+ x += w + 1;
2373+ }
2374+ f.render_widget(Paragraph::new(Line::from(spans)), area);
2375+ }
2376+
2377+ /// A small on-screen keyboard for the built-in editor (mouse support):
2378+ /// every character clickable, plus Enter/Backspace/arrows.
2379+ fn draw_keyboard(&mut self, f: &mut ratatui::Frame, area: Rect) {
2380+ self.kb_rects.clear();
2381+ self.kb_special.clear();
2382+ let rows: [&str; 3] = [
2383+ "1234567890-=",
2384+ "qwertyuiop[]",
2385+ "asdfghjkl;'",
2386+ ];
2387+ let mut y = area.y;
2388+ for row in rows {
2389+ let mut x = area.x + 1;
2390+ for c in row.chars() {
2391+ self.kb_rects.push((
2392+ Rect {
2393+ x,
2394+ y,
2395+ width: 1,
2396+ height: 1,
2397+ },
2398+ c,
2399+ ));
2400+ x += 1;
2401+ }
2402+ y += 1;
2403+ }
2404+ // fourth row: the rest of the letters + specials
2405+ let letters = "zxcvbnm,./";
2406+ let mut x = area.x + 1;
2407+ for c in letters.chars() {
2408+ self.kb_rects.push((
2409+ Rect {
2410+ x,
2411+ y,
2412+ width: 1,
2413+ height: 1,
2414+ },
2415+ c,
2416+ ));
2417+ x += 1;
2418+ }
2419+ let specials: &[&str] = &["Space", "Enter", "Bksp", "↑", "↓", "←", "→"];
2420+ for s in specials {
2421+ let w = s.chars().count() as u16;
2422+ self.kb_special.push((
2423+ Rect {
2424+ x,
2425+ y,
2426+ width: w,
2427+ height: 1,
2428+ },
2429+ s,
2430+ ));
2431+ x += w + 1;
2432+ }
2433+ // render all keys
2434+ for (r, c) in &self.kb_rects {
2435+ f.render_widget(
2436+ Paragraph::new(c.to_string()).style(Style::default().fg(Color::Cyan)),
2437+ *r,
2438+ );
2439+ }
2440+ for (r, s) in &self.kb_special {
2441+ f.render_widget(
2442+ Paragraph::new(s.to_string()).style(Style::default().fg(Color::Cyan)),
2443+ *r,
2444+ );
2445+ }
2446+ }
2447+
2448+ fn draw_status(&mut self, f: &mut ratatui::Frame, area: Rect) { let file = self
20862449 .current()
20872450 .map(|r| self.vault.tree.files[r.0].path.clone())
20882451 .unwrap_or_else(|| "root.md".into());
@@ -2148,8 +2511,18 @@ impl App {
21482511 TSpan::styled("/ ", Style::default().add_modifier(Modifier::BOLD)),
21492512 TSpan::raw(self.filter.clone()),
21502513 ])];
2151− for r in self.filter_rows.iter().take(h as usize - 2) {
2514+ self.filter_rows_rects.clear();
2515+ for (i, r) in self.filter_rows.iter().take(h as usize - 2).enumerate() {
21522516 let path = self.vault.tree.path(*r).join(" › ");
2517+ self.filter_rows_rects.push((
2518+ Rect {
2519+ x: rect.x + 1,
2520+ y: rect.y + 2 + i as u16,
2521+ width: rect.width.saturating_sub(2),
2522+ height: 1,
2523+ },
2524+ *r,
2525+ ));
21532526 lines.push(Line::from(TSpan::raw(format!(" {}", path))));
21542527 }
21552528 let block = WBlock::default().borders(Borders::ALL).title(" filter ");
@@ -2196,7 +2569,17 @@ impl App {
21962569 TSpan::styled(": ", Style::default().add_modifier(Modifier::BOLD)),
21972570 TSpan::raw(self.palette.clone()),
21982571 ])];
2199− for a in hits.iter().take(h as usize - 2) {
2572+ self.palette_rows.clear();
2573+ for (i, a) in hits.iter().take(h as usize - 2).enumerate() {
2574+ self.palette_rows.push((
2575+ Rect {
2576+ x: rect.x + 1,
2577+ y: rect.y + 2 + i as u16,
2578+ width: rect.width.saturating_sub(2),
2579+ height: 1,
2580+ },
2581+ a.name,
2582+ ));
22002583 lines.push(Line::from(vec![
22012584 TSpan::raw(format!(" {:<20}", a.name)),
22022585 TSpan::styled(
@@ -2300,8 +2683,10 @@ pub fn help_text() -> Vec<Line<'static>> {
23002683 ("[[ / ]]", "previous / next heading · / search, n/N next"),
23012684 ("", ""),
23022685 ("MOUSE", ""),
2303− ("click", "select a node · click ▾/▸ to fold · double-click zooms"),
2686+ ("click", "select · ▾/▸ folds · double-click zooms/follows/toggles"),
23042687 ("wheel", "scroll the pane under the pointer"),
2688+ ("toolbar", "the button bar above the status line has every action"),
2689+ ("keyboard", "editing/prompts show an on-screen keyboard"),
23052690 ("", ""),
23062691 ("CONFLICTS & QUIT", ""),
23072692 (":merge", "fold sync-conflict files in, then resolve:"),
crates/fold-tui/tests/mouse_all.rsnew file+174
@@ -0,0 +1,174 @@
1+use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
2+use fold_tui::app::App;
3+use ratatui::backend::TestBackend;
4+use ratatui::Terminal;
5+
6+fn toolbar_labels(app: &mut App) -> Vec<String> {
7+ draw_once(app, 160, 30);
8+ app.toolbar_labels()
9+}
10+
11+fn app_with(text: &str) -> (tempfile::TempDir, App) {
12+ let dir = tempfile::tempdir().unwrap();
13+ std::fs::write(dir.path().join("root.md"), text).unwrap();
14+ let app = App::new(dir.path()).unwrap();
15+ (dir, app)
16+}
17+
18+fn draw_once(app: &mut App, w: u16, h: u16) {
19+ let backend = TestBackend::new(w, h);
20+ let mut terminal = Terminal::new(backend).unwrap();
21+ terminal.draw(|f| app.draw(f)).unwrap();
22+}
23+
24+fn click(x: u16, y: u16) -> MouseEvent {
25+ MouseEvent {
26+ kind: MouseEventKind::Down(MouseButton::Left),
27+ column: x,
28+ row: y,
29+ modifiers: crossterm::event::KeyModifiers::NONE,
30+ }
31+}
32+
33+fn toolbar_click(app: &mut App, label: &str) {
34+ draw_once(app, 160, 30);
35+ let (x, y) = app.toolbar_pos(label).unwrap_or_else(|| panic!("no button {}", label));
36+ app.handle_mouse(click(x, y));
37+}
38+
39+#[test]
40+fn toolbar_done_toggles_task() {
41+ let (_d, mut app) = app_with("# A\n\n- [ ] do it\n");
42+ app.enable_mouse();
43+ // select the task row first (row 1)
44+ draw_once(&mut app, 160, 30);
45+ app.handle_mouse(click(10, 2));
46+ toolbar_click(&mut app, "Done");
47+ let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
48+ assert!(root.contains("- [x] do it"), "{}", root);
49+}
50+
51+#[test]
52+fn toolbar_edit_and_keyboard_types() {
53+ let (_d, mut app) = app_with("# A\n\nold\n");
54+ app.enable_mouse();
55+ toolbar_click(&mut app, "Edit");
56+ assert_eq!(app.mode_pub(), "edit");
57+ // click on the on-screen 'w' key, then Enter, then Close
58+ draw_once(&mut app, 160, 30);
59+ let (wx, wy) = app.kb_pos('w').expect("w key");
60+ app.handle_mouse(click(wx, wy));
61+ let root_before = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
62+ assert!(!root_before.contains("w"));
63+ toolbar_click(&mut app, "Save");
64+ toolbar_click(&mut app, "Close");
65+ let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
66+ assert!(root.contains('w'), "{}", root);
67+}
68+
69+#[test]
70+fn toolbar_capture_prompt_via_keyboard() {
71+ let (_d, mut app) = app_with("# Inbox\n");
72+ app.enable_mouse();
73+ toolbar_click(&mut app, "Capture");
74+ // prompt is open; type "hi" with the on-screen keyboard
75+ draw_once(&mut app, 160, 30);
76+ for c in ['h', 'i'] {
77+ let (x, y) = app.kb_pos(c).unwrap();
78+ app.handle_mouse(click(x, y));
79+ }
80+ // accept: click Enter key
81+ draw_once(&mut app, 160, 30);
82+ let (x, y) = app.kb_special_pos("Enter").unwrap();
83+ app.handle_mouse(click(x, y));
84+ let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
85+ assert!(root.contains("- hi"), "{}", root);
86+}
87+
88+#[test]
89+fn toolbar_help_and_close() {
90+ let (_d, mut app) = app_with("# A\n");
91+ app.enable_mouse();
92+ draw_once(&mut app, 160, 30); // first draw populates the toolbar
93+ toolbar_click(&mut app, "Help");
94+ assert_eq!(app.mode_pub(), "help");
95+ // click anywhere outside the toolbar closes it
96+ draw_once(&mut app, 160, 30);
97+ app.handle_mouse(click(80, 10));
98+ assert_eq!(app.mode_pub(), "normal");
99+}
100+
101+#[test]
102+fn palette_row_click_runs_action() {
103+ let (_d, mut app) = app_with("# A\n\n- [x] finished\n");
104+ app.enable_mouse();
105+ draw_once(&mut app, 160, 30); // first draw populates the toolbar
106+ toolbar_click(&mut app, "Menu");
107+ assert_eq!(app.mode_pub(), "picker");
108+ // click the "clear done" row
109+ draw_once(&mut app, 160, 30);
110+ let (x, y) = app.palette_row_pos("clear done").expect("row");
111+ app.handle_mouse(click(x, y));
112+ let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
113+ assert!(!root.contains("finished"), "{}", root);
114+}
115+
116+#[test]
117+fn toolbar_undo_restores() {
118+ let (_d, mut app) = app_with("# A\n\n- keep me\n");
119+ app.enable_mouse();
120+ draw_once(&mut app, 160, 30);
121+ app.handle_mouse(click(10, 2)); // select the item
122+ toolbar_click(&mut app, "Trash");
123+ let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
124+ assert!(!root.contains("keep me"));
125+ toolbar_click(&mut app, "Undo");
126+ let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
127+ assert!(root.contains("keep me"), "{}", root);
128+}
129+
130+#[test]
131+fn filter_result_click_zooms() {
132+ let (_d, mut app) = app_with("# Alpha\n\n# Beta\n\n# Gamma\n");
133+ app.enable_mouse();
134+ toolbar_click(&mut app, "Filter");
135+ draw_once(&mut app, 160, 30);
136+ for c in ['g', 'a', 'm', 'm', 'a'] {
137+ let (x, y) = app.kb_pos(c).unwrap();
138+ app.handle_mouse(click(x, y));
139+ }
140+ draw_once(&mut app, 160, 30);
141+ let (x, y) = app.filter_row_pos(0).expect("one hit");
142+ app.handle_mouse(click(x, y));
143+ assert_eq!(app.mode_pub(), "normal");
144+ // cursor on Gamma
145+ let cur = app.cursor;
146+ let rows = app.rows();
147+ assert_eq!(app.title_of(rows[cur].nref), "Gamma");
148+}
149+
150+#[test]
151+fn props_row_click_selects() {
152+ let (_d, mut app) = app_with("# A\n\n- task\n");
153+ app.enable_mouse();
154+ draw_once(&mut app, 160, 30);
155+ app.handle_mouse(click(10, 2));
156+ // give it a property first via core
157+ let r = app.rows()[1].nref;
158+ fold_core::ops::set_property(app.vault_mut(), r, "due", "2026-09-20").unwrap();
159+ toolbar_click(&mut app, "Props");
160+ assert_eq!(app.mode_pub(), "props");
161+ draw_once(&mut app, 160, 30);
162+ let (x, y) = app.props_row_pos(0).expect("prop row");
163+ app.handle_mouse(click(x, y));
164+ // now Del via toolbar deletes it
165+ toolbar_click(&mut app, "Del");
166+ let files: Vec<_> = std::fs::read_dir(app.vault_dir())
167+ .unwrap()
168+ .filter_map(|e| e.ok())
169+ .map(|e| e.file_name().to_string_lossy().to_string())
170+ .filter(|n| n.ends_with(".md") && n != "root.md")
171+ .collect();
172+ let text = std::fs::read_to_string(app.vault_dir().join(&files[0])).unwrap();
173+ assert!(!text.contains("due:"), "{}", text);
174+}