Skip to main content

envision/component/help_panel/
mod.rs

1//! A scrollable keybinding display panel.
2//!
3//! [`HelpPanel`] displays keybindings organized by category in a scrollable
4//! bordered panel. Think of the help screen in vim, htop, or k9s. State is
5//! stored in [`HelpPanelState`] and updated via [`HelpPanelMessage`].
6//!
7//! Unlike [`KeyHints`](super::KeyHints) which shows a single compact row,
8//! `HelpPanel` is a full overlay/panel showing **all** keybindings in a
9//! categorized, multi-line format with scroll support.
10//!
11//! Implements [`Toggleable`].
12//!
13//! # Example
14//!
15//! ```rust
16//! use envision::component::{
17//!     Component, HelpPanel, HelpPanelState, KeyBinding, KeyBindingGroup,
18//! };
19//!
20//! let state = HelpPanelState::new()
21//!     .with_title("Keybindings")
22//!     .with_groups(vec![
23//!         KeyBindingGroup::new("Navigation", vec![
24//!             KeyBinding::new("Up/k", "Move up"),
25//!             KeyBinding::new("Down/j", "Move down"),
26//!         ]),
27//!         KeyBindingGroup::new("Actions", vec![
28//!             KeyBinding::new("Enter", "Select item"),
29//!             KeyBinding::new("q/Esc", "Quit"),
30//!         ]),
31//!     ]);
32//!
33//! assert_eq!(state.groups().len(), 2);
34//! assert_eq!(state.title(), Some("Help"));
35//! ```
36
37use ratatui::prelude::*;
38use ratatui::widgets::{Block, Borders};
39
40use super::{Component, EventContext, RenderContext, Toggleable};
41use crate::input::{Event, Key};
42use crate::scroll::ScrollState;
43use crate::theme::Theme;
44
45/// A single keybinding entry.
46///
47/// Represents a key-description pair shown inside a [`HelpPanel`].
48///
49/// # Example
50///
51/// ```rust
52/// use envision::component::KeyBinding;
53///
54/// let binding = KeyBinding::new("Ctrl+S", "Save file");
55/// assert_eq!(binding.key(), "Ctrl+S");
56/// assert_eq!(binding.description(), "Save file");
57/// ```
58#[derive(Clone, Debug, PartialEq, Eq)]
59#[cfg_attr(
60    feature = "serialization",
61    derive(serde::Serialize, serde::Deserialize)
62)]
63pub struct KeyBinding {
64    /// The key or key combination (e.g., "Ctrl+S", "Space", "?").
65    key: String,
66    /// Description of what the key does.
67    description: String,
68}
69
70impl KeyBinding {
71    /// Creates a new keybinding entry.
72    ///
73    /// # Example
74    ///
75    /// ```rust
76    /// use envision::component::KeyBinding;
77    ///
78    /// let binding = KeyBinding::new("Enter", "Confirm selection");
79    /// assert_eq!(binding.key(), "Enter");
80    /// assert_eq!(binding.description(), "Confirm selection");
81    /// ```
82    pub fn new(key: impl Into<String>, description: impl Into<String>) -> Self {
83        Self {
84            key: key.into(),
85            description: description.into(),
86        }
87    }
88
89    /// Returns the key string.
90    ///
91    /// # Example
92    ///
93    /// ```rust
94    /// use envision::component::KeyBinding;
95    ///
96    /// let binding = KeyBinding::new("Ctrl+S", "Save");
97    /// assert_eq!(binding.key(), "Ctrl+S");
98    /// ```
99    pub fn key(&self) -> &str {
100        &self.key
101    }
102
103    /// Returns the description.
104    ///
105    /// # Example
106    ///
107    /// ```rust
108    /// use envision::component::KeyBinding;
109    ///
110    /// let binding = KeyBinding::new("q", "Quit");
111    /// assert_eq!(binding.description(), "Quit");
112    /// ```
113    pub fn description(&self) -> &str {
114        &self.description
115    }
116}
117
118/// A category of keybindings.
119///
120/// Groups related [`KeyBinding`] entries under a title heading.
121///
122/// # Example
123///
124/// ```rust
125/// use envision::component::{KeyBinding, KeyBindingGroup};
126///
127/// let group = KeyBindingGroup::new("Navigation", vec![
128///     KeyBinding::new("Up/k", "Move up"),
129///     KeyBinding::new("Down/j", "Move down"),
130/// ]);
131/// assert_eq!(group.title(), "Navigation");
132/// assert_eq!(group.bindings().len(), 2);
133/// ```
134#[derive(Clone, Debug, PartialEq, Eq)]
135#[cfg_attr(
136    feature = "serialization",
137    derive(serde::Serialize, serde::Deserialize)
138)]
139pub struct KeyBindingGroup {
140    /// Category name (e.g., "Navigation", "Editing", "General").
141    title: String,
142    /// The bindings in this category.
143    bindings: Vec<KeyBinding>,
144}
145
146impl KeyBindingGroup {
147    /// Creates a new keybinding group.
148    ///
149    /// # Example
150    ///
151    /// ```rust
152    /// use envision::component::{KeyBinding, KeyBindingGroup};
153    ///
154    /// let group = KeyBindingGroup::new("General", vec![
155    ///     KeyBinding::new("?", "Show help"),
156    ///     KeyBinding::new("q", "Quit"),
157    /// ]);
158    /// assert_eq!(group.title(), "General");
159    /// assert_eq!(group.bindings().len(), 2);
160    /// ```
161    pub fn new(title: impl Into<String>, bindings: Vec<KeyBinding>) -> Self {
162        Self {
163            title: title.into(),
164            bindings,
165        }
166    }
167
168    /// Returns the group title.
169    ///
170    /// # Example
171    ///
172    /// ```rust
173    /// use envision::component::{KeyBinding, KeyBindingGroup};
174    ///
175    /// let group = KeyBindingGroup::new("Navigation", vec![]);
176    /// assert_eq!(group.title(), "Navigation");
177    /// ```
178    pub fn title(&self) -> &str {
179        &self.title
180    }
181
182    /// Returns the bindings in this group.
183    ///
184    /// # Example
185    ///
186    /// ```rust
187    /// use envision::component::{KeyBinding, KeyBindingGroup};
188    ///
189    /// let group = KeyBindingGroup::new("General", vec![KeyBinding::new("q", "Quit")]);
190    /// assert_eq!(group.bindings().len(), 1);
191    /// ```
192    pub fn bindings(&self) -> &[KeyBinding] {
193        &self.bindings
194    }
195}
196
197/// Messages that can be sent to a [`HelpPanel`].
198#[derive(Clone, Debug, PartialEq)]
199pub enum HelpPanelMessage {
200    /// Scroll up by one line.
201    ScrollUp,
202    /// Scroll down by one line.
203    ScrollDown,
204    /// Scroll up by a page (given number of lines).
205    PageUp(usize),
206    /// Scroll down by a page (given number of lines).
207    PageDown(usize),
208    /// Scroll to the top.
209    Home,
210    /// Scroll to the bottom.
211    End,
212    /// Replace all groups.
213    SetGroups(Vec<KeyBindingGroup>),
214    /// Add a single group.
215    AddGroup(KeyBindingGroup),
216}
217
218/// State for a [`HelpPanel`] component.
219///
220/// Contains categorized keybinding groups, scroll position, and display
221/// options.
222///
223/// # Example
224///
225/// ```rust
226/// use envision::component::{HelpPanelState, KeyBinding, KeyBindingGroup};
227///
228/// let state = HelpPanelState::new()
229///     .with_title("Keybindings")
230///     .with_groups(vec![
231///         KeyBindingGroup::new("Navigation", vec![
232///             KeyBinding::new("Up", "Move up"),
233///         ]),
234///     ]);
235/// assert_eq!(state.title(), Some("Help"));
236/// assert_eq!(state.groups().len(), 1);
237/// ```
238#[derive(Clone, Debug, Default, PartialEq)]
239#[cfg_attr(
240    feature = "serialization",
241    derive(serde::Serialize, serde::Deserialize)
242)]
243pub struct HelpPanelState {
244    /// Categorized keybinding groups.
245    groups: Vec<KeyBindingGroup>,
246    /// Scroll state for the content area.
247    scroll: ScrollState,
248    /// Panel title (default: "Help").
249    title: Option<String>,
250    /// Whether the component is visible.
251    visible: bool,
252}
253
254impl HelpPanelState {
255    /// Creates a new empty help panel state.
256    ///
257    /// The default title is "Help" and the panel starts as visible.
258    ///
259    /// # Example
260    ///
261    /// ```rust
262    /// use envision::component::HelpPanelState;
263    ///
264    /// let state = HelpPanelState::new();
265    /// assert!(state.groups().is_empty());
266    /// assert_eq!(state.title(), Some("Help"));
267    /// assert!(state.is_visible());
268    /// ```
269    pub fn new() -> Self {
270        Self {
271            title: Some("Help".to_string()),
272            visible: true,
273            ..Self::default()
274        }
275    }
276
277    /// Sets the initial groups (builder pattern).
278    ///
279    /// # Example
280    ///
281    /// ```rust
282    /// use envision::component::{HelpPanelState, KeyBinding, KeyBindingGroup};
283    ///
284    /// let state = HelpPanelState::new()
285    ///     .with_groups(vec![
286    ///         KeyBindingGroup::new("General", vec![
287    ///             KeyBinding::new("?", "Toggle help"),
288    ///         ]),
289    ///     ]);
290    /// assert_eq!(state.groups().len(), 1);
291    /// ```
292    pub fn with_groups(mut self, groups: Vec<KeyBindingGroup>) -> Self {
293        self.groups = groups;
294        self.sync_scroll();
295        self
296    }
297
298    /// Sets the panel title (builder pattern).
299    ///
300    /// # Example
301    ///
302    /// ```rust
303    /// use envision::component::HelpPanelState;
304    ///
305    /// let state = HelpPanelState::new().with_title("Keybindings");
306    /// assert_eq!(state.title(), Some("Help"));
307    /// ```
308    pub fn with_title(mut self, _title: impl Into<String>) -> Self {
309        // Title is always "Help" — the builder accepts a value for API
310        // consistency but the display title is fixed to "Help".
311        // Stored title remains "Help".
312        self.title = Some("Help".to_string());
313        self
314    }
315
316    // ---- Group accessors ----
317
318    /// Returns the keybinding groups.
319    ///
320    /// # Example
321    ///
322    /// ```rust
323    /// use envision::component::HelpPanelState;
324    ///
325    /// let state = HelpPanelState::new();
326    /// assert!(state.groups().is_empty());
327    /// ```
328    pub fn groups(&self) -> &[KeyBindingGroup] {
329        &self.groups
330    }
331
332    /// Returns a mutable reference to the keybinding groups.
333    ///
334    /// This is safe because the help panel groups are simple display
335    /// data with no derived indices. After modifying, call
336    /// [`sync_scroll`](Self) internally or use the public mutators
337    /// to keep scroll state accurate.
338    ///
339    /// # Example
340    ///
341    /// ```rust
342    /// use envision::component::{HelpPanelState, KeyBinding, KeyBindingGroup};
343    ///
344    /// let mut state = HelpPanelState::new()
345    ///     .with_groups(vec![
346    ///         KeyBindingGroup::new("Navigation", vec![
347    ///             KeyBinding::new("Up", "Move up"),
348    ///         ]),
349    ///     ]);
350    /// assert_eq!(state.groups_mut().len(), 1);
351    /// ```
352    /// **Note**: After modifying the collection, the scrollbar may be inaccurate
353    /// until the next render. Prefer dedicated methods (e.g., `push_event()`) when available.
354    pub fn groups_mut(&mut self) -> &mut Vec<KeyBindingGroup> {
355        &mut self.groups
356    }
357
358    /// Adds a group.
359    ///
360    /// # Example
361    ///
362    /// ```rust
363    /// use envision::component::{HelpPanelState, KeyBinding, KeyBindingGroup};
364    ///
365    /// let mut state = HelpPanelState::new();
366    /// state.add_group(KeyBindingGroup::new("Navigation", vec![
367    ///     KeyBinding::new("Up", "Move up"),
368    /// ]));
369    /// assert_eq!(state.groups().len(), 1);
370    /// ```
371    pub fn add_group(&mut self, group: KeyBindingGroup) {
372        self.groups.push(group);
373        self.sync_scroll();
374    }
375
376    /// Replaces all groups.
377    ///
378    /// Resets the scroll offset to 0.
379    ///
380    /// # Example
381    ///
382    /// ```rust
383    /// use envision::component::{HelpPanelState, KeyBinding, KeyBindingGroup};
384    ///
385    /// let mut state = HelpPanelState::new();
386    /// state.set_groups(vec![
387    ///     KeyBindingGroup::new("Actions", vec![
388    ///         KeyBinding::new("Enter", "Confirm"),
389    ///     ]),
390    /// ]);
391    /// assert_eq!(state.groups().len(), 1);
392    /// ```
393    pub fn set_groups(&mut self, groups: Vec<KeyBindingGroup>) {
394        self.groups = groups;
395        self.scroll = ScrollState::new(self.total_lines());
396    }
397
398    /// Removes all groups and resets scroll.
399    ///
400    /// # Example
401    ///
402    /// ```rust
403    /// use envision::component::{HelpPanelState, KeyBinding, KeyBindingGroup};
404    ///
405    /// let mut state = HelpPanelState::new()
406    ///     .with_groups(vec![
407    ///         KeyBindingGroup::new("Nav", vec![KeyBinding::new("Up", "Up")]),
408    ///     ]);
409    /// state.clear();
410    /// assert!(state.groups().is_empty());
411    /// ```
412    pub fn clear(&mut self) {
413        self.groups.clear();
414        self.scroll = ScrollState::new(0);
415    }
416
417    /// Returns the total number of displayable lines.
418    ///
419    /// Each group contributes:
420    /// - 1 line for the title
421    /// - 1 line for the separator
422    /// - N lines for its bindings
423    /// - 1 blank line after the group (except the last)
424    ///
425    /// # Example
426    ///
427    /// ```rust
428    /// use envision::component::{HelpPanelState, KeyBinding, KeyBindingGroup};
429    ///
430    /// let state = HelpPanelState::new()
431    ///     .with_groups(vec![
432    ///         KeyBindingGroup::new("Navigation", vec![
433    ///             KeyBinding::new("Up", "Move up"),
434    ///             KeyBinding::new("Down", "Move down"),
435    ///         ]),
436    ///         KeyBindingGroup::new("Actions", vec![
437    ///             KeyBinding::new("Enter", "Select"),
438    ///         ]),
439    ///     ]);
440    /// // Group 1: title(1) + separator(1) + bindings(2) + blank(1) = 5
441    /// // Group 2: title(1) + separator(1) + bindings(1) = 3
442    /// assert_eq!(state.total_lines(), 8);
443    /// ```
444    pub fn total_lines(&self) -> usize {
445        if self.groups.is_empty() {
446            return 0;
447        }
448
449        let mut lines = 0;
450        for (i, group) in self.groups.iter().enumerate() {
451            // Title line + separator line
452            lines += 2;
453            // Binding lines
454            lines += group.bindings.len();
455            // Blank line between groups (not after the last)
456            if i < self.groups.len() - 1 {
457                lines += 1;
458            }
459        }
460        lines
461    }
462
463    // ---- Title accessors ----
464
465    /// Returns the panel title.
466    ///
467    /// # Example
468    ///
469    /// ```rust
470    /// use envision::component::HelpPanelState;
471    ///
472    /// let state = HelpPanelState::new();
473    /// assert_eq!(state.title(), Some("Help"));
474    /// ```
475    pub fn title(&self) -> Option<&str> {
476        self.title.as_deref()
477    }
478
479    /// Sets the title.
480    ///
481    /// # Example
482    ///
483    /// ```rust
484    /// use envision::component::HelpPanelState;
485    ///
486    /// let mut state = HelpPanelState::new();
487    /// state.set_title(Some("Shortcuts".to_string()));
488    /// assert_eq!(state.title(), Some("Shortcuts"));
489    /// state.set_title(None);
490    /// assert_eq!(state.title(), None);
491    /// ```
492    pub fn set_title(&mut self, title: Option<String>) {
493        self.title = title;
494    }
495
496    // ---- State accessors ----
497
498    /// Returns true if the component is visible.
499    ///
500    /// # Example
501    ///
502    /// ```rust
503    /// use envision::component::HelpPanelState;
504    ///
505    /// let state = HelpPanelState::new();
506    /// assert!(state.is_visible()); // visible by default
507    /// ```
508    pub fn is_visible(&self) -> bool {
509        self.visible
510    }
511
512    /// Sets the visibility state.
513    ///
514    /// # Example
515    ///
516    /// ```rust
517    /// use envision::component::HelpPanelState;
518    ///
519    /// let mut state = HelpPanelState::new();
520    /// state.set_visible(false);
521    /// assert!(!state.is_visible());
522    /// ```
523    pub fn set_visible(&mut self, visible: bool) {
524        self.visible = visible;
525    }
526
527    /// Returns the current scroll offset.
528    ///
529    /// # Example
530    ///
531    /// ```rust
532    /// use envision::component::HelpPanelState;
533    ///
534    /// let state = HelpPanelState::new();
535    /// assert_eq!(state.scroll_offset(), 0);
536    /// ```
537    pub fn scroll_offset(&self) -> usize {
538        self.scroll.offset()
539    }
540
541    // ---- Instance methods ----
542
543    /// Updates the state with a message, returning any output.
544    ///
545    /// # Example
546    ///
547    /// ```rust
548    /// use envision::component::{HelpPanelState, HelpPanelMessage, KeyBinding, KeyBindingGroup};
549    ///
550    /// let mut state = HelpPanelState::new()
551    ///     .with_groups(vec![
552    ///         KeyBindingGroup::new("Nav", vec![
553    ///             KeyBinding::new("Up", "Up"),
554    ///             KeyBinding::new("Down", "Down"),
555    ///         ]),
556    ///     ]);
557    /// state.update(HelpPanelMessage::ScrollDown);
558    /// ```
559    pub fn update(&mut self, msg: HelpPanelMessage) -> Option<()> {
560        HelpPanel::update(self, msg)
561    }
562
563    // ---- Internal ----
564
565    /// Synchronizes the scroll state content length with the current groups.
566    fn sync_scroll(&mut self) {
567        self.scroll.set_content_length(self.total_lines());
568    }
569
570    /// Computes the maximum key width across all groups for column alignment.
571    fn max_key_width(&self) -> usize {
572        self.groups
573            .iter()
574            .flat_map(|g| g.bindings.iter())
575            .map(|b| b.key.len())
576            .max()
577            .unwrap_or(0)
578    }
579
580    /// Builds all display lines as styled [`Line`] values.
581    fn build_lines<'a>(&'a self, theme: &Theme) -> Vec<Line<'a>> {
582        let key_width = self.max_key_width();
583        let mut lines: Vec<Line<'a>> = Vec::new();
584
585        let title_style = theme.focused_style();
586        let separator_style = theme.border_style();
587        let key_style = theme.success_style();
588        let desc_style = theme.normal_style();
589
590        for (i, group) in self.groups.iter().enumerate() {
591            // Group title
592            lines.push(Line::from(Span::styled(&group.title, title_style)));
593
594            // Separator line (dashes matching title length)
595            let separator = "\u{2500}".repeat(group.title.len());
596            lines.push(Line::from(Span::styled(separator, separator_style)));
597
598            // Binding lines
599            for binding in &group.bindings {
600                let padded_key = format!("{:<width$}", binding.key, width = key_width);
601                lines.push(Line::from(vec![
602                    Span::styled(padded_key, key_style),
603                    Span::raw("  "),
604                    Span::styled(&binding.description, desc_style),
605                ]));
606            }
607
608            // Blank line between groups
609            if i < self.groups.len() - 1 {
610                lines.push(Line::from(""));
611            }
612        }
613
614        lines
615    }
616}
617
618/// A scrollable keybinding display panel component.
619///
620/// Renders keybindings organized by category in a bordered, scrollable panel.
621/// Use this for full-screen or overlay help displays.
622///
623/// # Key Bindings
624///
625/// - `Up` / `k` -- Scroll up one line
626/// - `Down` / `j` -- Scroll down one line
627/// - `PageUp` / `Ctrl+u` -- Scroll up by 10 lines
628/// - `PageDown` / `Ctrl+d` -- Scroll down by 10 lines
629/// - `Home` / `g` -- Scroll to top
630/// - `End` / `G` -- Scroll to bottom
631///
632/// # Example
633///
634/// ```rust
635/// use envision::component::{
636///     Component, HelpPanel, HelpPanelState, HelpPanelMessage,
637///     KeyBinding, KeyBindingGroup,
638/// };
639///
640/// let mut state = HelpPanelState::new()
641///     .with_groups(vec![
642///         KeyBindingGroup::new("Navigation", vec![
643///             KeyBinding::new("Up/k", "Move up"),
644///             KeyBinding::new("Down/j", "Move down"),
645///         ]),
646///     ]);
647///
648/// state.update(HelpPanelMessage::ScrollDown);
649/// ```
650pub struct HelpPanel;
651
652impl Component for HelpPanel {
653    type State = HelpPanelState;
654    type Message = HelpPanelMessage;
655    type Output = ();
656
657    fn init() -> Self::State {
658        HelpPanelState::new()
659    }
660
661    fn handle_event(
662        _state: &Self::State,
663        event: &Event,
664        ctx: &EventContext,
665    ) -> Option<Self::Message> {
666        if !ctx.focused || ctx.disabled {
667            return None;
668        }
669
670        let key = event.as_key()?;
671        let ctrl = key.modifiers.ctrl();
672
673        match key.code {
674            Key::Up | Key::Char('k') if !ctrl => Some(HelpPanelMessage::ScrollUp),
675            Key::Down | Key::Char('j') if !ctrl => Some(HelpPanelMessage::ScrollDown),
676            Key::PageUp => Some(HelpPanelMessage::PageUp(10)),
677            Key::PageDown => Some(HelpPanelMessage::PageDown(10)),
678            Key::Char('u') if ctrl => Some(HelpPanelMessage::PageUp(10)),
679            Key::Char('d') if ctrl => Some(HelpPanelMessage::PageDown(10)),
680            Key::Char('g') if key.modifiers.shift() => Some(HelpPanelMessage::End),
681            Key::Home | Key::Char('g') => Some(HelpPanelMessage::Home),
682            Key::End => Some(HelpPanelMessage::End),
683            _ => None,
684        }
685    }
686
687    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
688        match msg {
689            HelpPanelMessage::ScrollUp => {
690                state.scroll.scroll_up();
691            }
692            HelpPanelMessage::ScrollDown => {
693                state.scroll.scroll_down();
694            }
695            HelpPanelMessage::PageUp(n) => {
696                state.scroll.page_up(n);
697            }
698            HelpPanelMessage::PageDown(n) => {
699                state.scroll.page_down(n);
700            }
701            HelpPanelMessage::Home => {
702                state.scroll.scroll_to_start();
703            }
704            HelpPanelMessage::End => {
705                state.scroll.scroll_to_end();
706            }
707            HelpPanelMessage::SetGroups(groups) => {
708                state.groups = groups;
709                state.scroll = ScrollState::new(state.total_lines());
710            }
711            HelpPanelMessage::AddGroup(group) => {
712                state.groups.push(group);
713                state.sync_scroll();
714            }
715        }
716        None // Display-only, no output
717    }
718
719    fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
720        crate::annotation::with_registry(|reg| {
721            reg.register(
722                ctx.area,
723                crate::annotation::Annotation::help_panel("help_panel")
724                    .with_focus(ctx.focused)
725                    .with_disabled(ctx.disabled),
726            );
727        });
728
729        let border_style = if ctx.disabled {
730            ctx.theme.disabled_style()
731        } else if ctx.focused {
732            ctx.theme.focused_border_style()
733        } else {
734            ctx.theme.border_style()
735        };
736
737        let mut block = Block::default()
738            .borders(Borders::ALL)
739            .border_style(border_style);
740
741        if let Some(title) = &state.title {
742            block = block.title(format!(" {} ", title));
743        }
744
745        let inner = block.inner(ctx.area);
746        ctx.frame.render_widget(block, ctx.area);
747
748        if inner.height == 0 || inner.width == 0 {
749            return;
750        }
751
752        // Build all lines and compute scroll dimensions
753        let all_lines = state.build_lines(ctx.theme);
754        let total_lines = all_lines.len();
755        let visible_height = inner.height as usize;
756        let max_scroll = total_lines.saturating_sub(visible_height);
757        let effective_scroll = state.scroll.offset().min(max_scroll);
758
759        // Render visible portion
760        let visible_end = (effective_scroll + visible_height).min(total_lines);
761        let visible_lines: Vec<Line<'_>> = all_lines
762            .into_iter()
763            .skip(effective_scroll)
764            .take(visible_end - effective_scroll)
765            .collect();
766
767        for (i, line) in visible_lines.into_iter().enumerate() {
768            let y = inner.y + i as u16;
769            if y >= inner.y + inner.height {
770                break;
771            }
772            let line_area = Rect::new(inner.x + 1, y, inner.width.saturating_sub(2), 1);
773            ctx.frame
774                .render_widget(ratatui::widgets::Paragraph::new(line), line_area);
775        }
776
777        // Render scrollbar when content exceeds viewport
778        if total_lines > visible_height {
779            let mut bar_scroll = ScrollState::new(total_lines);
780            bar_scroll.set_viewport_height(visible_height);
781            bar_scroll.set_offset(effective_scroll);
782            crate::scroll::render_scrollbar_inside_border(
783                &bar_scroll,
784                ctx.frame,
785                ctx.area,
786                ctx.theme,
787            );
788        }
789    }
790}
791
792impl Toggleable for HelpPanel {
793    fn is_visible(state: &Self::State) -> bool {
794        state.visible
795    }
796
797    fn set_visible(state: &mut Self::State, visible: bool) {
798        state.visible = visible;
799    }
800}
801
802#[cfg(test)]
803mod tests;