@@ -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:"),