Skip to main content

ftui_widgets/
help.rs

1//! Help widget for displaying keybinding lists.
2//!
3//! Renders a styled list of key/description pairs for showing available
4//! keyboard shortcuts in a TUI application.
5//!
6//! # Example
7//!
8//! ```
9//! use ftui_widgets::help::{Help, HelpEntry};
10//!
11//! let help = Help::new()
12//!     .entry("q", "quit")
13//!     .entry("^s", "save")
14//!     .entry("?", "toggle help");
15//!
16//! assert_eq!(help.entries().len(), 3);
17//! ```
18
19use crate::{StatefulWidget, Widget, clear_text_area, draw_text_span};
20use ftui_core::geometry::Rect;
21use ftui_render::budget::DegradationLevel;
22use ftui_render::buffer::Buffer;
23use ftui_render::cell::{Cell, PackedRgba};
24use ftui_render::frame::Frame;
25use ftui_style::Style;
26use ftui_style::StyleFlags;
27use ftui_text::wrap::display_width;
28use std::hash::{Hash, Hasher};
29
30/// Category for organizing help entries into logical groups.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
32pub enum HelpCategory {
33    /// General/uncategorized keybinding.
34    #[default]
35    General,
36    /// Navigation keys (arrows, page up/down, home/end, etc.).
37    Navigation,
38    /// Editing actions (cut, copy, paste, undo, redo, etc.).
39    Editing,
40    /// File operations (save, open, close, new, etc.).
41    File,
42    /// View/display controls (zoom, scroll, toggle panels, etc.).
43    View,
44    /// Application-level shortcuts (quit, settings, help, etc.).
45    Global,
46    /// Custom category with a user-defined label.
47    Custom(String),
48}
49
50impl HelpCategory {
51    /// Return a display label for this category.
52    #[must_use]
53    pub fn label(&self) -> &str {
54        match self {
55            Self::General => "General",
56            Self::Navigation => "Navigation",
57            Self::Editing => "Editing",
58            Self::File => "File",
59            Self::View => "View",
60            Self::Global => "Global",
61            Self::Custom(s) => s,
62        }
63    }
64}
65
66/// A single keybinding entry in the help view.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct HelpEntry {
69    /// The key or key combination (e.g. "^C", "↑/k").
70    pub key: String,
71    /// Description of what the key does.
72    pub desc: String,
73    /// Whether this entry is enabled (disabled entries are hidden).
74    pub enabled: bool,
75    /// Category for grouping related entries.
76    pub category: HelpCategory,
77}
78
79impl HelpEntry {
80    /// Create a new enabled help entry.
81    #[must_use]
82    pub fn new(key: impl Into<String>, desc: impl Into<String>) -> Self {
83        Self {
84            key: key.into(),
85            desc: desc.into(),
86            enabled: true,
87            category: HelpCategory::default(),
88        }
89    }
90
91    /// Set whether this entry is enabled.
92    #[must_use]
93    pub fn with_enabled(mut self, enabled: bool) -> Self {
94        self.enabled = enabled;
95        self
96    }
97
98    /// Set the category for this entry.
99    #[must_use]
100    pub fn with_category(mut self, category: HelpCategory) -> Self {
101        self.category = category;
102        self
103    }
104}
105
106/// Display mode for the help widget.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
108pub enum HelpMode {
109    /// Short inline mode: entries separated by a bullet on one line.
110    #[default]
111    Short,
112    /// Full mode: entries stacked vertically with aligned columns.
113    Full,
114}
115
116/// Help widget that renders keybinding entries.
117///
118/// In [`HelpMode::Short`] mode, entries are shown inline separated by a bullet
119/// character, truncated with an ellipsis if they exceed the available width.
120///
121/// In [`HelpMode::Full`] mode, entries are rendered in a vertical list with
122/// keys and descriptions in aligned columns.
123#[derive(Debug, Clone)]
124pub struct Help {
125    entries: Vec<HelpEntry>,
126    mode: HelpMode,
127    /// Separator between entries in short mode.
128    separator: String,
129    /// Ellipsis shown when truncated.
130    ellipsis: String,
131    /// Style for key text.
132    key_style: Style,
133    /// Style for description text.
134    desc_style: Style,
135    /// Style for separator/ellipsis.
136    separator_style: Style,
137}
138
139/// Cached render state for [`Help`], enabling incremental layout reuse and
140/// dirty-rect updates for keybinding hint panels.
141///
142/// # Invariants
143/// - Layout is reused only when entry count and slot widths remain compatible.
144/// - Dirty rects always cover the full prior slot width for changed entries.
145/// - Layout rebuilds on any change that could cause reflow.
146///
147/// # Failure Modes
148/// - If a changed entry exceeds its cached slot width, we rebuild the layout.
149/// - If enabled entry count changes, we rebuild the layout.
150#[derive(Debug, Default)]
151pub struct HelpRenderState {
152    cache: Option<HelpCache>,
153    enabled_indices: Vec<usize>,
154    dirty_indices: Vec<usize>,
155    dirty_rects: Vec<Rect>,
156    stats: HelpCacheStats,
157}
158
159/// Cache hit/miss statistics for [`HelpRenderState`].
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
161pub struct HelpCacheStats {
162    pub hits: u64,
163    pub misses: u64,
164    pub dirty_updates: u64,
165    pub layout_rebuilds: u64,
166}
167
168impl HelpRenderState {
169    /// Return cache statistics.
170    #[must_use]
171    pub fn stats(&self) -> HelpCacheStats {
172        self.stats
173    }
174
175    /// Clear recorded dirty rects.
176    pub fn clear_dirty_rects(&mut self) {
177        self.dirty_rects.clear();
178    }
179
180    /// Take dirty rects for logging/inspection.
181    #[must_use]
182    pub fn take_dirty_rects(&mut self) -> Vec<Rect> {
183        std::mem::take(&mut self.dirty_rects)
184    }
185
186    /// Read dirty rects without clearing.
187    #[must_use]
188    pub fn dirty_rects(&self) -> &[Rect] {
189        &self.dirty_rects
190    }
191
192    /// Reset cache stats (useful for perf logging).
193    pub fn reset_stats(&mut self) {
194        self.stats = HelpCacheStats::default();
195    }
196}
197
198#[derive(Debug)]
199struct HelpCache {
200    buffer: Buffer,
201    layout: HelpLayout,
202    key: LayoutKey,
203    entry_hashes: Vec<u64>,
204    enabled_count: usize,
205}
206
207#[derive(Debug, Clone)]
208struct HelpLayout {
209    mode: HelpMode,
210    width: u16,
211    entries: Vec<EntrySlot>,
212    ellipsis: Option<EllipsisSlot>,
213    max_key_width: usize,
214    separator_width: usize,
215}
216
217#[derive(Debug, Clone)]
218struct EntrySlot {
219    x: u16,
220    y: u16,
221    width: u16,
222    key_width: usize,
223}
224
225#[derive(Debug, Clone)]
226struct EllipsisSlot {
227    x: u16,
228    width: u16,
229    prefix_space: bool,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
233struct StyleKey {
234    fg: Option<PackedRgba>,
235    bg: Option<PackedRgba>,
236    attrs: Option<StyleFlags>,
237}
238
239impl From<Style> for StyleKey {
240    fn from(style: Style) -> Self {
241        Self {
242            fg: style.fg,
243            bg: style.bg,
244            attrs: style.attrs,
245        }
246    }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
250struct LayoutKey {
251    mode: HelpMode,
252    width: u16,
253    height: u16,
254    separator_hash: u64,
255    ellipsis_hash: u64,
256    key_style: StyleKey,
257    desc_style: StyleKey,
258    separator_style: StyleKey,
259    degradation: DegradationLevel,
260}
261
262impl Default for Help {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268impl Help {
269    /// Create a new help widget with no entries.
270    #[must_use]
271    pub fn new() -> Self {
272        Self {
273            entries: Vec::new(),
274            mode: HelpMode::Short,
275            separator: " • ".to_string(),
276            ellipsis: "…".to_string(),
277            key_style: Style::new().bold(),
278            desc_style: Style::default(),
279            separator_style: Style::default(),
280        }
281    }
282
283    /// Add an entry to the help widget.
284    #[must_use]
285    pub fn entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
286        self.entries.push(HelpEntry::new(key, desc));
287        self
288    }
289
290    /// Add a pre-built entry.
291    #[must_use]
292    pub fn with_entry(mut self, entry: HelpEntry) -> Self {
293        self.entries.push(entry);
294        self
295    }
296
297    /// Set all entries at once.
298    #[must_use]
299    pub fn with_entries(mut self, entries: Vec<HelpEntry>) -> Self {
300        self.entries = entries;
301        self
302    }
303
304    /// Set the display mode.
305    #[must_use]
306    pub fn with_mode(mut self, mode: HelpMode) -> Self {
307        self.mode = mode;
308        self
309    }
310
311    /// Set the separator used between entries in short mode.
312    #[must_use]
313    pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
314        self.separator = sep.into();
315        self
316    }
317
318    /// Set the ellipsis string.
319    #[must_use]
320    pub fn with_ellipsis(mut self, ellipsis: impl Into<String>) -> Self {
321        self.ellipsis = ellipsis.into();
322        self
323    }
324
325    /// Set the style for key text.
326    #[must_use]
327    pub fn with_key_style(mut self, style: Style) -> Self {
328        self.key_style = style;
329        self
330    }
331
332    /// Set the style for description text.
333    #[must_use]
334    pub fn with_desc_style(mut self, style: Style) -> Self {
335        self.desc_style = style;
336        self
337    }
338
339    /// Set the style for separators and ellipsis.
340    #[must_use]
341    pub fn with_separator_style(mut self, style: Style) -> Self {
342        self.separator_style = style;
343        self
344    }
345
346    /// Get the entries.
347    #[must_use]
348    pub fn entries(&self) -> &[HelpEntry] {
349        &self.entries
350    }
351
352    /// Get the current mode.
353    #[must_use]
354    pub fn mode(&self) -> HelpMode {
355        self.mode
356    }
357
358    /// Toggle between short and full mode.
359    pub fn toggle_mode(&mut self) {
360        self.mode = match self.mode {
361            HelpMode::Short => HelpMode::Full,
362            HelpMode::Full => HelpMode::Short,
363        };
364    }
365
366    /// Add an entry mutably.
367    pub fn push_entry(&mut self, entry: HelpEntry) {
368        self.entries.push(entry);
369    }
370
371    /// Collect the enabled entries.
372    fn enabled_entries(&self) -> Vec<&HelpEntry> {
373        self.entries.iter().filter(|e| e.enabled).collect()
374    }
375
376    /// Render short mode: entries inline on one line.
377    fn render_short(&self, area: Rect, frame: &mut Frame) {
378        let entries = self.enabled_entries();
379        if entries.is_empty() || area.width == 0 || area.height == 0 {
380            return;
381        }
382
383        let deg = frame.buffer.degradation;
384        let sep_width = display_width(&self.separator);
385        let ellipsis_width = display_width(&self.ellipsis);
386        let max_x = area.right();
387        let y = area.y;
388        let mut x = area.x;
389
390        for (i, entry) in entries.iter().enumerate() {
391            if entry.key.is_empty() && entry.desc.is_empty() {
392                continue;
393            }
394
395            // Separator before non-first items
396            let sep_w = if i > 0 { sep_width } else { 0 };
397
398            // Calculate item width: key + " " + desc
399            let key_w = display_width(&entry.key);
400            let desc_w = display_width(&entry.desc);
401            let item_w = key_w + 1 + desc_w;
402            let total_item_w = sep_w + item_w;
403
404            // Check if this item fits, accounting for possible ellipsis
405            let space_left = (max_x as usize).saturating_sub(x as usize);
406            if total_item_w > space_left {
407                // Try to fit ellipsis
408                let ell_total = if i > 0 {
409                    1 + ellipsis_width
410                } else {
411                    ellipsis_width
412                };
413                if ell_total <= space_left {
414                    let ellipsis_style = if deg.apply_styling() {
415                        self.separator_style
416                    } else {
417                        Style::default()
418                    };
419                    if i > 0 {
420                        x = draw_text_span(frame, x, y, " ", ellipsis_style, max_x);
421                    }
422                    draw_text_span(frame, x, y, &self.ellipsis, ellipsis_style, max_x);
423                }
424                break;
425            }
426
427            // Draw separator
428            if i > 0 {
429                if deg.apply_styling() {
430                    x = draw_text_span(frame, x, y, &self.separator, self.separator_style, max_x);
431                } else {
432                    x = draw_text_span(frame, x, y, &self.separator, Style::default(), max_x);
433                }
434            }
435
436            // Draw key
437            if deg.apply_styling() {
438                x = draw_text_span(frame, x, y, &entry.key, self.key_style, max_x);
439                x = draw_text_span(frame, x, y, " ", self.desc_style, max_x);
440                x = draw_text_span(frame, x, y, &entry.desc, self.desc_style, max_x);
441            } else {
442                let text = format!("{} {}", entry.key, entry.desc);
443                x = draw_text_span(frame, x, y, &text, Style::default(), max_x);
444            }
445        }
446    }
447
448    /// Render full mode: entries stacked vertically with aligned columns.
449    fn render_full(&self, area: Rect, frame: &mut Frame) {
450        let entries = self.enabled_entries();
451        if entries.is_empty() || area.width == 0 || area.height == 0 {
452            return;
453        }
454
455        let deg = frame.buffer.degradation;
456
457        // Find max key width for alignment
458        let max_key_w = entries
459            .iter()
460            .filter(|e| !e.key.is_empty() || !e.desc.is_empty())
461            .map(|e| display_width(&e.key))
462            .max()
463            .unwrap_or(0);
464
465        let max_x = area.right();
466        let mut row: u16 = 0;
467        let key_style = if deg.apply_styling() {
468            self.key_style
469        } else {
470            Style::default()
471        };
472        let desc_style = if deg.apply_styling() {
473            self.desc_style
474        } else {
475            Style::default()
476        };
477
478        for entry in &entries {
479            if entry.key.is_empty() && entry.desc.is_empty() {
480                continue;
481            }
482            if row >= area.height {
483                break;
484            }
485
486            let y = area.y.saturating_add(row);
487            let mut x = area.x;
488            let key_w = display_width(&entry.key);
489            x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
490            let pad = max_key_w.saturating_sub(key_w);
491            for _ in 0..pad {
492                x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
493            }
494            x = draw_text_span(frame, x, y, "  ", Style::default(), max_x);
495            draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
496
497            row += 1;
498        }
499    }
500
501    fn entry_hash(entry: &HelpEntry) -> u64 {
502        let mut hasher = std::collections::hash_map::DefaultHasher::new();
503        entry.key.hash(&mut hasher);
504        entry.desc.hash(&mut hasher);
505        entry.enabled.hash(&mut hasher);
506        entry.category.hash(&mut hasher);
507        hasher.finish()
508    }
509
510    fn hash_str(value: &str) -> u64 {
511        let mut hasher = std::collections::hash_map::DefaultHasher::new();
512        value.hash(&mut hasher);
513        hasher.finish()
514    }
515
516    fn layout_key(&self, area: Rect, degradation: DegradationLevel) -> LayoutKey {
517        LayoutKey {
518            mode: self.mode,
519            width: area.width,
520            height: area.height,
521            separator_hash: Self::hash_str(&self.separator),
522            ellipsis_hash: Self::hash_str(&self.ellipsis),
523            key_style: StyleKey::from(self.key_style),
524            desc_style: StyleKey::from(self.desc_style),
525            separator_style: StyleKey::from(self.separator_style),
526            degradation,
527        }
528    }
529
530    fn build_layout(&self, area: Rect) -> HelpLayout {
531        match self.mode {
532            HelpMode::Short => self.build_short_layout(area),
533            HelpMode::Full => self.build_full_layout(area),
534        }
535    }
536
537    fn build_short_layout(&self, area: Rect) -> HelpLayout {
538        let mut entries = Vec::new();
539        let mut ellipsis = None;
540        let sep_width = display_width(&self.separator);
541        let ellipsis_width = display_width(&self.ellipsis);
542        let max_x = area.width;
543        let mut x: u16 = 0;
544        let mut first = true;
545
546        for entry in self
547            .entries
548            .iter()
549            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
550        {
551            let key_width = display_width(&entry.key);
552            let desc_width = display_width(&entry.desc);
553            let item_width = key_width + 1 + desc_width;
554            let total_width = if first {
555                item_width
556            } else {
557                sep_width + item_width
558            };
559            let space_left = (max_x as usize).saturating_sub(x as usize);
560
561            if total_width > space_left {
562                let ell_total = if first {
563                    ellipsis_width
564                } else {
565                    1 + ellipsis_width
566                };
567                if ell_total <= space_left {
568                    ellipsis = Some(EllipsisSlot {
569                        x,
570                        width: ell_total as u16,
571                        prefix_space: !first,
572                    });
573                }
574                break;
575            }
576
577            entries.push(EntrySlot {
578                x,
579                y: 0,
580                width: total_width as u16,
581                key_width,
582            });
583            x = x.saturating_add(total_width as u16);
584            first = false;
585        }
586
587        HelpLayout {
588            mode: HelpMode::Short,
589            width: area.width,
590            entries,
591            ellipsis,
592            max_key_width: 0,
593            separator_width: sep_width,
594        }
595    }
596
597    fn build_full_layout(&self, area: Rect) -> HelpLayout {
598        let mut max_key_width = 0usize;
599        for entry in self
600            .entries
601            .iter()
602            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
603        {
604            let key_width = display_width(&entry.key);
605            max_key_width = max_key_width.max(key_width);
606        }
607
608        let mut entries = Vec::new();
609        let mut row: u16 = 0;
610        for entry in self
611            .entries
612            .iter()
613            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
614        {
615            if row >= area.height {
616                break;
617            }
618            let key_width = display_width(&entry.key);
619            let desc_width = display_width(&entry.desc);
620            let entry_width = max_key_width.saturating_add(2).saturating_add(desc_width);
621            let slot_width = entry_width.min(area.width as usize) as u16;
622            entries.push(EntrySlot {
623                x: 0,
624                y: row,
625                width: slot_width,
626                key_width,
627            });
628            row = row.saturating_add(1);
629        }
630
631        HelpLayout {
632            mode: HelpMode::Full,
633            width: area.width,
634            entries,
635            ellipsis: None,
636            max_key_width,
637            separator_width: 0,
638        }
639    }
640
641    fn render_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
642        match layout.mode {
643            HelpMode::Short => self.render_short_cached(area, frame, layout),
644            HelpMode::Full => self.render_full_cached(area, frame, layout),
645        }
646    }
647
648    fn render_short_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
649        if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
650            return;
651        }
652
653        let deg = frame.buffer.degradation;
654        let max_x = area.right();
655        let mut enabled_iter = self
656            .entries
657            .iter()
658            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
659
660        for (idx, slot) in layout.entries.iter().enumerate() {
661            let Some(entry) = enabled_iter.next() else {
662                break;
663            };
664            let mut x = area.x.saturating_add(slot.x);
665            let y = area.y.saturating_add(slot.y);
666
667            if idx > 0 {
668                let sep_style = if deg.apply_styling() {
669                    self.separator_style
670                } else {
671                    Style::default()
672                };
673                x = draw_text_span(frame, x, y, &self.separator, sep_style, max_x);
674            }
675
676            let key_style = if deg.apply_styling() {
677                self.key_style
678            } else {
679                Style::default()
680            };
681            let desc_style = if deg.apply_styling() {
682                self.desc_style
683            } else {
684                Style::default()
685            };
686
687            x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
688            x = draw_text_span(frame, x, y, " ", desc_style, max_x);
689            draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
690        }
691
692        if let Some(ellipsis) = &layout.ellipsis {
693            let y = area.y.saturating_add(0);
694            let mut x = area.x.saturating_add(ellipsis.x);
695            let ellipsis_style = if deg.apply_styling() {
696                self.separator_style
697            } else {
698                Style::default()
699            };
700            if ellipsis.prefix_space {
701                x = draw_text_span(frame, x, y, " ", ellipsis_style, max_x);
702            }
703            draw_text_span(frame, x, y, &self.ellipsis, ellipsis_style, max_x);
704        }
705    }
706
707    fn render_full_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
708        if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
709            return;
710        }
711
712        let deg = frame.buffer.degradation;
713        let max_x = area.right();
714
715        let mut enabled_iter = self
716            .entries
717            .iter()
718            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
719
720        for slot in layout.entries.iter() {
721            let Some(entry) = enabled_iter.next() else {
722                break;
723            };
724
725            let y = area.y.saturating_add(slot.y);
726            let mut x = area.x.saturating_add(slot.x);
727
728            let key_style = if deg.apply_styling() {
729                self.key_style
730            } else {
731                Style::default()
732            };
733            let desc_style = if deg.apply_styling() {
734                self.desc_style
735            } else {
736                Style::default()
737            };
738
739            x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
740            let pad = layout.max_key_width.saturating_sub(slot.key_width);
741            for _ in 0..pad {
742                x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
743            }
744            x = draw_text_span(frame, x, y, "  ", Style::default(), max_x);
745            draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
746        }
747    }
748
749    fn render_short_entry(&self, slot: &EntrySlot, entry: &HelpEntry, frame: &mut Frame) {
750        let deg = frame.buffer.degradation;
751        let max_x = slot.x.saturating_add(slot.width);
752
753        let rect = Rect::new(slot.x, slot.y, slot.width, 1);
754        frame.buffer.fill(rect, Cell::default());
755
756        let mut x = slot.x;
757        if slot.x > 0 {
758            let sep_style = if deg.apply_styling() {
759                self.separator_style
760            } else {
761                Style::default()
762            };
763            x = draw_text_span(frame, x, slot.y, &self.separator, sep_style, max_x);
764        }
765
766        let key_style = if deg.apply_styling() {
767            self.key_style
768        } else {
769            Style::default()
770        };
771        let desc_style = if deg.apply_styling() {
772            self.desc_style
773        } else {
774            Style::default()
775        };
776
777        x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
778        x = draw_text_span(frame, x, slot.y, " ", desc_style, max_x);
779        draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
780    }
781
782    fn render_full_entry(
783        &self,
784        slot: &EntrySlot,
785        entry: &HelpEntry,
786        layout: &HelpLayout,
787        frame: &mut Frame,
788    ) {
789        let deg = frame.buffer.degradation;
790        let max_x = slot.x.saturating_add(slot.width);
791
792        let rect = Rect::new(slot.x, slot.y, slot.width, 1);
793        frame.buffer.fill(rect, Cell::default());
794
795        let mut x = slot.x;
796        let key_style = if deg.apply_styling() {
797            self.key_style
798        } else {
799            Style::default()
800        };
801        let desc_style = if deg.apply_styling() {
802            self.desc_style
803        } else {
804            Style::default()
805        };
806
807        x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
808        let pad = layout.max_key_width.saturating_sub(slot.key_width);
809        for _ in 0..pad {
810            x = draw_text_span(frame, x, slot.y, " ", Style::default(), max_x);
811        }
812        x = draw_text_span(frame, x, slot.y, "  ", Style::default(), max_x);
813        draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
814    }
815}
816
817impl Widget for Help {
818    fn render(&self, area: Rect, frame: &mut Frame) {
819        if area.is_empty() || area.width == 0 || area.height == 0 {
820            return;
821        }
822
823        clear_text_area(frame, area, Style::default());
824
825        match self.mode {
826            HelpMode::Short => self.render_short(area, frame),
827            HelpMode::Full => self.render_full(area, frame),
828        }
829    }
830
831    fn is_essential(&self) -> bool {
832        false
833    }
834}
835
836impl StatefulWidget for Help {
837    type State = HelpRenderState;
838
839    fn render(&self, area: Rect, frame: &mut Frame, state: &mut HelpRenderState) {
840        if area.is_empty() || area.width == 0 || area.height == 0 {
841            state.cache = None;
842            state.dirty_rects.clear();
843            state.dirty_indices.clear();
844            state.enabled_indices.clear();
845            return;
846        }
847
848        state.dirty_rects.clear();
849        state.dirty_indices.clear();
850
851        let layout_key = self.layout_key(area, frame.buffer.degradation);
852        let enabled_count = collect_enabled_indices(&self.entries, &mut state.enabled_indices);
853
854        let cache_miss = state
855            .cache
856            .as_ref()
857            .is_none_or(|cache| cache.key != layout_key);
858
859        if cache_miss {
860            rebuild_cache(self, area, frame, state, layout_key, enabled_count);
861            blit_cache(state.cache.as_ref(), area, frame);
862            return;
863        }
864
865        let cache = state
866            .cache
867            .as_mut()
868            .expect("cache present after miss check");
869        if enabled_count != cache.enabled_count {
870            rebuild_cache(self, area, frame, state, layout_key, enabled_count);
871            blit_cache(state.cache.as_ref(), area, frame);
872            return;
873        }
874
875        let mut layout_changed = false;
876        let visible_count = cache.layout.entries.len();
877
878        for (pos, entry_idx) in state.enabled_indices.iter().enumerate() {
879            let entry = &self.entries[*entry_idx];
880            let hash = Help::entry_hash(entry);
881
882            if pos >= cache.entry_hashes.len() {
883                layout_changed = true;
884                break;
885            }
886
887            if hash != cache.entry_hashes[pos] {
888                if pos >= visible_count || !entry_fits_slot(entry, pos, &cache.layout) {
889                    layout_changed = true;
890                    break;
891                }
892                cache.entry_hashes[pos] = hash;
893                state.dirty_indices.push(pos);
894            }
895        }
896
897        if layout_changed {
898            rebuild_cache(self, area, frame, state, layout_key, enabled_count);
899            blit_cache(state.cache.as_ref(), area, frame);
900            return;
901        }
902
903        if state.dirty_indices.is_empty() {
904            state.stats.hits += 1;
905            blit_cache(state.cache.as_ref(), area, frame);
906            return;
907        }
908
909        // Partial update: only changed entries are redrawn into the cached buffer.
910        state.stats.dirty_updates += 1;
911
912        let cache = state
913            .cache
914            .as_mut()
915            .expect("cache present for dirty update");
916        let mut cache_buffer = std::mem::take(&mut cache.buffer);
917        cache_buffer.degradation = frame.buffer.degradation;
918        {
919            let mut cache_frame = Frame::from_buffer(cache_buffer, frame.pool);
920            cache_frame.widget_budget = frame.widget_budget.clone();
921            cache_frame.set_degradation(frame.buffer.degradation);
922
923            for idx in &state.dirty_indices {
924                if let Some(entry_idx) = state.enabled_indices.get(*idx)
925                    && let Some(slot) = cache.layout.entries.get(*idx)
926                {
927                    let entry = &self.entries[*entry_idx];
928                    match cache.layout.mode {
929                        HelpMode::Short => self.render_short_entry(slot, entry, &mut cache_frame),
930                        HelpMode::Full => {
931                            self.render_full_entry(slot, entry, &cache.layout, &mut cache_frame)
932                        }
933                    }
934                    state
935                        .dirty_rects
936                        .push(Rect::new(slot.x, slot.y, slot.width, 1));
937                }
938            }
939
940            cache_buffer = cache_frame.buffer;
941        }
942        cache.buffer = cache_buffer;
943
944        blit_cache(state.cache.as_ref(), area, frame);
945    }
946}
947
948fn collect_enabled_indices(entries: &[HelpEntry], out: &mut Vec<usize>) -> usize {
949    out.clear();
950    for (idx, entry) in entries.iter().enumerate() {
951        if entry.enabled && (!entry.key.is_empty() || !entry.desc.is_empty()) {
952            out.push(idx);
953        }
954    }
955    out.len()
956}
957
958fn entry_fits_slot(entry: &HelpEntry, index: usize, layout: &HelpLayout) -> bool {
959    match layout.mode {
960        HelpMode::Short => {
961            let entry_width = display_width(&entry.key) + 1 + display_width(&entry.desc);
962            let slot = match layout.entries.get(index) {
963                Some(slot) => slot,
964                None => return false,
965            };
966            let sep_width = layout.separator_width;
967            let max_width = if slot.x == 0 {
968                slot.width as usize
969            } else {
970                slot.width.saturating_sub(sep_width as u16) as usize
971            };
972            entry_width <= max_width
973        }
974        HelpMode::Full => {
975            let key_width = display_width(&entry.key);
976            let desc_width = display_width(&entry.desc);
977            let entry_width = layout
978                .max_key_width
979                .saturating_add(2)
980                .saturating_add(desc_width);
981            let slot = match layout.entries.get(index) {
982                Some(slot) => slot,
983                None => return false,
984            };
985            if slot.width == layout.width {
986                key_width <= layout.max_key_width
987            } else {
988                key_width <= layout.max_key_width && entry_width <= slot.width as usize
989            }
990        }
991    }
992}
993
994fn rebuild_cache(
995    help: &Help,
996    area: Rect,
997    frame: &mut Frame,
998    state: &mut HelpRenderState,
999    layout_key: LayoutKey,
1000    enabled_count: usize,
1001) {
1002    state.stats.misses += 1;
1003    state.stats.layout_rebuilds += 1;
1004
1005    let layout_area = Rect::new(0, 0, area.width, area.height);
1006    let layout = help.build_layout(layout_area);
1007
1008    let mut buffer = Buffer::new(area.width, area.height);
1009    buffer.degradation = frame.buffer.degradation;
1010    {
1011        let mut cache_frame = Frame::from_buffer(buffer, frame.pool);
1012        cache_frame.widget_budget = frame.widget_budget.clone();
1013        cache_frame.set_degradation(frame.buffer.degradation);
1014        help.render_cached(layout_area, &mut cache_frame, &layout);
1015        buffer = cache_frame.buffer;
1016    }
1017
1018    let mut entry_hashes = Vec::with_capacity(state.enabled_indices.len());
1019    for idx in &state.enabled_indices {
1020        entry_hashes.push(Help::entry_hash(&help.entries[*idx]));
1021    }
1022
1023    state.cache = Some(HelpCache {
1024        buffer,
1025        layout,
1026        key: layout_key,
1027        entry_hashes,
1028        enabled_count,
1029    });
1030}
1031
1032fn blit_cache(cache: Option<&HelpCache>, area: Rect, frame: &mut Frame) {
1033    let Some(cache) = cache else {
1034        return;
1035    };
1036
1037    for slot in &cache.layout.entries {
1038        let src = Rect::new(slot.x, slot.y, slot.width, 1);
1039        frame
1040            .buffer
1041            .copy_from(&cache.buffer, src, area.x + slot.x, area.y + slot.y);
1042    }
1043
1044    if let Some(ellipsis) = &cache.layout.ellipsis {
1045        let src = Rect::new(ellipsis.x, 0, ellipsis.width, 1);
1046        frame
1047            .buffer
1048            .copy_from(&cache.buffer, src, area.x + ellipsis.x, area.y);
1049    }
1050}
1051
1052/// Format for displaying key labels in hints.
1053#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1054pub enum KeyFormat {
1055    /// Plain key display: `q quit`
1056    #[default]
1057    Plain,
1058    /// Bracketed key display: `[q] quit`
1059    Bracketed,
1060}
1061
1062/// A keybinding hints widget with category grouping and context-aware filtering.
1063///
1064/// Supports two entry scopes:
1065/// - **Global**: shortcuts always visible regardless of context.
1066/// - **Contextual**: shortcuts shown only when [`show_context`](Self::with_show_context)
1067///   is enabled (typically when a particular widget has focus).
1068///
1069/// In [`HelpMode::Full`] mode with categories enabled, entries are grouped
1070/// under category headers. In [`HelpMode::Short`] mode, entries are rendered
1071/// inline.
1072///
1073/// # Example
1074///
1075/// ```
1076/// use ftui_widgets::help::{KeybindingHints, HelpCategory, KeyFormat};
1077///
1078/// let hints = KeybindingHints::new()
1079///     .with_key_format(KeyFormat::Bracketed)
1080///     .global_entry("q", "quit")
1081///     .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1082///     .contextual_entry_categorized("^s", "save", HelpCategory::File);
1083///
1084/// assert_eq!(hints.global_entries().len(), 2);
1085/// assert_eq!(hints.contextual_entries().len(), 1);
1086/// ```
1087#[derive(Debug, Clone)]
1088pub struct KeybindingHints {
1089    global_entries: Vec<HelpEntry>,
1090    contextual_entries: Vec<HelpEntry>,
1091    key_format: KeyFormat,
1092    mode: HelpMode,
1093    key_style: Style,
1094    desc_style: Style,
1095    separator_style: Style,
1096    category_style: Style,
1097    separator: String,
1098    ellipsis: String,
1099    show_categories: bool,
1100    show_context: bool,
1101}
1102
1103impl Default for KeybindingHints {
1104    fn default() -> Self {
1105        Self::new()
1106    }
1107}
1108
1109impl KeybindingHints {
1110    /// Create a new hints widget with no entries.
1111    #[must_use]
1112    pub fn new() -> Self {
1113        Self {
1114            global_entries: Vec::new(),
1115            contextual_entries: Vec::new(),
1116            key_format: KeyFormat::default(),
1117            mode: HelpMode::Short,
1118            key_style: Style::new().bold(),
1119            desc_style: Style::default(),
1120            separator_style: Style::default(),
1121            category_style: Style::new().bold().underline(),
1122            separator: " • ".to_string(),
1123            ellipsis: "…".to_string(),
1124            show_categories: true,
1125            show_context: false,
1126        }
1127    }
1128
1129    /// Add a global entry (always visible).
1130    #[must_use]
1131    pub fn global_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1132        self.global_entries
1133            .push(HelpEntry::new(key, desc).with_category(HelpCategory::Global));
1134        self
1135    }
1136
1137    /// Add a global entry with a specific category.
1138    #[must_use]
1139    pub fn global_entry_categorized(
1140        mut self,
1141        key: impl Into<String>,
1142        desc: impl Into<String>,
1143        category: HelpCategory,
1144    ) -> Self {
1145        self.global_entries
1146            .push(HelpEntry::new(key, desc).with_category(category));
1147        self
1148    }
1149
1150    /// Add a contextual entry (shown when context is active).
1151    #[must_use]
1152    pub fn contextual_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1153        self.contextual_entries.push(HelpEntry::new(key, desc));
1154        self
1155    }
1156
1157    /// Add a contextual entry with a specific category.
1158    #[must_use]
1159    pub fn contextual_entry_categorized(
1160        mut self,
1161        key: impl Into<String>,
1162        desc: impl Into<String>,
1163        category: HelpCategory,
1164    ) -> Self {
1165        self.contextual_entries
1166            .push(HelpEntry::new(key, desc).with_category(category));
1167        self
1168    }
1169
1170    /// Add a pre-built global entry.
1171    #[must_use]
1172    pub fn with_global_entry(mut self, entry: HelpEntry) -> Self {
1173        self.global_entries.push(entry);
1174        self
1175    }
1176
1177    /// Add a pre-built contextual entry.
1178    #[must_use]
1179    pub fn with_contextual_entry(mut self, entry: HelpEntry) -> Self {
1180        self.contextual_entries.push(entry);
1181        self
1182    }
1183
1184    /// Set the key display format.
1185    #[must_use]
1186    pub fn with_key_format(mut self, format: KeyFormat) -> Self {
1187        self.key_format = format;
1188        self
1189    }
1190
1191    /// Set the display mode.
1192    #[must_use]
1193    pub fn with_mode(mut self, mode: HelpMode) -> Self {
1194        self.mode = mode;
1195        self
1196    }
1197
1198    /// Set whether contextual entries are shown.
1199    #[must_use]
1200    pub fn with_show_context(mut self, show: bool) -> Self {
1201        self.show_context = show;
1202        self
1203    }
1204
1205    /// Set whether category headers are shown in full mode.
1206    #[must_use]
1207    pub fn with_show_categories(mut self, show: bool) -> Self {
1208        self.show_categories = show;
1209        self
1210    }
1211
1212    /// Set the style for key text.
1213    #[must_use]
1214    pub fn with_key_style(mut self, style: Style) -> Self {
1215        self.key_style = style;
1216        self
1217    }
1218
1219    /// Set the style for description text.
1220    #[must_use]
1221    pub fn with_desc_style(mut self, style: Style) -> Self {
1222        self.desc_style = style;
1223        self
1224    }
1225
1226    /// Set the style for separators.
1227    #[must_use]
1228    pub fn with_separator_style(mut self, style: Style) -> Self {
1229        self.separator_style = style;
1230        self
1231    }
1232
1233    /// Set the style for category headers.
1234    #[must_use]
1235    pub fn with_category_style(mut self, style: Style) -> Self {
1236        self.category_style = style;
1237        self
1238    }
1239
1240    /// Set the separator string for short mode.
1241    #[must_use]
1242    pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
1243        self.separator = sep.into();
1244        self
1245    }
1246
1247    /// Get the global entries.
1248    #[must_use]
1249    pub fn global_entries(&self) -> &[HelpEntry] {
1250        &self.global_entries
1251    }
1252
1253    /// Get the contextual entries.
1254    #[must_use]
1255    pub fn contextual_entries(&self) -> &[HelpEntry] {
1256        &self.contextual_entries
1257    }
1258
1259    /// Get the current mode.
1260    #[must_use]
1261    pub fn mode(&self) -> HelpMode {
1262        self.mode
1263    }
1264
1265    /// Get the key format.
1266    #[must_use]
1267    pub fn key_format(&self) -> KeyFormat {
1268        self.key_format
1269    }
1270
1271    /// Toggle between short and full mode.
1272    pub fn toggle_mode(&mut self) {
1273        self.mode = match self.mode {
1274            HelpMode::Short => HelpMode::Full,
1275            HelpMode::Full => HelpMode::Short,
1276        };
1277    }
1278
1279    /// Set whether contextual entries are shown (mutable).
1280    pub fn set_show_context(&mut self, show: bool) {
1281        self.show_context = show;
1282    }
1283
1284    /// Format a key string according to the current key format.
1285    fn format_key(&self, key: &str) -> String {
1286        match self.key_format {
1287            KeyFormat::Plain => key.to_string(),
1288            KeyFormat::Bracketed => format!("[{key}]"),
1289        }
1290    }
1291
1292    /// Collect visible entries, applying scope filter and key formatting.
1293    #[must_use]
1294    pub fn visible_entries(&self) -> Vec<HelpEntry> {
1295        let mut entries = Vec::new();
1296        for e in &self.global_entries {
1297            if e.enabled {
1298                entries.push(HelpEntry {
1299                    key: self.format_key(&e.key),
1300                    desc: e.desc.clone(),
1301                    enabled: true,
1302                    category: e.category.clone(),
1303                });
1304            }
1305        }
1306        if self.show_context {
1307            for e in &self.contextual_entries {
1308                if e.enabled {
1309                    entries.push(HelpEntry {
1310                        key: self.format_key(&e.key),
1311                        desc: e.desc.clone(),
1312                        enabled: true,
1313                        category: e.category.clone(),
1314                    });
1315                }
1316            }
1317        }
1318        entries
1319    }
1320
1321    /// Group entries by category, preserving insertion order within each group.
1322    fn grouped_entries(entries: &[HelpEntry]) -> Vec<(&HelpCategory, Vec<&HelpEntry>)> {
1323        let mut groups: Vec<(&HelpCategory, Vec<&HelpEntry>)> = Vec::new();
1324        for entry in entries {
1325            if let Some(group) = groups.iter_mut().find(|(cat, _)| **cat == entry.category) {
1326                group.1.push(entry);
1327            } else {
1328                groups.push((&entry.category, vec![entry]));
1329            }
1330        }
1331        groups
1332    }
1333
1334    /// Render full mode with category headers.
1335    fn render_full_grouped(&self, entries: &[HelpEntry], area: Rect, frame: &mut Frame) {
1336        let groups = Self::grouped_entries(entries);
1337        let deg = frame.buffer.degradation;
1338        let max_x = area.right();
1339        let mut y = area.y;
1340
1341        // Find max key width across all entries for alignment.
1342        let max_key_w = entries
1343            .iter()
1344            .map(|e| display_width(&e.key))
1345            .max()
1346            .unwrap_or(0);
1347
1348        for (i, (cat, group_entries)) in groups.iter().enumerate() {
1349            if y >= area.bottom() {
1350                break;
1351            }
1352
1353            // Category header
1354            let cat_style = if deg.apply_styling() {
1355                self.category_style
1356            } else {
1357                Style::default()
1358            };
1359            draw_text_span(frame, area.x, y, cat.label(), cat_style, max_x);
1360            y += 1;
1361
1362            // Entries in this category
1363            for entry in group_entries {
1364                if y >= area.bottom() {
1365                    break;
1366                }
1367
1368                let key_style = if deg.apply_styling() {
1369                    self.key_style
1370                } else {
1371                    Style::default()
1372                };
1373                let desc_style = if deg.apply_styling() {
1374                    self.desc_style
1375                } else {
1376                    Style::default()
1377                };
1378
1379                let mut x = area.x;
1380                x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
1381                let pad = max_key_w.saturating_sub(display_width(&entry.key));
1382                for _ in 0..pad {
1383                    x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
1384                }
1385                x = draw_text_span(frame, x, y, "  ", Style::default(), max_x);
1386                draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
1387                y += 1;
1388            }
1389
1390            // Blank line between groups (except after last)
1391            if i + 1 < groups.len() {
1392                y += 1;
1393            }
1394        }
1395    }
1396}
1397
1398impl Widget for KeybindingHints {
1399    fn render(&self, area: Rect, frame: &mut Frame) {
1400        if area.is_empty() || area.width == 0 || area.height == 0 {
1401            return;
1402        }
1403
1404        clear_text_area(frame, area, Style::default());
1405
1406        let entries = self.visible_entries();
1407        if entries.is_empty() {
1408            return;
1409        }
1410
1411        match self.mode {
1412            HelpMode::Short => {
1413                // In short mode, render all entries inline using Help widget.
1414                let help = Help::new()
1415                    .with_mode(HelpMode::Short)
1416                    .with_key_style(self.key_style)
1417                    .with_desc_style(self.desc_style)
1418                    .with_separator_style(self.separator_style)
1419                    .with_separator(self.separator.clone())
1420                    .with_ellipsis(self.ellipsis.clone())
1421                    .with_entries(entries);
1422                Widget::render(&help, area, frame);
1423            }
1424            HelpMode::Full => {
1425                if self.show_categories {
1426                    self.render_full_grouped(&entries, area, frame);
1427                } else {
1428                    let help = Help::new()
1429                        .with_mode(HelpMode::Full)
1430                        .with_key_style(self.key_style)
1431                        .with_desc_style(self.desc_style)
1432                        .with_entries(entries);
1433                    Widget::render(&help, area, frame);
1434                }
1435            }
1436        }
1437    }
1438
1439    fn is_essential(&self) -> bool {
1440        false
1441    }
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446    use super::*;
1447    use ftui_render::buffer::Buffer;
1448    use ftui_render::frame::Frame;
1449    use ftui_render::grapheme_pool::GraphemePool;
1450    use proptest::prelude::*;
1451    use proptest::string::string_regex;
1452    use std::time::Instant;
1453
1454    fn row_text(buf: &Buffer, y: u16, width: u16) -> String {
1455        (0..width)
1456            .map(|x| {
1457                buf.get(x, y)
1458                    .and_then(|cell| cell.content.as_char())
1459                    .unwrap_or(' ')
1460            })
1461            .collect()
1462    }
1463
1464    fn find_char_column(buf: &Buffer, y: u16, width: u16, target: char) -> Option<usize> {
1465        row_text(buf, y, width).chars().position(|ch| ch == target)
1466    }
1467
1468    #[test]
1469    fn new_help_is_empty() {
1470        let help = Help::new();
1471        assert!(help.entries().is_empty());
1472        assert_eq!(help.mode(), HelpMode::Short);
1473    }
1474
1475    #[test]
1476    fn entry_builder() {
1477        let help = Help::new().entry("q", "quit").entry("^s", "save");
1478        assert_eq!(help.entries().len(), 2);
1479        assert_eq!(help.entries()[0].key, "q");
1480        assert_eq!(help.entries()[0].desc, "quit");
1481    }
1482
1483    #[test]
1484    fn with_entries_replaces() {
1485        let help = Help::new()
1486            .entry("old", "old")
1487            .with_entries(vec![HelpEntry::new("new", "new")]);
1488        assert_eq!(help.entries().len(), 1);
1489        assert_eq!(help.entries()[0].key, "new");
1490    }
1491
1492    #[test]
1493    fn disabled_entries_hidden() {
1494        let help = Help::new()
1495            .with_entry(HelpEntry::new("a", "shown"))
1496            .with_entry(HelpEntry::new("b", "hidden").with_enabled(false))
1497            .with_entry(HelpEntry::new("c", "also shown"));
1498        assert_eq!(help.enabled_entries().len(), 2);
1499    }
1500
1501    #[test]
1502    fn toggle_mode() {
1503        let mut help = Help::new();
1504        assert_eq!(help.mode(), HelpMode::Short);
1505        help.toggle_mode();
1506        assert_eq!(help.mode(), HelpMode::Full);
1507        help.toggle_mode();
1508        assert_eq!(help.mode(), HelpMode::Short);
1509    }
1510
1511    #[test]
1512    fn push_entry() {
1513        let mut help = Help::new();
1514        help.push_entry(HelpEntry::new("x", "action"));
1515        assert_eq!(help.entries().len(), 1);
1516    }
1517
1518    #[test]
1519    fn render_short_basic() {
1520        let help = Help::new().entry("q", "quit").entry("^s", "save");
1521
1522        let mut pool = GraphemePool::new();
1523        let mut frame = Frame::new(40, 1, &mut pool);
1524        let area = Rect::new(0, 0, 40, 1);
1525        Widget::render(&help, area, &mut frame);
1526
1527        // Check that key text appears in buffer
1528        let cell_q = frame.buffer.get(0, 0).unwrap();
1529        assert_eq!(cell_q.content.as_char(), Some('q'));
1530    }
1531
1532    #[test]
1533    fn render_short_truncation() {
1534        let help = Help::new()
1535            .entry("q", "quit")
1536            .entry("^s", "save")
1537            .entry("^x", "something very long that should not fit");
1538
1539        let mut pool = GraphemePool::new();
1540        let mut frame = Frame::new(20, 1, &mut pool);
1541        let area = Rect::new(0, 0, 20, 1);
1542        Widget::render(&help, area, &mut frame);
1543
1544        // First entry should be present
1545        let cell = frame.buffer.get(0, 0).unwrap();
1546        assert_eq!(cell.content.as_char(), Some('q'));
1547    }
1548
1549    #[test]
1550    fn render_short_truncation_keeps_ellipsis_without_styling() {
1551        let help = Help::new()
1552            .entry("q", "quit")
1553            .entry("^s", "save")
1554            .entry("^x", "something very long that should not fit");
1555
1556        let mut pool = GraphemePool::new();
1557        let mut frame = Frame::new(20, 1, &mut pool);
1558        frame.buffer.degradation = DegradationLevel::NoStyling;
1559        let area = Rect::new(0, 0, 20, 1);
1560        Widget::render(&help, area, &mut frame);
1561
1562        let saw_ellipsis = (area.x..area.right()).any(|x| {
1563            frame
1564                .buffer
1565                .get(x, area.y)
1566                .and_then(|cell| cell.content.as_char())
1567                == Some('…')
1568        });
1569        assert!(saw_ellipsis);
1570    }
1571
1572    #[test]
1573    fn render_short_empty_entries() {
1574        let help = Help::new();
1575
1576        let mut pool = GraphemePool::new();
1577        let mut frame = Frame::new(20, 1, &mut pool);
1578        let area = Rect::new(0, 0, 20, 1);
1579        Widget::render(&help, area, &mut frame);
1580
1581        // Buffer should remain default (empty cell)
1582        let cell = frame.buffer.get(0, 0).unwrap();
1583        assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
1584    }
1585
1586    #[test]
1587    fn render_short_shrinking_clears_stale_suffix() {
1588        let long = Help::new().entry("^x", "explode").entry("^s", "save");
1589        let short = Help::new().entry("q", "quit");
1590
1591        let mut pool = GraphemePool::new();
1592        let mut frame = Frame::new(24, 1, &mut pool);
1593        let area = Rect::new(0, 0, 24, 1);
1594
1595        Widget::render(&long, area, &mut frame);
1596        Widget::render(&short, area, &mut frame);
1597
1598        assert_eq!(row_text(&frame.buffer, 0, 24), "q quit                  ");
1599    }
1600
1601    #[test]
1602    fn render_short_empty_entries_clear_stale_row() {
1603        let populated = Help::new().entry("q", "quit").entry("^s", "save");
1604        let empty = Help::new();
1605
1606        let mut pool = GraphemePool::new();
1607        let mut frame = Frame::new(20, 1, &mut pool);
1608        let area = Rect::new(0, 0, 20, 1);
1609
1610        Widget::render(&populated, area, &mut frame);
1611        Widget::render(&empty, area, &mut frame);
1612
1613        assert_eq!(row_text(&frame.buffer, 0, 20), " ".repeat(20));
1614    }
1615
1616    #[test]
1617    fn render_full_basic() {
1618        let help = Help::new()
1619            .with_mode(HelpMode::Full)
1620            .entry("q", "quit")
1621            .entry("^s", "save file");
1622
1623        let mut pool = GraphemePool::new();
1624        let mut frame = Frame::new(30, 5, &mut pool);
1625        let area = Rect::new(0, 0, 30, 5);
1626        Widget::render(&help, area, &mut frame);
1627
1628        // First row should have "q" key
1629        let cell = frame.buffer.get(0, 0).unwrap();
1630        assert!(cell.content.as_char() == Some(' ') || cell.content.as_char() == Some('q'));
1631        // Second row should have "^s" key (right-padded: " ^s")
1632        let cell_row2 = frame.buffer.get(0, 1).unwrap();
1633        assert!(
1634            cell_row2.content.as_char() == Some('^') || cell_row2.content.as_char() == Some(' ')
1635        );
1636    }
1637
1638    #[test]
1639    fn render_full_to_short_clears_stale_lower_rows() {
1640        let full = Help::new()
1641            .with_mode(HelpMode::Full)
1642            .entry("a", "alpha")
1643            .entry("b", "beta");
1644        let short = Help::new().entry("q", "quit");
1645
1646        let mut pool = GraphemePool::new();
1647        let mut frame = Frame::new(20, 2, &mut pool);
1648        let area = Rect::new(0, 0, 20, 2);
1649
1650        Widget::render(&full, area, &mut frame);
1651        Widget::render(&short, area, &mut frame);
1652
1653        assert_eq!(row_text(&frame.buffer, 0, 20), "q quit              ");
1654        assert_eq!(row_text(&frame.buffer, 1, 20), " ".repeat(20));
1655    }
1656
1657    #[test]
1658    fn render_full_respects_height() {
1659        let help = Help::new()
1660            .with_mode(HelpMode::Full)
1661            .entry("a", "first")
1662            .entry("b", "second")
1663            .entry("c", "third");
1664
1665        let mut pool = GraphemePool::new();
1666        // Only 2 rows available
1667        let mut frame = Frame::new(30, 2, &mut pool);
1668        let area = Rect::new(0, 0, 30, 2);
1669        Widget::render(&help, area, &mut frame);
1670
1671        // Only first two entries should render (height=2)
1672        // No crash, no panic
1673    }
1674
1675    #[test]
1676    fn help_entry_equality() {
1677        let a = HelpEntry::new("q", "quit");
1678        let b = HelpEntry::new("q", "quit");
1679        let c = HelpEntry::new("x", "exit");
1680        assert_eq!(a, b);
1681        assert_ne!(a, c);
1682    }
1683
1684    #[test]
1685    fn help_entry_disabled() {
1686        let entry = HelpEntry::new("q", "quit").with_enabled(false);
1687        assert!(!entry.enabled);
1688    }
1689
1690    #[test]
1691    fn with_separator() {
1692        let help = Help::new().with_separator(" | ");
1693        assert_eq!(help.separator, " | ");
1694    }
1695
1696    #[test]
1697    fn with_ellipsis() {
1698        let help = Help::new().with_ellipsis("...");
1699        assert_eq!(help.ellipsis, "...");
1700    }
1701
1702    #[test]
1703    fn render_zero_area() {
1704        let help = Help::new().entry("q", "quit");
1705
1706        let mut pool = GraphemePool::new();
1707        let mut frame = Frame::new(20, 1, &mut pool);
1708        let area = Rect::new(0, 0, 0, 0);
1709        Widget::render(&help, area, &mut frame); // Should not panic
1710    }
1711
1712    #[test]
1713    fn is_not_essential() {
1714        let help = Help::new();
1715        assert!(!help.is_essential());
1716    }
1717
1718    #[test]
1719    fn render_full_alignment() {
1720        // Verify key column alignment in full mode
1721        let help = Help::new()
1722            .with_mode(HelpMode::Full)
1723            .entry("q", "quit")
1724            .entry("ctrl+s", "save");
1725
1726        let mut pool = GraphemePool::new();
1727        let mut frame = Frame::new(30, 3, &mut pool);
1728        let area = Rect::new(0, 0, 30, 3);
1729        Widget::render(&help, area, &mut frame);
1730
1731        // "q" is 1 char, "ctrl+s" is 6 chars, max_key_w = 6
1732        // Row 0: "q      quit" (q + 5 spaces + 2 spaces + quit)
1733        // Row 1: "ctrl+s  save"
1734        // Check that descriptions start at the same column
1735        // Key col = 6, gap = 2, desc starts at col 8
1736    }
1737
1738    #[test]
1739    fn render_full_no_styling_keeps_left_aligned_key_column() {
1740        let help = Help::new()
1741            .with_mode(HelpMode::Full)
1742            .entry("q", "quit")
1743            .entry("ctrl+s", "save");
1744
1745        let mut pool = GraphemePool::new();
1746        let mut frame = Frame::new(30, 3, &mut pool);
1747        frame.buffer.degradation = DegradationLevel::NoStyling;
1748        let area = Rect::new(0, 0, 30, 3);
1749        Widget::render(&help, area, &mut frame);
1750
1751        assert_eq!(
1752            frame
1753                .buffer
1754                .get(0, 0)
1755                .and_then(|cell| cell.content.as_char()),
1756            Some('q'),
1757            "short key should stay left-aligned in degraded full mode"
1758        );
1759        assert_eq!(
1760            find_char_column(&frame.buffer, 0, area.width, 'q'),
1761            Some(0),
1762            "short key drifted right in degraded full mode"
1763        );
1764        assert_eq!(
1765            find_char_column(&frame.buffer, 0, area.width, 'q'),
1766            find_char_column(&frame.buffer, 1, area.width, 'c')
1767        );
1768    }
1769
1770    #[test]
1771    fn render_full_no_styling_uses_display_width_for_wide_keys() {
1772        let help = Help::new()
1773            .with_mode(HelpMode::Full)
1774            .entry("🦀", "crab")
1775            .entry("ctrl+s", "write");
1776
1777        let mut pool = GraphemePool::new();
1778        let mut frame = Frame::new(30, 3, &mut pool);
1779        frame.buffer.degradation = DegradationLevel::NoStyling;
1780        let area = Rect::new(0, 0, 30, 3);
1781        Widget::render(&help, area, &mut frame);
1782
1783        let crab_desc_col = find_char_column(&frame.buffer, 0, area.width, 'c');
1784        let save_desc_col = find_char_column(&frame.buffer, 1, area.width, 'w');
1785        assert_eq!(
1786            crab_desc_col, save_desc_col,
1787            "wide-key descriptions should align to the same display column in degraded full mode"
1788        );
1789    }
1790
1791    #[test]
1792    fn default_impl() {
1793        let help = Help::default();
1794        assert!(help.entries().is_empty());
1795    }
1796
1797    #[test]
1798    fn cache_hit_same_hints() {
1799        let help = Help::new().entry("q", "quit").entry("^s", "save");
1800        let mut state = HelpRenderState::default();
1801        let mut pool = GraphemePool::new();
1802        let mut frame = Frame::new(40, 1, &mut pool);
1803        let area = Rect::new(0, 0, 40, 1);
1804
1805        StatefulWidget::render(&help, area, &mut frame, &mut state);
1806        let stats_after_first = state.stats();
1807        StatefulWidget::render(&help, area, &mut frame, &mut state);
1808        let stats_after_second = state.stats();
1809
1810        assert!(
1811            stats_after_second.hits > stats_after_first.hits,
1812            "Second render should be a cache hit"
1813        );
1814        assert!(state.dirty_rects().is_empty(), "No dirty rects on hit");
1815    }
1816
1817    #[test]
1818    fn dirty_rect_only_changes() {
1819        let mut help = Help::new()
1820            .with_mode(HelpMode::Full)
1821            .entry("q", "quit")
1822            .entry("w", "write")
1823            .entry("e", "edit");
1824
1825        let mut state = HelpRenderState::default();
1826        let mut pool = GraphemePool::new();
1827        let mut frame = Frame::new(40, 3, &mut pool);
1828        let area = Rect::new(0, 0, 40, 3);
1829
1830        StatefulWidget::render(&help, area, &mut frame, &mut state);
1831
1832        help.entries[1].desc.clear();
1833        help.entries[1].desc.push_str("save");
1834
1835        StatefulWidget::render(&help, area, &mut frame, &mut state);
1836        let dirty = state.take_dirty_rects();
1837
1838        assert_eq!(dirty.len(), 1, "Only one row should be dirty");
1839        assert_eq!(dirty[0].y, 1, "Second entry row should be dirty");
1840    }
1841
1842    proptest! {
1843        #[test]
1844        fn prop_cache_hits_on_stable_entries(entries in prop::collection::vec(
1845            (string_regex("[a-z]{1,6}").unwrap(), string_regex("[a-z]{1,10}").unwrap()),
1846            1..6
1847        )) {
1848            let mut help = Help::new();
1849            for (key, desc) in entries {
1850                help = help.entry(key, desc);
1851            }
1852            let mut state = HelpRenderState::default();
1853            let mut pool = GraphemePool::new();
1854            let mut frame = Frame::new(80, 1, &mut pool);
1855            let area = Rect::new(0, 0, 80, 1);
1856
1857            StatefulWidget::render(&help, area, &mut frame, &mut state);
1858            let stats_after_first = state.stats();
1859            StatefulWidget::render(&help, area, &mut frame, &mut state);
1860            let stats_after_second = state.stats();
1861
1862            prop_assert!(stats_after_second.hits > stats_after_first.hits);
1863            prop_assert!(state.dirty_rects().is_empty());
1864        }
1865    }
1866
1867    #[test]
1868    fn perf_micro_hint_update() {
1869        let mut help = Help::new()
1870            .with_mode(HelpMode::Short)
1871            .entry("^T", "Theme")
1872            .entry("^C", "Quit")
1873            .entry("?", "Help")
1874            .entry("F12", "Debug");
1875
1876        let mut state = HelpRenderState::default();
1877        let mut pool = GraphemePool::new();
1878        let mut frame = Frame::new(120, 1, &mut pool);
1879        let area = Rect::new(0, 0, 120, 1);
1880
1881        StatefulWidget::render(&help, area, &mut frame, &mut state);
1882
1883        let iterations = 200u32;
1884        let mut times_us = Vec::with_capacity(iterations as usize);
1885        for i in 0..iterations {
1886            let label = if i % 2 == 0 { "Close" } else { "Open" };
1887            help.entries[1].desc.clear();
1888            help.entries[1].desc.push_str(label);
1889
1890            let start = Instant::now();
1891            StatefulWidget::render(&help, area, &mut frame, &mut state);
1892            let elapsed = start.elapsed();
1893            times_us.push(elapsed.as_micros() as u64);
1894        }
1895
1896        times_us.sort();
1897        let len = times_us.len();
1898        let p50 = times_us[len / 2];
1899        let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1900        let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1901        let updates_per_sec = 1_000_000u64.checked_div(p50).unwrap_or(0);
1902
1903        eprintln!(
1904            "{{\"ts\":\"2026-02-03T00:00:00Z\",\"case\":\"help_hint_update\",\"iterations\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"updates_per_sec\":{},\"hits\":{},\"misses\":{},\"dirty_updates\":{}}}",
1905            iterations,
1906            p50,
1907            p95,
1908            p99,
1909            updates_per_sec,
1910            state.stats().hits,
1911            state.stats().misses,
1912            state.stats().dirty_updates
1913        );
1914
1915        // Budget: keep p95 under 2ms in CI (500 updates/sec).
1916        assert!(p95 <= 2000, "p95 too slow: {p95}us");
1917    }
1918
1919    // ── HelpCategory tests ─────────────────────────────────────────
1920
1921    #[test]
1922    fn help_category_default_is_general() {
1923        assert_eq!(HelpCategory::default(), HelpCategory::General);
1924    }
1925
1926    #[test]
1927    fn help_category_labels() {
1928        assert_eq!(HelpCategory::General.label(), "General");
1929        assert_eq!(HelpCategory::Navigation.label(), "Navigation");
1930        assert_eq!(HelpCategory::Editing.label(), "Editing");
1931        assert_eq!(HelpCategory::File.label(), "File");
1932        assert_eq!(HelpCategory::View.label(), "View");
1933        assert_eq!(HelpCategory::Global.label(), "Global");
1934        assert_eq!(
1935            HelpCategory::Custom("My Section".into()).label(),
1936            "My Section"
1937        );
1938    }
1939
1940    #[test]
1941    fn help_entry_with_category() {
1942        let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1943        assert_eq!(entry.category, HelpCategory::Navigation);
1944    }
1945
1946    #[test]
1947    fn help_entry_default_category_is_general() {
1948        let entry = HelpEntry::new("q", "quit");
1949        assert_eq!(entry.category, HelpCategory::General);
1950    }
1951
1952    #[test]
1953    fn category_changes_entry_hash() {
1954        let a = HelpEntry::new("q", "quit");
1955        let b = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1956        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
1957    }
1958
1959    // ── KeyFormat tests ────────────────────────────────────────────
1960
1961    #[test]
1962    fn key_format_default_is_plain() {
1963        assert_eq!(KeyFormat::default(), KeyFormat::Plain);
1964    }
1965
1966    // ── KeybindingHints tests ──────────────────────────────────────
1967
1968    #[test]
1969    fn keybinding_hints_new_is_empty() {
1970        let hints = KeybindingHints::new();
1971        assert!(hints.global_entries().is_empty());
1972        assert!(hints.contextual_entries().is_empty());
1973        assert_eq!(hints.mode(), HelpMode::Short);
1974        assert_eq!(hints.key_format(), KeyFormat::Plain);
1975    }
1976
1977    #[test]
1978    fn keybinding_hints_default() {
1979        let hints = KeybindingHints::default();
1980        assert!(hints.global_entries().is_empty());
1981    }
1982
1983    #[test]
1984    fn keybinding_hints_global_entry() {
1985        let hints = KeybindingHints::new()
1986            .global_entry("q", "quit")
1987            .global_entry("^s", "save");
1988        assert_eq!(hints.global_entries().len(), 2);
1989        assert_eq!(hints.global_entries()[0].key, "q");
1990        assert_eq!(hints.global_entries()[0].category, HelpCategory::Global);
1991    }
1992
1993    #[test]
1994    fn keybinding_hints_categorized_entries() {
1995        let hints = KeybindingHints::new()
1996            .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1997            .global_entry_categorized("q", "quit", HelpCategory::Global);
1998        assert_eq!(hints.global_entries()[0].category, HelpCategory::Navigation);
1999        assert_eq!(hints.global_entries()[1].category, HelpCategory::Global);
2000    }
2001
2002    #[test]
2003    fn keybinding_hints_contextual_entry() {
2004        let hints = KeybindingHints::new()
2005            .contextual_entry("^s", "save")
2006            .contextual_entry_categorized("^f", "find", HelpCategory::Editing);
2007        assert_eq!(hints.contextual_entries().len(), 2);
2008        assert_eq!(
2009            hints.contextual_entries()[0].category,
2010            HelpCategory::General
2011        );
2012        assert_eq!(
2013            hints.contextual_entries()[1].category,
2014            HelpCategory::Editing
2015        );
2016    }
2017
2018    #[test]
2019    fn keybinding_hints_with_prebuilt_entries() {
2020        let global = HelpEntry::new("q", "quit").with_category(HelpCategory::Global);
2021        let ctx = HelpEntry::new("^s", "save").with_category(HelpCategory::File);
2022        let hints = KeybindingHints::new()
2023            .with_global_entry(global)
2024            .with_contextual_entry(ctx);
2025        assert_eq!(hints.global_entries().len(), 1);
2026        assert_eq!(hints.contextual_entries().len(), 1);
2027    }
2028
2029    #[test]
2030    fn keybinding_hints_toggle_mode() {
2031        let mut hints = KeybindingHints::new();
2032        assert_eq!(hints.mode(), HelpMode::Short);
2033        hints.toggle_mode();
2034        assert_eq!(hints.mode(), HelpMode::Full);
2035        hints.toggle_mode();
2036        assert_eq!(hints.mode(), HelpMode::Short);
2037    }
2038
2039    #[test]
2040    fn keybinding_hints_set_show_context() {
2041        let mut hints = KeybindingHints::new()
2042            .global_entry("q", "quit")
2043            .contextual_entry("^s", "save");
2044
2045        // Context off: only global visible
2046        let visible = hints.visible_entries();
2047        assert_eq!(visible.len(), 1);
2048
2049        // Context on: both visible
2050        hints.set_show_context(true);
2051        let visible = hints.visible_entries();
2052        assert_eq!(visible.len(), 2);
2053    }
2054
2055    #[test]
2056    fn keybinding_hints_bracketed_format() {
2057        let hints = KeybindingHints::new()
2058            .with_key_format(KeyFormat::Bracketed)
2059            .global_entry("q", "quit");
2060        let visible = hints.visible_entries();
2061        assert_eq!(visible[0].key, "[q]");
2062    }
2063
2064    #[test]
2065    fn keybinding_hints_plain_format() {
2066        let hints = KeybindingHints::new()
2067            .with_key_format(KeyFormat::Plain)
2068            .global_entry("q", "quit");
2069        let visible = hints.visible_entries();
2070        assert_eq!(visible[0].key, "q");
2071    }
2072
2073    #[test]
2074    fn keybinding_hints_disabled_entries_hidden() {
2075        let hints = KeybindingHints::new()
2076            .with_global_entry(HelpEntry::new("a", "shown"))
2077            .with_global_entry(HelpEntry::new("b", "hidden").with_enabled(false));
2078        let visible = hints.visible_entries();
2079        assert_eq!(visible.len(), 1);
2080        assert_eq!(visible[0].key, "a");
2081    }
2082
2083    #[test]
2084    fn keybinding_hints_grouped_entries() {
2085        let entries = vec![
2086            HelpEntry::new("Tab", "next").with_category(HelpCategory::Navigation),
2087            HelpEntry::new("q", "quit").with_category(HelpCategory::Global),
2088            HelpEntry::new("S-Tab", "prev").with_category(HelpCategory::Navigation),
2089        ];
2090        let groups = KeybindingHints::grouped_entries(&entries);
2091        assert_eq!(groups.len(), 2);
2092        assert_eq!(*groups[0].0, HelpCategory::Navigation);
2093        assert_eq!(groups[0].1.len(), 2);
2094        assert_eq!(*groups[1].0, HelpCategory::Global);
2095        assert_eq!(groups[1].1.len(), 1);
2096    }
2097
2098    #[test]
2099    fn keybinding_hints_render_short() {
2100        let hints = KeybindingHints::new()
2101            .global_entry("q", "quit")
2102            .global_entry("^s", "save");
2103
2104        let mut pool = GraphemePool::new();
2105        let mut frame = Frame::new(40, 1, &mut pool);
2106        let area = Rect::new(0, 0, 40, 1);
2107        Widget::render(&hints, area, &mut frame);
2108
2109        // First char should be 'q' (plain format)
2110        let cell = frame.buffer.get(0, 0).unwrap();
2111        assert_eq!(cell.content.as_char(), Some('q'));
2112    }
2113
2114    #[test]
2115    fn keybinding_hints_render_short_bracketed() {
2116        let hints = KeybindingHints::new()
2117            .with_key_format(KeyFormat::Bracketed)
2118            .global_entry("q", "quit");
2119
2120        let mut pool = GraphemePool::new();
2121        let mut frame = Frame::new(40, 1, &mut pool);
2122        let area = Rect::new(0, 0, 40, 1);
2123        Widget::render(&hints, area, &mut frame);
2124
2125        // First char should be '[' (bracketed format)
2126        let cell = frame.buffer.get(0, 0).unwrap();
2127        assert_eq!(cell.content.as_char(), Some('['));
2128    }
2129
2130    #[test]
2131    fn keybinding_hints_render_full_grouped() {
2132        let hints = KeybindingHints::new()
2133            .with_mode(HelpMode::Full)
2134            .with_show_categories(true)
2135            .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
2136            .global_entry_categorized("q", "quit", HelpCategory::Global);
2137
2138        let mut pool = GraphemePool::new();
2139        let mut frame = Frame::new(40, 10, &mut pool);
2140        let area = Rect::new(0, 0, 40, 10);
2141        Widget::render(&hints, area, &mut frame);
2142
2143        // Row 0 should contain category header "Navigation"
2144        let mut row0 = String::new();
2145        for x in 0..40u16 {
2146            if let Some(cell) = frame.buffer.get(x, 0)
2147                && let Some(ch) = cell.content.as_char()
2148            {
2149                row0.push(ch);
2150            }
2151        }
2152        assert!(
2153            row0.contains("Navigation"),
2154            "First row should be Navigation header: {row0}"
2155        );
2156    }
2157
2158    #[test]
2159    fn keybinding_hints_render_full_no_categories() {
2160        let hints = KeybindingHints::new()
2161            .with_mode(HelpMode::Full)
2162            .with_show_categories(false)
2163            .global_entry("q", "quit")
2164            .global_entry("^s", "save");
2165
2166        let mut pool = GraphemePool::new();
2167        let mut frame = Frame::new(40, 5, &mut pool);
2168        let area = Rect::new(0, 0, 40, 5);
2169        // Should not panic
2170        Widget::render(&hints, area, &mut frame);
2171    }
2172
2173    #[test]
2174    fn keybinding_hints_render_empty() {
2175        let hints = KeybindingHints::new();
2176
2177        let mut pool = GraphemePool::new();
2178        let mut frame = Frame::new(20, 1, &mut pool);
2179        let area = Rect::new(0, 0, 20, 1);
2180        // Should not panic
2181        Widget::render(&hints, area, &mut frame);
2182        assert_eq!(row_text(&frame.buffer, 0, 20), " ".repeat(20));
2183    }
2184
2185    #[test]
2186    fn keybinding_hints_empty_clears_stale_row() {
2187        let populated = KeybindingHints::new()
2188            .global_entry("q", "quit")
2189            .global_entry("^s", "save");
2190        let empty = KeybindingHints::new();
2191
2192        let mut pool = GraphemePool::new();
2193        let mut frame = Frame::new(20, 1, &mut pool);
2194        let area = Rect::new(0, 0, 20, 1);
2195
2196        Widget::render(&populated, area, &mut frame);
2197        Widget::render(&empty, area, &mut frame);
2198
2199        assert_eq!(row_text(&frame.buffer, 0, 20), " ".repeat(20));
2200    }
2201
2202    #[test]
2203    fn keybinding_hints_full_to_short_clears_stale_lower_rows() {
2204        let full = KeybindingHints::new()
2205            .with_mode(HelpMode::Full)
2206            .with_show_categories(true)
2207            .global_entry("q", "quit")
2208            .global_entry("^s", "save");
2209        let short = KeybindingHints::new().global_entry("x", "exit");
2210
2211        let mut pool = GraphemePool::new();
2212        let mut frame = Frame::new(24, 4, &mut pool);
2213        let area = Rect::new(0, 0, 24, 4);
2214
2215        Widget::render(&full, area, &mut frame);
2216        Widget::render(&short, area, &mut frame);
2217
2218        assert_eq!(row_text(&frame.buffer, 0, 24), "x exit                  ");
2219        assert_eq!(row_text(&frame.buffer, 1, 24), " ".repeat(24));
2220        assert_eq!(row_text(&frame.buffer, 2, 24), " ".repeat(24));
2221        assert_eq!(row_text(&frame.buffer, 3, 24), " ".repeat(24));
2222    }
2223
2224    #[test]
2225    fn keybinding_hints_render_zero_area() {
2226        let hints = KeybindingHints::new().global_entry("q", "quit");
2227
2228        let mut pool = GraphemePool::new();
2229        let mut frame = Frame::new(20, 1, &mut pool);
2230        let area = Rect::new(0, 0, 0, 0);
2231        // Should not panic
2232        Widget::render(&hints, area, &mut frame);
2233    }
2234
2235    #[test]
2236    fn keybinding_hints_is_not_essential() {
2237        let hints = KeybindingHints::new();
2238        assert!(!hints.is_essential());
2239    }
2240
2241    // ── Property tests for KeybindingHints ──────────────────────────
2242
2243    proptest! {
2244        #[test]
2245        fn prop_visible_entries_count(
2246            n_global in 0..5usize,
2247            n_ctx in 0..5usize,
2248            show_ctx in proptest::bool::ANY,
2249        ) {
2250            let mut hints = KeybindingHints::new().with_show_context(show_ctx);
2251            for i in 0..n_global {
2252                hints = hints.global_entry(format!("g{i}"), format!("global {i}"));
2253            }
2254            for i in 0..n_ctx {
2255                hints = hints.contextual_entry(format!("c{i}"), format!("ctx {i}"));
2256            }
2257            let visible = hints.visible_entries();
2258            let expected = if show_ctx { n_global + n_ctx } else { n_global };
2259            prop_assert_eq!(visible.len(), expected);
2260        }
2261
2262        #[test]
2263        fn prop_bracketed_keys_wrapped(
2264            keys in prop::collection::vec(string_regex("[a-z]{1,4}").unwrap(), 1..5),
2265        ) {
2266            let mut hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
2267            for key in &keys {
2268                hints = hints.global_entry(key.clone(), "action");
2269            }
2270            let visible = hints.visible_entries();
2271            for entry in &visible {
2272                prop_assert!(entry.key.starts_with('['), "Key should start with [: {}", entry.key);
2273                prop_assert!(entry.key.ends_with(']'), "Key should end with ]: {}", entry.key);
2274            }
2275        }
2276
2277        #[test]
2278        fn prop_grouped_preserves_count(
2279            entries in prop::collection::vec(
2280                (string_regex("[a-z]{1,4}").unwrap(), 0..3u8),
2281                1..8
2282            ),
2283        ) {
2284            let help_entries: Vec<HelpEntry> = entries.into_iter().map(|(key, cat_idx)| {
2285                let cat = match cat_idx {
2286                    0 => HelpCategory::Navigation,
2287                    1 => HelpCategory::Editing,
2288                    _ => HelpCategory::Global,
2289                };
2290                HelpEntry::new(key, "action").with_category(cat)
2291            }).collect();
2292
2293            let total = help_entries.len();
2294            let groups = KeybindingHints::grouped_entries(&help_entries);
2295            let grouped_total: usize = groups.iter().map(|(_, v)| v.len()).sum();
2296            prop_assert_eq!(total, grouped_total, "Grouping should preserve total entry count");
2297        }
2298
2299        #[test]
2300        fn prop_render_no_panic(
2301            n_global in 0..5usize,
2302            n_ctx in 0..5usize,
2303            width in 1..80u16,
2304            height in 1..20u16,
2305            show_ctx in proptest::bool::ANY,
2306            use_full in proptest::bool::ANY,
2307            use_brackets in proptest::bool::ANY,
2308            show_cats in proptest::bool::ANY,
2309        ) {
2310            let mode = if use_full { HelpMode::Full } else { HelpMode::Short };
2311            let fmt = if use_brackets { KeyFormat::Bracketed } else { KeyFormat::Plain };
2312            let mut hints = KeybindingHints::new()
2313                .with_mode(mode)
2314                .with_key_format(fmt)
2315                .with_show_context(show_ctx)
2316                .with_show_categories(show_cats);
2317
2318            for i in 0..n_global {
2319                hints = hints.global_entry(format!("g{i}"), format!("global action {i}"));
2320            }
2321            for i in 0..n_ctx {
2322                hints = hints.contextual_entry(format!("c{i}"), format!("ctx action {i}"));
2323            }
2324
2325            let mut pool = GraphemePool::new();
2326            let mut frame = Frame::new(width, height, &mut pool);
2327            let area = Rect::new(0, 0, width, height);
2328            Widget::render(&hints, area, &mut frame);
2329            // No panic = pass
2330        }
2331    }
2332
2333    // ========================================================================
2334    // Edge-case tests (bd-1noim)
2335    // ========================================================================
2336
2337    // ── HelpCategory edge cases ─────────────────────────────────────
2338
2339    #[test]
2340    fn help_category_custom_empty_string() {
2341        let cat = HelpCategory::Custom(String::new());
2342        assert_eq!(cat.label(), "");
2343    }
2344
2345    #[test]
2346    fn help_category_custom_eq() {
2347        let a = HelpCategory::Custom("Foo".into());
2348        let b = HelpCategory::Custom("Foo".into());
2349        let c = HelpCategory::Custom("Bar".into());
2350        assert_eq!(a, b);
2351        assert_ne!(a, c);
2352    }
2353
2354    #[test]
2355    fn help_category_clone() {
2356        let cat = HelpCategory::Navigation;
2357        let cloned = cat.clone();
2358        assert_eq!(cat, cloned);
2359    }
2360
2361    #[test]
2362    fn help_category_hash_consistency() {
2363        use std::collections::hash_map::DefaultHasher;
2364        let mut h1 = DefaultHasher::new();
2365        let mut h2 = DefaultHasher::new();
2366        HelpCategory::File.hash(&mut h1);
2367        HelpCategory::File.hash(&mut h2);
2368        assert_eq!(h1.finish(), h2.finish());
2369    }
2370
2371    #[test]
2372    fn help_category_debug_format() {
2373        let dbg = format!("{:?}", HelpCategory::General);
2374        assert!(dbg.contains("General"));
2375        let dbg_custom = format!("{:?}", HelpCategory::Custom("X".into()));
2376        assert!(dbg_custom.contains("Custom"));
2377    }
2378
2379    // ── HelpEntry edge cases ────────────────────────────────────────
2380
2381    #[test]
2382    fn help_entry_empty_key_and_desc() {
2383        let entry = HelpEntry::new("", "");
2384        assert!(entry.key.is_empty());
2385        assert!(entry.desc.is_empty());
2386        assert!(entry.enabled);
2387    }
2388
2389    #[test]
2390    fn help_entry_clone() {
2391        let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
2392        let cloned = entry.clone();
2393        assert_eq!(entry, cloned);
2394    }
2395
2396    #[test]
2397    fn help_entry_debug_format() {
2398        let entry = HelpEntry::new("^s", "save");
2399        let dbg = format!("{:?}", entry);
2400        assert!(dbg.contains("HelpEntry"));
2401        assert!(dbg.contains("save"));
2402    }
2403
2404    // ── HelpMode edge cases ─────────────────────────────────────────
2405
2406    #[test]
2407    fn help_mode_default_is_short() {
2408        assert_eq!(HelpMode::default(), HelpMode::Short);
2409    }
2410
2411    #[test]
2412    fn help_mode_eq_and_hash() {
2413        use std::collections::hash_map::DefaultHasher;
2414        assert_eq!(HelpMode::Short, HelpMode::Short);
2415        assert_ne!(HelpMode::Short, HelpMode::Full);
2416        let mut h = DefaultHasher::new();
2417        HelpMode::Full.hash(&mut h);
2418        // Just verify it doesn't panic
2419    }
2420
2421    #[test]
2422    fn help_mode_copy() {
2423        let m = HelpMode::Full;
2424        let m2 = m; // Copy
2425        assert_eq!(m, m2);
2426    }
2427
2428    // ── Help rendering edge cases ───────────────────────────────────
2429
2430    #[test]
2431    fn render_short_all_disabled() {
2432        let help = Help::new()
2433            .with_entry(HelpEntry::new("a", "first").with_enabled(false))
2434            .with_entry(HelpEntry::new("b", "second").with_enabled(false));
2435
2436        let mut pool = GraphemePool::new();
2437        let mut frame = Frame::new(40, 1, &mut pool);
2438        let area = Rect::new(0, 0, 40, 1);
2439        Widget::render(&help, area, &mut frame);
2440        // No visible entries, buffer stays default
2441        let cell = frame.buffer.get(0, 0).unwrap();
2442        assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
2443    }
2444
2445    #[test]
2446    fn render_short_empty_key_desc_entries_skipped() {
2447        let help = Help::new()
2448            .with_entry(HelpEntry::new("", ""))
2449            .entry("q", "quit");
2450
2451        let mut pool = GraphemePool::new();
2452        let mut frame = Frame::new(40, 1, &mut pool);
2453        let area = Rect::new(0, 0, 40, 1);
2454        Widget::render(&help, area, &mut frame);
2455        // The empty entry produces no text but separator logic still fires.
2456        // Verify 'q' appears somewhere in the rendered row.
2457        let mut found_q = false;
2458        for x in 0..40 {
2459            if let Some(cell) = frame.buffer.get(x, 0)
2460                && cell.content.as_char() == Some('q')
2461            {
2462                found_q = true;
2463                break;
2464            }
2465        }
2466        assert!(found_q, "'q' should appear in the rendered row");
2467    }
2468
2469    #[test]
2470    fn render_short_width_one() {
2471        let help = Help::new().entry("q", "quit");
2472
2473        let mut pool = GraphemePool::new();
2474        let mut frame = Frame::new(1, 1, &mut pool);
2475        let area = Rect::new(0, 0, 1, 1);
2476        Widget::render(&help, area, &mut frame);
2477        // Should not panic; may show ellipsis or partial
2478    }
2479
2480    #[test]
2481    fn render_full_width_one() {
2482        let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
2483
2484        let mut pool = GraphemePool::new();
2485        let mut frame = Frame::new(1, 5, &mut pool);
2486        let area = Rect::new(0, 0, 1, 5);
2487        Widget::render(&help, area, &mut frame);
2488        // Should not panic
2489    }
2490
2491    #[test]
2492    fn render_full_height_one() {
2493        let help = Help::new()
2494            .with_mode(HelpMode::Full)
2495            .entry("a", "first")
2496            .entry("b", "second")
2497            .entry("c", "third");
2498
2499        let mut pool = GraphemePool::new();
2500        let mut frame = Frame::new(40, 1, &mut pool);
2501        let area = Rect::new(0, 0, 40, 1);
2502        Widget::render(&help, area, &mut frame);
2503        // Only first entry should render
2504    }
2505
2506    #[test]
2507    fn render_short_single_entry_exact_fit() {
2508        // "q quit" = 6 chars, area width = 6
2509        let help = Help::new().entry("q", "quit");
2510
2511        let mut pool = GraphemePool::new();
2512        let mut frame = Frame::new(6, 1, &mut pool);
2513        let area = Rect::new(0, 0, 6, 1);
2514        Widget::render(&help, area, &mut frame);
2515        let cell = frame.buffer.get(0, 0).unwrap();
2516        assert_eq!(cell.content.as_char(), Some('q'));
2517    }
2518
2519    #[test]
2520    fn render_short_empty_separator() {
2521        let help = Help::new()
2522            .with_separator("")
2523            .entry("a", "x")
2524            .entry("b", "y");
2525
2526        let mut pool = GraphemePool::new();
2527        let mut frame = Frame::new(40, 1, &mut pool);
2528        let area = Rect::new(0, 0, 40, 1);
2529        Widget::render(&help, area, &mut frame);
2530        // Both entries render without separator
2531        let cell = frame.buffer.get(0, 0).unwrap();
2532        assert_eq!(cell.content.as_char(), Some('a'));
2533    }
2534
2535    // ── Help builder edge cases ─────────────────────────────────────
2536
2537    #[test]
2538    fn help_with_mode_full() {
2539        let help = Help::new().with_mode(HelpMode::Full);
2540        assert_eq!(help.mode(), HelpMode::Full);
2541    }
2542
2543    #[test]
2544    fn help_clone() {
2545        let help = Help::new()
2546            .entry("q", "quit")
2547            .with_separator(" | ")
2548            .with_ellipsis("...");
2549        let cloned = help.clone();
2550        assert_eq!(cloned.entries().len(), 1);
2551        assert_eq!(cloned.separator, " | ");
2552        assert_eq!(cloned.ellipsis, "...");
2553    }
2554
2555    #[test]
2556    fn help_debug_format() {
2557        let help = Help::new().entry("q", "quit");
2558        let dbg = format!("{:?}", help);
2559        assert!(dbg.contains("Help"));
2560    }
2561
2562    // ── HelpRenderState edge cases ──────────────────────────────────
2563
2564    #[test]
2565    fn help_render_state_default() {
2566        let state = HelpRenderState::default();
2567        assert!(state.cache.is_none());
2568        assert!(state.dirty_rects().is_empty());
2569        assert_eq!(state.stats().hits, 0);
2570        assert_eq!(state.stats().misses, 0);
2571    }
2572
2573    #[test]
2574    fn help_render_state_clear_dirty_rects() {
2575        let mut state = HelpRenderState::default();
2576        state.dirty_rects.push(Rect::new(0, 0, 10, 1));
2577        assert_eq!(state.dirty_rects().len(), 1);
2578        state.clear_dirty_rects();
2579        assert!(state.dirty_rects().is_empty());
2580    }
2581
2582    #[test]
2583    fn help_render_state_take_dirty_rects() {
2584        let mut state = HelpRenderState::default();
2585        state.dirty_rects.push(Rect::new(0, 0, 5, 1));
2586        state.dirty_rects.push(Rect::new(0, 1, 5, 1));
2587        let taken = state.take_dirty_rects();
2588        assert_eq!(taken.len(), 2);
2589        assert!(state.dirty_rects().is_empty()); // cleared after take
2590    }
2591
2592    #[test]
2593    fn help_render_state_reset_stats() {
2594        let mut state = HelpRenderState::default();
2595        state.stats.hits = 42;
2596        state.stats.misses = 7;
2597        state.stats.dirty_updates = 3;
2598        state.stats.layout_rebuilds = 2;
2599        state.reset_stats();
2600        assert_eq!(state.stats(), HelpCacheStats::default());
2601    }
2602
2603    #[test]
2604    fn help_cache_stats_default() {
2605        let stats = HelpCacheStats::default();
2606        assert_eq!(stats.hits, 0);
2607        assert_eq!(stats.misses, 0);
2608        assert_eq!(stats.dirty_updates, 0);
2609        assert_eq!(stats.layout_rebuilds, 0);
2610    }
2611
2612    #[test]
2613    fn help_cache_stats_clone_eq() {
2614        let a = HelpCacheStats {
2615            hits: 5,
2616            misses: 2,
2617            dirty_updates: 1,
2618            layout_rebuilds: 3,
2619        };
2620        let b = a;
2621        assert_eq!(a, b);
2622    }
2623
2624    #[test]
2625    fn stateful_render_empty_area_clears_cache() {
2626        let help = Help::new().entry("q", "quit");
2627        let mut state = HelpRenderState::default();
2628        let mut pool = GraphemePool::new();
2629        let mut frame = Frame::new(40, 1, &mut pool);
2630        let area = Rect::new(0, 0, 40, 1);
2631
2632        // First render populates cache
2633        StatefulWidget::render(&help, area, &mut frame, &mut state);
2634        assert!(state.cache.is_some());
2635        state.dirty_rects.push(Rect::new(0, 0, 3, 1));
2636        state.dirty_indices.push(0);
2637        state.enabled_indices.push(0);
2638
2639        // Render with empty area clears cache and transient dirty state.
2640        let empty = Rect::new(0, 0, 0, 0);
2641        StatefulWidget::render(&help, empty, &mut frame, &mut state);
2642        assert!(state.cache.is_none());
2643        assert!(state.dirty_rects().is_empty());
2644        assert!(state.dirty_indices.is_empty());
2645        assert!(state.enabled_indices.is_empty());
2646    }
2647
2648    #[test]
2649    fn stateful_render_cache_miss_on_area_change() {
2650        let help = Help::new().entry("q", "quit").entry("^s", "save");
2651        let mut state = HelpRenderState::default();
2652        let mut pool = GraphemePool::new();
2653        let mut frame = Frame::new(80, 5, &mut pool);
2654
2655        StatefulWidget::render(&help, Rect::new(0, 0, 40, 1), &mut frame, &mut state);
2656        let misses1 = state.stats().misses;
2657
2658        StatefulWidget::render(&help, Rect::new(0, 0, 60, 1), &mut frame, &mut state);
2659        let misses2 = state.stats().misses;
2660
2661        assert!(misses2 > misses1, "Area change should cause cache miss");
2662    }
2663
2664    #[test]
2665    fn stateful_render_cache_miss_on_mode_change() {
2666        let mut help = Help::new().entry("q", "quit");
2667        let mut state = HelpRenderState::default();
2668        let mut pool = GraphemePool::new();
2669        let mut frame = Frame::new(40, 5, &mut pool);
2670        let area = Rect::new(0, 0, 40, 5);
2671
2672        StatefulWidget::render(&help, area, &mut frame, &mut state);
2673        let misses1 = state.stats().misses;
2674
2675        help.toggle_mode();
2676        StatefulWidget::render(&help, area, &mut frame, &mut state);
2677        let misses2 = state.stats().misses;
2678
2679        assert!(misses2 > misses1, "Mode change should cause cache miss");
2680    }
2681
2682    #[test]
2683    fn stateful_render_layout_rebuild_on_enabled_count_change() {
2684        let mut help = Help::new()
2685            .entry("q", "quit")
2686            .entry("^s", "save")
2687            .entry("^x", "exit");
2688        let mut state = HelpRenderState::default();
2689        let mut pool = GraphemePool::new();
2690        let mut frame = Frame::new(80, 1, &mut pool);
2691        let area = Rect::new(0, 0, 80, 1);
2692
2693        StatefulWidget::render(&help, area, &mut frame, &mut state);
2694        let rebuilds1 = state.stats().layout_rebuilds;
2695
2696        // Disable one entry
2697        help.entries[1].enabled = false;
2698        StatefulWidget::render(&help, area, &mut frame, &mut state);
2699        let rebuilds2 = state.stats().layout_rebuilds;
2700
2701        assert!(
2702            rebuilds2 > rebuilds1,
2703            "Enabled count change should trigger layout rebuild"
2704        );
2705    }
2706
2707    // ── KeyFormat edge cases ────────────────────────────────────────
2708
2709    #[test]
2710    fn key_format_eq_and_hash() {
2711        use std::collections::hash_map::DefaultHasher;
2712        assert_eq!(KeyFormat::Plain, KeyFormat::Plain);
2713        assert_ne!(KeyFormat::Plain, KeyFormat::Bracketed);
2714        let mut h = DefaultHasher::new();
2715        KeyFormat::Bracketed.hash(&mut h);
2716    }
2717
2718    #[test]
2719    fn key_format_copy() {
2720        let f = KeyFormat::Bracketed;
2721        let f2 = f;
2722        assert_eq!(f, f2);
2723    }
2724
2725    #[test]
2726    fn key_format_debug() {
2727        let dbg = format!("{:?}", KeyFormat::Bracketed);
2728        assert!(dbg.contains("Bracketed"));
2729    }
2730
2731    // ── KeybindingHints edge cases ──────────────────────────────────
2732
2733    #[test]
2734    fn keybinding_hints_clone() {
2735        let hints = KeybindingHints::new()
2736            .global_entry("q", "quit")
2737            .contextual_entry("^s", "save");
2738        let cloned = hints.clone();
2739        assert_eq!(cloned.global_entries().len(), 1);
2740        assert_eq!(cloned.contextual_entries().len(), 1);
2741    }
2742
2743    #[test]
2744    fn keybinding_hints_debug() {
2745        let hints = KeybindingHints::new().global_entry("q", "quit");
2746        let dbg = format!("{:?}", hints);
2747        assert!(dbg.contains("KeybindingHints"));
2748    }
2749
2750    #[test]
2751    fn keybinding_hints_with_separator() {
2752        let hints = KeybindingHints::new().with_separator(" | ");
2753        assert_eq!(hints.separator, " | ");
2754    }
2755
2756    #[test]
2757    fn keybinding_hints_with_styles() {
2758        let hints = KeybindingHints::new()
2759            .with_key_style(Style::new().bold())
2760            .with_desc_style(Style::default())
2761            .with_separator_style(Style::default())
2762            .with_category_style(Style::new().underline());
2763        // Just verify builder doesn't panic
2764        assert_eq!(hints.mode(), HelpMode::Short);
2765    }
2766
2767    #[test]
2768    fn keybinding_hints_visible_entries_disabled_contextual() {
2769        let hints = KeybindingHints::new()
2770            .with_show_context(true)
2771            .global_entry("q", "quit")
2772            .with_contextual_entry(HelpEntry::new("^s", "save").with_enabled(false));
2773        let visible = hints.visible_entries();
2774        // Only global "q" visible; disabled contextual "^s" hidden
2775        assert_eq!(visible.len(), 1);
2776        assert_eq!(visible[0].desc, "quit");
2777    }
2778
2779    #[test]
2780    fn keybinding_hints_empty_global_nonempty_ctx_hidden() {
2781        let hints = KeybindingHints::new()
2782            .contextual_entry("^s", "save")
2783            .contextual_entry("^f", "find");
2784        // Context off by default
2785        let visible = hints.visible_entries();
2786        assert!(visible.is_empty());
2787    }
2788
2789    #[test]
2790    fn keybinding_hints_render_full_grouped_height_limit() {
2791        let hints = KeybindingHints::new()
2792            .with_mode(HelpMode::Full)
2793            .with_show_categories(true)
2794            .global_entry_categorized("a", "first", HelpCategory::Navigation)
2795            .global_entry_categorized("b", "second", HelpCategory::Navigation)
2796            .global_entry_categorized("c", "third", HelpCategory::Navigation)
2797            .global_entry_categorized("d", "fourth", HelpCategory::Global)
2798            .global_entry_categorized("e", "fifth", HelpCategory::Global);
2799
2800        let mut pool = GraphemePool::new();
2801        // Only 3 rows: header + 2 entries, can't fit all
2802        let mut frame = Frame::new(40, 3, &mut pool);
2803        let area = Rect::new(0, 0, 40, 3);
2804        Widget::render(&hints, area, &mut frame);
2805        // Should not panic; clips to available height
2806    }
2807
2808    #[test]
2809    fn keybinding_hints_render_empty_area() {
2810        let hints = KeybindingHints::new().global_entry("q", "quit");
2811        let mut pool = GraphemePool::new();
2812        let mut frame = Frame::new(1, 1, &mut pool);
2813        Widget::render(&hints, Rect::new(0, 0, 0, 0), &mut frame);
2814        // Should not panic (is_empty check)
2815    }
2816
2817    // ── Help entry_hash edge cases ──────────────────────────────────
2818
2819    #[test]
2820    fn entry_hash_differs_for_different_keys() {
2821        let a = HelpEntry::new("q", "quit");
2822        let b = HelpEntry::new("x", "quit");
2823        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2824    }
2825
2826    #[test]
2827    fn entry_hash_differs_for_different_descs() {
2828        let a = HelpEntry::new("q", "quit");
2829        let b = HelpEntry::new("q", "exit");
2830        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2831    }
2832
2833    #[test]
2834    fn entry_hash_differs_for_enabled_flag() {
2835        let a = HelpEntry::new("q", "quit");
2836        let b = HelpEntry::new("q", "quit").with_enabled(false);
2837        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2838    }
2839
2840    #[test]
2841    fn entry_hash_same_for_equal_entries() {
2842        let a = HelpEntry::new("q", "quit");
2843        let b = HelpEntry::new("q", "quit");
2844        assert_eq!(Help::entry_hash(&a), Help::entry_hash(&b));
2845    }
2846
2847    // ── Additional edge-case tests (bd-1noim, LavenderStone) ─────────
2848
2849    // ── HelpCategory: variant inequality + Custom("General") != General ──
2850
2851    #[test]
2852    fn help_category_custom_general_not_eq_general() {
2853        // Custom("General") and General are different enum variants
2854        assert_ne!(
2855            HelpCategory::Custom("General".into()),
2856            HelpCategory::General
2857        );
2858    }
2859
2860    #[test]
2861    fn help_category_all_variants_distinct() {
2862        let variants: Vec<HelpCategory> = vec![
2863            HelpCategory::General,
2864            HelpCategory::Navigation,
2865            HelpCategory::Editing,
2866            HelpCategory::File,
2867            HelpCategory::View,
2868            HelpCategory::Global,
2869            HelpCategory::Custom("X".into()),
2870        ];
2871        for (i, a) in variants.iter().enumerate() {
2872            for (j, b) in variants.iter().enumerate() {
2873                if i != j {
2874                    assert_ne!(a, b, "Variant {i} should differ from variant {j}");
2875                }
2876            }
2877        }
2878    }
2879
2880    // ── HelpEntry: field-by-field hash sensitivity ───────────────────
2881
2882    #[test]
2883    fn help_entry_hash_differs_by_category() {
2884        let a = HelpEntry::new("q", "quit");
2885        let b = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
2886        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2887    }
2888
2889    #[test]
2890    fn help_entry_only_key_no_desc_renders() {
2891        let help = Help::new().with_entry(HelpEntry::new("q", ""));
2892        let mut pool = GraphemePool::new();
2893        let mut frame = Frame::new(20, 1, &mut pool);
2894        let area = Rect::new(0, 0, 20, 1);
2895        Widget::render(&help, area, &mut frame);
2896        // "q " should render (key + space + empty desc)
2897        let cell = frame.buffer.get(0, 0).unwrap();
2898        assert_eq!(cell.content.as_char(), Some('q'));
2899    }
2900
2901    #[test]
2902    fn help_entry_only_desc_no_key_renders() {
2903        let help = Help::new().with_entry(HelpEntry::new("", "quit"));
2904        let mut pool = GraphemePool::new();
2905        let mut frame = Frame::new(20, 1, &mut pool);
2906        let area = Rect::new(0, 0, 20, 1);
2907        Widget::render(&help, area, &mut frame);
2908        // " quit" should render (empty key + space + desc)
2909        let cell = frame.buffer.get(1, 0).unwrap();
2910        assert_eq!(cell.content.as_char(), Some('q'));
2911    }
2912
2913    #[test]
2914    fn help_entry_unicode_key_and_desc() {
2915        let help = Help::new().with_entry(HelpEntry::new("\u{2191}", "up arrow"));
2916        let mut pool = GraphemePool::new();
2917        let mut frame = Frame::new(20, 1, &mut pool);
2918        let area = Rect::new(0, 0, 20, 1);
2919        Widget::render(&help, area, &mut frame);
2920    }
2921
2922    #[test]
2923    fn help_entry_chained_builder_overrides() {
2924        let entry = HelpEntry::new("q", "quit")
2925            .with_enabled(false)
2926            .with_category(HelpCategory::File)
2927            .with_enabled(true)
2928            .with_category(HelpCategory::View);
2929        assert!(entry.enabled);
2930        assert_eq!(entry.category, HelpCategory::View);
2931    }
2932
2933    // ── Help: rendering with area offsets ─────────────────────────────
2934
2935    #[test]
2936    fn render_short_area_offset() {
2937        let help = Help::new().entry("x", "action");
2938        let mut pool = GraphemePool::new();
2939        let mut frame = Frame::new(40, 5, &mut pool);
2940        let area = Rect::new(5, 2, 20, 1);
2941        Widget::render(&help, area, &mut frame);
2942        let cell = frame.buffer.get(5, 2).unwrap();
2943        assert_eq!(cell.content.as_char(), Some('x'));
2944        // Column 0,0 should be untouched
2945        let cell_origin = frame.buffer.get(0, 0).unwrap();
2946        assert!(cell_origin.content.is_empty() || cell_origin.content.as_char() == Some(' '));
2947    }
2948
2949    #[test]
2950    fn render_full_area_offset() {
2951        let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
2952        let mut pool = GraphemePool::new();
2953        let mut frame = Frame::new(40, 5, &mut pool);
2954        let area = Rect::new(3, 1, 20, 3);
2955        Widget::render(&help, area, &mut frame);
2956        let cell = frame.buffer.get(3, 1).unwrap();
2957        assert_eq!(cell.content.as_char(), Some('q'));
2958    }
2959
2960    // ── Help: full mode with all entries disabled ─────────────────────
2961
2962    #[test]
2963    fn render_full_all_disabled() {
2964        let help = Help::new()
2965            .with_mode(HelpMode::Full)
2966            .with_entry(HelpEntry::new("a", "first").with_enabled(false))
2967            .with_entry(HelpEntry::new("b", "second").with_enabled(false));
2968        let mut pool = GraphemePool::new();
2969        let mut frame = Frame::new(30, 3, &mut pool);
2970        let area = Rect::new(0, 0, 30, 3);
2971        Widget::render(&help, area, &mut frame);
2972    }
2973
2974    // ── Help: ellipsis with empty ellipsis string ─────────────────────
2975
2976    #[test]
2977    fn render_short_empty_ellipsis_string() {
2978        let help = Help::new()
2979            .with_ellipsis("")
2980            .entry("q", "quit")
2981            .entry("w", "this is a very long description that overflows");
2982        let mut pool = GraphemePool::new();
2983        let mut frame = Frame::new(12, 1, &mut pool);
2984        let area = Rect::new(0, 0, 12, 1);
2985        Widget::render(&help, area, &mut frame);
2986    }
2987
2988    // ── Help: entry wider than entire area ────────────────────────────
2989
2990    #[test]
2991    fn render_short_entry_wider_than_area() {
2992        let help = Help::new().entry("verylongkey", "very long description text");
2993        let mut pool = GraphemePool::new();
2994        let mut frame = Frame::new(3, 1, &mut pool);
2995        let area = Rect::new(0, 0, 3, 1);
2996        Widget::render(&help, area, &mut frame);
2997    }
2998
2999    // ── Stateful: style change invalidates cache ──────────────────────
3000
3001    #[test]
3002    fn stateful_cache_invalidated_on_style_change() {
3003        let help1 = Help::new().entry("q", "quit");
3004        let help2 = Help::new()
3005            .entry("q", "quit")
3006            .with_key_style(Style::new().italic());
3007        let mut state = HelpRenderState::default();
3008        let mut pool = GraphemePool::new();
3009        let mut frame = Frame::new(40, 1, &mut pool);
3010        let area = Rect::new(0, 0, 40, 1);
3011
3012        StatefulWidget::render(&help1, area, &mut frame, &mut state);
3013        let misses_1 = state.stats().misses;
3014
3015        StatefulWidget::render(&help2, area, &mut frame, &mut state);
3016        assert!(
3017            state.stats().misses > misses_1,
3018            "Style change should cause cache miss"
3019        );
3020    }
3021
3022    // ── Stateful: entry addition triggers layout rebuild ──────────────
3023
3024    #[test]
3025    fn stateful_entry_addition_rebuilds_layout() {
3026        let mut help = Help::new().entry("q", "quit");
3027        let mut state = HelpRenderState::default();
3028        let mut pool = GraphemePool::new();
3029        let mut frame = Frame::new(40, 3, &mut pool);
3030        let area = Rect::new(0, 0, 40, 3);
3031
3032        StatefulWidget::render(&help, area, &mut frame, &mut state);
3033        let rebuilds_1 = state.stats().layout_rebuilds;
3034
3035        help.push_entry(HelpEntry::new("w", "write"));
3036        StatefulWidget::render(&help, area, &mut frame, &mut state);
3037        assert!(
3038            state.stats().layout_rebuilds > rebuilds_1,
3039            "Entry addition should rebuild layout"
3040        );
3041    }
3042
3043    // ── Stateful: separator change invalidates cache ──────────────────
3044
3045    #[test]
3046    fn stateful_separator_change_invalidates_cache() {
3047        let help1 = Help::new()
3048            .with_separator(" | ")
3049            .entry("q", "quit")
3050            .entry("w", "write");
3051        let help2 = Help::new()
3052            .with_separator(" - ")
3053            .entry("q", "quit")
3054            .entry("w", "write");
3055        let mut state = HelpRenderState::default();
3056        let mut pool = GraphemePool::new();
3057        let mut frame = Frame::new(40, 1, &mut pool);
3058        let area = Rect::new(0, 0, 40, 1);
3059
3060        StatefulWidget::render(&help1, area, &mut frame, &mut state);
3061        let misses_1 = state.stats().misses;
3062
3063        StatefulWidget::render(&help2, area, &mut frame, &mut state);
3064        assert!(
3065            state.stats().misses > misses_1,
3066            "Separator change should cause cache miss"
3067        );
3068    }
3069
3070    // ── Stateful: dirty update in full mode tracks correct rects ──────
3071
3072    #[test]
3073    fn stateful_full_mode_dirty_update_multiple() {
3074        let mut help = Help::new()
3075            .with_mode(HelpMode::Full)
3076            .entry("q", "quit")
3077            .entry("w", "save")
3078            .entry("e", "edit");
3079        let mut state = HelpRenderState::default();
3080        let mut pool = GraphemePool::new();
3081        let mut frame = Frame::new(40, 5, &mut pool);
3082        let area = Rect::new(0, 0, 40, 5);
3083
3084        StatefulWidget::render(&help, area, &mut frame, &mut state);
3085
3086        // Change two entries (same-length descs to stay within slot width)
3087        help.entries[0].desc = "exit".to_string();
3088        help.entries[2].desc = "view".to_string();
3089        StatefulWidget::render(&help, area, &mut frame, &mut state);
3090        let dirty = state.take_dirty_rects();
3091        assert_eq!(dirty.len(), 2, "Two changed entries produce 2 dirty rects");
3092    }
3093
3094    // ── Stateful: short mode dirty update ─────────────────────────────
3095
3096    #[test]
3097    fn stateful_short_mode_dirty_update() {
3098        let mut help = Help::new()
3099            .with_mode(HelpMode::Short)
3100            .entry("q", "quit")
3101            .entry("w", "write");
3102        let mut state = HelpRenderState::default();
3103        let mut pool = GraphemePool::new();
3104        let mut frame = Frame::new(40, 1, &mut pool);
3105        let area = Rect::new(0, 0, 40, 1);
3106
3107        StatefulWidget::render(&help, area, &mut frame, &mut state);
3108
3109        help.entries[0].desc = "exit".to_string();
3110        StatefulWidget::render(&help, area, &mut frame, &mut state);
3111        assert!(
3112            state.stats().dirty_updates > 0,
3113            "Changed desc should trigger dirty update"
3114        );
3115    }
3116
3117    // ── Layout builder edge cases ────────────────────────────────────
3118
3119    #[test]
3120    fn build_short_layout_no_enabled_entries() {
3121        let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
3122        let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
3123        assert!(layout.entries.is_empty());
3124        assert!(layout.ellipsis.is_none());
3125    }
3126
3127    #[test]
3128    fn build_full_layout_no_enabled_entries() {
3129        let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
3130        let layout = help.build_full_layout(Rect::new(0, 0, 40, 5));
3131        assert!(layout.entries.is_empty());
3132        assert_eq!(layout.max_key_width, 0);
3133    }
3134
3135    #[test]
3136    fn build_short_layout_triggers_ellipsis() {
3137        let help = Help::new()
3138            .entry("longkey", "long description text here")
3139            .entry("another", "even longer description text");
3140        let layout = help.build_short_layout(Rect::new(0, 0, 20, 1));
3141        // Second entry won't fit; ellipsis should appear
3142        assert!(
3143            !layout.entries.is_empty() || layout.ellipsis.is_some(),
3144            "Should have entries or ellipsis"
3145        );
3146    }
3147
3148    #[test]
3149    fn build_full_layout_respects_height() {
3150        let help = Help::new()
3151            .entry("a", "first")
3152            .entry("b", "second")
3153            .entry("c", "third")
3154            .entry("d", "fourth");
3155        let layout = help.build_full_layout(Rect::new(0, 0, 40, 2));
3156        assert_eq!(layout.entries.len(), 2, "Should respect height=2 limit");
3157    }
3158
3159    #[test]
3160    fn build_short_layout_zero_width() {
3161        let help = Help::new().entry("q", "quit");
3162        let layout = help.build_short_layout(Rect::new(0, 0, 0, 1));
3163        assert!(layout.entries.is_empty());
3164    }
3165
3166    #[test]
3167    fn build_full_layout_zero_height() {
3168        let help = Help::new().entry("q", "quit");
3169        let layout = help.build_full_layout(Rect::new(0, 0, 40, 0));
3170        assert!(layout.entries.is_empty());
3171    }
3172
3173    // ── entry_fits_slot edge cases ───────────────────────────────────
3174
3175    #[test]
3176    fn entry_fits_slot_out_of_bounds_index_short() {
3177        let help = Help::new().entry("q", "quit");
3178        let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
3179        let entry = &help.entries[0];
3180        assert!(!entry_fits_slot(entry, 999, &layout));
3181    }
3182
3183    #[test]
3184    fn entry_fits_slot_out_of_bounds_index_full() {
3185        let help = Help::new().entry("q", "quit");
3186        let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
3187        let entry = &help.entries[0];
3188        assert!(!entry_fits_slot(entry, 999, &layout));
3189    }
3190
3191    #[test]
3192    fn entry_fits_slot_full_key_too_wide() {
3193        let help = Help::new().entry("x", "d");
3194        let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
3195        if !layout.entries.is_empty() {
3196            let wide_entry = HelpEntry::new("verylongkeyname", "d");
3197            assert!(!entry_fits_slot(&wide_entry, 0, &layout));
3198        }
3199    }
3200
3201    // ── collect_enabled_indices edge cases ────────────────────────────
3202
3203    #[test]
3204    fn collect_enabled_indices_all_disabled() {
3205        let entries = vec![
3206            HelpEntry::new("a", "b").with_enabled(false),
3207            HelpEntry::new("c", "d").with_enabled(false),
3208        ];
3209        let mut out = Vec::new();
3210        let count = collect_enabled_indices(&entries, &mut out);
3211        assert_eq!(count, 0);
3212        assert!(out.is_empty());
3213    }
3214
3215    #[test]
3216    fn collect_enabled_indices_empty_entries_filtered() {
3217        let entries = vec![
3218            HelpEntry::new("", ""),
3219            HelpEntry::new("q", "quit"),
3220            HelpEntry::new("", ""),
3221        ];
3222        let mut out = Vec::new();
3223        let count = collect_enabled_indices(&entries, &mut out);
3224        assert_eq!(count, 1);
3225        assert_eq!(out, vec![1]);
3226    }
3227
3228    #[test]
3229    fn collect_enabled_indices_mixed() {
3230        let entries = vec![
3231            HelpEntry::new("a", "first"),
3232            HelpEntry::new("b", "second").with_enabled(false),
3233            HelpEntry::new("", ""),
3234            HelpEntry::new("d", "fourth"),
3235        ];
3236        let mut out = Vec::new();
3237        let count = collect_enabled_indices(&entries, &mut out);
3238        assert_eq!(count, 2);
3239        assert_eq!(out, vec![0, 3]);
3240    }
3241
3242    #[test]
3243    fn collect_enabled_indices_clears_previous_data() {
3244        let entries = vec![HelpEntry::new("a", "b")];
3245        let mut out = vec![99, 100, 101];
3246        let count = collect_enabled_indices(&entries, &mut out);
3247        assert_eq!(count, 1);
3248        assert_eq!(out, vec![0]);
3249    }
3250
3251    // ── blit_cache edge cases ────────────────────────────────────────
3252
3253    #[test]
3254    fn blit_cache_none_is_noop() {
3255        let mut pool = GraphemePool::new();
3256        let mut frame = Frame::new(10, 1, &mut pool);
3257        let area = Rect::new(0, 0, 10, 1);
3258        blit_cache(None, area, &mut frame);
3259    }
3260
3261    // ── StyleKey edge cases ──────────────────────────────────────────
3262
3263    #[test]
3264    fn style_key_from_default_style() {
3265        let sk = StyleKey::from(Style::default());
3266        assert!(sk.fg.is_none());
3267        assert!(sk.bg.is_none());
3268        assert!(sk.attrs.is_none());
3269    }
3270
3271    #[test]
3272    fn style_key_from_styled() {
3273        let style = Style::new().bold();
3274        let sk = StyleKey::from(style);
3275        assert!(sk.attrs.is_some());
3276    }
3277
3278    #[test]
3279    fn style_key_equality_and_hash() {
3280        use std::collections::hash_map::DefaultHasher;
3281        let a = StyleKey::from(Style::new().italic());
3282        let b = StyleKey::from(Style::new().italic());
3283        assert_eq!(a, b);
3284        let mut h1 = DefaultHasher::new();
3285        let mut h2 = DefaultHasher::new();
3286        a.hash(&mut h1);
3287        b.hash(&mut h2);
3288        assert_eq!(h1.finish(), h2.finish());
3289    }
3290
3291    #[test]
3292    fn style_key_different_styles_ne() {
3293        let a = StyleKey::from(Style::new().bold());
3294        let b = StyleKey::from(Style::new().italic());
3295        assert_ne!(a, b);
3296    }
3297
3298    // ── hash_str edge cases ──────────────────────────────────────────
3299
3300    #[test]
3301    fn hash_str_empty_deterministic() {
3302        assert_eq!(Help::hash_str(""), Help::hash_str(""));
3303    }
3304
3305    #[test]
3306    fn hash_str_different_strings_differ() {
3307        assert_ne!(Help::hash_str("abc"), Help::hash_str("def"));
3308    }
3309
3310    // ── KeybindingHints: custom categories in grouped view ───────────
3311
3312    #[test]
3313    fn keybinding_hints_custom_categories_grouped() {
3314        let entries = vec![
3315            HelpEntry::new("a", "one").with_category(HelpCategory::Custom("Alpha".into())),
3316            HelpEntry::new("b", "two").with_category(HelpCategory::Custom("Beta".into())),
3317            HelpEntry::new("c", "three").with_category(HelpCategory::Custom("Alpha".into())),
3318        ];
3319        let groups = KeybindingHints::grouped_entries(&entries);
3320        assert_eq!(groups.len(), 2);
3321        assert_eq!(groups[0].1.len(), 2); // Alpha has 2 entries
3322        assert_eq!(groups[1].1.len(), 1); // Beta has 1 entry
3323    }
3324
3325    #[test]
3326    fn keybinding_hints_all_contextual_context_on() {
3327        let hints = KeybindingHints::new()
3328            .with_show_context(true)
3329            .contextual_entry("^s", "save")
3330            .contextual_entry("^f", "find");
3331        let visible = hints.visible_entries();
3332        assert_eq!(visible.len(), 2);
3333    }
3334
3335    #[test]
3336    fn keybinding_hints_format_key_plain_empty() {
3337        let hints = KeybindingHints::new().with_key_format(KeyFormat::Plain);
3338        assert_eq!(hints.format_key(""), "");
3339    }
3340
3341    #[test]
3342    fn keybinding_hints_format_key_bracketed_empty() {
3343        let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
3344        assert_eq!(hints.format_key(""), "[]");
3345    }
3346
3347    #[test]
3348    fn keybinding_hints_format_key_bracketed_unicode() {
3349        let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
3350        assert_eq!(hints.format_key("\u{2191}"), "[\u{2191}]");
3351    }
3352
3353    // ── KeybindingHints: render full grouped with single category ─────
3354
3355    #[test]
3356    fn keybinding_hints_render_full_grouped_single_category() {
3357        let hints = KeybindingHints::new()
3358            .with_mode(HelpMode::Full)
3359            .with_show_categories(true)
3360            .global_entry_categorized("a", "first", HelpCategory::Navigation)
3361            .global_entry_categorized("b", "second", HelpCategory::Navigation);
3362        let mut pool = GraphemePool::new();
3363        let mut frame = Frame::new(40, 10, &mut pool);
3364        let area = Rect::new(0, 0, 40, 10);
3365        Widget::render(&hints, area, &mut frame);
3366        // Single category: header "Navigation" + 2 entries, no trailing blank
3367    }
3368
3369    // ── HelpCacheStats trait coverage ─────────────────────────────────
3370
3371    #[test]
3372    fn help_cache_stats_ne() {
3373        let a = HelpCacheStats::default();
3374        let b = HelpCacheStats {
3375            hits: 1,
3376            ..Default::default()
3377        };
3378        assert_ne!(a, b);
3379    }
3380
3381    #[test]
3382    fn help_cache_stats_debug() {
3383        let stats = HelpCacheStats {
3384            hits: 5,
3385            misses: 2,
3386            dirty_updates: 1,
3387            layout_rebuilds: 3,
3388        };
3389        let dbg = format!("{stats:?}");
3390        assert!(dbg.contains("hits"));
3391        assert!(dbg.contains("misses"));
3392        assert!(dbg.contains("dirty_updates"));
3393        assert!(dbg.contains("layout_rebuilds"));
3394    }
3395
3396    // ── LayoutKey copy + hash coverage ────────────────────────────────
3397
3398    #[test]
3399    fn layout_key_copy_and_eq() {
3400        let help = Help::new().entry("q", "quit");
3401        let area = Rect::new(0, 0, 40, 1);
3402        let key1 = help.layout_key(area, DegradationLevel::Full);
3403        let key2 = key1; // Copy
3404        assert_eq!(key1, key2);
3405    }
3406
3407    #[test]
3408    fn layout_key_differs_by_mode() {
3409        let help_s = Help::new().entry("q", "quit");
3410        let help_f = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
3411        let area = Rect::new(0, 0, 40, 1);
3412        let deg = DegradationLevel::Full;
3413        assert_ne!(help_s.layout_key(area, deg), help_f.layout_key(area, deg));
3414    }
3415
3416    #[test]
3417    fn layout_key_differs_by_dimensions() {
3418        let help = Help::new().entry("q", "quit");
3419        let deg = DegradationLevel::Full;
3420        let k1 = help.layout_key(Rect::new(0, 0, 40, 1), deg);
3421        let k2 = help.layout_key(Rect::new(0, 0, 80, 1), deg);
3422        assert_ne!(k1, k2);
3423    }
3424
3425    #[test]
3426    fn layout_key_hash_consistent() {
3427        use std::collections::hash_map::DefaultHasher;
3428        let help = Help::new().entry("q", "quit");
3429        let key = help.layout_key(Rect::new(0, 0, 40, 1), DegradationLevel::Full);
3430        let mut h1 = DefaultHasher::new();
3431        let mut h2 = DefaultHasher::new();
3432        key.hash(&mut h1);
3433        key.hash(&mut h2);
3434        assert_eq!(h1.finish(), h2.finish());
3435    }
3436}