1use 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
21mod 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#[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#[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 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
348pub 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
378fn is_key_press(key: &KeyEvent) -> bool {
380 key.kind == KeyEventKind::Press
381}
382
383fn 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
427fn 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 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 #[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}