1use crate::{StatefulWidget, Widget, 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
32pub enum HelpCategory {
33 #[default]
35 General,
36 Navigation,
38 Editing,
40 File,
42 View,
44 Global,
46 Custom(String),
48}
49
50impl HelpCategory {
51 #[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#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct HelpEntry {
69 pub key: String,
71 pub desc: String,
73 pub enabled: bool,
75 pub category: HelpCategory,
77}
78
79impl HelpEntry {
80 #[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 #[must_use]
93 pub fn with_enabled(mut self, enabled: bool) -> Self {
94 self.enabled = enabled;
95 self
96 }
97
98 #[must_use]
100 pub fn with_category(mut self, category: HelpCategory) -> Self {
101 self.category = category;
102 self
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
108pub enum HelpMode {
109 #[default]
111 Short,
112 Full,
114}
115
116#[derive(Debug, Clone)]
124pub struct Help {
125 entries: Vec<HelpEntry>,
126 mode: HelpMode,
127 separator: String,
129 ellipsis: String,
131 key_style: Style,
133 desc_style: Style,
135 separator_style: Style,
137}
138
139#[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#[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 #[must_use]
171 pub fn stats(&self) -> HelpCacheStats {
172 self.stats
173 }
174
175 pub fn clear_dirty_rects(&mut self) {
177 self.dirty_rects.clear();
178 }
179
180 #[must_use]
182 pub fn take_dirty_rects(&mut self) -> Vec<Rect> {
183 std::mem::take(&mut self.dirty_rects)
184 }
185
186 #[must_use]
188 pub fn dirty_rects(&self) -> &[Rect] {
189 &self.dirty_rects
190 }
191
192 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 #[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 #[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 #[must_use]
292 pub fn with_entry(mut self, entry: HelpEntry) -> Self {
293 self.entries.push(entry);
294 self
295 }
296
297 #[must_use]
299 pub fn with_entries(mut self, entries: Vec<HelpEntry>) -> Self {
300 self.entries = entries;
301 self
302 }
303
304 #[must_use]
306 pub fn with_mode(mut self, mode: HelpMode) -> Self {
307 self.mode = mode;
308 self
309 }
310
311 #[must_use]
313 pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
314 self.separator = sep.into();
315 self
316 }
317
318 #[must_use]
320 pub fn with_ellipsis(mut self, ellipsis: impl Into<String>) -> Self {
321 self.ellipsis = ellipsis.into();
322 self
323 }
324
325 #[must_use]
327 pub fn with_key_style(mut self, style: Style) -> Self {
328 self.key_style = style;
329 self
330 }
331
332 #[must_use]
334 pub fn with_desc_style(mut self, style: Style) -> Self {
335 self.desc_style = style;
336 self
337 }
338
339 #[must_use]
341 pub fn with_separator_style(mut self, style: Style) -> Self {
342 self.separator_style = style;
343 self
344 }
345
346 #[must_use]
348 pub fn entries(&self) -> &[HelpEntry] {
349 &self.entries
350 }
351
352 #[must_use]
354 pub fn mode(&self) -> HelpMode {
355 self.mode
356 }
357
358 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 pub fn push_entry(&mut self, entry: HelpEntry) {
368 self.entries.push(entry);
369 }
370
371 fn enabled_entries(&self) -> Vec<&HelpEntry> {
373 self.entries.iter().filter(|e| e.enabled).collect()
374 }
375
376 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 let sep_w = if i > 0 { sep_width } else { 0 };
397
398 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 let space_left = (max_x as usize).saturating_sub(x as usize);
406 if total_item_w > space_left {
407 let ell_total = if i > 0 {
409 1 + ellipsis_width
410 } else {
411 ellipsis_width
412 };
413 if ell_total <= space_left && deg.apply_styling() {
414 if i > 0 {
415 x = draw_text_span(frame, x, y, " ", self.separator_style, max_x);
416 }
417 draw_text_span(frame, x, y, &self.ellipsis, self.separator_style, max_x);
418 }
419 break;
420 }
421
422 if i > 0 {
424 if deg.apply_styling() {
425 x = draw_text_span(frame, x, y, &self.separator, self.separator_style, max_x);
426 } else {
427 x = draw_text_span(frame, x, y, &self.separator, Style::default(), max_x);
428 }
429 }
430
431 if deg.apply_styling() {
433 x = draw_text_span(frame, x, y, &entry.key, self.key_style, max_x);
434 x = draw_text_span(frame, x, y, " ", self.desc_style, max_x);
435 x = draw_text_span(frame, x, y, &entry.desc, self.desc_style, max_x);
436 } else {
437 let text = format!("{} {}", entry.key, entry.desc);
438 x = draw_text_span(frame, x, y, &text, Style::default(), max_x);
439 }
440 }
441 }
442
443 fn render_full(&self, area: Rect, frame: &mut Frame) {
445 let entries = self.enabled_entries();
446 if entries.is_empty() || area.width == 0 || area.height == 0 {
447 return;
448 }
449
450 let deg = frame.buffer.degradation;
451
452 let max_key_w = entries
454 .iter()
455 .filter(|e| !e.key.is_empty() || !e.desc.is_empty())
456 .map(|e| display_width(&e.key))
457 .max()
458 .unwrap_or(0);
459
460 let max_x = area.right();
461 let mut row: u16 = 0;
462
463 for entry in &entries {
464 if entry.key.is_empty() && entry.desc.is_empty() {
465 continue;
466 }
467 if row >= area.height {
468 break;
469 }
470
471 let y = area.y.saturating_add(row);
472 let mut x = area.x;
473
474 if deg.apply_styling() {
475 let key_w = display_width(&entry.key);
477 x = draw_text_span(frame, x, y, &entry.key, self.key_style, max_x);
478 let pad = max_key_w.saturating_sub(key_w);
480 for _ in 0..pad {
481 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
482 }
483 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
485 draw_text_span(frame, x, y, &entry.desc, self.desc_style, max_x);
487 } else {
488 let text = format!("{:>width$} {}", entry.key, entry.desc, width = max_key_w);
489 draw_text_span(frame, x, y, &text, Style::default(), max_x);
490 }
491
492 row += 1;
493 }
494 }
495
496 fn entry_hash(entry: &HelpEntry) -> u64 {
497 let mut hasher = std::collections::hash_map::DefaultHasher::new();
498 entry.key.hash(&mut hasher);
499 entry.desc.hash(&mut hasher);
500 entry.enabled.hash(&mut hasher);
501 entry.category.hash(&mut hasher);
502 hasher.finish()
503 }
504
505 fn hash_str(value: &str) -> u64 {
506 let mut hasher = std::collections::hash_map::DefaultHasher::new();
507 value.hash(&mut hasher);
508 hasher.finish()
509 }
510
511 fn layout_key(&self, area: Rect, degradation: DegradationLevel) -> LayoutKey {
512 LayoutKey {
513 mode: self.mode,
514 width: area.width,
515 height: area.height,
516 separator_hash: Self::hash_str(&self.separator),
517 ellipsis_hash: Self::hash_str(&self.ellipsis),
518 key_style: StyleKey::from(self.key_style),
519 desc_style: StyleKey::from(self.desc_style),
520 separator_style: StyleKey::from(self.separator_style),
521 degradation,
522 }
523 }
524
525 fn build_layout(&self, area: Rect) -> HelpLayout {
526 match self.mode {
527 HelpMode::Short => self.build_short_layout(area),
528 HelpMode::Full => self.build_full_layout(area),
529 }
530 }
531
532 fn build_short_layout(&self, area: Rect) -> HelpLayout {
533 let mut entries = Vec::new();
534 let mut ellipsis = None;
535 let sep_width = display_width(&self.separator);
536 let ellipsis_width = display_width(&self.ellipsis);
537 let max_x = area.width;
538 let mut x: u16 = 0;
539 let mut first = true;
540
541 for entry in self
542 .entries
543 .iter()
544 .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
545 {
546 let key_width = display_width(&entry.key);
547 let desc_width = display_width(&entry.desc);
548 let item_width = key_width + 1 + desc_width;
549 let total_width = if first {
550 item_width
551 } else {
552 sep_width + item_width
553 };
554 let space_left = (max_x as usize).saturating_sub(x as usize);
555
556 if total_width > space_left {
557 let ell_total = if first {
558 ellipsis_width
559 } else {
560 1 + ellipsis_width
561 };
562 if ell_total <= space_left {
563 ellipsis = Some(EllipsisSlot {
564 x,
565 width: ell_total as u16,
566 prefix_space: !first,
567 });
568 }
569 break;
570 }
571
572 entries.push(EntrySlot {
573 x,
574 y: 0,
575 width: total_width as u16,
576 key_width,
577 });
578 x = x.saturating_add(total_width as u16);
579 first = false;
580 }
581
582 HelpLayout {
583 mode: HelpMode::Short,
584 width: area.width,
585 entries,
586 ellipsis,
587 max_key_width: 0,
588 separator_width: sep_width,
589 }
590 }
591
592 fn build_full_layout(&self, area: Rect) -> HelpLayout {
593 let mut max_key_width = 0usize;
594 for entry in self
595 .entries
596 .iter()
597 .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
598 {
599 let key_width = display_width(&entry.key);
600 max_key_width = max_key_width.max(key_width);
601 }
602
603 let mut entries = Vec::new();
604 let mut row: u16 = 0;
605 for entry in self
606 .entries
607 .iter()
608 .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
609 {
610 if row >= area.height {
611 break;
612 }
613 let key_width = display_width(&entry.key);
614 let desc_width = display_width(&entry.desc);
615 let entry_width = max_key_width.saturating_add(2).saturating_add(desc_width);
616 let slot_width = entry_width.min(area.width as usize) as u16;
617 entries.push(EntrySlot {
618 x: 0,
619 y: row,
620 width: slot_width,
621 key_width,
622 });
623 row = row.saturating_add(1);
624 }
625
626 HelpLayout {
627 mode: HelpMode::Full,
628 width: area.width,
629 entries,
630 ellipsis: None,
631 max_key_width,
632 separator_width: 0,
633 }
634 }
635
636 fn render_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
637 match layout.mode {
638 HelpMode::Short => self.render_short_cached(area, frame, layout),
639 HelpMode::Full => self.render_full_cached(area, frame, layout),
640 }
641 }
642
643 fn render_short_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
644 if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
645 return;
646 }
647
648 let deg = frame.buffer.degradation;
649 let max_x = area.right();
650 let mut enabled_iter = self
651 .entries
652 .iter()
653 .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
654
655 for (idx, slot) in layout.entries.iter().enumerate() {
656 let Some(entry) = enabled_iter.next() else {
657 break;
658 };
659 let mut x = area.x.saturating_add(slot.x);
660 let y = area.y.saturating_add(slot.y);
661
662 if idx > 0 {
663 let sep_style = if deg.apply_styling() {
664 self.separator_style
665 } else {
666 Style::default()
667 };
668 x = draw_text_span(frame, x, y, &self.separator, sep_style, max_x);
669 }
670
671 let key_style = if deg.apply_styling() {
672 self.key_style
673 } else {
674 Style::default()
675 };
676 let desc_style = if deg.apply_styling() {
677 self.desc_style
678 } else {
679 Style::default()
680 };
681
682 x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
683 x = draw_text_span(frame, x, y, " ", desc_style, max_x);
684 draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
685 }
686
687 if let Some(ellipsis) = &layout.ellipsis {
688 let y = area.y.saturating_add(0);
689 let mut x = area.x.saturating_add(ellipsis.x);
690 let ellipsis_style = if deg.apply_styling() {
691 self.separator_style
692 } else {
693 Style::default()
694 };
695 if ellipsis.prefix_space {
696 x = draw_text_span(frame, x, y, " ", ellipsis_style, max_x);
697 }
698 draw_text_span(frame, x, y, &self.ellipsis, ellipsis_style, max_x);
699 }
700 }
701
702 fn render_full_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
703 if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
704 return;
705 }
706
707 let deg = frame.buffer.degradation;
708 let max_x = area.right();
709
710 let mut enabled_iter = self
711 .entries
712 .iter()
713 .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
714
715 for slot in layout.entries.iter() {
716 let Some(entry) = enabled_iter.next() else {
717 break;
718 };
719
720 let y = area.y.saturating_add(slot.y);
721 let mut x = area.x.saturating_add(slot.x);
722
723 let key_style = if deg.apply_styling() {
724 self.key_style
725 } else {
726 Style::default()
727 };
728 let desc_style = if deg.apply_styling() {
729 self.desc_style
730 } else {
731 Style::default()
732 };
733
734 x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
735 let pad = layout.max_key_width.saturating_sub(slot.key_width);
736 for _ in 0..pad {
737 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
738 }
739 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
740 draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
741 }
742 }
743
744 fn render_short_entry(&self, slot: &EntrySlot, entry: &HelpEntry, frame: &mut Frame) {
745 let deg = frame.buffer.degradation;
746 let max_x = slot.x.saturating_add(slot.width);
747
748 let rect = Rect::new(slot.x, slot.y, slot.width, 1);
749 frame.buffer.fill(rect, Cell::default());
750
751 let mut x = slot.x;
752 if slot.x > 0 {
753 let sep_style = if deg.apply_styling() {
754 self.separator_style
755 } else {
756 Style::default()
757 };
758 x = draw_text_span(frame, x, slot.y, &self.separator, sep_style, max_x);
759 }
760
761 let key_style = if deg.apply_styling() {
762 self.key_style
763 } else {
764 Style::default()
765 };
766 let desc_style = if deg.apply_styling() {
767 self.desc_style
768 } else {
769 Style::default()
770 };
771
772 x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
773 x = draw_text_span(frame, x, slot.y, " ", desc_style, max_x);
774 draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
775 }
776
777 fn render_full_entry(
778 &self,
779 slot: &EntrySlot,
780 entry: &HelpEntry,
781 layout: &HelpLayout,
782 frame: &mut Frame,
783 ) {
784 let deg = frame.buffer.degradation;
785 let max_x = slot.x.saturating_add(slot.width);
786
787 let rect = Rect::new(slot.x, slot.y, slot.width, 1);
788 frame.buffer.fill(rect, Cell::default());
789
790 let mut x = slot.x;
791 let key_style = if deg.apply_styling() {
792 self.key_style
793 } else {
794 Style::default()
795 };
796 let desc_style = if deg.apply_styling() {
797 self.desc_style
798 } else {
799 Style::default()
800 };
801
802 x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
803 let pad = layout.max_key_width.saturating_sub(slot.key_width);
804 for _ in 0..pad {
805 x = draw_text_span(frame, x, slot.y, " ", Style::default(), max_x);
806 }
807 x = draw_text_span(frame, x, slot.y, " ", Style::default(), max_x);
808 draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
809 }
810}
811
812impl Widget for Help {
813 fn render(&self, area: Rect, frame: &mut Frame) {
814 match self.mode {
815 HelpMode::Short => self.render_short(area, frame),
816 HelpMode::Full => self.render_full(area, frame),
817 }
818 }
819
820 fn is_essential(&self) -> bool {
821 false
822 }
823}
824
825impl StatefulWidget for Help {
826 type State = HelpRenderState;
827
828 fn render(&self, area: Rect, frame: &mut Frame, state: &mut HelpRenderState) {
829 if area.is_empty() || area.width == 0 || area.height == 0 {
830 state.cache = None;
831 return;
832 }
833
834 state.dirty_rects.clear();
835 state.dirty_indices.clear();
836
837 let layout_key = self.layout_key(area, frame.buffer.degradation);
838 let enabled_count = collect_enabled_indices(&self.entries, &mut state.enabled_indices);
839
840 let cache_miss = state
841 .cache
842 .as_ref()
843 .is_none_or(|cache| cache.key != layout_key);
844
845 if cache_miss {
846 rebuild_cache(self, area, frame, state, layout_key, enabled_count);
847 blit_cache(state.cache.as_ref(), area, frame);
848 return;
849 }
850
851 let cache = state
852 .cache
853 .as_mut()
854 .expect("cache present after miss check");
855 if enabled_count != cache.enabled_count {
856 rebuild_cache(self, area, frame, state, layout_key, enabled_count);
857 blit_cache(state.cache.as_ref(), area, frame);
858 return;
859 }
860
861 let mut layout_changed = false;
862 let visible_count = cache.layout.entries.len();
863
864 for (pos, entry_idx) in state.enabled_indices.iter().enumerate() {
865 let entry = &self.entries[*entry_idx];
866 let hash = Help::entry_hash(entry);
867
868 if pos >= cache.entry_hashes.len() {
869 layout_changed = true;
870 break;
871 }
872
873 if hash != cache.entry_hashes[pos] {
874 if pos >= visible_count || !entry_fits_slot(entry, pos, &cache.layout) {
875 layout_changed = true;
876 break;
877 }
878 cache.entry_hashes[pos] = hash;
879 state.dirty_indices.push(pos);
880 }
881 }
882
883 if layout_changed {
884 rebuild_cache(self, area, frame, state, layout_key, enabled_count);
885 blit_cache(state.cache.as_ref(), area, frame);
886 return;
887 }
888
889 if state.dirty_indices.is_empty() {
890 state.stats.hits += 1;
891 blit_cache(state.cache.as_ref(), area, frame);
892 return;
893 }
894
895 state.stats.dirty_updates += 1;
897
898 let cache = state
899 .cache
900 .as_mut()
901 .expect("cache present for dirty update");
902 let mut cache_buffer = std::mem::take(&mut cache.buffer);
903 cache_buffer.degradation = frame.buffer.degradation;
904 {
905 let mut cache_frame = Frame {
906 buffer: cache_buffer,
907 pool: frame.pool,
908 links: None,
909 hit_grid: None,
910 widget_budget: frame.widget_budget.clone(),
911 widget_signals: Vec::new(),
912 cursor_position: None,
913 cursor_visible: true,
914 degradation: frame.buffer.degradation,
915 arena: None,
916 };
917
918 for idx in &state.dirty_indices {
919 if let Some(entry_idx) = state.enabled_indices.get(*idx)
920 && let Some(slot) = cache.layout.entries.get(*idx)
921 {
922 let entry = &self.entries[*entry_idx];
923 match cache.layout.mode {
924 HelpMode::Short => self.render_short_entry(slot, entry, &mut cache_frame),
925 HelpMode::Full => {
926 self.render_full_entry(slot, entry, &cache.layout, &mut cache_frame)
927 }
928 }
929 state
930 .dirty_rects
931 .push(Rect::new(slot.x, slot.y, slot.width, 1));
932 }
933 }
934
935 cache_buffer = cache_frame.buffer;
936 }
937 cache.buffer = cache_buffer;
938
939 blit_cache(state.cache.as_ref(), area, frame);
940 }
941}
942
943fn collect_enabled_indices(entries: &[HelpEntry], out: &mut Vec<usize>) -> usize {
944 out.clear();
945 for (idx, entry) in entries.iter().enumerate() {
946 if entry.enabled && (!entry.key.is_empty() || !entry.desc.is_empty()) {
947 out.push(idx);
948 }
949 }
950 out.len()
951}
952
953fn entry_fits_slot(entry: &HelpEntry, index: usize, layout: &HelpLayout) -> bool {
954 match layout.mode {
955 HelpMode::Short => {
956 let entry_width = display_width(&entry.key) + 1 + display_width(&entry.desc);
957 let slot = match layout.entries.get(index) {
958 Some(slot) => slot,
959 None => return false,
960 };
961 let sep_width = layout.separator_width;
962 let max_width = if slot.x == 0 {
963 slot.width as usize
964 } else {
965 slot.width.saturating_sub(sep_width as u16) as usize
966 };
967 entry_width <= max_width
968 }
969 HelpMode::Full => {
970 let key_width = display_width(&entry.key);
971 let desc_width = display_width(&entry.desc);
972 let entry_width = layout
973 .max_key_width
974 .saturating_add(2)
975 .saturating_add(desc_width);
976 let slot = match layout.entries.get(index) {
977 Some(slot) => slot,
978 None => return false,
979 };
980 if slot.width == layout.width {
981 key_width <= layout.max_key_width
982 } else {
983 key_width <= layout.max_key_width && entry_width <= slot.width as usize
984 }
985 }
986 }
987}
988
989fn rebuild_cache(
990 help: &Help,
991 area: Rect,
992 frame: &mut Frame,
993 state: &mut HelpRenderState,
994 layout_key: LayoutKey,
995 enabled_count: usize,
996) {
997 state.stats.misses += 1;
998 state.stats.layout_rebuilds += 1;
999
1000 let layout_area = Rect::new(0, 0, area.width, area.height);
1001 let layout = help.build_layout(layout_area);
1002
1003 let mut buffer = Buffer::new(area.width, area.height);
1004 buffer.degradation = frame.buffer.degradation;
1005 {
1006 let mut cache_frame = Frame {
1007 buffer,
1008 pool: frame.pool,
1009 links: None,
1010 hit_grid: None,
1011 widget_budget: frame.widget_budget.clone(),
1012 widget_signals: Vec::new(),
1013 cursor_position: None,
1014 cursor_visible: true,
1015 degradation: frame.buffer.degradation,
1016 arena: None,
1017 };
1018 help.render_cached(layout_area, &mut cache_frame, &layout);
1019 buffer = cache_frame.buffer;
1020 }
1021
1022 let mut entry_hashes = Vec::with_capacity(state.enabled_indices.len());
1023 for idx in &state.enabled_indices {
1024 entry_hashes.push(Help::entry_hash(&help.entries[*idx]));
1025 }
1026
1027 state.cache = Some(HelpCache {
1028 buffer,
1029 layout,
1030 key: layout_key,
1031 entry_hashes,
1032 enabled_count,
1033 });
1034}
1035
1036fn blit_cache(cache: Option<&HelpCache>, area: Rect, frame: &mut Frame) {
1037 let Some(cache) = cache else {
1038 return;
1039 };
1040
1041 for slot in &cache.layout.entries {
1042 let src = Rect::new(slot.x, slot.y, slot.width, 1);
1043 frame
1044 .buffer
1045 .copy_from(&cache.buffer, src, area.x + slot.x, area.y + slot.y);
1046 }
1047
1048 if let Some(ellipsis) = &cache.layout.ellipsis {
1049 let src = Rect::new(ellipsis.x, 0, ellipsis.width, 1);
1050 frame
1051 .buffer
1052 .copy_from(&cache.buffer, src, area.x + ellipsis.x, area.y);
1053 }
1054}
1055
1056#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1058pub enum KeyFormat {
1059 #[default]
1061 Plain,
1062 Bracketed,
1064}
1065
1066#[derive(Debug, Clone)]
1092pub struct KeybindingHints {
1093 global_entries: Vec<HelpEntry>,
1094 contextual_entries: Vec<HelpEntry>,
1095 key_format: KeyFormat,
1096 mode: HelpMode,
1097 key_style: Style,
1098 desc_style: Style,
1099 separator_style: Style,
1100 category_style: Style,
1101 separator: String,
1102 ellipsis: String,
1103 show_categories: bool,
1104 show_context: bool,
1105}
1106
1107impl Default for KeybindingHints {
1108 fn default() -> Self {
1109 Self::new()
1110 }
1111}
1112
1113impl KeybindingHints {
1114 #[must_use]
1116 pub fn new() -> Self {
1117 Self {
1118 global_entries: Vec::new(),
1119 contextual_entries: Vec::new(),
1120 key_format: KeyFormat::default(),
1121 mode: HelpMode::Short,
1122 key_style: Style::new().bold(),
1123 desc_style: Style::default(),
1124 separator_style: Style::default(),
1125 category_style: Style::new().bold().underline(),
1126 separator: " • ".to_string(),
1127 ellipsis: "…".to_string(),
1128 show_categories: true,
1129 show_context: false,
1130 }
1131 }
1132
1133 #[must_use]
1135 pub fn global_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1136 self.global_entries
1137 .push(HelpEntry::new(key, desc).with_category(HelpCategory::Global));
1138 self
1139 }
1140
1141 #[must_use]
1143 pub fn global_entry_categorized(
1144 mut self,
1145 key: impl Into<String>,
1146 desc: impl Into<String>,
1147 category: HelpCategory,
1148 ) -> Self {
1149 self.global_entries
1150 .push(HelpEntry::new(key, desc).with_category(category));
1151 self
1152 }
1153
1154 #[must_use]
1156 pub fn contextual_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1157 self.contextual_entries.push(HelpEntry::new(key, desc));
1158 self
1159 }
1160
1161 #[must_use]
1163 pub fn contextual_entry_categorized(
1164 mut self,
1165 key: impl Into<String>,
1166 desc: impl Into<String>,
1167 category: HelpCategory,
1168 ) -> Self {
1169 self.contextual_entries
1170 .push(HelpEntry::new(key, desc).with_category(category));
1171 self
1172 }
1173
1174 #[must_use]
1176 pub fn with_global_entry(mut self, entry: HelpEntry) -> Self {
1177 self.global_entries.push(entry);
1178 self
1179 }
1180
1181 #[must_use]
1183 pub fn with_contextual_entry(mut self, entry: HelpEntry) -> Self {
1184 self.contextual_entries.push(entry);
1185 self
1186 }
1187
1188 #[must_use]
1190 pub fn with_key_format(mut self, format: KeyFormat) -> Self {
1191 self.key_format = format;
1192 self
1193 }
1194
1195 #[must_use]
1197 pub fn with_mode(mut self, mode: HelpMode) -> Self {
1198 self.mode = mode;
1199 self
1200 }
1201
1202 #[must_use]
1204 pub fn with_show_context(mut self, show: bool) -> Self {
1205 self.show_context = show;
1206 self
1207 }
1208
1209 #[must_use]
1211 pub fn with_show_categories(mut self, show: bool) -> Self {
1212 self.show_categories = show;
1213 self
1214 }
1215
1216 #[must_use]
1218 pub fn with_key_style(mut self, style: Style) -> Self {
1219 self.key_style = style;
1220 self
1221 }
1222
1223 #[must_use]
1225 pub fn with_desc_style(mut self, style: Style) -> Self {
1226 self.desc_style = style;
1227 self
1228 }
1229
1230 #[must_use]
1232 pub fn with_separator_style(mut self, style: Style) -> Self {
1233 self.separator_style = style;
1234 self
1235 }
1236
1237 #[must_use]
1239 pub fn with_category_style(mut self, style: Style) -> Self {
1240 self.category_style = style;
1241 self
1242 }
1243
1244 #[must_use]
1246 pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
1247 self.separator = sep.into();
1248 self
1249 }
1250
1251 #[must_use]
1253 pub fn global_entries(&self) -> &[HelpEntry] {
1254 &self.global_entries
1255 }
1256
1257 #[must_use]
1259 pub fn contextual_entries(&self) -> &[HelpEntry] {
1260 &self.contextual_entries
1261 }
1262
1263 #[must_use]
1265 pub fn mode(&self) -> HelpMode {
1266 self.mode
1267 }
1268
1269 #[must_use]
1271 pub fn key_format(&self) -> KeyFormat {
1272 self.key_format
1273 }
1274
1275 pub fn toggle_mode(&mut self) {
1277 self.mode = match self.mode {
1278 HelpMode::Short => HelpMode::Full,
1279 HelpMode::Full => HelpMode::Short,
1280 };
1281 }
1282
1283 pub fn set_show_context(&mut self, show: bool) {
1285 self.show_context = show;
1286 }
1287
1288 fn format_key(&self, key: &str) -> String {
1290 match self.key_format {
1291 KeyFormat::Plain => key.to_string(),
1292 KeyFormat::Bracketed => format!("[{key}]"),
1293 }
1294 }
1295
1296 #[must_use]
1298 pub fn visible_entries(&self) -> Vec<HelpEntry> {
1299 let mut entries = Vec::new();
1300 for e in &self.global_entries {
1301 if e.enabled {
1302 entries.push(HelpEntry {
1303 key: self.format_key(&e.key),
1304 desc: e.desc.clone(),
1305 enabled: true,
1306 category: e.category.clone(),
1307 });
1308 }
1309 }
1310 if self.show_context {
1311 for e in &self.contextual_entries {
1312 if e.enabled {
1313 entries.push(HelpEntry {
1314 key: self.format_key(&e.key),
1315 desc: e.desc.clone(),
1316 enabled: true,
1317 category: e.category.clone(),
1318 });
1319 }
1320 }
1321 }
1322 entries
1323 }
1324
1325 fn grouped_entries(entries: &[HelpEntry]) -> Vec<(&HelpCategory, Vec<&HelpEntry>)> {
1327 let mut groups: Vec<(&HelpCategory, Vec<&HelpEntry>)> = Vec::new();
1328 for entry in entries {
1329 if let Some(group) = groups.iter_mut().find(|(cat, _)| **cat == entry.category) {
1330 group.1.push(entry);
1331 } else {
1332 groups.push((&entry.category, vec![entry]));
1333 }
1334 }
1335 groups
1336 }
1337
1338 fn render_full_grouped(&self, entries: &[HelpEntry], area: Rect, frame: &mut Frame) {
1340 let groups = Self::grouped_entries(entries);
1341 let deg = frame.buffer.degradation;
1342 let max_x = area.right();
1343 let mut y = area.y;
1344
1345 let max_key_w = entries
1347 .iter()
1348 .map(|e| display_width(&e.key))
1349 .max()
1350 .unwrap_or(0);
1351
1352 for (i, (cat, group_entries)) in groups.iter().enumerate() {
1353 if y >= area.bottom() {
1354 break;
1355 }
1356
1357 let cat_style = if deg.apply_styling() {
1359 self.category_style
1360 } else {
1361 Style::default()
1362 };
1363 draw_text_span(frame, area.x, y, cat.label(), cat_style, max_x);
1364 y += 1;
1365
1366 for entry in group_entries {
1368 if y >= area.bottom() {
1369 break;
1370 }
1371
1372 let key_style = if deg.apply_styling() {
1373 self.key_style
1374 } else {
1375 Style::default()
1376 };
1377 let desc_style = if deg.apply_styling() {
1378 self.desc_style
1379 } else {
1380 Style::default()
1381 };
1382
1383 let mut x = area.x;
1384 x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
1385 let pad = max_key_w.saturating_sub(display_width(&entry.key));
1386 for _ in 0..pad {
1387 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
1388 }
1389 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
1390 draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
1391 y += 1;
1392 }
1393
1394 if i + 1 < groups.len() {
1396 y += 1;
1397 }
1398 }
1399 }
1400}
1401
1402impl Widget for KeybindingHints {
1403 fn render(&self, area: Rect, frame: &mut Frame) {
1404 let entries = self.visible_entries();
1405 if entries.is_empty() || area.is_empty() {
1406 return;
1407 }
1408
1409 match self.mode {
1410 HelpMode::Short => {
1411 let help = Help::new()
1413 .with_mode(HelpMode::Short)
1414 .with_key_style(self.key_style)
1415 .with_desc_style(self.desc_style)
1416 .with_separator_style(self.separator_style)
1417 .with_separator(self.separator.clone())
1418 .with_ellipsis(self.ellipsis.clone())
1419 .with_entries(entries);
1420 Widget::render(&help, area, frame);
1421 }
1422 HelpMode::Full => {
1423 if self.show_categories {
1424 self.render_full_grouped(&entries, area, frame);
1425 } else {
1426 let help = Help::new()
1427 .with_mode(HelpMode::Full)
1428 .with_key_style(self.key_style)
1429 .with_desc_style(self.desc_style)
1430 .with_entries(entries);
1431 Widget::render(&help, area, frame);
1432 }
1433 }
1434 }
1435 }
1436
1437 fn is_essential(&self) -> bool {
1438 false
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445 use ftui_render::frame::Frame;
1446 use ftui_render::grapheme_pool::GraphemePool;
1447 use proptest::prelude::*;
1448 use proptest::string::string_regex;
1449 use std::time::Instant;
1450
1451 #[test]
1452 fn new_help_is_empty() {
1453 let help = Help::new();
1454 assert!(help.entries().is_empty());
1455 assert_eq!(help.mode(), HelpMode::Short);
1456 }
1457
1458 #[test]
1459 fn entry_builder() {
1460 let help = Help::new().entry("q", "quit").entry("^s", "save");
1461 assert_eq!(help.entries().len(), 2);
1462 assert_eq!(help.entries()[0].key, "q");
1463 assert_eq!(help.entries()[0].desc, "quit");
1464 }
1465
1466 #[test]
1467 fn with_entries_replaces() {
1468 let help = Help::new()
1469 .entry("old", "old")
1470 .with_entries(vec![HelpEntry::new("new", "new")]);
1471 assert_eq!(help.entries().len(), 1);
1472 assert_eq!(help.entries()[0].key, "new");
1473 }
1474
1475 #[test]
1476 fn disabled_entries_hidden() {
1477 let help = Help::new()
1478 .with_entry(HelpEntry::new("a", "shown"))
1479 .with_entry(HelpEntry::new("b", "hidden").with_enabled(false))
1480 .with_entry(HelpEntry::new("c", "also shown"));
1481 assert_eq!(help.enabled_entries().len(), 2);
1482 }
1483
1484 #[test]
1485 fn toggle_mode() {
1486 let mut help = Help::new();
1487 assert_eq!(help.mode(), HelpMode::Short);
1488 help.toggle_mode();
1489 assert_eq!(help.mode(), HelpMode::Full);
1490 help.toggle_mode();
1491 assert_eq!(help.mode(), HelpMode::Short);
1492 }
1493
1494 #[test]
1495 fn push_entry() {
1496 let mut help = Help::new();
1497 help.push_entry(HelpEntry::new("x", "action"));
1498 assert_eq!(help.entries().len(), 1);
1499 }
1500
1501 #[test]
1502 fn render_short_basic() {
1503 let help = Help::new().entry("q", "quit").entry("^s", "save");
1504
1505 let mut pool = GraphemePool::new();
1506 let mut frame = Frame::new(40, 1, &mut pool);
1507 let area = Rect::new(0, 0, 40, 1);
1508 Widget::render(&help, area, &mut frame);
1509
1510 let cell_q = frame.buffer.get(0, 0).unwrap();
1512 assert_eq!(cell_q.content.as_char(), Some('q'));
1513 }
1514
1515 #[test]
1516 fn render_short_truncation() {
1517 let help = Help::new()
1518 .entry("q", "quit")
1519 .entry("^s", "save")
1520 .entry("^x", "something very long that should not fit");
1521
1522 let mut pool = GraphemePool::new();
1523 let mut frame = Frame::new(20, 1, &mut pool);
1524 let area = Rect::new(0, 0, 20, 1);
1525 Widget::render(&help, area, &mut frame);
1526
1527 let cell = frame.buffer.get(0, 0).unwrap();
1529 assert_eq!(cell.content.as_char(), Some('q'));
1530 }
1531
1532 #[test]
1533 fn render_short_empty_entries() {
1534 let help = Help::new();
1535
1536 let mut pool = GraphemePool::new();
1537 let mut frame = Frame::new(20, 1, &mut pool);
1538 let area = Rect::new(0, 0, 20, 1);
1539 Widget::render(&help, area, &mut frame);
1540
1541 let cell = frame.buffer.get(0, 0).unwrap();
1543 assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
1544 }
1545
1546 #[test]
1547 fn render_full_basic() {
1548 let help = Help::new()
1549 .with_mode(HelpMode::Full)
1550 .entry("q", "quit")
1551 .entry("^s", "save file");
1552
1553 let mut pool = GraphemePool::new();
1554 let mut frame = Frame::new(30, 5, &mut pool);
1555 let area = Rect::new(0, 0, 30, 5);
1556 Widget::render(&help, area, &mut frame);
1557
1558 let cell = frame.buffer.get(0, 0).unwrap();
1560 assert!(cell.content.as_char() == Some(' ') || cell.content.as_char() == Some('q'));
1561 let cell_row2 = frame.buffer.get(0, 1).unwrap();
1563 assert!(
1564 cell_row2.content.as_char() == Some('^') || cell_row2.content.as_char() == Some(' ')
1565 );
1566 }
1567
1568 #[test]
1569 fn render_full_respects_height() {
1570 let help = Help::new()
1571 .with_mode(HelpMode::Full)
1572 .entry("a", "first")
1573 .entry("b", "second")
1574 .entry("c", "third");
1575
1576 let mut pool = GraphemePool::new();
1577 let mut frame = Frame::new(30, 2, &mut pool);
1579 let area = Rect::new(0, 0, 30, 2);
1580 Widget::render(&help, area, &mut frame);
1581
1582 }
1585
1586 #[test]
1587 fn help_entry_equality() {
1588 let a = HelpEntry::new("q", "quit");
1589 let b = HelpEntry::new("q", "quit");
1590 let c = HelpEntry::new("x", "exit");
1591 assert_eq!(a, b);
1592 assert_ne!(a, c);
1593 }
1594
1595 #[test]
1596 fn help_entry_disabled() {
1597 let entry = HelpEntry::new("q", "quit").with_enabled(false);
1598 assert!(!entry.enabled);
1599 }
1600
1601 #[test]
1602 fn with_separator() {
1603 let help = Help::new().with_separator(" | ");
1604 assert_eq!(help.separator, " | ");
1605 }
1606
1607 #[test]
1608 fn with_ellipsis() {
1609 let help = Help::new().with_ellipsis("...");
1610 assert_eq!(help.ellipsis, "...");
1611 }
1612
1613 #[test]
1614 fn render_zero_area() {
1615 let help = Help::new().entry("q", "quit");
1616
1617 let mut pool = GraphemePool::new();
1618 let mut frame = Frame::new(20, 1, &mut pool);
1619 let area = Rect::new(0, 0, 0, 0);
1620 Widget::render(&help, area, &mut frame); }
1622
1623 #[test]
1624 fn is_not_essential() {
1625 let help = Help::new();
1626 assert!(!help.is_essential());
1627 }
1628
1629 #[test]
1630 fn render_full_alignment() {
1631 let help = Help::new()
1633 .with_mode(HelpMode::Full)
1634 .entry("q", "quit")
1635 .entry("ctrl+s", "save");
1636
1637 let mut pool = GraphemePool::new();
1638 let mut frame = Frame::new(30, 3, &mut pool);
1639 let area = Rect::new(0, 0, 30, 3);
1640 Widget::render(&help, area, &mut frame);
1641
1642 }
1648
1649 #[test]
1650 fn default_impl() {
1651 let help = Help::default();
1652 assert!(help.entries().is_empty());
1653 }
1654
1655 #[test]
1656 fn cache_hit_same_hints() {
1657 let help = Help::new().entry("q", "quit").entry("^s", "save");
1658 let mut state = HelpRenderState::default();
1659 let mut pool = GraphemePool::new();
1660 let mut frame = Frame::new(40, 1, &mut pool);
1661 let area = Rect::new(0, 0, 40, 1);
1662
1663 StatefulWidget::render(&help, area, &mut frame, &mut state);
1664 let stats_after_first = state.stats();
1665 StatefulWidget::render(&help, area, &mut frame, &mut state);
1666 let stats_after_second = state.stats();
1667
1668 assert!(
1669 stats_after_second.hits > stats_after_first.hits,
1670 "Second render should be a cache hit"
1671 );
1672 assert!(state.dirty_rects().is_empty(), "No dirty rects on hit");
1673 }
1674
1675 #[test]
1676 fn dirty_rect_only_changes() {
1677 let mut help = Help::new()
1678 .with_mode(HelpMode::Full)
1679 .entry("q", "quit")
1680 .entry("w", "write")
1681 .entry("e", "edit");
1682
1683 let mut state = HelpRenderState::default();
1684 let mut pool = GraphemePool::new();
1685 let mut frame = Frame::new(40, 3, &mut pool);
1686 let area = Rect::new(0, 0, 40, 3);
1687
1688 StatefulWidget::render(&help, area, &mut frame, &mut state);
1689
1690 help.entries[1].desc.clear();
1691 help.entries[1].desc.push_str("save");
1692
1693 StatefulWidget::render(&help, area, &mut frame, &mut state);
1694 let dirty = state.take_dirty_rects();
1695
1696 assert_eq!(dirty.len(), 1, "Only one row should be dirty");
1697 assert_eq!(dirty[0].y, 1, "Second entry row should be dirty");
1698 }
1699
1700 proptest! {
1701 #[test]
1702 fn prop_cache_hits_on_stable_entries(entries in prop::collection::vec(
1703 (string_regex("[a-z]{1,6}").unwrap(), string_regex("[a-z]{1,10}").unwrap()),
1704 1..6
1705 )) {
1706 let mut help = Help::new();
1707 for (key, desc) in entries {
1708 help = help.entry(key, desc);
1709 }
1710 let mut state = HelpRenderState::default();
1711 let mut pool = GraphemePool::new();
1712 let mut frame = Frame::new(80, 1, &mut pool);
1713 let area = Rect::new(0, 0, 80, 1);
1714
1715 StatefulWidget::render(&help, area, &mut frame, &mut state);
1716 let stats_after_first = state.stats();
1717 StatefulWidget::render(&help, area, &mut frame, &mut state);
1718 let stats_after_second = state.stats();
1719
1720 prop_assert!(stats_after_second.hits > stats_after_first.hits);
1721 prop_assert!(state.dirty_rects().is_empty());
1722 }
1723 }
1724
1725 #[test]
1726 fn perf_micro_hint_update() {
1727 let mut help = Help::new()
1728 .with_mode(HelpMode::Short)
1729 .entry("^T", "Theme")
1730 .entry("^C", "Quit")
1731 .entry("?", "Help")
1732 .entry("F12", "Debug");
1733
1734 let mut state = HelpRenderState::default();
1735 let mut pool = GraphemePool::new();
1736 let mut frame = Frame::new(120, 1, &mut pool);
1737 let area = Rect::new(0, 0, 120, 1);
1738
1739 StatefulWidget::render(&help, area, &mut frame, &mut state);
1740
1741 let iterations = 200u32;
1742 let mut times_us = Vec::with_capacity(iterations as usize);
1743 for i in 0..iterations {
1744 let label = if i % 2 == 0 { "Close" } else { "Open" };
1745 help.entries[1].desc.clear();
1746 help.entries[1].desc.push_str(label);
1747
1748 let start = Instant::now();
1749 StatefulWidget::render(&help, area, &mut frame, &mut state);
1750 let elapsed = start.elapsed();
1751 times_us.push(elapsed.as_micros() as u64);
1752 }
1753
1754 times_us.sort();
1755 let len = times_us.len();
1756 let p50 = times_us[len / 2];
1757 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1758 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1759 let updates_per_sec = 1_000_000u64.checked_div(p50).unwrap_or(0);
1760
1761 eprintln!(
1762 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"case\":\"help_hint_update\",\"iterations\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"updates_per_sec\":{},\"hits\":{},\"misses\":{},\"dirty_updates\":{}}}",
1763 iterations,
1764 p50,
1765 p95,
1766 p99,
1767 updates_per_sec,
1768 state.stats().hits,
1769 state.stats().misses,
1770 state.stats().dirty_updates
1771 );
1772
1773 assert!(p95 <= 2000, "p95 too slow: {p95}us");
1775 }
1776
1777 #[test]
1780 fn help_category_default_is_general() {
1781 assert_eq!(HelpCategory::default(), HelpCategory::General);
1782 }
1783
1784 #[test]
1785 fn help_category_labels() {
1786 assert_eq!(HelpCategory::General.label(), "General");
1787 assert_eq!(HelpCategory::Navigation.label(), "Navigation");
1788 assert_eq!(HelpCategory::Editing.label(), "Editing");
1789 assert_eq!(HelpCategory::File.label(), "File");
1790 assert_eq!(HelpCategory::View.label(), "View");
1791 assert_eq!(HelpCategory::Global.label(), "Global");
1792 assert_eq!(
1793 HelpCategory::Custom("My Section".into()).label(),
1794 "My Section"
1795 );
1796 }
1797
1798 #[test]
1799 fn help_entry_with_category() {
1800 let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1801 assert_eq!(entry.category, HelpCategory::Navigation);
1802 }
1803
1804 #[test]
1805 fn help_entry_default_category_is_general() {
1806 let entry = HelpEntry::new("q", "quit");
1807 assert_eq!(entry.category, HelpCategory::General);
1808 }
1809
1810 #[test]
1811 fn category_changes_entry_hash() {
1812 let a = HelpEntry::new("q", "quit");
1813 let b = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1814 assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
1815 }
1816
1817 #[test]
1820 fn key_format_default_is_plain() {
1821 assert_eq!(KeyFormat::default(), KeyFormat::Plain);
1822 }
1823
1824 #[test]
1827 fn keybinding_hints_new_is_empty() {
1828 let hints = KeybindingHints::new();
1829 assert!(hints.global_entries().is_empty());
1830 assert!(hints.contextual_entries().is_empty());
1831 assert_eq!(hints.mode(), HelpMode::Short);
1832 assert_eq!(hints.key_format(), KeyFormat::Plain);
1833 }
1834
1835 #[test]
1836 fn keybinding_hints_default() {
1837 let hints = KeybindingHints::default();
1838 assert!(hints.global_entries().is_empty());
1839 }
1840
1841 #[test]
1842 fn keybinding_hints_global_entry() {
1843 let hints = KeybindingHints::new()
1844 .global_entry("q", "quit")
1845 .global_entry("^s", "save");
1846 assert_eq!(hints.global_entries().len(), 2);
1847 assert_eq!(hints.global_entries()[0].key, "q");
1848 assert_eq!(hints.global_entries()[0].category, HelpCategory::Global);
1849 }
1850
1851 #[test]
1852 fn keybinding_hints_categorized_entries() {
1853 let hints = KeybindingHints::new()
1854 .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1855 .global_entry_categorized("q", "quit", HelpCategory::Global);
1856 assert_eq!(hints.global_entries()[0].category, HelpCategory::Navigation);
1857 assert_eq!(hints.global_entries()[1].category, HelpCategory::Global);
1858 }
1859
1860 #[test]
1861 fn keybinding_hints_contextual_entry() {
1862 let hints = KeybindingHints::new()
1863 .contextual_entry("^s", "save")
1864 .contextual_entry_categorized("^f", "find", HelpCategory::Editing);
1865 assert_eq!(hints.contextual_entries().len(), 2);
1866 assert_eq!(
1867 hints.contextual_entries()[0].category,
1868 HelpCategory::General
1869 );
1870 assert_eq!(
1871 hints.contextual_entries()[1].category,
1872 HelpCategory::Editing
1873 );
1874 }
1875
1876 #[test]
1877 fn keybinding_hints_with_prebuilt_entries() {
1878 let global = HelpEntry::new("q", "quit").with_category(HelpCategory::Global);
1879 let ctx = HelpEntry::new("^s", "save").with_category(HelpCategory::File);
1880 let hints = KeybindingHints::new()
1881 .with_global_entry(global)
1882 .with_contextual_entry(ctx);
1883 assert_eq!(hints.global_entries().len(), 1);
1884 assert_eq!(hints.contextual_entries().len(), 1);
1885 }
1886
1887 #[test]
1888 fn keybinding_hints_toggle_mode() {
1889 let mut hints = KeybindingHints::new();
1890 assert_eq!(hints.mode(), HelpMode::Short);
1891 hints.toggle_mode();
1892 assert_eq!(hints.mode(), HelpMode::Full);
1893 hints.toggle_mode();
1894 assert_eq!(hints.mode(), HelpMode::Short);
1895 }
1896
1897 #[test]
1898 fn keybinding_hints_set_show_context() {
1899 let mut hints = KeybindingHints::new()
1900 .global_entry("q", "quit")
1901 .contextual_entry("^s", "save");
1902
1903 let visible = hints.visible_entries();
1905 assert_eq!(visible.len(), 1);
1906
1907 hints.set_show_context(true);
1909 let visible = hints.visible_entries();
1910 assert_eq!(visible.len(), 2);
1911 }
1912
1913 #[test]
1914 fn keybinding_hints_bracketed_format() {
1915 let hints = KeybindingHints::new()
1916 .with_key_format(KeyFormat::Bracketed)
1917 .global_entry("q", "quit");
1918 let visible = hints.visible_entries();
1919 assert_eq!(visible[0].key, "[q]");
1920 }
1921
1922 #[test]
1923 fn keybinding_hints_plain_format() {
1924 let hints = KeybindingHints::new()
1925 .with_key_format(KeyFormat::Plain)
1926 .global_entry("q", "quit");
1927 let visible = hints.visible_entries();
1928 assert_eq!(visible[0].key, "q");
1929 }
1930
1931 #[test]
1932 fn keybinding_hints_disabled_entries_hidden() {
1933 let hints = KeybindingHints::new()
1934 .with_global_entry(HelpEntry::new("a", "shown"))
1935 .with_global_entry(HelpEntry::new("b", "hidden").with_enabled(false));
1936 let visible = hints.visible_entries();
1937 assert_eq!(visible.len(), 1);
1938 assert_eq!(visible[0].key, "a");
1939 }
1940
1941 #[test]
1942 fn keybinding_hints_grouped_entries() {
1943 let entries = vec![
1944 HelpEntry::new("Tab", "next").with_category(HelpCategory::Navigation),
1945 HelpEntry::new("q", "quit").with_category(HelpCategory::Global),
1946 HelpEntry::new("S-Tab", "prev").with_category(HelpCategory::Navigation),
1947 ];
1948 let groups = KeybindingHints::grouped_entries(&entries);
1949 assert_eq!(groups.len(), 2);
1950 assert_eq!(*groups[0].0, HelpCategory::Navigation);
1951 assert_eq!(groups[0].1.len(), 2);
1952 assert_eq!(*groups[1].0, HelpCategory::Global);
1953 assert_eq!(groups[1].1.len(), 1);
1954 }
1955
1956 #[test]
1957 fn keybinding_hints_render_short() {
1958 let hints = KeybindingHints::new()
1959 .global_entry("q", "quit")
1960 .global_entry("^s", "save");
1961
1962 let mut pool = GraphemePool::new();
1963 let mut frame = Frame::new(40, 1, &mut pool);
1964 let area = Rect::new(0, 0, 40, 1);
1965 Widget::render(&hints, area, &mut frame);
1966
1967 let cell = frame.buffer.get(0, 0).unwrap();
1969 assert_eq!(cell.content.as_char(), Some('q'));
1970 }
1971
1972 #[test]
1973 fn keybinding_hints_render_short_bracketed() {
1974 let hints = KeybindingHints::new()
1975 .with_key_format(KeyFormat::Bracketed)
1976 .global_entry("q", "quit");
1977
1978 let mut pool = GraphemePool::new();
1979 let mut frame = Frame::new(40, 1, &mut pool);
1980 let area = Rect::new(0, 0, 40, 1);
1981 Widget::render(&hints, area, &mut frame);
1982
1983 let cell = frame.buffer.get(0, 0).unwrap();
1985 assert_eq!(cell.content.as_char(), Some('['));
1986 }
1987
1988 #[test]
1989 fn keybinding_hints_render_full_grouped() {
1990 let hints = KeybindingHints::new()
1991 .with_mode(HelpMode::Full)
1992 .with_show_categories(true)
1993 .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1994 .global_entry_categorized("q", "quit", HelpCategory::Global);
1995
1996 let mut pool = GraphemePool::new();
1997 let mut frame = Frame::new(40, 10, &mut pool);
1998 let area = Rect::new(0, 0, 40, 10);
1999 Widget::render(&hints, area, &mut frame);
2000
2001 let mut row0 = String::new();
2003 for x in 0..40u16 {
2004 if let Some(cell) = frame.buffer.get(x, 0)
2005 && let Some(ch) = cell.content.as_char()
2006 {
2007 row0.push(ch);
2008 }
2009 }
2010 assert!(
2011 row0.contains("Navigation"),
2012 "First row should be Navigation header: {row0}"
2013 );
2014 }
2015
2016 #[test]
2017 fn keybinding_hints_render_full_no_categories() {
2018 let hints = KeybindingHints::new()
2019 .with_mode(HelpMode::Full)
2020 .with_show_categories(false)
2021 .global_entry("q", "quit")
2022 .global_entry("^s", "save");
2023
2024 let mut pool = GraphemePool::new();
2025 let mut frame = Frame::new(40, 5, &mut pool);
2026 let area = Rect::new(0, 0, 40, 5);
2027 Widget::render(&hints, area, &mut frame);
2029 }
2030
2031 #[test]
2032 fn keybinding_hints_render_empty() {
2033 let hints = KeybindingHints::new();
2034
2035 let mut pool = GraphemePool::new();
2036 let mut frame = Frame::new(20, 1, &mut pool);
2037 let area = Rect::new(0, 0, 20, 1);
2038 Widget::render(&hints, area, &mut frame);
2040 }
2041
2042 #[test]
2043 fn keybinding_hints_render_zero_area() {
2044 let hints = KeybindingHints::new().global_entry("q", "quit");
2045
2046 let mut pool = GraphemePool::new();
2047 let mut frame = Frame::new(20, 1, &mut pool);
2048 let area = Rect::new(0, 0, 0, 0);
2049 Widget::render(&hints, area, &mut frame);
2051 }
2052
2053 #[test]
2054 fn keybinding_hints_is_not_essential() {
2055 let hints = KeybindingHints::new();
2056 assert!(!hints.is_essential());
2057 }
2058
2059 proptest! {
2062 #[test]
2063 fn prop_visible_entries_count(
2064 n_global in 0..5usize,
2065 n_ctx in 0..5usize,
2066 show_ctx in proptest::bool::ANY,
2067 ) {
2068 let mut hints = KeybindingHints::new().with_show_context(show_ctx);
2069 for i in 0..n_global {
2070 hints = hints.global_entry(format!("g{i}"), format!("global {i}"));
2071 }
2072 for i in 0..n_ctx {
2073 hints = hints.contextual_entry(format!("c{i}"), format!("ctx {i}"));
2074 }
2075 let visible = hints.visible_entries();
2076 let expected = if show_ctx { n_global + n_ctx } else { n_global };
2077 prop_assert_eq!(visible.len(), expected);
2078 }
2079
2080 #[test]
2081 fn prop_bracketed_keys_wrapped(
2082 keys in prop::collection::vec(string_regex("[a-z]{1,4}").unwrap(), 1..5),
2083 ) {
2084 let mut hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
2085 for key in &keys {
2086 hints = hints.global_entry(key.clone(), "action");
2087 }
2088 let visible = hints.visible_entries();
2089 for entry in &visible {
2090 prop_assert!(entry.key.starts_with('['), "Key should start with [: {}", entry.key);
2091 prop_assert!(entry.key.ends_with(']'), "Key should end with ]: {}", entry.key);
2092 }
2093 }
2094
2095 #[test]
2096 fn prop_grouped_preserves_count(
2097 entries in prop::collection::vec(
2098 (string_regex("[a-z]{1,4}").unwrap(), 0..3u8),
2099 1..8
2100 ),
2101 ) {
2102 let help_entries: Vec<HelpEntry> = entries.into_iter().map(|(key, cat_idx)| {
2103 let cat = match cat_idx {
2104 0 => HelpCategory::Navigation,
2105 1 => HelpCategory::Editing,
2106 _ => HelpCategory::Global,
2107 };
2108 HelpEntry::new(key, "action").with_category(cat)
2109 }).collect();
2110
2111 let total = help_entries.len();
2112 let groups = KeybindingHints::grouped_entries(&help_entries);
2113 let grouped_total: usize = groups.iter().map(|(_, v)| v.len()).sum();
2114 prop_assert_eq!(total, grouped_total, "Grouping should preserve total entry count");
2115 }
2116
2117 #[test]
2118 fn prop_render_no_panic(
2119 n_global in 0..5usize,
2120 n_ctx in 0..5usize,
2121 width in 1..80u16,
2122 height in 1..20u16,
2123 show_ctx in proptest::bool::ANY,
2124 use_full in proptest::bool::ANY,
2125 use_brackets in proptest::bool::ANY,
2126 show_cats in proptest::bool::ANY,
2127 ) {
2128 let mode = if use_full { HelpMode::Full } else { HelpMode::Short };
2129 let fmt = if use_brackets { KeyFormat::Bracketed } else { KeyFormat::Plain };
2130 let mut hints = KeybindingHints::new()
2131 .with_mode(mode)
2132 .with_key_format(fmt)
2133 .with_show_context(show_ctx)
2134 .with_show_categories(show_cats);
2135
2136 for i in 0..n_global {
2137 hints = hints.global_entry(format!("g{i}"), format!("global action {i}"));
2138 }
2139 for i in 0..n_ctx {
2140 hints = hints.contextual_entry(format!("c{i}"), format!("ctx action {i}"));
2141 }
2142
2143 let mut pool = GraphemePool::new();
2144 let mut frame = Frame::new(width, height, &mut pool);
2145 let area = Rect::new(0, 0, width, height);
2146 Widget::render(&hints, area, &mut frame);
2147 }
2149 }
2150
2151 #[test]
2158 fn help_category_custom_empty_string() {
2159 let cat = HelpCategory::Custom(String::new());
2160 assert_eq!(cat.label(), "");
2161 }
2162
2163 #[test]
2164 fn help_category_custom_eq() {
2165 let a = HelpCategory::Custom("Foo".into());
2166 let b = HelpCategory::Custom("Foo".into());
2167 let c = HelpCategory::Custom("Bar".into());
2168 assert_eq!(a, b);
2169 assert_ne!(a, c);
2170 }
2171
2172 #[test]
2173 fn help_category_clone() {
2174 let cat = HelpCategory::Navigation;
2175 let cloned = cat.clone();
2176 assert_eq!(cat, cloned);
2177 }
2178
2179 #[test]
2180 fn help_category_hash_consistency() {
2181 use std::collections::hash_map::DefaultHasher;
2182 let mut h1 = DefaultHasher::new();
2183 let mut h2 = DefaultHasher::new();
2184 HelpCategory::File.hash(&mut h1);
2185 HelpCategory::File.hash(&mut h2);
2186 assert_eq!(h1.finish(), h2.finish());
2187 }
2188
2189 #[test]
2190 fn help_category_debug_format() {
2191 let dbg = format!("{:?}", HelpCategory::General);
2192 assert!(dbg.contains("General"));
2193 let dbg_custom = format!("{:?}", HelpCategory::Custom("X".into()));
2194 assert!(dbg_custom.contains("Custom"));
2195 }
2196
2197 #[test]
2200 fn help_entry_empty_key_and_desc() {
2201 let entry = HelpEntry::new("", "");
2202 assert!(entry.key.is_empty());
2203 assert!(entry.desc.is_empty());
2204 assert!(entry.enabled);
2205 }
2206
2207 #[test]
2208 fn help_entry_clone() {
2209 let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
2210 let cloned = entry.clone();
2211 assert_eq!(entry, cloned);
2212 }
2213
2214 #[test]
2215 fn help_entry_debug_format() {
2216 let entry = HelpEntry::new("^s", "save");
2217 let dbg = format!("{:?}", entry);
2218 assert!(dbg.contains("HelpEntry"));
2219 assert!(dbg.contains("save"));
2220 }
2221
2222 #[test]
2225 fn help_mode_default_is_short() {
2226 assert_eq!(HelpMode::default(), HelpMode::Short);
2227 }
2228
2229 #[test]
2230 fn help_mode_eq_and_hash() {
2231 use std::collections::hash_map::DefaultHasher;
2232 assert_eq!(HelpMode::Short, HelpMode::Short);
2233 assert_ne!(HelpMode::Short, HelpMode::Full);
2234 let mut h = DefaultHasher::new();
2235 HelpMode::Full.hash(&mut h);
2236 }
2238
2239 #[test]
2240 fn help_mode_copy() {
2241 let m = HelpMode::Full;
2242 let m2 = m; assert_eq!(m, m2);
2244 }
2245
2246 #[test]
2249 fn render_short_all_disabled() {
2250 let help = Help::new()
2251 .with_entry(HelpEntry::new("a", "first").with_enabled(false))
2252 .with_entry(HelpEntry::new("b", "second").with_enabled(false));
2253
2254 let mut pool = GraphemePool::new();
2255 let mut frame = Frame::new(40, 1, &mut pool);
2256 let area = Rect::new(0, 0, 40, 1);
2257 Widget::render(&help, area, &mut frame);
2258 let cell = frame.buffer.get(0, 0).unwrap();
2260 assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
2261 }
2262
2263 #[test]
2264 fn render_short_empty_key_desc_entries_skipped() {
2265 let help = Help::new()
2266 .with_entry(HelpEntry::new("", ""))
2267 .entry("q", "quit");
2268
2269 let mut pool = GraphemePool::new();
2270 let mut frame = Frame::new(40, 1, &mut pool);
2271 let area = Rect::new(0, 0, 40, 1);
2272 Widget::render(&help, area, &mut frame);
2273 let mut found_q = false;
2276 for x in 0..40 {
2277 if let Some(cell) = frame.buffer.get(x, 0)
2278 && cell.content.as_char() == Some('q')
2279 {
2280 found_q = true;
2281 break;
2282 }
2283 }
2284 assert!(found_q, "'q' should appear in the rendered row");
2285 }
2286
2287 #[test]
2288 fn render_short_width_one() {
2289 let help = Help::new().entry("q", "quit");
2290
2291 let mut pool = GraphemePool::new();
2292 let mut frame = Frame::new(1, 1, &mut pool);
2293 let area = Rect::new(0, 0, 1, 1);
2294 Widget::render(&help, area, &mut frame);
2295 }
2297
2298 #[test]
2299 fn render_full_width_one() {
2300 let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
2301
2302 let mut pool = GraphemePool::new();
2303 let mut frame = Frame::new(1, 5, &mut pool);
2304 let area = Rect::new(0, 0, 1, 5);
2305 Widget::render(&help, area, &mut frame);
2306 }
2308
2309 #[test]
2310 fn render_full_height_one() {
2311 let help = Help::new()
2312 .with_mode(HelpMode::Full)
2313 .entry("a", "first")
2314 .entry("b", "second")
2315 .entry("c", "third");
2316
2317 let mut pool = GraphemePool::new();
2318 let mut frame = Frame::new(40, 1, &mut pool);
2319 let area = Rect::new(0, 0, 40, 1);
2320 Widget::render(&help, area, &mut frame);
2321 }
2323
2324 #[test]
2325 fn render_short_single_entry_exact_fit() {
2326 let help = Help::new().entry("q", "quit");
2328
2329 let mut pool = GraphemePool::new();
2330 let mut frame = Frame::new(6, 1, &mut pool);
2331 let area = Rect::new(0, 0, 6, 1);
2332 Widget::render(&help, area, &mut frame);
2333 let cell = frame.buffer.get(0, 0).unwrap();
2334 assert_eq!(cell.content.as_char(), Some('q'));
2335 }
2336
2337 #[test]
2338 fn render_short_empty_separator() {
2339 let help = Help::new()
2340 .with_separator("")
2341 .entry("a", "x")
2342 .entry("b", "y");
2343
2344 let mut pool = GraphemePool::new();
2345 let mut frame = Frame::new(40, 1, &mut pool);
2346 let area = Rect::new(0, 0, 40, 1);
2347 Widget::render(&help, area, &mut frame);
2348 let cell = frame.buffer.get(0, 0).unwrap();
2350 assert_eq!(cell.content.as_char(), Some('a'));
2351 }
2352
2353 #[test]
2356 fn help_with_mode_full() {
2357 let help = Help::new().with_mode(HelpMode::Full);
2358 assert_eq!(help.mode(), HelpMode::Full);
2359 }
2360
2361 #[test]
2362 fn help_clone() {
2363 let help = Help::new()
2364 .entry("q", "quit")
2365 .with_separator(" | ")
2366 .with_ellipsis("...");
2367 let cloned = help.clone();
2368 assert_eq!(cloned.entries().len(), 1);
2369 assert_eq!(cloned.separator, " | ");
2370 assert_eq!(cloned.ellipsis, "...");
2371 }
2372
2373 #[test]
2374 fn help_debug_format() {
2375 let help = Help::new().entry("q", "quit");
2376 let dbg = format!("{:?}", help);
2377 assert!(dbg.contains("Help"));
2378 }
2379
2380 #[test]
2383 fn help_render_state_default() {
2384 let state = HelpRenderState::default();
2385 assert!(state.cache.is_none());
2386 assert!(state.dirty_rects().is_empty());
2387 assert_eq!(state.stats().hits, 0);
2388 assert_eq!(state.stats().misses, 0);
2389 }
2390
2391 #[test]
2392 fn help_render_state_clear_dirty_rects() {
2393 let mut state = HelpRenderState::default();
2394 state.dirty_rects.push(Rect::new(0, 0, 10, 1));
2395 assert_eq!(state.dirty_rects().len(), 1);
2396 state.clear_dirty_rects();
2397 assert!(state.dirty_rects().is_empty());
2398 }
2399
2400 #[test]
2401 fn help_render_state_take_dirty_rects() {
2402 let mut state = HelpRenderState::default();
2403 state.dirty_rects.push(Rect::new(0, 0, 5, 1));
2404 state.dirty_rects.push(Rect::new(0, 1, 5, 1));
2405 let taken = state.take_dirty_rects();
2406 assert_eq!(taken.len(), 2);
2407 assert!(state.dirty_rects().is_empty()); }
2409
2410 #[test]
2411 fn help_render_state_reset_stats() {
2412 let mut state = HelpRenderState::default();
2413 state.stats.hits = 42;
2414 state.stats.misses = 7;
2415 state.stats.dirty_updates = 3;
2416 state.stats.layout_rebuilds = 2;
2417 state.reset_stats();
2418 assert_eq!(state.stats(), HelpCacheStats::default());
2419 }
2420
2421 #[test]
2422 fn help_cache_stats_default() {
2423 let stats = HelpCacheStats::default();
2424 assert_eq!(stats.hits, 0);
2425 assert_eq!(stats.misses, 0);
2426 assert_eq!(stats.dirty_updates, 0);
2427 assert_eq!(stats.layout_rebuilds, 0);
2428 }
2429
2430 #[test]
2431 fn help_cache_stats_clone_eq() {
2432 let a = HelpCacheStats {
2433 hits: 5,
2434 misses: 2,
2435 dirty_updates: 1,
2436 layout_rebuilds: 3,
2437 };
2438 let b = a;
2439 assert_eq!(a, b);
2440 }
2441
2442 #[test]
2443 fn stateful_render_empty_area_clears_cache() {
2444 let help = Help::new().entry("q", "quit");
2445 let mut state = HelpRenderState::default();
2446 let mut pool = GraphemePool::new();
2447 let mut frame = Frame::new(40, 1, &mut pool);
2448 let area = Rect::new(0, 0, 40, 1);
2449
2450 StatefulWidget::render(&help, area, &mut frame, &mut state);
2452 assert!(state.cache.is_some());
2453
2454 let empty = Rect::new(0, 0, 0, 0);
2456 StatefulWidget::render(&help, empty, &mut frame, &mut state);
2457 assert!(state.cache.is_none());
2458 }
2459
2460 #[test]
2461 fn stateful_render_cache_miss_on_area_change() {
2462 let help = Help::new().entry("q", "quit").entry("^s", "save");
2463 let mut state = HelpRenderState::default();
2464 let mut pool = GraphemePool::new();
2465 let mut frame = Frame::new(80, 5, &mut pool);
2466
2467 StatefulWidget::render(&help, Rect::new(0, 0, 40, 1), &mut frame, &mut state);
2468 let misses1 = state.stats().misses;
2469
2470 StatefulWidget::render(&help, Rect::new(0, 0, 60, 1), &mut frame, &mut state);
2471 let misses2 = state.stats().misses;
2472
2473 assert!(misses2 > misses1, "Area change should cause cache miss");
2474 }
2475
2476 #[test]
2477 fn stateful_render_cache_miss_on_mode_change() {
2478 let mut help = Help::new().entry("q", "quit");
2479 let mut state = HelpRenderState::default();
2480 let mut pool = GraphemePool::new();
2481 let mut frame = Frame::new(40, 5, &mut pool);
2482 let area = Rect::new(0, 0, 40, 5);
2483
2484 StatefulWidget::render(&help, area, &mut frame, &mut state);
2485 let misses1 = state.stats().misses;
2486
2487 help.toggle_mode();
2488 StatefulWidget::render(&help, area, &mut frame, &mut state);
2489 let misses2 = state.stats().misses;
2490
2491 assert!(misses2 > misses1, "Mode change should cause cache miss");
2492 }
2493
2494 #[test]
2495 fn stateful_render_layout_rebuild_on_enabled_count_change() {
2496 let mut help = Help::new()
2497 .entry("q", "quit")
2498 .entry("^s", "save")
2499 .entry("^x", "exit");
2500 let mut state = HelpRenderState::default();
2501 let mut pool = GraphemePool::new();
2502 let mut frame = Frame::new(80, 1, &mut pool);
2503 let area = Rect::new(0, 0, 80, 1);
2504
2505 StatefulWidget::render(&help, area, &mut frame, &mut state);
2506 let rebuilds1 = state.stats().layout_rebuilds;
2507
2508 help.entries[1].enabled = false;
2510 StatefulWidget::render(&help, area, &mut frame, &mut state);
2511 let rebuilds2 = state.stats().layout_rebuilds;
2512
2513 assert!(
2514 rebuilds2 > rebuilds1,
2515 "Enabled count change should trigger layout rebuild"
2516 );
2517 }
2518
2519 #[test]
2522 fn key_format_eq_and_hash() {
2523 use std::collections::hash_map::DefaultHasher;
2524 assert_eq!(KeyFormat::Plain, KeyFormat::Plain);
2525 assert_ne!(KeyFormat::Plain, KeyFormat::Bracketed);
2526 let mut h = DefaultHasher::new();
2527 KeyFormat::Bracketed.hash(&mut h);
2528 }
2529
2530 #[test]
2531 fn key_format_copy() {
2532 let f = KeyFormat::Bracketed;
2533 let f2 = f;
2534 assert_eq!(f, f2);
2535 }
2536
2537 #[test]
2538 fn key_format_debug() {
2539 let dbg = format!("{:?}", KeyFormat::Bracketed);
2540 assert!(dbg.contains("Bracketed"));
2541 }
2542
2543 #[test]
2546 fn keybinding_hints_clone() {
2547 let hints = KeybindingHints::new()
2548 .global_entry("q", "quit")
2549 .contextual_entry("^s", "save");
2550 let cloned = hints.clone();
2551 assert_eq!(cloned.global_entries().len(), 1);
2552 assert_eq!(cloned.contextual_entries().len(), 1);
2553 }
2554
2555 #[test]
2556 fn keybinding_hints_debug() {
2557 let hints = KeybindingHints::new().global_entry("q", "quit");
2558 let dbg = format!("{:?}", hints);
2559 assert!(dbg.contains("KeybindingHints"));
2560 }
2561
2562 #[test]
2563 fn keybinding_hints_with_separator() {
2564 let hints = KeybindingHints::new().with_separator(" | ");
2565 assert_eq!(hints.separator, " | ");
2566 }
2567
2568 #[test]
2569 fn keybinding_hints_with_styles() {
2570 let hints = KeybindingHints::new()
2571 .with_key_style(Style::new().bold())
2572 .with_desc_style(Style::default())
2573 .with_separator_style(Style::default())
2574 .with_category_style(Style::new().underline());
2575 assert_eq!(hints.mode(), HelpMode::Short);
2577 }
2578
2579 #[test]
2580 fn keybinding_hints_visible_entries_disabled_contextual() {
2581 let hints = KeybindingHints::new()
2582 .with_show_context(true)
2583 .global_entry("q", "quit")
2584 .with_contextual_entry(HelpEntry::new("^s", "save").with_enabled(false));
2585 let visible = hints.visible_entries();
2586 assert_eq!(visible.len(), 1);
2588 assert_eq!(visible[0].desc, "quit");
2589 }
2590
2591 #[test]
2592 fn keybinding_hints_empty_global_nonempty_ctx_hidden() {
2593 let hints = KeybindingHints::new()
2594 .contextual_entry("^s", "save")
2595 .contextual_entry("^f", "find");
2596 let visible = hints.visible_entries();
2598 assert!(visible.is_empty());
2599 }
2600
2601 #[test]
2602 fn keybinding_hints_render_full_grouped_height_limit() {
2603 let hints = KeybindingHints::new()
2604 .with_mode(HelpMode::Full)
2605 .with_show_categories(true)
2606 .global_entry_categorized("a", "first", HelpCategory::Navigation)
2607 .global_entry_categorized("b", "second", HelpCategory::Navigation)
2608 .global_entry_categorized("c", "third", HelpCategory::Navigation)
2609 .global_entry_categorized("d", "fourth", HelpCategory::Global)
2610 .global_entry_categorized("e", "fifth", HelpCategory::Global);
2611
2612 let mut pool = GraphemePool::new();
2613 let mut frame = Frame::new(40, 3, &mut pool);
2615 let area = Rect::new(0, 0, 40, 3);
2616 Widget::render(&hints, area, &mut frame);
2617 }
2619
2620 #[test]
2621 fn keybinding_hints_render_empty_area() {
2622 let hints = KeybindingHints::new().global_entry("q", "quit");
2623 let mut pool = GraphemePool::new();
2624 let mut frame = Frame::new(1, 1, &mut pool);
2625 Widget::render(&hints, Rect::new(0, 0, 0, 0), &mut frame);
2626 }
2628
2629 #[test]
2632 fn entry_hash_differs_for_different_keys() {
2633 let a = HelpEntry::new("q", "quit");
2634 let b = HelpEntry::new("x", "quit");
2635 assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2636 }
2637
2638 #[test]
2639 fn entry_hash_differs_for_different_descs() {
2640 let a = HelpEntry::new("q", "quit");
2641 let b = HelpEntry::new("q", "exit");
2642 assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2643 }
2644
2645 #[test]
2646 fn entry_hash_differs_for_enabled_flag() {
2647 let a = HelpEntry::new("q", "quit");
2648 let b = HelpEntry::new("q", "quit").with_enabled(false);
2649 assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2650 }
2651
2652 #[test]
2653 fn entry_hash_same_for_equal_entries() {
2654 let a = HelpEntry::new("q", "quit");
2655 let b = HelpEntry::new("q", "quit");
2656 assert_eq!(Help::entry_hash(&a), Help::entry_hash(&b));
2657 }
2658
2659 #[test]
2664 fn help_category_custom_general_not_eq_general() {
2665 assert_ne!(
2667 HelpCategory::Custom("General".into()),
2668 HelpCategory::General
2669 );
2670 }
2671
2672 #[test]
2673 fn help_category_all_variants_distinct() {
2674 let variants: Vec<HelpCategory> = vec![
2675 HelpCategory::General,
2676 HelpCategory::Navigation,
2677 HelpCategory::Editing,
2678 HelpCategory::File,
2679 HelpCategory::View,
2680 HelpCategory::Global,
2681 HelpCategory::Custom("X".into()),
2682 ];
2683 for (i, a) in variants.iter().enumerate() {
2684 for (j, b) in variants.iter().enumerate() {
2685 if i != j {
2686 assert_ne!(a, b, "Variant {i} should differ from variant {j}");
2687 }
2688 }
2689 }
2690 }
2691
2692 #[test]
2695 fn help_entry_hash_differs_by_category() {
2696 let a = HelpEntry::new("q", "quit");
2697 let b = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
2698 assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2699 }
2700
2701 #[test]
2702 fn help_entry_only_key_no_desc_renders() {
2703 let help = Help::new().with_entry(HelpEntry::new("q", ""));
2704 let mut pool = GraphemePool::new();
2705 let mut frame = Frame::new(20, 1, &mut pool);
2706 let area = Rect::new(0, 0, 20, 1);
2707 Widget::render(&help, area, &mut frame);
2708 let cell = frame.buffer.get(0, 0).unwrap();
2710 assert_eq!(cell.content.as_char(), Some('q'));
2711 }
2712
2713 #[test]
2714 fn help_entry_only_desc_no_key_renders() {
2715 let help = Help::new().with_entry(HelpEntry::new("", "quit"));
2716 let mut pool = GraphemePool::new();
2717 let mut frame = Frame::new(20, 1, &mut pool);
2718 let area = Rect::new(0, 0, 20, 1);
2719 Widget::render(&help, area, &mut frame);
2720 let cell = frame.buffer.get(1, 0).unwrap();
2722 assert_eq!(cell.content.as_char(), Some('q'));
2723 }
2724
2725 #[test]
2726 fn help_entry_unicode_key_and_desc() {
2727 let help = Help::new().with_entry(HelpEntry::new("\u{2191}", "up arrow"));
2728 let mut pool = GraphemePool::new();
2729 let mut frame = Frame::new(20, 1, &mut pool);
2730 let area = Rect::new(0, 0, 20, 1);
2731 Widget::render(&help, area, &mut frame);
2732 }
2733
2734 #[test]
2735 fn help_entry_chained_builder_overrides() {
2736 let entry = HelpEntry::new("q", "quit")
2737 .with_enabled(false)
2738 .with_category(HelpCategory::File)
2739 .with_enabled(true)
2740 .with_category(HelpCategory::View);
2741 assert!(entry.enabled);
2742 assert_eq!(entry.category, HelpCategory::View);
2743 }
2744
2745 #[test]
2748 fn render_short_area_offset() {
2749 let help = Help::new().entry("x", "action");
2750 let mut pool = GraphemePool::new();
2751 let mut frame = Frame::new(40, 5, &mut pool);
2752 let area = Rect::new(5, 2, 20, 1);
2753 Widget::render(&help, area, &mut frame);
2754 let cell = frame.buffer.get(5, 2).unwrap();
2755 assert_eq!(cell.content.as_char(), Some('x'));
2756 let cell_origin = frame.buffer.get(0, 0).unwrap();
2758 assert!(cell_origin.content.is_empty() || cell_origin.content.as_char() == Some(' '));
2759 }
2760
2761 #[test]
2762 fn render_full_area_offset() {
2763 let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
2764 let mut pool = GraphemePool::new();
2765 let mut frame = Frame::new(40, 5, &mut pool);
2766 let area = Rect::new(3, 1, 20, 3);
2767 Widget::render(&help, area, &mut frame);
2768 let cell = frame.buffer.get(3, 1).unwrap();
2769 assert_eq!(cell.content.as_char(), Some('q'));
2770 }
2771
2772 #[test]
2775 fn render_full_all_disabled() {
2776 let help = Help::new()
2777 .with_mode(HelpMode::Full)
2778 .with_entry(HelpEntry::new("a", "first").with_enabled(false))
2779 .with_entry(HelpEntry::new("b", "second").with_enabled(false));
2780 let mut pool = GraphemePool::new();
2781 let mut frame = Frame::new(30, 3, &mut pool);
2782 let area = Rect::new(0, 0, 30, 3);
2783 Widget::render(&help, area, &mut frame);
2784 }
2785
2786 #[test]
2789 fn render_short_empty_ellipsis_string() {
2790 let help = Help::new()
2791 .with_ellipsis("")
2792 .entry("q", "quit")
2793 .entry("w", "this is a very long description that overflows");
2794 let mut pool = GraphemePool::new();
2795 let mut frame = Frame::new(12, 1, &mut pool);
2796 let area = Rect::new(0, 0, 12, 1);
2797 Widget::render(&help, area, &mut frame);
2798 }
2799
2800 #[test]
2803 fn render_short_entry_wider_than_area() {
2804 let help = Help::new().entry("verylongkey", "very long description text");
2805 let mut pool = GraphemePool::new();
2806 let mut frame = Frame::new(3, 1, &mut pool);
2807 let area = Rect::new(0, 0, 3, 1);
2808 Widget::render(&help, area, &mut frame);
2809 }
2810
2811 #[test]
2814 fn stateful_cache_invalidated_on_style_change() {
2815 let help1 = Help::new().entry("q", "quit");
2816 let help2 = Help::new()
2817 .entry("q", "quit")
2818 .with_key_style(Style::new().italic());
2819 let mut state = HelpRenderState::default();
2820 let mut pool = GraphemePool::new();
2821 let mut frame = Frame::new(40, 1, &mut pool);
2822 let area = Rect::new(0, 0, 40, 1);
2823
2824 StatefulWidget::render(&help1, area, &mut frame, &mut state);
2825 let misses_1 = state.stats().misses;
2826
2827 StatefulWidget::render(&help2, area, &mut frame, &mut state);
2828 assert!(
2829 state.stats().misses > misses_1,
2830 "Style change should cause cache miss"
2831 );
2832 }
2833
2834 #[test]
2837 fn stateful_entry_addition_rebuilds_layout() {
2838 let mut help = Help::new().entry("q", "quit");
2839 let mut state = HelpRenderState::default();
2840 let mut pool = GraphemePool::new();
2841 let mut frame = Frame::new(40, 3, &mut pool);
2842 let area = Rect::new(0, 0, 40, 3);
2843
2844 StatefulWidget::render(&help, area, &mut frame, &mut state);
2845 let rebuilds_1 = state.stats().layout_rebuilds;
2846
2847 help.push_entry(HelpEntry::new("w", "write"));
2848 StatefulWidget::render(&help, area, &mut frame, &mut state);
2849 assert!(
2850 state.stats().layout_rebuilds > rebuilds_1,
2851 "Entry addition should rebuild layout"
2852 );
2853 }
2854
2855 #[test]
2858 fn stateful_separator_change_invalidates_cache() {
2859 let help1 = Help::new()
2860 .with_separator(" | ")
2861 .entry("q", "quit")
2862 .entry("w", "write");
2863 let help2 = Help::new()
2864 .with_separator(" - ")
2865 .entry("q", "quit")
2866 .entry("w", "write");
2867 let mut state = HelpRenderState::default();
2868 let mut pool = GraphemePool::new();
2869 let mut frame = Frame::new(40, 1, &mut pool);
2870 let area = Rect::new(0, 0, 40, 1);
2871
2872 StatefulWidget::render(&help1, area, &mut frame, &mut state);
2873 let misses_1 = state.stats().misses;
2874
2875 StatefulWidget::render(&help2, area, &mut frame, &mut state);
2876 assert!(
2877 state.stats().misses > misses_1,
2878 "Separator change should cause cache miss"
2879 );
2880 }
2881
2882 #[test]
2885 fn stateful_full_mode_dirty_update_multiple() {
2886 let mut help = Help::new()
2887 .with_mode(HelpMode::Full)
2888 .entry("q", "quit")
2889 .entry("w", "save")
2890 .entry("e", "edit");
2891 let mut state = HelpRenderState::default();
2892 let mut pool = GraphemePool::new();
2893 let mut frame = Frame::new(40, 5, &mut pool);
2894 let area = Rect::new(0, 0, 40, 5);
2895
2896 StatefulWidget::render(&help, area, &mut frame, &mut state);
2897
2898 help.entries[0].desc = "exit".to_string();
2900 help.entries[2].desc = "view".to_string();
2901 StatefulWidget::render(&help, area, &mut frame, &mut state);
2902 let dirty = state.take_dirty_rects();
2903 assert_eq!(dirty.len(), 2, "Two changed entries produce 2 dirty rects");
2904 }
2905
2906 #[test]
2909 fn stateful_short_mode_dirty_update() {
2910 let mut help = Help::new()
2911 .with_mode(HelpMode::Short)
2912 .entry("q", "quit")
2913 .entry("w", "write");
2914 let mut state = HelpRenderState::default();
2915 let mut pool = GraphemePool::new();
2916 let mut frame = Frame::new(40, 1, &mut pool);
2917 let area = Rect::new(0, 0, 40, 1);
2918
2919 StatefulWidget::render(&help, area, &mut frame, &mut state);
2920
2921 help.entries[0].desc = "exit".to_string();
2922 StatefulWidget::render(&help, area, &mut frame, &mut state);
2923 assert!(
2924 state.stats().dirty_updates > 0,
2925 "Changed desc should trigger dirty update"
2926 );
2927 }
2928
2929 #[test]
2932 fn build_short_layout_no_enabled_entries() {
2933 let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
2934 let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
2935 assert!(layout.entries.is_empty());
2936 assert!(layout.ellipsis.is_none());
2937 }
2938
2939 #[test]
2940 fn build_full_layout_no_enabled_entries() {
2941 let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
2942 let layout = help.build_full_layout(Rect::new(0, 0, 40, 5));
2943 assert!(layout.entries.is_empty());
2944 assert_eq!(layout.max_key_width, 0);
2945 }
2946
2947 #[test]
2948 fn build_short_layout_triggers_ellipsis() {
2949 let help = Help::new()
2950 .entry("longkey", "long description text here")
2951 .entry("another", "even longer description text");
2952 let layout = help.build_short_layout(Rect::new(0, 0, 20, 1));
2953 assert!(
2955 !layout.entries.is_empty() || layout.ellipsis.is_some(),
2956 "Should have entries or ellipsis"
2957 );
2958 }
2959
2960 #[test]
2961 fn build_full_layout_respects_height() {
2962 let help = Help::new()
2963 .entry("a", "first")
2964 .entry("b", "second")
2965 .entry("c", "third")
2966 .entry("d", "fourth");
2967 let layout = help.build_full_layout(Rect::new(0, 0, 40, 2));
2968 assert_eq!(layout.entries.len(), 2, "Should respect height=2 limit");
2969 }
2970
2971 #[test]
2972 fn build_short_layout_zero_width() {
2973 let help = Help::new().entry("q", "quit");
2974 let layout = help.build_short_layout(Rect::new(0, 0, 0, 1));
2975 assert!(layout.entries.is_empty());
2976 }
2977
2978 #[test]
2979 fn build_full_layout_zero_height() {
2980 let help = Help::new().entry("q", "quit");
2981 let layout = help.build_full_layout(Rect::new(0, 0, 40, 0));
2982 assert!(layout.entries.is_empty());
2983 }
2984
2985 #[test]
2988 fn entry_fits_slot_out_of_bounds_index_short() {
2989 let help = Help::new().entry("q", "quit");
2990 let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
2991 let entry = &help.entries[0];
2992 assert!(!entry_fits_slot(entry, 999, &layout));
2993 }
2994
2995 #[test]
2996 fn entry_fits_slot_out_of_bounds_index_full() {
2997 let help = Help::new().entry("q", "quit");
2998 let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
2999 let entry = &help.entries[0];
3000 assert!(!entry_fits_slot(entry, 999, &layout));
3001 }
3002
3003 #[test]
3004 fn entry_fits_slot_full_key_too_wide() {
3005 let help = Help::new().entry("x", "d");
3006 let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
3007 if !layout.entries.is_empty() {
3008 let wide_entry = HelpEntry::new("verylongkeyname", "d");
3009 assert!(!entry_fits_slot(&wide_entry, 0, &layout));
3010 }
3011 }
3012
3013 #[test]
3016 fn collect_enabled_indices_all_disabled() {
3017 let entries = vec![
3018 HelpEntry::new("a", "b").with_enabled(false),
3019 HelpEntry::new("c", "d").with_enabled(false),
3020 ];
3021 let mut out = Vec::new();
3022 let count = collect_enabled_indices(&entries, &mut out);
3023 assert_eq!(count, 0);
3024 assert!(out.is_empty());
3025 }
3026
3027 #[test]
3028 fn collect_enabled_indices_empty_entries_filtered() {
3029 let entries = vec![
3030 HelpEntry::new("", ""),
3031 HelpEntry::new("q", "quit"),
3032 HelpEntry::new("", ""),
3033 ];
3034 let mut out = Vec::new();
3035 let count = collect_enabled_indices(&entries, &mut out);
3036 assert_eq!(count, 1);
3037 assert_eq!(out, vec![1]);
3038 }
3039
3040 #[test]
3041 fn collect_enabled_indices_mixed() {
3042 let entries = vec![
3043 HelpEntry::new("a", "first"),
3044 HelpEntry::new("b", "second").with_enabled(false),
3045 HelpEntry::new("", ""),
3046 HelpEntry::new("d", "fourth"),
3047 ];
3048 let mut out = Vec::new();
3049 let count = collect_enabled_indices(&entries, &mut out);
3050 assert_eq!(count, 2);
3051 assert_eq!(out, vec![0, 3]);
3052 }
3053
3054 #[test]
3055 fn collect_enabled_indices_clears_previous_data() {
3056 let entries = vec![HelpEntry::new("a", "b")];
3057 let mut out = vec![99, 100, 101];
3058 let count = collect_enabled_indices(&entries, &mut out);
3059 assert_eq!(count, 1);
3060 assert_eq!(out, vec![0]);
3061 }
3062
3063 #[test]
3066 fn blit_cache_none_is_noop() {
3067 let mut pool = GraphemePool::new();
3068 let mut frame = Frame::new(10, 1, &mut pool);
3069 let area = Rect::new(0, 0, 10, 1);
3070 blit_cache(None, area, &mut frame);
3071 }
3072
3073 #[test]
3076 fn style_key_from_default_style() {
3077 let sk = StyleKey::from(Style::default());
3078 assert!(sk.fg.is_none());
3079 assert!(sk.bg.is_none());
3080 assert!(sk.attrs.is_none());
3081 }
3082
3083 #[test]
3084 fn style_key_from_styled() {
3085 let style = Style::new().bold();
3086 let sk = StyleKey::from(style);
3087 assert!(sk.attrs.is_some());
3088 }
3089
3090 #[test]
3091 fn style_key_equality_and_hash() {
3092 use std::collections::hash_map::DefaultHasher;
3093 let a = StyleKey::from(Style::new().italic());
3094 let b = StyleKey::from(Style::new().italic());
3095 assert_eq!(a, b);
3096 let mut h1 = DefaultHasher::new();
3097 let mut h2 = DefaultHasher::new();
3098 a.hash(&mut h1);
3099 b.hash(&mut h2);
3100 assert_eq!(h1.finish(), h2.finish());
3101 }
3102
3103 #[test]
3104 fn style_key_different_styles_ne() {
3105 let a = StyleKey::from(Style::new().bold());
3106 let b = StyleKey::from(Style::new().italic());
3107 assert_ne!(a, b);
3108 }
3109
3110 #[test]
3113 fn hash_str_empty_deterministic() {
3114 assert_eq!(Help::hash_str(""), Help::hash_str(""));
3115 }
3116
3117 #[test]
3118 fn hash_str_different_strings_differ() {
3119 assert_ne!(Help::hash_str("abc"), Help::hash_str("def"));
3120 }
3121
3122 #[test]
3125 fn keybinding_hints_custom_categories_grouped() {
3126 let entries = vec![
3127 HelpEntry::new("a", "one").with_category(HelpCategory::Custom("Alpha".into())),
3128 HelpEntry::new("b", "two").with_category(HelpCategory::Custom("Beta".into())),
3129 HelpEntry::new("c", "three").with_category(HelpCategory::Custom("Alpha".into())),
3130 ];
3131 let groups = KeybindingHints::grouped_entries(&entries);
3132 assert_eq!(groups.len(), 2);
3133 assert_eq!(groups[0].1.len(), 2); assert_eq!(groups[1].1.len(), 1); }
3136
3137 #[test]
3138 fn keybinding_hints_all_contextual_context_on() {
3139 let hints = KeybindingHints::new()
3140 .with_show_context(true)
3141 .contextual_entry("^s", "save")
3142 .contextual_entry("^f", "find");
3143 let visible = hints.visible_entries();
3144 assert_eq!(visible.len(), 2);
3145 }
3146
3147 #[test]
3148 fn keybinding_hints_format_key_plain_empty() {
3149 let hints = KeybindingHints::new().with_key_format(KeyFormat::Plain);
3150 assert_eq!(hints.format_key(""), "");
3151 }
3152
3153 #[test]
3154 fn keybinding_hints_format_key_bracketed_empty() {
3155 let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
3156 assert_eq!(hints.format_key(""), "[]");
3157 }
3158
3159 #[test]
3160 fn keybinding_hints_format_key_bracketed_unicode() {
3161 let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
3162 assert_eq!(hints.format_key("\u{2191}"), "[\u{2191}]");
3163 }
3164
3165 #[test]
3168 fn keybinding_hints_render_full_grouped_single_category() {
3169 let hints = KeybindingHints::new()
3170 .with_mode(HelpMode::Full)
3171 .with_show_categories(true)
3172 .global_entry_categorized("a", "first", HelpCategory::Navigation)
3173 .global_entry_categorized("b", "second", HelpCategory::Navigation);
3174 let mut pool = GraphemePool::new();
3175 let mut frame = Frame::new(40, 10, &mut pool);
3176 let area = Rect::new(0, 0, 40, 10);
3177 Widget::render(&hints, area, &mut frame);
3178 }
3180
3181 #[test]
3184 fn help_cache_stats_ne() {
3185 let a = HelpCacheStats::default();
3186 let b = HelpCacheStats {
3187 hits: 1,
3188 ..Default::default()
3189 };
3190 assert_ne!(a, b);
3191 }
3192
3193 #[test]
3194 fn help_cache_stats_debug() {
3195 let stats = HelpCacheStats {
3196 hits: 5,
3197 misses: 2,
3198 dirty_updates: 1,
3199 layout_rebuilds: 3,
3200 };
3201 let dbg = format!("{stats:?}");
3202 assert!(dbg.contains("hits"));
3203 assert!(dbg.contains("misses"));
3204 assert!(dbg.contains("dirty_updates"));
3205 assert!(dbg.contains("layout_rebuilds"));
3206 }
3207
3208 #[test]
3211 fn layout_key_copy_and_eq() {
3212 let help = Help::new().entry("q", "quit");
3213 let area = Rect::new(0, 0, 40, 1);
3214 let key1 = help.layout_key(area, DegradationLevel::Full);
3215 let key2 = key1; assert_eq!(key1, key2);
3217 }
3218
3219 #[test]
3220 fn layout_key_differs_by_mode() {
3221 let help_s = Help::new().entry("q", "quit");
3222 let help_f = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
3223 let area = Rect::new(0, 0, 40, 1);
3224 let deg = DegradationLevel::Full;
3225 assert_ne!(help_s.layout_key(area, deg), help_f.layout_key(area, deg));
3226 }
3227
3228 #[test]
3229 fn layout_key_differs_by_dimensions() {
3230 let help = Help::new().entry("q", "quit");
3231 let deg = DegradationLevel::Full;
3232 let k1 = help.layout_key(Rect::new(0, 0, 40, 1), deg);
3233 let k2 = help.layout_key(Rect::new(0, 0, 80, 1), deg);
3234 assert_ne!(k1, k2);
3235 }
3236
3237 #[test]
3238 fn layout_key_hash_consistent() {
3239 use std::collections::hash_map::DefaultHasher;
3240 let help = Help::new().entry("q", "quit");
3241 let key = help.layout_key(Rect::new(0, 0, 40, 1), DegradationLevel::Full);
3242 let mut h1 = DefaultHasher::new();
3243 let mut h2 = DefaultHasher::new();
3244 key.hash(&mut h1);
3245 key.hash(&mut h2);
3246 assert_eq!(h1.finish(), h2.finish());
3247 }
3248}