feat: show keybindings in a normal-mode popup

This commit is contained in:
Valdemar Lorenzen
2026-10-19 15:46:17 +02:00
parent c75c6654f1
commit 39bf6a4a99
3 changed files with 115 additions and 24 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
- [x] highlight the current cell isntead of just the current row in the results pane. - [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] 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 - [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) - [ ] 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) - [ ] 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. - [ ] `line:col` indicator in the bottom-right status bar should never disappear but instead display "N/A" in that case.
+111 -22
View File
@@ -29,6 +29,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
let mut form = Form::default(); let mut form = Form::default();
let mut show_form = false; let mut show_form = false;
let mut info: Option<usize> = None; let mut info: Option<usize> = None;
let mut help: Option<u16> = None;
loop { loop {
let entries = explorer_entries( let entries = explorer_entries(
profiles.entries.len(), profiles.entries.len(),
@@ -38,7 +39,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
); );
terminal.draw(|frame| { terminal.draw(|frame| {
let [top, workspace, bottom] = shell_areas(frame.area()); let [top, workspace, bottom] = shell_areas(frame.area());
let position = if info.is_some() { let position = if info.is_some() || help.is_some() {
None None
} else if show_form { } else if show_form {
form.input().map(Input::position) form.input().map(Input::position)
@@ -53,24 +54,8 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}; };
let name = browser.as_ref().map_or("Not connected", App::database_name); 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); 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)]) let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)])
.areas(main); .areas(workspace);
let items = entries.iter().map(|entry| { let items = entries.iter().map(|entry| {
ListItem::new(match *entry { ListItem::new(match *entry {
ExplorerEntry::Database(index) => { ExplorerEntry::Database(index) => {
@@ -86,31 +71,44 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}) })
}); });
let list = List::new(items) 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 }))) Style::default().fg(if focus == Focus::Explorer { Color::Cyan } else { Color::DarkGray })))
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White)); .highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
frame.render_stateful_widget(list, sidebar, &mut selection); frame.render_stateful_widget(list, sidebar, &mut selection);
if let Some(browser) = &mut browser { if let Some(browser) = &mut browser {
browser.draw(frame, content, focus); browser.draw(frame, content, if help.is_some() { Focus::Explorer } else { focus });
} else { } 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); 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 { 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(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 { if show_form {
form.draw(frame, workspace); form.draw(frame, workspace);
} }
if let Some(index) = info { if let Some(index) = info {
profiles.entries[index].draw_info(frame, workspace); profiles.entries[index].draw_info(frame, workspace);
} }
if let Some(offset) = &mut help {
draw_help(frame, workspace, offset);
}
})?; })?;
match event::read()? { match event::read()? {
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(()); 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 info.is_some() {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
info = None; info = None;
@@ -168,6 +166,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
} }
} else { } else {
match key.code { match key.code {
KeyCode::Char('?')
if focus != Focus::Query
|| !browser.as_ref().is_some_and(App::query_insert_mode) =>
{
help = Some(0);
}
KeyCode::Esc => { KeyCode::Esc => {
if focus != Focus::Query if focus != Focus::Query
|| !browser.as_mut().is_some_and(App::leave_query_insert) || !browser.as_mut().is_some_and(App::leave_query_insert)
@@ -288,7 +292,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
} }
} }
} }
Event::Paste(_) if info.is_some() => {} Event::Paste(_) if info.is_some() || help.is_some() => {}
Event::Paste(text) if show_form => { Event::Paste(text) if show_form => {
form.error = None; form.error = None;
if let Some(input) = form.input_mut() { if let Some(input) = form.input_mut() {
@@ -305,6 +309,69 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> 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 { fn database_label(label: &str, connected: bool, expanded: bool, width: u16) -> String {
if width < 4 { if width < 4 {
return if connected && width > 0 { return if connected && width > 0 {
@@ -1008,6 +1075,28 @@ impl Input {
mod tests { mod tests {
use super::*; 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] #[test]
fn connected_marker_survives_long_wide_profile_labels() -> Result<()> { fn connected_marker_survives_long_wide_profile_labels() -> Result<()> {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?;
+3 -1
View File
@@ -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. The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×22/74×18.
Navigation: Navigation:
? Show keybindings in Normal mode (Esc/Enter/? closes)
Tab/Shift-Tab Cycle explorer, query editor, and rows/schema 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 Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation
Home/End, g/G First / last item Home/End, g/G First / last item
@@ -41,7 +42,8 @@ Navigation:
r Refresh relations and the active relation r Refresh relations and the active relation
Connected databases have a trailing * and show their tables/views beneath them. Connected databases have a trailing * and show their tables/views beneath them.
Enter on the connected database collapses/expands it without reconnecting. 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. 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. Only badges have colored backgrounds; the remaining bar area uses terminal defaults.
Focused inputs show one-based line:column (Unicode characters, not screen cells). Focused inputs show one-based line:column (Unicode characters, not screen cells).