sql_cli/ui/key_handling/
indicator.rs1use ratatui::{
2 layout::Rect,
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, Borders, Paragraph},
6 Frame,
7};
8use std::collections::VecDeque;
9use std::time::Instant;
10
11pub struct KeyPressIndicator {
13 key_history: VecDeque<(String, Instant)>,
15 max_keys: usize,
17 fade_start_ms: u64,
19 fade_duration_ms: u64,
21 pub enabled: bool,
23}
24
25impl Default for KeyPressIndicator {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl KeyPressIndicator {
32 #[must_use]
33 pub fn new() -> Self {
34 Self {
35 key_history: VecDeque::with_capacity(10),
36 max_keys: 10, fade_start_ms: 500,
38 fade_duration_ms: 1500,
39 enabled: true, }
41 }
42
43 pub fn set_enabled(&mut self, enabled: bool) {
45 self.enabled = enabled;
46 if !enabled {
47 self.key_history.clear();
48 }
49 }
50
51 pub fn record_key(&mut self, key: String) {
53 if !self.enabled {
54 return;
55 }
56
57 self.key_history.push_back((key, Instant::now()));
59
60 while self.key_history.len() > self.max_keys {
62 self.key_history.pop_front();
63 }
64
65 let fade_complete = self.fade_start_ms + self.fade_duration_ms;
67 self.key_history
68 .retain(|(_, time)| time.elapsed().as_millis() < u128::from(fade_complete));
69 }
70
71 pub fn render(&self, frame: &mut Frame, area: Rect) {
73 if !self.enabled || self.key_history.is_empty() {
74 return;
75 }
76
77 let mut spans = Vec::new();
78
79 for (i, (key, time)) in self.key_history.iter().enumerate() {
80 let elapsed_ms = time.elapsed().as_millis() as u64;
81
82 let opacity = if elapsed_ms < self.fade_start_ms {
84 1.0
85 } else if elapsed_ms < self.fade_start_ms + self.fade_duration_ms {
86 let fade_progress =
87 (elapsed_ms - self.fade_start_ms) as f32 / self.fade_duration_ms as f32;
88 1.0 - fade_progress
89 } else {
90 0.0
91 };
92
93 if opacity > 0.0 {
94 let color = self.opacity_to_color(opacity);
96
97 if i > 0 {
99 spans.push(Span::styled(" → ", Style::default().fg(Color::DarkGray)));
100 }
101
102 spans.push(Span::styled(
104 key.clone(),
105 Style::default().fg(color).add_modifier(Modifier::ITALIC),
106 ));
107 }
108 }
109
110 if !spans.is_empty() {
111 let paragraph = Paragraph::new(Line::from(spans)).block(
112 Block::default()
113 .borders(Borders::NONE)
114 .style(Style::default()),
115 );
116 frame.render_widget(paragraph, area);
117 }
118 }
119
120 fn opacity_to_color(&self, opacity: f32) -> Color {
122 if opacity > 0.7 {
124 Color::Cyan
125 } else if opacity > 0.4 {
126 Color::Gray
127 } else {
128 Color::DarkGray
129 }
130 }
131
132 #[must_use]
134 pub fn to_string(&self) -> String {
135 if !self.enabled || self.key_history.is_empty() {
136 return String::new();
137 }
138
139 self.key_history
140 .iter()
141 .map(|(key, _)| key.clone())
142 .collect::<Vec<_>>()
143 .join(" → ")
144 }
145}
146
147#[must_use]
149pub fn format_key_for_display(key: &crossterm::event::KeyEvent) -> String {
150 use crossterm::event::{KeyCode, KeyModifiers};
151
152 let mut parts = Vec::new();
153
154 if key.modifiers.contains(KeyModifiers::CONTROL) {
156 parts.push("Ctrl");
157 }
158 if key.modifiers.contains(KeyModifiers::ALT) {
159 parts.push("Alt");
160 }
161 if key.modifiers.contains(KeyModifiers::SHIFT) {
162 parts.push("Shift");
163 }
164
165 let key_str = match key.code {
167 KeyCode::Char(c) => {
168 if key.modifiers.contains(KeyModifiers::CONTROL) {
169 c.to_uppercase().to_string()
170 } else {
171 c.to_string()
172 }
173 }
174 KeyCode::Enter => "Enter".to_string(),
175 KeyCode::Esc => "Esc".to_string(),
176 KeyCode::Backspace => "⌫".to_string(),
177 KeyCode::Tab => "Tab".to_string(),
178 KeyCode::Up => "↑".to_string(),
179 KeyCode::Down => "↓".to_string(),
180 KeyCode::Left => "←".to_string(),
181 KeyCode::Right => "→".to_string(),
182 KeyCode::Home => "Home".to_string(),
183 KeyCode::End => "End".to_string(),
184 KeyCode::PageUp => "PgUp".to_string(),
185 KeyCode::PageDown => "PgDn".to_string(),
186 KeyCode::Delete => "Del".to_string(),
187 KeyCode::F(n) => format!("F{n}"),
188 _ => "?".to_string(),
189 };
190
191 if parts.is_empty() {
192 key_str
193 } else {
194 format!("{}-{}", parts.join("+"), key_str)
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn test_key_indicator() {
204 let mut indicator = KeyPressIndicator::new();
205 indicator.set_enabled(true);
206
207 indicator.record_key("j".to_string());
208 indicator.record_key("k".to_string());
209 indicator.record_key("Enter".to_string());
210
211 let display = indicator.to_string();
212 assert!(display.contains('j'));
213 assert!(display.contains('k'));
214 assert!(display.contains("Enter"));
215 }
216
217 #[test]
218 fn test_key_formatting() {
219 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
220
221 let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
222 assert_eq!(format_key_for_display(&key), "Ctrl-C");
223
224 let key = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
225 assert_eq!(format_key_for_display(&key), "↑");
226 }
227}