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;