Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 267 additions & 7 deletions crates/tsql/src/app/app.rs

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions crates/tsql/src/config/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ pub enum Action {

// Sidebar
ToggleSidebar,

// Goto sequences (triggered by g + key)
GotoFirst,
GotoEditor,
GotoConnections,
GotoTables,
GotoResults,
}

impl Action {
Expand Down Expand Up @@ -168,6 +175,11 @@ impl Action {
Action::TestConnection => "Test connection",
Action::ClearField => "Clear current field",
Action::ToggleSidebar => "Toggle sidebar",
Action::GotoFirst => "Go to first row/document start",
Action::GotoEditor => "Go to query editor",
Action::GotoConnections => "Go to connections sidebar",
Action::GotoTables => "Go to tables/schema sidebar",
Action::GotoResults => "Go to results grid",
}
}
}
Expand Down Expand Up @@ -270,6 +282,13 @@ impl FromStr for Action {
// Sidebar
"toggle_sidebar" => Ok(Action::ToggleSidebar),

// Goto sequences
"goto_first" => Ok(Action::GotoFirst),
"goto_editor" => Ok(Action::GotoEditor),
"goto_connections" => Ok(Action::GotoConnections),
"goto_tables" => Ok(Action::GotoTables),
"goto_results" => Ok(Action::GotoResults),

_ => Err(format!("Unknown action: {}", s)),
}
}
Expand Down Expand Up @@ -934,6 +953,19 @@ mod tests {
// Test case insensitivity
assert_eq!("MOVE_UP".parse::<Action>().unwrap(), Action::MoveUp);
assert_eq!("Move_Up".parse::<Action>().unwrap(), Action::MoveUp);

// Goto sequences
assert_eq!("goto_first".parse::<Action>().unwrap(), Action::GotoFirst);
assert_eq!("goto_editor".parse::<Action>().unwrap(), Action::GotoEditor);
assert_eq!(
"goto_connections".parse::<Action>().unwrap(),
Action::GotoConnections
);
assert_eq!("goto_tables".parse::<Action>().unwrap(), Action::GotoTables);
assert_eq!(
"goto_results".parse::<Action>().unwrap(),
Action::GotoResults
);
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions crates/tsql/src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ impl Default for ConnectionConfig {
pub struct KeymapConfig {
/// Use vim-style keybindings
pub vim_mode: bool,
/// Timeout in milliseconds before showing key sequence hints (e.g., after pressing 'g')
pub key_sequence_timeout_ms: u64,
/// Custom keybindings for normal mode
#[serde(default)]
pub normal: Vec<CustomKeyBinding>,
Expand All @@ -149,6 +151,7 @@ impl Default for KeymapConfig {
fn default() -> Self {
Self {
vim_mode: true,
key_sequence_timeout_ms: 500,
normal: Vec::new(),
insert: Vec::new(),
visual: Vec::new(),
Expand Down
4 changes: 3 additions & 1 deletion crates/tsql/src/ui/grid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pub enum GridKeyResult {
OpenRowDetail { row: usize },
/// Display a status message.
StatusMessage(String),
/// Go to the first row (from `gg` sequence, handled at app level).
GotoFirstRow,
}

/// A match location in the grid (row, column).
Expand Down Expand Up @@ -213,7 +215,7 @@ impl GridState {
self.cursor_row = (self.cursor_row + 10).min(row_count - 1);
}
}
(KeyCode::Home, _) | (KeyCode::Char('g'), _) => {
(KeyCode::Home, _) => {
self.cursor_row = 0;
}
(KeyCode::End, _) | (KeyCode::Char('G'), _) => {
Expand Down
13 changes: 13 additions & 0 deletions crates/tsql/src/ui/help_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ const GLOBAL: HelpSection = HelpSection::new(
KeyBinding::new("?", "Toggle this help"),
KeyBinding::new("Ctrl+O", "Open connection picker"),
KeyBinding::new("Ctrl+Shift+C", "Open connection manager"),
KeyBinding::new("Ctrl+B", "Toggle sidebar"),
],
);

const GOTO: HelpSection = HelpSection::new(
"Go To (g prefix)",
&[
KeyBinding::new("gg", "Go to first row / document start"),
KeyBinding::new("ge", "Go to editor"),
KeyBinding::new("gc", "Go to connections sidebar"),
KeyBinding::new("gt", "Go to tables/schema sidebar"),
KeyBinding::new("gr", "Go to results grid"),
],
);

Expand Down Expand Up @@ -207,6 +219,7 @@ const SCHEMA_COMMANDS: HelpSection = HelpSection::new(

const ALL_SECTIONS: &[HelpSection] = &[
GLOBAL,
GOTO,
QUERY_NAVIGATION,
QUERY_EDITING,
QUERY_VISUAL,
Expand Down
209 changes: 209 additions & 0 deletions crates/tsql/src/ui/key_hint_popup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! A minimal key hint popup displayed in the bottom-right corner.
//!
//! Shows available key completions when a multi-key sequence is pending.
//! Inspired by Helix editor's which-key style hints.

use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use unicode_width::UnicodeWidthStr;

use super::key_sequence::PendingKey;

/// A single hint entry showing a key and its description.
#[derive(Debug, Clone)]
pub struct KeyHint {
/// The key to press (e.g., "g", "e", "c")
pub key: &'static str,
/// Short description of what the key does
pub description: &'static str,
}

impl KeyHint {
pub const fn new(key: &'static str, description: &'static str) -> Self {
Self { key, description }
}
}

/// Hints for the 'g' (goto) prefix
const G_HINTS: &[KeyHint] = &[
KeyHint::new("g", "first row"),
KeyHint::new("e", "editor"),
KeyHint::new("c", "connections"),
KeyHint::new("t", "tables"),
KeyHint::new("r", "results"),
];

/// The key hint popup widget.
pub struct KeyHintPopup {
/// The currently pending key
pending_key: PendingKey,
}

impl KeyHintPopup {
/// Creates a new popup for the given pending key.
pub fn new(pending_key: PendingKey) -> Self {
Self { pending_key }
}

/// Returns the hints for the current pending key.
fn hints(&self) -> &'static [KeyHint] {
match self.pending_key {
PendingKey::G => G_HINTS,
}
}

/// Returns the title character for the popup.
fn title_char(&self) -> char {
self.pending_key.display_char()
}

/// Calculates the popup area positioned in the bottom-right corner.
fn popup_area(&self, frame_area: Rect) -> Rect {
let hints = self.hints();

// Minimum sensible dimensions
const MIN_WIDTH: u16 = 10;
const MIN_HEIGHT: u16 = 3;
const PADDING: u16 = 2;

// Calculate dimensions based on content using Unicode display width
// Width: " key" (space + key) + " " (2 spaces) + description + " " (trailing space) + borders (2)
let max_content_width = hints
.iter()
.map(|h| {
let key_width = 1 + h.key.width(); // leading space + key
let desc_width = h.description.width();
key_width + 2 + desc_width + 1 // " key" + " " + desc + " "
})
.max()
.unwrap_or(10);

// Add borders (2 chars for left + right)
let desired_width = (max_content_width + 2) as u16;

// Height: number of hints + borders (top and bottom)
let desired_height = (hints.len() + 2) as u16;

// Clamp dimensions to fit within frame_area, respecting padding
let max_available_width = frame_area.width.saturating_sub(PADDING);
let max_available_height = frame_area.height.saturating_sub(PADDING);

let width = desired_width
.max(MIN_WIDTH)
.min(max_available_width)
.min(frame_area.width); // Final safety clamp

let height = desired_height
.max(MIN_HEIGHT)
.min(max_available_height)
.min(frame_area.height); // Final safety clamp

// Position in bottom-right with padding
let x = frame_area.width.saturating_sub(width + PADDING);
let y = frame_area.height.saturating_sub(height + PADDING);

Rect::new(x, y, width, height)
}

/// Renders the popup to the frame.
pub fn render(&self, frame: &mut Frame, frame_area: Rect) {
let area = self.popup_area(frame_area);

// Clear the background
frame.render_widget(Clear, area);

// Build the content lines
let hints = self.hints();
let lines: Vec<Line> = hints
.iter()
.map(|hint| {
Line::from(vec![
Span::styled(
format!(" {}", hint.key),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(hint.description, Style::default().fg(Color::Gray)),
Span::raw(" "),
])
})
.collect();

// Create the paragraph with a titled border
let title = format!(" {} ", self.title_char());
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
title,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));

let paragraph = Paragraph::new(lines).block(block);

frame.render_widget(paragraph, area);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_g_hints() {
let popup = KeyHintPopup::new(PendingKey::G);
let hints = popup.hints();

assert_eq!(hints.len(), 5);
assert_eq!(hints[0].key, "g");
assert_eq!(hints[0].description, "first row");
assert_eq!(hints[1].key, "e");
assert_eq!(hints[4].key, "r");
}

#[test]
fn test_title_char() {
let popup = KeyHintPopup::new(PendingKey::G);
assert_eq!(popup.title_char(), 'g');
}

#[test]
fn test_popup_area_calculation() {
let popup = KeyHintPopup::new(PendingKey::G);
let frame_area = Rect::new(0, 0, 100, 50);
let area = popup.popup_area(frame_area);

// Should be in bottom-right
assert!(area.x > 50);
assert!(area.y > 40);

// Should have reasonable size
assert!(area.width >= 15);
assert!(area.height == 7); // 5 hints + 2 borders
}

#[test]
fn test_popup_area_clamped_on_small_terminal() {
let popup = KeyHintPopup::new(PendingKey::G);
// Very small terminal
let frame_area = Rect::new(0, 0, 15, 5);
let area = popup.popup_area(frame_area);

// Width and height should never exceed frame_area
assert!(area.width <= frame_area.width);
assert!(area.height <= frame_area.height);

// Position should be valid (within frame bounds)
assert!(area.x + area.width <= frame_area.width);
assert!(area.y + area.height <= frame_area.height);
}
}
Loading
Loading