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] 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
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user