Skip to main content

mq_db/
tui.rs

1//! Terminal User Interface for mq-db using ratatui + crossterm.
2
3use std::io;
4
5use crossterm::{
6    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
7    execute,
8    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
9};
10use ratatui::{
11    Frame, Terminal,
12    backend::CrosstermBackend,
13    layout::{Constraint, Direction, Layout, Rect},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
17};
18
19use crate::{DocumentStore, MqEngine, MqdbError, SqlEngine, block::BlockType};
20
21// Theme — mirrors the warm paper/ink/accent palette of docs/index.html
22
23mod theme {
24    use ratatui::style::Color;
25
26    pub const PAPER: Color = Color::Rgb(27, 23, 20);
27    pub const PAPER_ALT: Color = Color::Rgb(35, 30, 26);
28    pub const PAPER_DEEP: Color = Color::Rgb(56, 48, 40);
29    pub const INK: Color = Color::Rgb(236, 228, 216);
30    pub const INK_DIM: Color = Color::Rgb(168, 156, 140);
31    pub const ACCENT: Color = Color::Rgb(217, 113, 79);
32    pub const ACCENT_DIM: Color = Color::Rgb(74, 46, 36);
33    pub const MARK: Color = Color::Rgb(214, 168, 76);
34    pub const ERROR: Color = Color::Rgb(224, 90, 90);
35    pub const SAGE: Color = Color::Rgb(150, 168, 111);
36    pub const LAVENDER: Color = Color::Rgb(163, 140, 173);
37    pub const TEAL: Color = Color::Rgb(122, 170, 163);
38    pub const DUSK: Color = Color::Rgb(140, 150, 191);
39}
40
41// State
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum QueryMode {
45    Mq,
46    Sql,
47}
48
49impl QueryMode {
50    fn label(self) -> &'static str {
51        match self {
52            QueryMode::Mq => "mq",
53            QueryMode::Sql => "SQL",
54        }
55    }
56
57    fn toggle(self) -> Self {
58        match self {
59            QueryMode::Mq => QueryMode::Sql,
60            QueryMode::Sql => QueryMode::Mq,
61        }
62    }
63}
64
65// A single displayable line in the results pane, optionally styled.
66#[derive(Clone)]
67struct ResultLine {
68    text: String,
69    style: Style,
70}
71
72impl ResultLine {
73    fn plain(text: impl Into<String>) -> Self {
74        Self {
75            text: text.into(),
76            style: Style::default(),
77        }
78    }
79
80    fn styled(text: impl Into<String>, style: Style) -> Self {
81        Self {
82            text: text.into(),
83            style,
84        }
85    }
86}
87
88struct App {
89    store: DocumentStore,
90    mode: QueryMode,
91    input: String,
92    cursor_pos: usize,
93    result_lines: Vec<ResultLine>,
94    doc_list_state: ListState,
95    input_focused: bool,
96    result_scroll: u16,
97    status_msg: Option<String>,
98}
99
100impl App {
101    fn new(store: DocumentStore) -> Self {
102        let mut doc_list_state = ListState::default();
103        if !store.documents().is_empty() {
104            doc_list_state.select(Some(0));
105        }
106        Self {
107            store,
108            mode: QueryMode::Sql,
109            input: String::new(),
110            cursor_pos: 0,
111            result_lines: Vec::new(),
112            doc_list_state,
113            input_focused: false,
114            result_scroll: 0,
115            status_msg: None,
116        }
117    }
118
119    fn run_query(&mut self) {
120        let code = self.input.trim().to_string();
121        if code.is_empty() {
122            return;
123        }
124        self.result_scroll = 0;
125        self.status_msg = None;
126
127        match self.mode {
128            QueryMode::Sql => match SqlEngine::new(&self.store) {
129                Ok(engine) => match engine.execute(&code) {
130                    Ok(out) => {
131                        self.result_lines = out.to_table().lines().map(ResultLine::plain).collect();
132                        self.status_msg = Some(format!(
133                            "{} row{}",
134                            out.rows.len(),
135                            if out.rows.len() == 1 { "" } else { "s" }
136                        ));
137                    }
138                    Err(e) => {
139                        self.result_lines = vec![ResultLine::styled(
140                            format!("error: {}", e),
141                            Style::default().fg(theme::ERROR),
142                        )];
143                    }
144                },
145                Err(e) => {
146                    self.result_lines = vec![ResultLine::styled(
147                        format!("engine error: {}", e),
148                        Style::default().fg(theme::ERROR),
149                    )];
150                }
151            },
152            QueryMode::Mq => match MqEngine::eval_store(&code, &self.store) {
153                Ok(lines) => {
154                    if lines.is_empty() {
155                        self.result_lines = vec![ResultLine::styled(
156                            "(no results)".to_string(),
157                            Style::default().fg(theme::INK_DIM),
158                        )];
159                    } else {
160                        self.result_lines = lines.iter().map(ResultLine::plain).collect();
161                        self.status_msg = Some(format!(
162                            "{} result{}",
163                            lines.len(),
164                            if lines.len() == 1 { "" } else { "s" }
165                        ));
166                    }
167                }
168                Err(e) => {
169                    self.result_lines = vec![ResultLine::styled(
170                        format!("error: {}", e),
171                        Style::default().fg(theme::ERROR),
172                    )];
173                }
174            },
175        }
176    }
177
178    fn show_selected_document(&mut self) {
179        let Some(idx) = self.doc_list_state.selected() else {
180            return;
181        };
182        let Some(doc) = self.store.documents().get(idx) else {
183            return;
184        };
185
186        let mut lines: Vec<ResultLine> = Vec::new();
187
188        let path = doc
189            .path
190            .as_ref()
191            .map(|p| p.to_string_lossy().to_string())
192            .unwrap_or_else(|| format!("<inline doc {}>", doc.id));
193
194        lines.push(ResultLine::styled(
195            path,
196            Style::default()
197                .fg(theme::ACCENT)
198                .add_modifier(Modifier::BOLD),
199        ));
200        if let Some(title) = &doc.zone_maps.title {
201            lines.push(ResultLine::styled(
202                format!("  title   {}", title),
203                Style::default().fg(theme::INK),
204            ));
205        }
206        lines.push(ResultLine::styled(
207            format!("  blocks  {}", doc.blocks.len()),
208            Style::default().fg(theme::INK_DIM),
209        ));
210        if !doc.zone_maps.tags.is_empty() {
211            lines.push(ResultLine::styled(
212                format!("  tags    {}", doc.zone_maps.tags.join(", ")),
213                Style::default().fg(theme::MARK),
214            ));
215        }
216        lines.push(ResultLine::plain(String::new()));
217
218        // Header
219        lines.push(ResultLine::styled(
220            format!("  {:<4}  {:<4}  {:<14}  content", "pre", "post", "type"),
221            Style::default().fg(theme::INK_DIM),
222        ));
223        lines.push(ResultLine::styled(
224            format!(
225                "  {}  {}  {}  {}",
226                "────",
227                "────",
228                "──────────────",
229                "─".repeat(40)
230            ),
231            Style::default().fg(theme::PAPER_DEEP),
232        ));
233
234        for block in &doc.blocks {
235            let (icon, type_label, color) = block_display(&block.block_type, block.heading_depth());
236            let depth = block.heading_depth().unwrap_or(0) as usize;
237            let indent = "  ".repeat(depth.saturating_sub(1));
238            let preview: String = block.content.chars().take(48).collect();
239            let preview = if block.content.chars().count() > 48 {
240                format!("{}…", preview)
241            } else {
242                preview
243            };
244            let preview = preview.replace('\n', " ");
245
246            lines.push(ResultLine::styled(
247                format!(
248                    "  {:>4}  {:>4}  {:<2} {:<12}  {}{}",
249                    block.pre, block.post, icon, type_label, indent, preview,
250                ),
251                Style::default().fg(color),
252            ));
253        }
254
255        self.result_lines = lines;
256        self.result_scroll = 0;
257        self.status_msg = None;
258    }
259
260    fn doc_count(&self) -> usize {
261        self.store.documents().len()
262    }
263
264    fn select_next(&mut self) {
265        let count = self.doc_count();
266        if count == 0 {
267            return;
268        }
269        let i = self
270            .doc_list_state
271            .selected()
272            .map_or(0, |i| (i + 1).min(count - 1));
273        self.doc_list_state.select(Some(i));
274        self.show_selected_document();
275    }
276
277    fn select_prev(&mut self) {
278        let count = self.doc_count();
279        if count == 0 {
280            return;
281        }
282        let i = self
283            .doc_list_state
284            .selected()
285            .map_or(0, |i| i.saturating_sub(1));
286        self.doc_list_state.select(Some(i));
287        self.show_selected_document();
288    }
289
290    fn insert_char(&mut self, c: char) {
291        let byte_pos = self
292            .input
293            .char_indices()
294            .nth(self.cursor_pos)
295            .map_or(self.input.len(), |(i, _)| i);
296        self.input.insert(byte_pos, c);
297        self.cursor_pos += 1;
298    }
299
300    fn delete_char_before(&mut self) {
301        if self.cursor_pos == 0 {
302            return;
303        }
304        self.cursor_pos -= 1;
305        let byte_pos = self
306            .input
307            .char_indices()
308            .nth(self.cursor_pos)
309            .map(|(i, _)| i)
310            .unwrap_or(self.input.len());
311        self.input.remove(byte_pos);
312    }
313
314    fn move_cursor_left(&mut self) {
315        self.cursor_pos = self.cursor_pos.saturating_sub(1);
316    }
317
318    fn move_cursor_right(&mut self) {
319        if self.cursor_pos < self.input.chars().count() {
320            self.cursor_pos += 1;
321        }
322    }
323}
324
325fn block_display(bt: &BlockType, depth: Option<u8>) -> (&'static str, String, Color) {
326    match bt {
327        BlockType::Heading => {
328            let icon = "#";
329            let label = format!("H{}", depth.unwrap_or(1));
330            (icon, label, theme::ACCENT)
331        }
332        BlockType::Paragraph => ("¶", "paragraph".to_string(), theme::INK),
333        BlockType::Code => ("{}", "code".to_string(), theme::MARK),
334        BlockType::List => ("•", "list".to_string(), theme::SAGE),
335        BlockType::Blockquote => ("❝", "blockquote".to_string(), theme::LAVENDER),
336        BlockType::TableCell | BlockType::TableRow | BlockType::TableAlign => {
337            ("▦", "table".to_string(), theme::TEAL)
338        }
339        BlockType::Yaml | BlockType::Toml => ("≡", "frontmatter".to_string(), theme::INK_DIM),
340        BlockType::Html => ("<>", "html".to_string(), theme::INK_DIM),
341        BlockType::HorizontalRule => ("─", "hr".to_string(), theme::PAPER_DEEP),
342        BlockType::Math => ("∑", "math".to_string(), theme::DUSK),
343        BlockType::Definition => ("§", "definition".to_string(), theme::INK_DIM),
344        BlockType::Footnote => ("†", "footnote".to_string(), theme::INK_DIM),
345    }
346}
347
348// Entry point
349
350/// Launch the TUI. Blocks until the user quits.
351pub fn run(store: DocumentStore) -> Result<(), MqdbError> {
352    enable_raw_mode()?;
353    let mut stdout = io::stdout();
354    execute!(stdout, EnterAlternateScreen)?;
355    let backend = CrosstermBackend::new(stdout);
356    let mut terminal = Terminal::new(backend)?;
357
358    let mut app = App::new(store);
359    app.show_selected_document();
360
361    loop {
362        terminal.draw(|f| ui(f, &mut app))?;
363
364        if event::poll(std::time::Duration::from_millis(50))?
365            && let Event::Key(key) = event::read()?
366            && is_key_press(&key)
367            && handle_key(&mut app, key)
368        {
369            break;
370        }
371    }
372
373    disable_raw_mode()?;
374    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
375    Ok(())
376}
377
378/// Windows reports both press and release per keystroke; only handle press.
379fn is_key_press(key: &KeyEvent) -> bool {
380    key.kind == KeyEventKind::Press
381}
382
383/// Returns `true` if the app should quit.
384fn handle_key(app: &mut App, key: KeyEvent) -> bool {
385    if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c') {
386        return true;
387    }
388
389    if app.input_focused {
390        match key.code {
391            KeyCode::Esc => app.input_focused = false,
392            KeyCode::Enter => app.run_query(),
393            KeyCode::Backspace => app.delete_char_before(),
394            KeyCode::Left => app.move_cursor_left(),
395            KeyCode::Right => app.move_cursor_right(),
396            KeyCode::Home => app.cursor_pos = 0,
397            KeyCode::End => app.cursor_pos = app.input.chars().count(),
398            KeyCode::Tab => app.mode = app.mode.toggle(),
399            KeyCode::Char(c) => app.insert_char(c),
400            _ => {}
401        }
402    } else {
403        match key.code {
404            KeyCode::Char('q') => return true,
405            KeyCode::Char('i') => app.input_focused = true,
406            KeyCode::Tab => app.mode = app.mode.toggle(),
407            KeyCode::Char('j') | KeyCode::Down => app.select_next(),
408            KeyCode::Char('k') | KeyCode::Up => app.select_prev(),
409            KeyCode::Char('g') => {
410                app.result_scroll = 0;
411            }
412            KeyCode::Char('G') => {
413                app.result_scroll = app.result_lines.len().saturating_sub(1) as u16;
414            }
415            KeyCode::PageDown | KeyCode::Char('d') => {
416                app.result_scroll = app.result_scroll.saturating_add(10);
417            }
418            KeyCode::PageUp | KeyCode::Char('u') => {
419                app.result_scroll = app.result_scroll.saturating_sub(10);
420            }
421            _ => {}
422        }
423    }
424    false
425}
426
427// Rendering
428
429fn ui(f: &mut Frame, app: &mut App) {
430    let area = f.area();
431
432    f.render_widget(
433        Block::default().style(Style::default().bg(theme::PAPER).fg(theme::INK)),
434        area,
435    );
436
437    let vertical = Layout::default()
438        .direction(Direction::Vertical)
439        .constraints([
440            Constraint::Length(1),
441            Constraint::Min(0),
442            Constraint::Length(1),
443        ])
444        .split(area);
445
446    render_title_bar(f, app, vertical[0]);
447
448    let main = Layout::default()
449        .direction(Direction::Horizontal)
450        .constraints([Constraint::Percentage(28), Constraint::Percentage(72)])
451        .split(vertical[1]);
452
453    render_doc_list(f, app, main[0]);
454
455    let right = Layout::default()
456        .direction(Direction::Vertical)
457        .constraints([Constraint::Length(3), Constraint::Min(0)])
458        .split(main[1]);
459
460    render_input(f, app, right[0]);
461    render_results(f, app, right[1]);
462
463    render_status_bar(f, app, vertical[2]);
464}
465
466fn render_title_bar(f: &mut Frame, app: &App, area: Rect) {
467    let mode_indicator = match app.mode {
468        QueryMode::Sql => "SQL",
469        QueryMode::Mq => " mq",
470    };
471    let text = format!(
472        " mq-db  {}  {}",
473        mode_indicator,
474        if app.input_focused {
475            "Tab:switch  Enter:run  Esc:blur  Ctrl+C:quit"
476        } else {
477            "Tab:switch  i:input  j/k:nav  d/u:scroll  q:quit"
478        }
479    );
480    f.render_widget(
481        Paragraph::new(text).style(
482            Style::default()
483                .bg(theme::ACCENT)
484                .fg(theme::PAPER)
485                .add_modifier(Modifier::BOLD),
486        ),
487        area,
488    );
489}
490
491fn render_status_bar(f: &mut Frame, app: &App, area: Rect) {
492    let msg = app.status_msg.as_deref().unwrap_or("");
493    let total = format!(
494        " {} doc{}  {} block{}  {}",
495        app.store.len(),
496        if app.store.len() == 1 { "" } else { "s" },
497        app.store
498            .documents()
499            .iter()
500            .map(|d| d.blocks.len())
501            .sum::<usize>(),
502        if app
503            .store
504            .documents()
505            .iter()
506            .map(|d| d.blocks.len())
507            .sum::<usize>()
508            == 1
509        {
510            ""
511        } else {
512            "s"
513        },
514        msg,
515    );
516    f.render_widget(
517        Paragraph::new(total).style(Style::default().bg(theme::PAPER_ALT).fg(theme::INK_DIM)),
518        area,
519    );
520}
521
522fn render_doc_list(f: &mut Frame, app: &mut App, area: Rect) {
523    let items: Vec<ListItem> = app
524        .store
525        .documents()
526        .iter()
527        .map(|doc| {
528            let filename = doc
529                .path
530                .as_ref()
531                .and_then(|p| p.file_name())
532                .map(|n| n.to_string_lossy().to_string())
533                .unwrap_or_else(|| format!("doc {}", doc.id));
534            let title = doc.zone_maps.title.as_deref().unwrap_or("");
535            let count = doc.blocks.len();
536
537            let name_line = Line::from(vec![Span::styled(
538                filename,
539                Style::default().fg(theme::INK).add_modifier(Modifier::BOLD),
540            )]);
541            let meta_line = Line::from(vec![Span::styled(
542                format!(
543                    "  {} blocks{}",
544                    count,
545                    if title.is_empty() {
546                        String::new()
547                    } else {
548                        format!("  {}", title.chars().take(18).collect::<String>())
549                    }
550                ),
551                Style::default().fg(theme::INK_DIM),
552            )]);
553
554            ListItem::new(vec![name_line, meta_line])
555        })
556        .collect();
557
558    let list = List::new(items)
559        .block(
560            Block::default()
561                .borders(Borders::ALL)
562                .border_style(Style::default().fg(theme::PAPER_DEEP))
563                .title(Span::styled(
564                    " Documents ",
565                    Style::default()
566                        .fg(theme::ACCENT)
567                        .add_modifier(Modifier::BOLD),
568                )),
569        )
570        .highlight_style(
571            Style::default()
572                .bg(theme::ACCENT_DIM)
573                .fg(theme::INK)
574                .add_modifier(Modifier::BOLD),
575        )
576        .highlight_symbol("▶ ");
577
578    f.render_stateful_widget(list, area, &mut app.doc_list_state);
579}
580
581fn render_input(f: &mut Frame, app: &App, area: Rect) {
582    let border_style = if app.input_focused {
583        Style::default().fg(theme::MARK)
584    } else {
585        Style::default().fg(theme::PAPER_DEEP)
586    };
587    let title_style = if app.input_focused {
588        Style::default()
589            .fg(theme::MARK)
590            .add_modifier(Modifier::BOLD)
591    } else {
592        Style::default().fg(theme::INK_DIM)
593    };
594    let title = format!(" {} ", app.mode.label());
595
596    // Build input content with cursor indicator
597    let before_cursor: String = app.input.chars().take(app.cursor_pos).collect();
598    let at_cursor: String = app
599        .input
600        .chars()
601        .nth(app.cursor_pos)
602        .map_or(" ".to_string(), |c| c.to_string());
603    let after_cursor: String = app.input.chars().skip(app.cursor_pos + 1).collect();
604
605    let spans = if app.input_focused {
606        vec![
607            Span::raw(before_cursor),
608            Span::styled(at_cursor, Style::default().bg(theme::MARK).fg(theme::PAPER)),
609            Span::raw(after_cursor),
610        ]
611    } else {
612        vec![Span::styled(
613            app.input.clone(),
614            Style::default().fg(theme::INK_DIM),
615        )]
616    };
617
618    let widget = Paragraph::new(Line::from(spans))
619        .block(
620            Block::default()
621                .borders(Borders::ALL)
622                .title(Span::styled(title, title_style))
623                .border_style(border_style),
624        )
625        .wrap(Wrap { trim: false });
626    f.render_widget(widget, area);
627
628    if app.input_focused {
629        let max_x = area.x + area.width.saturating_sub(2);
630        let cursor_x = (area.x + 1 + app.cursor_pos as u16).min(max_x);
631        f.set_cursor_position((cursor_x, area.y + 1));
632    }
633}
634
635fn render_results(f: &mut Frame, app: &App, area: Rect) {
636    let lines: Vec<Line> = app
637        .result_lines
638        .iter()
639        .map(|rl| Line::from(Span::styled(rl.text.clone(), rl.style)))
640        .collect();
641
642    let widget = Paragraph::new(lines)
643        .block(
644            Block::default()
645                .borders(Borders::ALL)
646                .border_style(Style::default().fg(theme::PAPER_DEEP))
647                .title(Span::styled(
648                    " Results ",
649                    Style::default()
650                        .fg(theme::ACCENT)
651                        .add_modifier(Modifier::BOLD),
652                )),
653        )
654        .wrap(Wrap { trim: false })
655        .scroll((app.result_scroll, 0));
656    f.render_widget(widget, area);
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    fn press(code: KeyCode) -> KeyEvent {
664        KeyEvent::new_with_kind(code, KeyModifiers::NONE, KeyEventKind::Press)
665    }
666
667    fn release(code: KeyCode) -> KeyEvent {
668        KeyEvent::new_with_kind(code, KeyModifiers::NONE, KeyEventKind::Release)
669    }
670
671    #[test]
672    fn is_key_press_accepts_press_only() {
673        assert!(is_key_press(&press(KeyCode::Char('i'))));
674        assert!(!is_key_press(&release(KeyCode::Char('i'))));
675        assert!(!is_key_press(&KeyEvent::new_with_kind(
676            KeyCode::Char('i'),
677            KeyModifiers::NONE,
678            KeyEventKind::Repeat,
679        )));
680    }
681
682    /// Regression test for the Windows double-input bug.
683    #[test]
684    fn windows_style_press_and_release_pair_inserts_char_once() {
685        let mut app = App::new(DocumentStore::default());
686        app.input_focused = true;
687
688        for key in [press(KeyCode::Char('j')), release(KeyCode::Char('j'))] {
689            if is_key_press(&key) {
690                handle_key(&mut app, key);
691            }
692        }
693
694        assert_eq!(app.input, "j");
695    }
696
697    #[test]
698    fn windows_style_press_and_release_pair_toggles_mode_once() {
699        let mut app = App::new(DocumentStore::default());
700        app.input_focused = true;
701
702        for key in [press(KeyCode::Tab), release(KeyCode::Tab)] {
703            if is_key_press(&key) {
704                handle_key(&mut app, key);
705            }
706        }
707
708        assert_eq!(app.mode, QueryMode::Mq);
709    }
710}