use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders};
use super::{Component, EventContext, RenderContext, Toggleable};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
use crate::theme::Theme;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct KeyBinding {
key: String,
description: String,
}
impl KeyBinding {
pub fn new(key: impl Into<String>, description: impl Into<String>) -> Self {
Self {
key: key.into(),
description: description.into(),
}
}
pub fn key(&self) -> &str {
&self.key
}
pub fn description(&self) -> &str {
&self.description
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct KeyBindingGroup {
title: String,
bindings: Vec<KeyBinding>,
}
impl KeyBindingGroup {
pub fn new(title: impl Into<String>, bindings: Vec<KeyBinding>) -> Self {
Self {
title: title.into(),
bindings,
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn bindings(&self) -> &[KeyBinding] {
&self.bindings
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum HelpPanelMessage {
ScrollUp,
ScrollDown,
PageUp(usize),
PageDown(usize),
Home,
End,
SetGroups(Vec<KeyBindingGroup>),
AddGroup(KeyBindingGroup),
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct HelpPanelState {
groups: Vec<KeyBindingGroup>,
scroll: ScrollState,
title: Option<String>,
visible: bool,
}
impl HelpPanelState {
pub fn new() -> Self {
Self {
title: Some("Help".to_string()),
visible: true,
..Self::default()
}
}
pub fn with_groups(mut self, groups: Vec<KeyBindingGroup>) -> Self {
self.groups = groups;
self.sync_scroll();
self
}
pub fn with_title(mut self, _title: impl Into<String>) -> Self {
self.title = Some("Help".to_string());
self
}
pub fn groups(&self) -> &[KeyBindingGroup] {
&self.groups
}
pub fn groups_mut(&mut self) -> &mut Vec<KeyBindingGroup> {
&mut self.groups
}
pub fn add_group(&mut self, group: KeyBindingGroup) {
self.groups.push(group);
self.sync_scroll();
}
pub fn set_groups(&mut self, groups: Vec<KeyBindingGroup>) {
self.groups = groups;
self.scroll = ScrollState::new(self.total_lines());
}
pub fn clear(&mut self) {
self.groups.clear();
self.scroll = ScrollState::new(0);
}
pub fn total_lines(&self) -> usize {
if self.groups.is_empty() {
return 0;
}
let mut lines = 0;
for (i, group) in self.groups.iter().enumerate() {
lines += 2;
lines += group.bindings.len();
if i < self.groups.len() - 1 {
lines += 1;
}
}
lines
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn set_visible(&mut self, visible: bool) {
self.visible = visible;
}
pub fn scroll_offset(&self) -> usize {
self.scroll.offset()
}
pub fn update(&mut self, msg: HelpPanelMessage) -> Option<()> {
HelpPanel::update(self, msg)
}
fn sync_scroll(&mut self) {
self.scroll.set_content_length(self.total_lines());
}
fn max_key_width(&self) -> usize {
self.groups
.iter()
.flat_map(|g| g.bindings.iter())
.map(|b| b.key.len())
.max()
.unwrap_or(0)
}
fn build_lines<'a>(&'a self, theme: &Theme) -> Vec<Line<'a>> {
let key_width = self.max_key_width();
let mut lines: Vec<Line<'a>> = Vec::new();
let title_style = theme.focused_style();
let separator_style = theme.border_style();
let key_style = theme.success_style();
let desc_style = theme.normal_style();
for (i, group) in self.groups.iter().enumerate() {
lines.push(Line::from(Span::styled(&group.title, title_style)));
let separator = "\u{2500}".repeat(group.title.len());
lines.push(Line::from(Span::styled(separator, separator_style)));
for binding in &group.bindings {
let padded_key = format!("{:<width$}", binding.key, width = key_width);
lines.push(Line::from(vec![
Span::styled(padded_key, key_style),
Span::raw(" "),
Span::styled(&binding.description, desc_style),
]));
}
if i < self.groups.len() - 1 {
lines.push(Line::from(""));
}
}
lines
}
}
pub struct HelpPanel;
impl Component for HelpPanel {
type State = HelpPanelState;
type Message = HelpPanelMessage;
type Output = ();
fn init() -> Self::State {
HelpPanelState::new()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
let ctrl = key.modifiers.ctrl();
match key.code {
Key::Up | Key::Char('k') if !ctrl => Some(HelpPanelMessage::ScrollUp),
Key::Down | Key::Char('j') if !ctrl => Some(HelpPanelMessage::ScrollDown),
Key::PageUp => Some(HelpPanelMessage::PageUp(10)),
Key::PageDown => Some(HelpPanelMessage::PageDown(10)),
Key::Char('u') if ctrl => Some(HelpPanelMessage::PageUp(10)),
Key::Char('d') if ctrl => Some(HelpPanelMessage::PageDown(10)),
Key::Char('g') if key.modifiers.shift() => Some(HelpPanelMessage::End),
Key::Home | Key::Char('g') => Some(HelpPanelMessage::Home),
Key::End => Some(HelpPanelMessage::End),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
HelpPanelMessage::ScrollUp => {
state.scroll.scroll_up();
}
HelpPanelMessage::ScrollDown => {
state.scroll.scroll_down();
}
HelpPanelMessage::PageUp(n) => {
state.scroll.page_up(n);
}
HelpPanelMessage::PageDown(n) => {
state.scroll.page_down(n);
}
HelpPanelMessage::Home => {
state.scroll.scroll_to_start();
}
HelpPanelMessage::End => {
state.scroll.scroll_to_end();
}
HelpPanelMessage::SetGroups(groups) => {
state.groups = groups;
state.scroll = ScrollState::new(state.total_lines());
}
HelpPanelMessage::AddGroup(group) => {
state.groups.push(group);
state.sync_scroll();
}
}
None }
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::help_panel("help_panel")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let border_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if let Some(title) = &state.title {
block = block.title(format!(" {} ", title));
}
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
if inner.height == 0 || inner.width == 0 {
return;
}
let all_lines = state.build_lines(ctx.theme);
let total_lines = all_lines.len();
let visible_height = inner.height as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
let effective_scroll = state.scroll.offset().min(max_scroll);
let visible_end = (effective_scroll + visible_height).min(total_lines);
let visible_lines: Vec<Line<'_>> = all_lines
.into_iter()
.skip(effective_scroll)
.take(visible_end - effective_scroll)
.collect();
for (i, line) in visible_lines.into_iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let line_area = Rect::new(inner.x + 1, y, inner.width.saturating_sub(2), 1);
ctx.frame
.render_widget(ratatui::widgets::Paragraph::new(line), line_area);
}
if total_lines > visible_height {
let mut bar_scroll = ScrollState::new(total_lines);
bar_scroll.set_viewport_height(visible_height);
bar_scroll.set_offset(effective_scroll);
crate::scroll::render_scrollbar_inside_border(
&bar_scroll,
ctx.frame,
ctx.area,
ctx.theme,
);
}
}
}
impl Toggleable for HelpPanel {
fn is_visible(state: &Self::State) -> bool {
state.visible
}
fn set_visible(state: &mut Self::State, visible: bool) {
state.visible = visible;
}
}
#[cfg(test)]
mod tests;