feat: show keybindings in a normal-mode popup
This commit is contained in:
@@ -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.
|
||||
|
||||
+111
-22
@@ -29,6 +29,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
let mut form = Form::default();
|
||||
let mut show_form = false;
|
||||
let mut info: Option<usize> = None;
|
||||
let mut help: Option<u16> = None;
|
||||
loop {
|
||||
let entries = explorer_entries(
|
||||
profiles.entries.len(),
|
||||
@@ -38,7 +39,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> 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<Database>) -> 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<Database>) -> 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<Database>) -> 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<Database>) -> 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<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 {
|
||||
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))?;
|
||||
|
||||
+3
-1
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user