refactor: split common/mod.rs into per-widget modules
This commit is contained in:
173
crates/lore-tui/src/view/common/help_overlay.rs
Normal file
173
crates/lore-tui/src/view/common/help_overlay.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Centered modal listing keybindings for the current screen.
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::commands::CommandRegistry;
|
||||
use crate::message::Screen;
|
||||
|
||||
/// Render a centered help overlay listing keybindings for the current screen.
|
||||
///
|
||||
/// The overlay is a bordered modal that lists all commands from the
|
||||
/// registry that are available on the current screen.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_help_overlay(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
registry: &CommandRegistry,
|
||||
screen: &Screen,
|
||||
border_color: PackedRgba,
|
||||
text_color: PackedRgba,
|
||||
muted_color: PackedRgba,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
if area.height < 5 || area.width < 20 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Overlay dimensions: 60% of screen, capped.
|
||||
let overlay_width = (area.width * 3 / 5).clamp(30, 70);
|
||||
let overlay_height = (area.height * 3 / 5).clamp(8, 30);
|
||||
|
||||
let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2;
|
||||
let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2;
|
||||
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
|
||||
|
||||
// Draw border.
|
||||
let border_cell = Cell {
|
||||
fg: border_color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_border(
|
||||
overlay_rect,
|
||||
ftui::render::drawing::BorderChars::ROUNDED,
|
||||
border_cell,
|
||||
);
|
||||
|
||||
// Title.
|
||||
let title = " Help (? to close) ";
|
||||
let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2;
|
||||
let title_cell = Cell {
|
||||
fg: border_color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right());
|
||||
|
||||
// Inner content area (inside border).
|
||||
let inner = Rect::new(
|
||||
overlay_x + 2,
|
||||
overlay_y + 1,
|
||||
overlay_width.saturating_sub(4),
|
||||
overlay_height.saturating_sub(2),
|
||||
);
|
||||
|
||||
// Get commands for this screen.
|
||||
let commands = registry.help_entries(screen);
|
||||
let visible_lines = inner.height as usize;
|
||||
|
||||
let key_cell = Cell {
|
||||
fg: text_color,
|
||||
..Cell::default()
|
||||
};
|
||||
let desc_cell = Cell {
|
||||
fg: muted_color,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() {
|
||||
if i >= visible_lines {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + i as u16;
|
||||
|
||||
// Key binding label (left).
|
||||
let key_label = cmd
|
||||
.keybinding
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |kb| kb.display());
|
||||
let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right());
|
||||
|
||||
// Spacer + description (right).
|
||||
let desc_x = label_end.saturating_add(2);
|
||||
if desc_x < inner.right() {
|
||||
frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right());
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator if needed.
|
||||
if commands.len() > visible_lines + scroll_offset {
|
||||
let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len());
|
||||
let ind_x = inner.right().saturating_sub(indicator.len() as u16);
|
||||
let ind_y = overlay_rect.bottom().saturating_sub(1);
|
||||
frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::build_registry;
|
||||
use crate::message::Screen;
|
||||
use ftui::render::grapheme_pool::GraphemePool;
|
||||
|
||||
macro_rules! with_frame {
|
||||
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||
let mut pool = GraphemePool::new();
|
||||
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||
$body
|
||||
}};
|
||||
}
|
||||
|
||||
fn white() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
fn gray() -> PackedRgba {
|
||||
PackedRgba::rgb(0x80, 0x80, 0x80)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_overlay_renders_border() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let registry = build_registry();
|
||||
render_help_overlay(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
gray(),
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
// The overlay should have non-empty cells in the center area.
|
||||
let has_content = (20..60u16).any(|x| {
|
||||
(8..16u16).any(|y| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
})
|
||||
});
|
||||
assert!(has_content, "Expected help overlay in center area");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_overlay_tiny_terminal_noop() {
|
||||
with_frame!(15, 4, |frame| {
|
||||
let registry = build_registry();
|
||||
render_help_overlay(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 15, 4),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
gray(),
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user