ratatui_toolkit/hotkey_modal/
mod.rs

1use ratatui::style::{Color, Modifier, Style};
2use ratatui::text::{Line, Span};
3use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap};
4use ratatui::Frame;
5
6/// A single hotkey binding with its description
7#[derive(Debug, Clone)]
8pub struct Hotkey {
9    pub key: String,
10    pub description: String,
11}
12
13impl Hotkey {
14    pub fn new(key: impl Into<String>, description: impl Into<String>) -> Self {
15        Self {
16            key: key.into(),
17            description: description.into(),
18        }
19    }
20}
21
22/// A category/section of hotkeys
23#[derive(Debug, Clone)]
24pub struct HotkeySection {
25    pub title: String,
26    pub hotkeys: Vec<Hotkey>,
27}
28
29impl HotkeySection {
30    pub fn new(title: impl Into<String>) -> Self {
31        Self {
32            title: title.into(),
33            hotkeys: Vec::new(),
34        }
35    }
36
37    pub fn with_hotkeys(mut self, hotkeys: Vec<Hotkey>) -> Self {
38        self.hotkeys = hotkeys;
39        self
40    }
41
42    pub fn add_hotkey(mut self, key: impl Into<String>, description: impl Into<String>) -> Self {
43        self.hotkeys.push(Hotkey::new(key, description));
44        self
45    }
46}
47
48/// Configuration for the hotkey modal appearance
49#[derive(Debug, Clone)]
50pub struct HotkeyModalConfig {
51    /// Title of the modal
52    pub title: String,
53    /// Border color
54    pub border_color: Color,
55    /// Width as a percentage of screen width (0.0 - 1.0)
56    pub width_percent: f32,
57    /// Height as a percentage of screen height (0.0 - 1.0)
58    pub height_percent: f32,
59    /// Footer text (e.g., "Press any key to close")
60    pub footer: Option<String>,
61    /// Whether to show title inside content instead of in border
62    pub title_inside: bool,
63}
64
65impl Default for HotkeyModalConfig {
66    fn default() -> Self {
67        Self {
68            title: "Help".to_string(),
69            border_color: Color::Cyan,
70            width_percent: 0.6,
71            height_percent: 0.6,
72            footer: Some("Press any key to close".to_string()),
73            title_inside: false,
74        }
75    }
76}
77
78impl HotkeyModalConfig {
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    pub fn with_title(mut self, title: impl Into<String>) -> Self {
84        self.title = title.into();
85        self
86    }
87
88    pub fn with_border_color(mut self, color: Color) -> Self {
89        self.border_color = color;
90        self
91    }
92
93    pub fn with_size(mut self, width_percent: f32, height_percent: f32) -> Self {
94        self.width_percent = width_percent.clamp(0.1, 1.0);
95        self.height_percent = height_percent.clamp(0.1, 1.0);
96        self
97    }
98
99    pub fn with_footer(mut self, footer: Option<String>) -> Self {
100        self.footer = footer;
101        self
102    }
103
104    pub fn with_title_inside(mut self, inside: bool) -> Self {
105        self.title_inside = inside;
106        self
107    }
108}
109
110/// Render a hotkey modal with darkened background
111pub fn render_hotkey_modal(
112    frame: &mut Frame,
113    sections: &[HotkeySection],
114    config: &HotkeyModalConfig,
115) {
116    let mut lines = Vec::new();
117
118    // Add title inside content if requested
119    if config.title_inside {
120        lines.push(Line::from(vec![Span::styled(
121            &config.title,
122            Style::default()
123                .add_modifier(Modifier::BOLD)
124                .fg(config.border_color),
125        )]));
126        lines.push(Line::from(""));
127    }
128
129    // Build the content from sections
130    for (i, section) in sections.iter().enumerate() {
131        // Add section title
132        lines.push(Line::from(vec![Span::styled(
133            &section.title,
134            Style::default().add_modifier(Modifier::BOLD),
135        )]));
136        lines.push(Line::from(""));
137
138        // Add hotkeys in this section
139        for hotkey in &section.hotkeys {
140            // Format: "  Key         Description"
141            let line = format!("  {:<12}{}", hotkey.key, hotkey.description);
142            lines.push(Line::from(line));
143        }
144
145        // Add spacing between sections (but not after the last one)
146        if i < sections.len() - 1 {
147            lines.push(Line::from(""));
148        }
149    }
150
151    // Add footer if provided
152    if let Some(ref footer) = config.footer {
153        lines.push(Line::from(""));
154        lines.push(Line::from(vec![Span::styled(
155            footer,
156            Style::default().fg(Color::DarkGray),
157        )]));
158    }
159
160    let area = frame.area();
161
162    // Render a darkened overlay over the entire screen (no block, just background)
163    let overlay_paragraph = Paragraph::new("").style(
164        Style::default()
165            .bg(Color::Rgb(0, 0, 0)) // Pure black for maximum dimming
166            .fg(Color::Rgb(40, 40, 40)),
167    );
168    frame.render_widget(overlay_paragraph, area);
169
170    // Calculate popup size
171    let popup_width = (area.width as f32 * config.width_percent) as u16;
172    let popup_height = (area.height as f32 * config.height_percent) as u16;
173    let popup_x = (area.width - popup_width) / 2;
174    let popup_y = (area.height - popup_height) / 2;
175
176    let popup_area = ratatui::layout::Rect {
177        x: popup_x,
178        y: popup_y,
179        width: popup_width,
180        height: popup_height,
181    };
182
183    // Clear the popup area to make it opaque
184    frame.render_widget(Clear, popup_area);
185
186    // Render the modal
187    let block = if config.title_inside {
188        // No title in border if showing inside
189        Block::default()
190            .borders(Borders::ALL)
191            .border_type(BorderType::Rounded)
192            .border_style(Style::default().fg(config.border_color))
193    } else {
194        // Show title in border
195        Block::default()
196            .title(format!(" {} ", config.title))
197            .borders(Borders::ALL)
198            .border_type(BorderType::Rounded)
199            .border_style(Style::default().fg(config.border_color))
200    };
201
202    let paragraph = Paragraph::new(lines)
203        .style(Style::default())
204        .wrap(Wrap { trim: false });
205
206    // Render the block
207    frame.render_widget(block, popup_area);
208
209    // Render content with padding inside the block
210    let inner_area = popup_area.inner(ratatui::layout::Margin {
211        horizontal: 2,
212        vertical: 2,
213    });
214    frame.render_widget(paragraph, inner_area);
215}