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 };
916
917 for idx in &state.dirty_indices {
918 if let Some(entry_idx) = state.enabled_indices.get(*idx)
919 && let Some(slot) = cache.layout.entries.get(*idx)
920 {
921 let entry = &self.entries[*entry_idx];
922 match cache.layout.mode {
923 HelpMode::Short => self.render_short_entry(slot, entry, &mut cache_frame),
924 HelpMode::Full => {
925 self.render_full_entry(slot, entry, &cache.layout, &mut cache_frame)
926 }
927 }
928 state
929 .dirty_rects
930 .push(Rect::new(slot.x, slot.y, slot.width, 1));
931 }
932 }
933
934 cache_buffer = cache_frame.buffer;
935 }
936 cache.buffer = cache_buffer;
937
938 blit_cache(state.cache.as_ref(), area, frame);
939 }
940}
941
942fn collect_enabled_indices(entries: &[HelpEntry], out: &mut Vec<usize>) -> usize {
943 out.clear();
944 for (idx, entry) in entries.iter().enumerate() {
945 if entry.enabled && (!entry.key.is_empty() || !entry.desc.is_empty()) {
946 out.push(idx);
947 }
948 }
949 out.len()
950}
951
952fn entry_fits_slot(entry: &HelpEntry, index: usize, layout: &HelpLayout) -> bool {
953 match layout.mode {
954 HelpMode::Short => {
955 let entry_width = display_width(&entry.key) + 1 + display_width(&entry.desc);
956 let slot = match layout.entries.get(index) {
957 Some(slot) => slot,
958 None => return false,
959 };
960 let sep_width = layout.separator_width;
961 let max_width = if slot.x == 0 {
962 slot.width as usize
963 } else {
964 slot.width.saturating_sub(sep_width as u16) as usize
965 };
966 entry_width <= max_width
967 }
968 HelpMode::Full => {
969 let key_width = display_width(&entry.key);
970 let desc_width = display_width(&entry.desc);
971 let entry_width = layout
972 .max_key_width
973 .saturating_add(2)
974 .saturating_add(desc_width);
975 let slot = match layout.entries.get(index) {
976 Some(slot) => slot,
977 None => return false,
978 };
979 if slot.width == layout.width {
980 key_width <= layout.max_key_width
981 } else {
982 key_width <= layout.max_key_width && entry_width <= slot.width as usize
983 }
984 }
985 }
986}
987
988fn rebuild_cache(
989 help: &Help,
990 area: Rect,
991 frame: &mut Frame,
992 state: &mut HelpRenderState,
993 layout_key: LayoutKey,
994 enabled_count: usize,
995) {
996 state.stats.misses += 1;
997 state.stats.layout_rebuilds += 1;
998
999 let layout_area = Rect::new(0, 0, area.width, area.height);
1000 let layout = help.build_layout(layout_area);
1001
1002 let mut buffer = Buffer::new(area.width, area.height);
1003 buffer.degradation = frame.buffer.degradation;
1004 {
1005 let mut cache_frame = Frame {
1006 buffer,
1007 pool: frame.pool,
1008 links: None,
1009 hit_grid: None,
1010 widget_budget: frame.widget_budget.clone(),
1011 widget_signals: Vec::new(),
1012 cursor_position: None,
1013 cursor_visible: true,
1014 degradation: frame.buffer.degradation,
1015 };
1016 help.render_cached(layout_area, &mut cache_frame, &layout);
1017 buffer = cache_frame.buffer;
1018 }
1019
1020 let mut entry_hashes = Vec::with_capacity(state.enabled_indices.len());
1021 for idx in &state.enabled_indices {
1022 entry_hashes.push(Help::entry_hash(&help.entries[*idx]));
1023 }
1024
1025 state.cache = Some(HelpCache {
1026 buffer,
1027 layout,
1028 key: layout_key,
1029 entry_hashes,
1030 enabled_count,
1031 });
1032}
1033
1034fn blit_cache(cache: Option<&HelpCache>, area: Rect, frame: &mut Frame) {
1035 let Some(cache) = cache else {
1036 return;
1037 };
1038
1039 for slot in &cache.layout.entries {
1040 let src = Rect::new(slot.x, slot.y, slot.width, 1);
1041 frame
1042 .buffer
1043 .copy_from(&cache.buffer, src, area.x + slot.x, area.y + slot.y);
1044 }
1045
1046 if let Some(ellipsis) = &cache.layout.ellipsis {
1047 let src = Rect::new(ellipsis.x, 0, ellipsis.width, 1);
1048 frame
1049 .buffer
1050 .copy_from(&cache.buffer, src, area.x + ellipsis.x, area.y);
1051 }
1052}
1053
1054#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1056pub enum KeyFormat {
1057 #[default]
1059 Plain,
1060 Bracketed,
1062}
1063
1064#[derive(Debug, Clone)]
1090pub struct KeybindingHints {
1091 global_entries: Vec<HelpEntry>,
1092 contextual_entries: Vec<HelpEntry>,
1093 key_format: KeyFormat,
1094 mode: HelpMode,
1095 key_style: Style,
1096 desc_style: Style,
1097 separator_style: Style,
1098 category_style: Style,
1099 separator: String,
1100 ellipsis: String,
1101 show_categories: bool,
1102 show_context: bool,
1103}
1104
1105impl Default for KeybindingHints {
1106 fn default() -> Self {
1107 Self::new()
1108 }
1109}
1110
1111impl KeybindingHints {
1112 #[must_use]
1114 pub fn new() -> Self {
1115 Self {
1116 global_entries: Vec::new(),
1117 contextual_entries: Vec::new(),
1118 key_format: KeyFormat::default(),
1119 mode: HelpMode::Short,
1120 key_style: Style::new().bold(),
1121 desc_style: Style::default(),
1122 separator_style: Style::default(),
1123 category_style: Style::new().bold().underline(),
1124 separator: " • ".to_string(),
1125 ellipsis: "…".to_string(),
1126 show_categories: true,
1127 show_context: false,
1128 }
1129 }
1130
1131 #[must_use]
1133 pub fn global_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1134 self.global_entries
1135 .push(HelpEntry::new(key, desc).with_category(HelpCategory::Global));
1136 self
1137 }
1138
1139 #[must_use]
1141 pub fn global_entry_categorized(
1142 mut self,
1143 key: impl Into<String>,
1144 desc: impl Into<String>,
1145 category: HelpCategory,
1146 ) -> Self {
1147 self.global_entries
1148 .push(HelpEntry::new(key, desc).with_category(category));
1149 self
1150 }
1151
1152 #[must_use]
1154 pub fn contextual_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1155 self.contextual_entries.push(HelpEntry::new(key, desc));
1156 self
1157 }
1158
1159 #[must_use]
1161 pub fn contextual_entry_categorized(
1162 mut self,
1163 key: impl Into<String>,
1164 desc: impl Into<String>,
1165 category: HelpCategory,
1166 ) -> Self {
1167 self.contextual_entries
1168 .push(HelpEntry::new(key, desc).with_category(category));
1169 self
1170 }
1171
1172 #[must_use]
1174 pub fn with_global_entry(mut self, entry: HelpEntry) -> Self {
1175 self.global_entries.push(entry);
1176 self
1177 }
1178
1179 #[must_use]
1181 pub fn with_contextual_entry(mut self, entry: HelpEntry) -> Self {
1182 self.contextual_entries.push(entry);
1183 self
1184 }
1185
1186 #[must_use]
1188 pub fn with_key_format(mut self, format: KeyFormat) -> Self {
1189 self.key_format = format;
1190 self
1191 }
1192
1193 #[must_use]
1195 pub fn with_mode(mut self, mode: HelpMode) -> Self {
1196 self.mode = mode;
1197 self
1198 }
1199
1200 #[must_use]
1202 pub fn with_show_context(mut self, show: bool) -> Self {
1203 self.show_context = show;
1204 self
1205 }
1206
1207 #[must_use]
1209 pub fn with_show_categories(mut self, show: bool) -> Self {
1210 self.show_categories = show;
1211 self
1212 }
1213
1214 #[must_use]
1216 pub fn with_key_style(mut self, style: Style) -> Self {
1217 self.key_style = style;
1218 self
1219 }
1220
1221 #[must_use]
1223 pub fn with_desc_style(mut self, style: Style) -> Self {
1224 self.desc_style = style;
1225 self
1226 }
1227
1228 #[must_use]
1230 pub fn with_separator_style(mut self, style: Style) -> Self {
1231 self.separator_style = style;
1232 self
1233 }
1234
1235 #[must_use]
1237 pub fn with_category_style(mut self, style: Style) -> Self {
1238 self.category_style = style;
1239 self
1240 }
1241
1242 #[must_use]
1244 pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
1245 self.separator = sep.into();
1246 self
1247 }
1248
1249 #[must_use]
1251 pub fn global_entries(&self) -> &[HelpEntry] {
1252 &self.global_entries
1253 }
1254
1255 #[must_use]
1257 pub fn contextual_entries(&self) -> &[HelpEntry] {
1258 &self.contextual_entries
1259 }
1260
1261 #[must_use]
1263 pub fn mode(&self) -> HelpMode {
1264 self.mode
1265 }
1266
1267 #[must_use]
1269 pub fn key_format(&self) -> KeyFormat {
1270 self.key_format
1271 }
1272
1273 pub fn toggle_mode(&mut self) {
1275 self.mode = match self.mode {
1276 HelpMode::Short => HelpMode::Full,
1277 HelpMode::Full => HelpMode::Short,
1278 };
1279 }
1280
1281 pub fn set_show_context(&mut self, show: bool) {
1283 self.show_context = show;
1284 }
1285
1286 fn format_key(&self, key: &str) -> String {
1288 match self.key_format {
1289 KeyFormat::Plain => key.to_string(),
1290 KeyFormat::Bracketed => format!("[{key}]"),
1291 }
1292 }
1293
1294 #[must_use]
1296 pub fn visible_entries(&self) -> Vec<HelpEntry> {
1297 let mut entries = Vec::new();
1298 for e in &self.global_entries {
1299 if e.enabled {
1300 entries.push(HelpEntry {
1301 key: self.format_key(&e.key),
1302 desc: e.desc.clone(),
1303 enabled: true,
1304 category: e.category.clone(),
1305 });
1306 }
1307 }
1308 if self.show_context {
1309 for e in &self.contextual_entries {
1310 if e.enabled {
1311 entries.push(HelpEntry {
1312 key: self.format_key(&e.key),
1313 desc: e.desc.clone(),
1314 enabled: true,
1315 category: e.category.clone(),
1316 });
1317 }
1318 }
1319 }
1320 entries
1321 }
1322
1323 fn grouped_entries(entries: &[HelpEntry]) -> Vec<(&HelpCategory, Vec<&HelpEntry>)> {
1325 let mut groups: Vec<(&HelpCategory, Vec<&HelpEntry>)> = Vec::new();
1326 for entry in entries {
1327 if let Some(group) = groups.iter_mut().find(|(cat, _)| **cat == entry.category) {
1328 group.1.push(entry);
1329 } else {
1330 groups.push((&entry.category, vec![entry]));
1331 }
1332 }
1333 groups
1334 }
1335
1336 fn render_full_grouped(&self, entries: &[HelpEntry], area: Rect, frame: &mut Frame) {
1338 let groups = Self::grouped_entries(entries);
1339 let deg = frame.buffer.degradation;
1340 let max_x = area.right();
1341 let mut y = area.y;
1342
1343 let max_key_w = entries
1345 .iter()
1346 .map(|e| display_width(&e.key))
1347 .max()
1348 .unwrap_or(0);
1349
1350 for (i, (cat, group_entries)) in groups.iter().enumerate() {
1351 if y >= area.bottom() {
1352 break;
1353 }
1354
1355 let cat_style = if deg.apply_styling() {
1357 self.category_style
1358 } else {
1359 Style::default()
1360 };
1361 draw_text_span(frame, area.x, y, cat.label(), cat_style, max_x);
1362 y += 1;
1363
1364 for entry in group_entries {
1366 if y >= area.bottom() {
1367 break;
1368 }
1369
1370 let key_style = if deg.apply_styling() {
1371 self.key_style
1372 } else {
1373 Style::default()
1374 };
1375 let desc_style = if deg.apply_styling() {
1376 self.desc_style
1377 } else {
1378 Style::default()
1379 };
1380
1381 let mut x = area.x;
1382 x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
1383 let pad = max_key_w.saturating_sub(display_width(&entry.key));
1384 for _ in 0..pad {
1385 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
1386 }
1387 x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
1388 draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
1389 y += 1;
1390 }
1391
1392 if i + 1 < groups.len() {
1394 y += 1;
1395 }
1396 }
1397 }
1398}
1399
1400impl Widget for KeybindingHints {
1401 fn render(&self, area: Rect, frame: &mut Frame) {
1402 let entries = self.visible_entries();
1403 if entries.is_empty() || area.is_empty() {
1404 return;
1405 }
1406
1407 match self.mode {
1408 HelpMode::Short => {
1409 let help = Help::new()
1411 .with_mode(HelpMode::Short)
1412 .with_key_style(self.key_style)
1413 .with_desc_style(self.desc_style)
1414 .with_separator_style(self.separator_style)
1415 .with_separator(self.separator.clone())
1416 .with_ellipsis(self.ellipsis.clone())
1417 .with_entries(entries);
1418 Widget::render(&help, area, frame);
1419 }
1420 HelpMode::Full => {
1421 if self.show_categories {
1422 self.render_full_grouped(&entries, area, frame);
1423 } else {
1424 let help = Help::new()
1425 .with_mode(HelpMode::Full)
1426 .with_key_style(self.key_style)
1427 .with_desc_style(self.desc_style)
1428 .with_entries(entries);
1429 Widget::render(&help, area, frame);
1430 }
1431 }
1432 }
1433 }
1434
1435 fn is_essential(&self) -> bool {
1436 false
1437 }
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442 use super::*;
1443 use ftui_render::frame::Frame;
1444 use ftui_render::grapheme_pool::GraphemePool;
1445 use proptest::prelude::*;
1446 use proptest::string::string_regex;
1447 use std::time::Instant;
1448
1449 #[test]
1450 fn new_help_is_empty() {
1451 let help = Help::new();
1452 assert!(help.entries().is_empty());
1453 assert_eq!(help.mode(), HelpMode::Short);
1454 }
1455
1456 #[test]
1457 fn entry_builder() {
1458 let help = Help::new().entry("q", "quit").entry("^s", "save");
1459 assert_eq!(help.entries().len(), 2);
1460 assert_eq!(help.entries()[0].key, "q");
1461 assert_eq!(help.entries()[0].desc, "quit");
1462 }
1463
1464 #[test]
1465 fn with_entries_replaces() {
1466 let help = Help::new()
1467 .entry("old", "old")
1468 .with_entries(vec![HelpEntry::new("new", "new")]);
1469 assert_eq!(help.entries().len(), 1);
1470 assert_eq!(help.entries()[0].key, "new");
1471 }
1472
1473 #[test]
1474 fn disabled_entries_hidden() {
1475 let help = Help::new()
1476 .with_entry(HelpEntry::new("a", "shown"))
1477 .with_entry(HelpEntry::new("b", "hidden").with_enabled(false))
1478 .with_entry(HelpEntry::new("c", "also shown"));
1479 assert_eq!(help.enabled_entries().len(), 2);
1480 }
1481
1482 #[test]
1483 fn toggle_mode() {
1484 let mut help = Help::new();
1485 assert_eq!(help.mode(), HelpMode::Short);
1486 help.toggle_mode();
1487 assert_eq!(help.mode(), HelpMode::Full);
1488 help.toggle_mode();
1489 assert_eq!(help.mode(), HelpMode::Short);
1490 }
1491
1492 #[test]
1493 fn push_entry() {
1494 let mut help = Help::new();
1495 help.push_entry(HelpEntry::new("x", "action"));
1496 assert_eq!(help.entries().len(), 1);
1497 }
1498
1499 #[test]
1500 fn render_short_basic() {
1501 let help = Help::new().entry("q", "quit").entry("^s", "save");
1502
1503 let mut pool = GraphemePool::new();
1504 let mut frame = Frame::new(40, 1, &mut pool);
1505 let area = Rect::new(0, 0, 40, 1);
1506 Widget::render(&help, area, &mut frame);
1507
1508 let cell_q = frame.buffer.get(0, 0).unwrap();
1510 assert_eq!(cell_q.content.as_char(), Some('q'));
1511 }
1512
1513 #[test]
1514 fn render_short_truncation() {
1515 let help = Help::new()
1516 .entry("q", "quit")
1517 .entry("^s", "save")
1518 .entry("^x", "something very long that should not fit");
1519
1520 let mut pool = GraphemePool::new();
1521 let mut frame = Frame::new(20, 1, &mut pool);
1522 let area = Rect::new(0, 0, 20, 1);
1523 Widget::render(&help, area, &mut frame);
1524
1525 let cell = frame.buffer.get(0, 0).unwrap();
1527 assert_eq!(cell.content.as_char(), Some('q'));
1528 }
1529
1530 #[test]
1531 fn render_short_empty_entries() {
1532 let help = Help::new();
1533
1534 let mut pool = GraphemePool::new();
1535 let mut frame = Frame::new(20, 1, &mut pool);
1536 let area = Rect::new(0, 0, 20, 1);
1537 Widget::render(&help, area, &mut frame);
1538
1539 let cell = frame.buffer.get(0, 0).unwrap();
1541 assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
1542 }
1543
1544 #[test]
1545 fn render_full_basic() {
1546 let help = Help::new()
1547 .with_mode(HelpMode::Full)
1548 .entry("q", "quit")
1549 .entry("^s", "save file");
1550
1551 let mut pool = GraphemePool::new();
1552 let mut frame = Frame::new(30, 5, &mut pool);
1553 let area = Rect::new(0, 0, 30, 5);
1554 Widget::render(&help, area, &mut frame);
1555
1556 let cell = frame.buffer.get(0, 0).unwrap();
1558 assert!(cell.content.as_char() == Some(' ') || cell.content.as_char() == Some('q'));
1559 let cell_row2 = frame.buffer.get(0, 1).unwrap();
1561 assert!(
1562 cell_row2.content.as_char() == Some('^') || cell_row2.content.as_char() == Some(' ')
1563 );
1564 }
1565
1566 #[test]
1567 fn render_full_respects_height() {
1568 let help = Help::new()
1569 .with_mode(HelpMode::Full)
1570 .entry("a", "first")
1571 .entry("b", "second")
1572 .entry("c", "third");
1573
1574 let mut pool = GraphemePool::new();
1575 let mut frame = Frame::new(30, 2, &mut pool);
1577 let area = Rect::new(0, 0, 30, 2);
1578 Widget::render(&help, area, &mut frame);
1579
1580 }
1583
1584 #[test]
1585 fn help_entry_equality() {
1586 let a = HelpEntry::new("q", "quit");
1587 let b = HelpEntry::new("q", "quit");
1588 let c = HelpEntry::new("x", "exit");
1589 assert_eq!(a, b);
1590 assert_ne!(a, c);
1591 }
1592
1593 #[test]
1594 fn help_entry_disabled() {
1595 let entry = HelpEntry::new("q", "quit").with_enabled(false);
1596 assert!(!entry.enabled);
1597 }
1598
1599 #[test]
1600 fn with_separator() {
1601 let help = Help::new().with_separator(" | ");
1602 assert_eq!(help.separator, " | ");
1603 }
1604
1605 #[test]
1606 fn with_ellipsis() {
1607 let help = Help::new().with_ellipsis("...");
1608 assert_eq!(help.ellipsis, "...");
1609 }
1610
1611 #[test]
1612 fn render_zero_area() {
1613 let help = Help::new().entry("q", "quit");
1614
1615 let mut pool = GraphemePool::new();
1616 let mut frame = Frame::new(20, 1, &mut pool);
1617 let area = Rect::new(0, 0, 0, 0);
1618 Widget::render(&help, area, &mut frame); }
1620
1621 #[test]
1622 fn is_not_essential() {
1623 let help = Help::new();
1624 assert!(!help.is_essential());
1625 }
1626
1627 #[test]
1628 fn render_full_alignment() {
1629 let help = Help::new()
1631 .with_mode(HelpMode::Full)
1632 .entry("q", "quit")
1633 .entry("ctrl+s", "save");
1634
1635 let mut pool = GraphemePool::new();
1636 let mut frame = Frame::new(30, 3, &mut pool);
1637 let area = Rect::new(0, 0, 30, 3);
1638 Widget::render(&help, area, &mut frame);
1639
1640 }
1646
1647 #[test]
1648 fn default_impl() {
1649 let help = Help::default();
1650 assert!(help.entries().is_empty());
1651 }
1652
1653 #[test]
1654 fn cache_hit_same_hints() {
1655 let help = Help::new().entry("q", "quit").entry("^s", "save");
1656 let mut state = HelpRenderState::default();
1657 let mut pool = GraphemePool::new();
1658 let mut frame = Frame::new(40, 1, &mut pool);
1659 let area = Rect::new(0, 0, 40, 1);
1660
1661 StatefulWidget::render(&help, area, &mut frame, &mut state);
1662 let stats_after_first = state.stats();
1663 StatefulWidget::render(&help, area, &mut frame, &mut state);
1664 let stats_after_second = state.stats();
1665
1666 assert!(
1667 stats_after_second.hits > stats_after_first.hits,
1668 "Second render should be a cache hit"
1669 );
1670 assert!(state.dirty_rects().is_empty(), "No dirty rects on hit");
1671 }
1672
1673 #[test]
1674 fn dirty_rect_only_changes() {
1675 let mut help = Help::new()
1676 .with_mode(HelpMode::Full)
1677 .entry("q", "quit")
1678 .entry("w", "write")
1679 .entry("e", "edit");
1680
1681 let mut state = HelpRenderState::default();
1682 let mut pool = GraphemePool::new();
1683 let mut frame = Frame::new(40, 3, &mut pool);
1684 let area = Rect::new(0, 0, 40, 3);
1685
1686 StatefulWidget::render(&help, area, &mut frame, &mut state);
1687
1688 help.entries[1].desc.clear();
1689 help.entries[1].desc.push_str("save");
1690
1691 StatefulWidget::render(&help, area, &mut frame, &mut state);
1692 let dirty = state.take_dirty_rects();
1693
1694 assert_eq!(dirty.len(), 1, "Only one row should be dirty");
1695 assert_eq!(dirty[0].y, 1, "Second entry row should be dirty");
1696 }
1697
1698 proptest! {
1699 #[test]
1700 fn prop_cache_hits_on_stable_entries(entries in prop::collection::vec(
1701 (string_regex("[a-z]{1,6}").unwrap(), string_regex("[a-z]{1,10}").unwrap()),
1702 1..6
1703 )) {
1704 let mut help = Help::new();
1705 for (key, desc) in entries {
1706 help = help.entry(key, desc);
1707 }
1708 let mut state = HelpRenderState::default();
1709 let mut pool = GraphemePool::new();
1710 let mut frame = Frame::new(80, 1, &mut pool);
1711 let area = Rect::new(0, 0, 80, 1);
1712
1713 StatefulWidget::render(&help, area, &mut frame, &mut state);
1714 let stats_after_first = state.stats();
1715 StatefulWidget::render(&help, area, &mut frame, &mut state);
1716 let stats_after_second = state.stats();
1717
1718 prop_assert!(stats_after_second.hits > stats_after_first.hits);
1719 prop_assert!(state.dirty_rects().is_empty());
1720 }
1721 }
1722
1723 #[test]
1724 fn perf_micro_hint_update() {
1725 let mut help = Help::new()
1726 .with_mode(HelpMode::Short)
1727 .entry("^T", "Theme")
1728 .entry("^C", "Quit")
1729 .entry("?", "Help")
1730 .entry("F12", "Debug");
1731
1732 let mut state = HelpRenderState::default();
1733 let mut pool = GraphemePool::new();
1734 let mut frame = Frame::new(120, 1, &mut pool);
1735 let area = Rect::new(0, 0, 120, 1);
1736
1737 StatefulWidget::render(&help, area, &mut frame, &mut state);
1738
1739 let iterations = 200u32;
1740 let mut times_us = Vec::with_capacity(iterations as usize);
1741 for i in 0..iterations {
1742 let label = if i % 2 == 0 { "Close" } else { "Open" };
1743 help.entries[1].desc.clear();
1744 help.entries[1].desc.push_str(label);
1745
1746 let start = Instant::now();
1747 StatefulWidget::render(&help, area, &mut frame, &mut state);
1748 let elapsed = start.elapsed();
1749 times_us.push(elapsed.as_micros() as u64);
1750 }
1751
1752 times_us.sort();
1753 let len = times_us.len();
1754 let p50 = times_us[len / 2];
1755 let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1756 let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1757 let updates_per_sec = 1_000_000u64.checked_div(p50).unwrap_or(0);
1758
1759 eprintln!(
1760 "{{\"ts\":\"2026-02-03T00:00:00Z\",\"case\":\"help_hint_update\",\"iterations\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"updates_per_sec\":{},\"hits\":{},\"misses\":{},\"dirty_updates\":{}}}",
1761 iterations,
1762 p50,
1763 p95,
1764 p99,
1765 updates_per_sec,
1766 state.stats().hits,
1767 state.stats().misses,
1768 state.stats().dirty_updates
1769 );
1770
1771 assert!(p95 <= 2000, "p95 too slow: {p95}us");
1773 }
1774
1775 #[test]
1778 fn help_category_default_is_general() {
1779 assert_eq!(HelpCategory::default(), HelpCategory::General);
1780 }
1781
1782 #[test]
1783 fn help_category_labels() {
1784 assert_eq!(HelpCategory::General.label(), "General");
1785 assert_eq!(HelpCategory::Navigation.label(), "Navigation");
1786 assert_eq!(HelpCategory::Editing.label(), "Editing");
1787 assert_eq!(HelpCategory::File.label(), "File");
1788 assert_eq!(HelpCategory::View.label(), "View");
1789 assert_eq!(HelpCategory::Global.label(), "Global");
1790 assert_eq!(
1791 HelpCategory::Custom("My Section".into()).label(),
1792 "My Section"
1793 );
1794 }
1795
1796 #[test]
1797 fn help_entry_with_category() {
1798 let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1799 assert_eq!(entry.category, HelpCategory::Navigation);
1800 }
1801
1802 #[test]
1803 fn help_entry_default_category_is_general() {
1804 let entry = HelpEntry::new("q", "quit");
1805 assert_eq!(entry.category, HelpCategory::General);
1806 }
1807
1808 #[test]
1809 fn category_changes_entry_hash() {
1810 let a = HelpEntry::new("q", "quit");
1811 let b = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1812 assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
1813 }
1814
1815 #[test]
1818 fn key_format_default_is_plain() {
1819 assert_eq!(KeyFormat::default(), KeyFormat::Plain);
1820 }
1821
1822 #[test]
1825 fn keybinding_hints_new_is_empty() {
1826 let hints = KeybindingHints::new();
1827 assert!(hints.global_entries().is_empty());
1828 assert!(hints.contextual_entries().is_empty());
1829 assert_eq!(hints.mode(), HelpMode::Short);
1830 assert_eq!(hints.key_format(), KeyFormat::Plain);
1831 }
1832
1833 #[test]
1834 fn keybinding_hints_default() {
1835 let hints = KeybindingHints::default();
1836 assert!(hints.global_entries().is_empty());
1837 }
1838
1839 #[test]
1840 fn keybinding_hints_global_entry() {
1841 let hints = KeybindingHints::new()
1842 .global_entry("q", "quit")
1843 .global_entry("^s", "save");
1844 assert_eq!(hints.global_entries().len(), 2);
1845 assert_eq!(hints.global_entries()[0].key, "q");
1846 assert_eq!(hints.global_entries()[0].category, HelpCategory::Global);
1847 }
1848
1849 #[test]
1850 fn keybinding_hints_categorized_entries() {
1851 let hints = KeybindingHints::new()
1852 .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1853 .global_entry_categorized("q", "quit", HelpCategory::Global);
1854 assert_eq!(hints.global_entries()[0].category, HelpCategory::Navigation);
1855 assert_eq!(hints.global_entries()[1].category, HelpCategory::Global);
1856 }
1857
1858 #[test]
1859 fn keybinding_hints_contextual_entry() {
1860 let hints = KeybindingHints::new()
1861 .contextual_entry("^s", "save")
1862 .contextual_entry_categorized("^f", "find", HelpCategory::Editing);
1863 assert_eq!(hints.contextual_entries().len(), 2);
1864 assert_eq!(
1865 hints.contextual_entries()[0].category,
1866 HelpCategory::General
1867 );
1868 assert_eq!(
1869 hints.contextual_entries()[1].category,
1870 HelpCategory::Editing
1871 );
1872 }
1873
1874 #[test]
1875 fn keybinding_hints_with_prebuilt_entries() {
1876 let global = HelpEntry::new("q", "quit").with_category(HelpCategory::Global);
1877 let ctx = HelpEntry::new("^s", "save").with_category(HelpCategory::File);
1878 let hints = KeybindingHints::new()
1879 .with_global_entry(global)
1880 .with_contextual_entry(ctx);
1881 assert_eq!(hints.global_entries().len(), 1);
1882 assert_eq!(hints.contextual_entries().len(), 1);
1883 }
1884
1885 #[test]
1886 fn keybinding_hints_toggle_mode() {
1887 let mut hints = KeybindingHints::new();
1888 assert_eq!(hints.mode(), HelpMode::Short);
1889 hints.toggle_mode();
1890 assert_eq!(hints.mode(), HelpMode::Full);
1891 hints.toggle_mode();
1892 assert_eq!(hints.mode(), HelpMode::Short);
1893 }
1894
1895 #[test]
1896 fn keybinding_hints_set_show_context() {
1897 let mut hints = KeybindingHints::new()
1898 .global_entry("q", "quit")
1899 .contextual_entry("^s", "save");
1900
1901 let visible = hints.visible_entries();
1903 assert_eq!(visible.len(), 1);
1904
1905 hints.set_show_context(true);
1907 let visible = hints.visible_entries();
1908 assert_eq!(visible.len(), 2);
1909 }
1910
1911 #[test]
1912 fn keybinding_hints_bracketed_format() {
1913 let hints = KeybindingHints::new()
1914 .with_key_format(KeyFormat::Bracketed)
1915 .global_entry("q", "quit");
1916 let visible = hints.visible_entries();
1917 assert_eq!(visible[0].key, "[q]");
1918 }
1919
1920 #[test]
1921 fn keybinding_hints_plain_format() {
1922 let hints = KeybindingHints::new()
1923 .with_key_format(KeyFormat::Plain)
1924 .global_entry("q", "quit");
1925 let visible = hints.visible_entries();
1926 assert_eq!(visible[0].key, "q");
1927 }
1928
1929 #[test]
1930 fn keybinding_hints_disabled_entries_hidden() {
1931 let hints = KeybindingHints::new()
1932 .with_global_entry(HelpEntry::new("a", "shown"))
1933 .with_global_entry(HelpEntry::new("b", "hidden").with_enabled(false));
1934 let visible = hints.visible_entries();
1935 assert_eq!(visible.len(), 1);
1936 assert_eq!(visible[0].key, "a");
1937 }
1938
1939 #[test]
1940 fn keybinding_hints_grouped_entries() {
1941 let entries = vec![
1942 HelpEntry::new("Tab", "next").with_category(HelpCategory::Navigation),
1943 HelpEntry::new("q", "quit").with_category(HelpCategory::Global),
1944 HelpEntry::new("S-Tab", "prev").with_category(HelpCategory::Navigation),
1945 ];
1946 let groups = KeybindingHints::grouped_entries(&entries);
1947 assert_eq!(groups.len(), 2);
1948 assert_eq!(*groups[0].0, HelpCategory::Navigation);
1949 assert_eq!(groups[0].1.len(), 2);
1950 assert_eq!(*groups[1].0, HelpCategory::Global);
1951 assert_eq!(groups[1].1.len(), 1);
1952 }
1953
1954 #[test]
1955 fn keybinding_hints_render_short() {
1956 let hints = KeybindingHints::new()
1957 .global_entry("q", "quit")
1958 .global_entry("^s", "save");
1959
1960 let mut pool = GraphemePool::new();
1961 let mut frame = Frame::new(40, 1, &mut pool);
1962 let area = Rect::new(0, 0, 40, 1);
1963 Widget::render(&hints, area, &mut frame);
1964
1965 let cell = frame.buffer.get(0, 0).unwrap();
1967 assert_eq!(cell.content.as_char(), Some('q'));
1968 }
1969
1970 #[test]
1971 fn keybinding_hints_render_short_bracketed() {
1972 let hints = KeybindingHints::new()
1973 .with_key_format(KeyFormat::Bracketed)
1974 .global_entry("q", "quit");
1975
1976 let mut pool = GraphemePool::new();
1977 let mut frame = Frame::new(40, 1, &mut pool);
1978 let area = Rect::new(0, 0, 40, 1);
1979 Widget::render(&hints, area, &mut frame);
1980
1981 let cell = frame.buffer.get(0, 0).unwrap();
1983 assert_eq!(cell.content.as_char(), Some('['));
1984 }
1985
1986 #[test]
1987 fn keybinding_hints_render_full_grouped() {
1988 let hints = KeybindingHints::new()
1989 .with_mode(HelpMode::Full)
1990 .with_show_categories(true)
1991 .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1992 .global_entry_categorized("q", "quit", HelpCategory::Global);
1993
1994 let mut pool = GraphemePool::new();
1995 let mut frame = Frame::new(40, 10, &mut pool);
1996 let area = Rect::new(0, 0, 40, 10);
1997 Widget::render(&hints, area, &mut frame);
1998
1999 let mut row0 = String::new();
2001 for x in 0..40u16 {
2002 if let Some(cell) = frame.buffer.get(x, 0)
2003 && let Some(ch) = cell.content.as_char()
2004 {
2005 row0.push(ch);
2006 }
2007 }
2008 assert!(
2009 row0.contains("Navigation"),
2010 "First row should be Navigation header: {row0}"
2011 );
2012 }
2013
2014 #[test]
2015 fn keybinding_hints_render_full_no_categories() {
2016 let hints = KeybindingHints::new()
2017 .with_mode(HelpMode::Full)
2018 .with_show_categories(false)
2019 .global_entry("q", "quit")
2020 .global_entry("^s", "save");
2021
2022 let mut pool = GraphemePool::new();
2023 let mut frame = Frame::new(40, 5, &mut pool);
2024 let area = Rect::new(0, 0, 40, 5);
2025 Widget::render(&hints, area, &mut frame);
2027 }
2028
2029 #[test]
2030 fn keybinding_hints_render_empty() {
2031 let hints = KeybindingHints::new();
2032
2033 let mut pool = GraphemePool::new();
2034 let mut frame = Frame::new(20, 1, &mut pool);
2035 let area = Rect::new(0, 0, 20, 1);
2036 Widget::render(&hints, area, &mut frame);
2038 }
2039
2040 #[test]
2041 fn keybinding_hints_render_zero_area() {
2042 let hints = KeybindingHints::new().global_entry("q", "quit");
2043
2044 let mut pool = GraphemePool::new();
2045 let mut frame = Frame::new(20, 1, &mut pool);
2046 let area = Rect::new(0, 0, 0, 0);
2047 Widget::render(&hints, area, &mut frame);
2049 }
2050
2051 #[test]
2052 fn keybinding_hints_is_not_essential() {
2053 let hints = KeybindingHints::new();
2054 assert!(!hints.is_essential());
2055 }
2056
2057 proptest! {
2060 #[test]
2061 fn prop_visible_entries_count(
2062 n_global in 0..5usize,
2063 n_ctx in 0..5usize,
2064 show_ctx in proptest::bool::ANY,
2065 ) {
2066 let mut hints = KeybindingHints::new().with_show_context(show_ctx);
2067 for i in 0..n_global {
2068 hints = hints.global_entry(format!("g{i}"), format!("global {i}"));
2069 }
2070 for i in 0..n_ctx {
2071 hints = hints.contextual_entry(format!("c{i}"), format!("ctx {i}"));
2072 }
2073 let visible = hints.visible_entries();
2074 let expected = if show_ctx { n_global + n_ctx } else { n_global };
2075 prop_assert_eq!(visible.len(), expected);
2076 }
2077
2078 #[test]
2079 fn prop_bracketed_keys_wrapped(
2080 keys in prop::collection::vec(string_regex("[a-z]{1,4}").unwrap(), 1..5),
2081 ) {
2082 let mut hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
2083 for key in &keys {
2084 hints = hints.global_entry(key.clone(), "action");
2085 }
2086 let visible = hints.visible_entries();
2087 for entry in &visible {
2088 prop_assert!(entry.key.starts_with('['), "Key should start with [: {}", entry.key);
2089 prop_assert!(entry.key.ends_with(']'), "Key should end with ]: {}", entry.key);
2090 }
2091 }
2092
2093 #[test]
2094 fn prop_grouped_preserves_count(
2095 entries in prop::collection::vec(
2096 (string_regex("[a-z]{1,4}").unwrap(), 0..3u8),
2097 1..8
2098 ),
2099 ) {
2100 let help_entries: Vec<HelpEntry> = entries.into_iter().map(|(key, cat_idx)| {
2101 let cat = match cat_idx {
2102 0 => HelpCategory::Navigation,
2103 1 => HelpCategory::Editing,
2104 _ => HelpCategory::Global,
2105 };
2106 HelpEntry::new(key, "action").with_category(cat)
2107 }).collect();
2108
2109 let total = help_entries.len();
2110 let groups = KeybindingHints::grouped_entries(&help_entries);
2111 let grouped_total: usize = groups.iter().map(|(_, v)| v.len()).sum();
2112 prop_assert_eq!(total, grouped_total, "Grouping should preserve total entry count");
2113 }
2114
2115 #[test]
2116 fn prop_render_no_panic(
2117 n_global in 0..5usize,
2118 n_ctx in 0..5usize,
2119 width in 1..80u16,
2120 height in 1..20u16,
2121 show_ctx in proptest::bool::ANY,
2122 use_full in proptest::bool::ANY,
2123 use_brackets in proptest::bool::ANY,
2124 show_cats in proptest::bool::ANY,
2125 ) {
2126 let mode = if use_full { HelpMode::Full } else { HelpMode::Short };
2127 let fmt = if use_brackets { KeyFormat::Bracketed } else { KeyFormat::Plain };
2128 let mut hints = KeybindingHints::new()
2129 .with_mode(mode)
2130 .with_key_format(fmt)
2131 .with_show_context(show_ctx)
2132 .with_show_categories(show_cats);
2133
2134 for i in 0..n_global {
2135 hints = hints.global_entry(format!("g{i}"), format!("global action {i}"));
2136 }
2137 for i in 0..n_ctx {
2138 hints = hints.contextual_entry(format!("c{i}"), format!("ctx action {i}"));
2139 }
2140
2141 let mut pool = GraphemePool::new();
2142 let mut frame = Frame::new(width, height, &mut pool);
2143 let area = Rect::new(0, 0, width, height);
2144 Widget::render(&hints, area, &mut frame);
2145 }
2147 }
2148}