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