From 39bf6a4a99c526786e1925fb1fdc3b6d1fa22e61 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 15:46:17 +0200 Subject: [PATCH] feat: show keybindings in a normal-mode popup --- TODO.md | 2 +- src/connection.rs | 133 ++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 4 +- 3 files changed, 115 insertions(+), 24 deletions(-) diff --git a/TODO.md b/TODO.md index c4a716e..f0b7555 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ - [x] highlight the current cell isntead of just the current row in the results pane. - [x] put the help text inside it's own pane so it fits the design more cleanly. - [x] move the table name in the results pane from the bottom-left border title to top-right border title instead -- [ ] remove the bottom navigation help panel and have a keybinding overview appear in a floating window when pressing `?` in normal mode +- [x] remove the bottom navigation help panel and have a keybinding overview appear in a floating window when pressing `?` in normal mode - [ ] remove the path from the database entry in the sidebar (can be viewed in the info panel) - [ ] info panel should also pop up when pressing `i` on a child entry of that database entry in the sidebar (instead of only when the parent/database connection is hovered) - [ ] `line:col` indicator in the bottom-right status bar should never disappear but instead display "N/A" in that case. diff --git a/src/connection.rs b/src/connection.rs index 6cc3672..d5c5dc2 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -29,6 +29,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< let mut form = Form::default(); let mut show_form = false; let mut info: Option = None; + let mut help: Option = None; loop { let entries = explorer_entries( profiles.entries.len(), @@ -38,7 +39,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< ); terminal.draw(|frame| { let [top, workspace, bottom] = shell_areas(frame.area()); - let position = if info.is_some() { + let position = if info.is_some() || help.is_some() { None } else if show_form { form.input().map(Input::position) @@ -53,24 +54,8 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< }; let name = browser.as_ref().map_or("Not connected", App::database_name); draw_bars(frame, top, bottom, browser.as_ref().map_or("—", App::provider), name, insert, position); - let navigation = Paragraph::new(if show_form { - "Tab/Shift-Tab fields · Enter save/connect · Esc cancel · Ctrl-C quit" - } else if focus == Focus::Query && insert { - "Esc normal · Tab/Shift-Tab focus · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit" - } else if focus == Focus::Query { - "i/a/I/A insert · hjkl/↑↓←→ move · Esc explorer · Tab/Shift-Tab focus · F5/Ctrl-Enter execute · Ctrl-C quit" - } else { - "Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · i database info · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit" - }).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray)); - let help_height = (navigation.line_count(workspace.width.saturating_sub(2)) as u16) - .saturating_add(2).min(workspace.height.saturating_sub(3)); - let help_height = if help_height >= 3 { help_height } else { 0 }; - let navigation = navigation.block(Block::bordered().title(" Navigation ")); - let [main, help] = Layout::vertical([ - Constraint::Min(1), Constraint::Length(help_height), - ]).areas(workspace); let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)]) - .areas(main); + .areas(workspace); let items = entries.iter().map(|entry| { ListItem::new(match *entry { ExplorerEntry::Database(index) => { @@ -86,31 +71,44 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< }) }); let list = List::new(items) - .block(Block::bordered().title(" Explorer · c add ").border_style( + .block(Block::bordered().title(" Explorer · c add · ? help ").border_style( Style::default().fg(if focus == Focus::Explorer { Color::Cyan } else { Color::DarkGray }))) .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)); frame.render_stateful_widget(list, sidebar, &mut selection); if let Some(browser) = &mut browser { - browser.draw(frame, content, focus); + browser.draw(frame, content, if help.is_some() { Focus::Explorer } else { focus }); } else { frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content); } if let Some(error) = &error { frame.render_widget(Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), Rect::new(content.x, content.bottom().saturating_sub(1), content.width, 1)); } - frame.render_widget(navigation, help); if show_form { form.draw(frame, workspace); } if let Some(index) = info { profiles.entries[index].draw_info(frame, workspace); } + if let Some(offset) = &mut help { + draw_help(frame, workspace, offset); + } })?; match event::read()? { Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { return Ok(()); } + if let Some(offset) = &mut help { + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => help = None, + KeyCode::Down | KeyCode::Char('j') => *offset = offset.saturating_add(1), + KeyCode::Up | KeyCode::Char('k') => *offset = offset.saturating_sub(1), + KeyCode::Home | KeyCode::Char('g') => *offset = 0, + KeyCode::End | KeyCode::Char('G') => *offset = u16::MAX, + _ => {} + } + continue; + } if info.is_some() { if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { info = None; @@ -168,6 +166,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } else { match key.code { + KeyCode::Char('?') + if focus != Focus::Query + || !browser.as_ref().is_some_and(App::query_insert_mode) => + { + help = Some(0); + } KeyCode::Esc => { if focus != Focus::Query || !browser.as_mut().is_some_and(App::leave_query_insert) @@ -288,7 +292,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } } - Event::Paste(_) if info.is_some() => {} + Event::Paste(_) if info.is_some() || help.is_some() => {} Event::Paste(text) if show_form => { form.error = None; if let Some(input) = form.input_mut() { @@ -305,6 +309,69 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } +fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { + let width = screen + .width + .saturating_sub(4) + .min(84) + .max(screen.width.min(4)); + let text = Paragraph::new( + "General (Normal mode)\n\ + ? Show keybindings\n\ + Tab / Shift-Tab Cycle explorer, query, and rows/schema\n\ + Esc Return to explorer\n\ + c Add a configured database\n\ + q Quit outside the query editor\n\ + Ctrl-C Quit from any surface\n\n\ + Explorer and rows/schema (Normal mode)\n\ + Up/Down, j/k Move selection\n\ + Home/End, g/G First / last item\n\ + Left/Right, h/l Collapse/expand explorer or select cells\n\ + Enter Connect/expand database or open relation\n\ + i Database info on saved database entries\n\ + s Toggle rows / column schema\n\ + n/p, PgDn/PgUp Next / previous 100-row relation page\n\ + r Refresh relations and active relation\n\n\ + Query editor\n\ + i/a/I/A Enter Insert mode (at/after/start/end)\n\ + hjkl / arrows Move cursor in Normal mode\n\ + Home/End Start / end of line in either mode\n\ + F5 / Ctrl-Enter Execute query in either mode\n\ + Type / paste Enter SQL in Insert mode\n\ + Enter Insert newline in Insert mode\n\ + Backspace/Delete Remove text in Insert mode\n\ + Ctrl-U Clear query in Insert mode\n\ + Esc Leave Insert first, then return to explorer\n\n\ + Connection / password windows\n\ + Tab / Shift-Tab Change fields\n\ + Arrows Choose provider; Left/Right changes TLS\n\ + Enter Edit / save configuration / submit password\n\ + Ctrl-U / paste Clear / enter text in a focused field\n\ + Esc Cancel", + ) + .wrap(Wrap { trim: false }); + let lines = text.line_count(width.saturating_sub(2)) as u16; + let height = lines + .saturating_add(2) + .min(screen.height.saturating_sub(2).max(screen.height.min(3))); + *offset = (*offset).min(lines.saturating_sub(height.saturating_sub(2))); + let area = Rect::new( + screen.x + (screen.width - width) / 2, + screen.y + (screen.height - height) / 2, + width, + height, + ); + frame.render_widget(Clear, area); + frame.render_widget( + text.scroll((*offset, 0)).block( + Block::bordered() + .title(" Keybindings ") + .title_bottom(" ↑↓/jk scroll · Esc/Enter/? close "), + ), + area, + ); +} + fn database_label(label: &str, connected: bool, expanded: bool, width: u16) -> String { if width < 4 { return if connected && width > 0 { @@ -1008,6 +1075,28 @@ impl Input { mod tests { use super::*; + #[test] + fn keybindings_scroll_to_the_end_and_clamp_after_resize() -> Result<()> { + use ratatui::{Terminal, backend::TestBackend}; + let mut terminal = Terminal::new(TestBackend::new(80, 12))?; + let mut offset = 0; + terminal.draw(|frame| draw_help(frame, frame.area(), &mut offset))?; + let first = terminal.backend().buffer().clone(); + offset = u16::MAX; + terminal.draw(|frame| draw_help(frame, frame.area(), &mut offset))?; + let last = terminal.backend().buffer().clone(); + assert_ne!(first, last); + let end = offset; + offset = offset.saturating_add(1); + terminal.draw(|frame| draw_help(frame, frame.area(), &mut offset))?; + assert_eq!(offset, end); + assert_eq!(terminal.backend().buffer(), &last); + terminal = Terminal::new(TestBackend::new(100, 60))?; + terminal.draw(|frame| draw_help(frame, frame.area(), &mut offset))?; + assert_eq!(offset, 0); + Ok(()) + } + #[test] fn connected_marker_survives_long_wide_profile_labels() -> Result<()> { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?; diff --git a/src/main.rs b/src/main.rs index b8ed84a..c622f02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ Use sslmode=disable only for trusted local connections. The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×22/74×18. Navigation: + ? Show keybindings in Normal mode (Esc/Enter/? closes) Tab/Shift-Tab Cycle explorer, query editor, and rows/schema Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation Home/End, g/G First / last item @@ -41,7 +42,8 @@ Navigation: r Refresh relations and the active relation Connected databases have a trailing * and show their tables/views beneath them. Enter on the connected database collapses/expands it without reconnecting. - Navigation help has its own bordered pane and wraps when needed. + Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump. + Query Insert mode and connection/password fields keep ? as literal text. Full-width bars show dbdash/provider/READ ONLY above and mode/database below. Only badges have colored backgrounds; the remaining bar area uses terminal defaults. Focused inputs show one-based line:column (Unicode characters, not screen cells).