1use crate::key::Binding;
23use bubbletea::{Cmd, Message, Model};
24use lipgloss::Style;
25use unicode_width::UnicodeWidthStr;
26
27#[derive(Debug, Clone)]
29pub struct Styles {
30 pub ellipsis: Style,
32 pub short_key: Style,
34 pub short_desc: Style,
36 pub short_separator: Style,
38 pub full_key: Style,
40 pub full_desc: Style,
42 pub full_separator: Style,
44}
45
46impl Default for Styles {
47 fn default() -> Self {
48 Self {
49 ellipsis: Style::new(),
50 short_key: Style::new(),
51 short_desc: Style::new(),
52 short_separator: Style::new(),
53 full_key: Style::new(),
54 full_desc: Style::new(),
55 full_separator: Style::new(),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Copy)]
62pub struct ToggleFullHelpMsg;
63
64#[derive(Debug, Clone, Copy)]
66pub struct SetWidthMsg(pub usize);
67
68#[derive(Debug, Clone)]
70pub struct SetBindingsMsg(pub Vec<Binding>);
71
72#[derive(Debug, Clone)]
74pub struct Help {
75 pub width: usize,
77 pub show_all: bool,
79 pub short_separator: String,
81 pub full_separator: String,
83 pub ellipsis: String,
85 pub styles: Styles,
87 bindings: Vec<Binding>,
89}
90
91impl Default for Help {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl Help {
98 #[must_use]
100 pub fn new() -> Self {
101 Self {
102 width: 0,
103 show_all: false,
104 short_separator: " • ".to_string(),
105 full_separator: " ".to_string(),
106 ellipsis: "…".to_string(),
107 styles: Styles::default(),
108 bindings: Vec::new(),
109 }
110 }
111
112 #[must_use]
114 pub fn with_bindings(mut self, bindings: Vec<Binding>) -> Self {
115 self.bindings = bindings;
116 self
117 }
118
119 pub fn set_bindings(&mut self, bindings: Vec<Binding>) {
121 self.bindings = bindings;
122 }
123
124 #[must_use]
126 pub fn bindings(&self) -> &[Binding] {
127 &self.bindings
128 }
129
130 #[must_use]
132 pub fn width(mut self, width: usize) -> Self {
133 self.width = width;
134 self
135 }
136
137 #[must_use]
139 pub fn show_all(mut self, show: bool) -> Self {
140 self.show_all = show;
141 self
142 }
143
144 #[must_use]
148 pub fn view(&self, bindings: &[&Binding]) -> String {
149 if self.show_all {
150 self.full_help_view(&[bindings.to_vec()])
151 } else {
152 self.short_help_view(bindings)
153 }
154 }
155
156 #[must_use]
158 pub fn short_help_view(&self, bindings: &[&Binding]) -> String {
159 if bindings.is_empty() {
160 return String::new();
161 }
162
163 let mut result = String::new();
164 let mut total_width = 0;
165
166 for binding in bindings {
167 if !binding.enabled() {
168 continue;
169 }
170
171 let help = binding.get_help();
172 if help.key.is_empty() && help.desc.is_empty() {
173 continue;
174 }
175
176 let sep = if total_width > 0 {
178 self.styles.short_separator.render(&self.short_separator)
179 } else {
180 String::new()
181 };
182
183 let key_str = self.styles.short_key.render(&help.key);
185 let desc_str = self.styles.short_desc.render(&help.desc);
186 let item = format!("{}{} {}", sep, key_str, desc_str);
187 let item_width = sep.width() + help.key.width() + 1 + help.desc.width();
188
189 if self.width > 0 {
191 let ellipsis_width = 1 + self.ellipsis.width();
192 if total_width + item_width > self.width {
193 if total_width + ellipsis_width < self.width {
194 result.push(' ');
195 result.push_str(&self.styles.ellipsis.render(&self.ellipsis));
196 }
197 break;
198 }
199 }
200
201 total_width += item_width;
202 result.push_str(&item);
203 }
204
205 result
206 }
207
208 #[must_use]
210 pub fn full_help_view(&self, groups: &[Vec<&Binding>]) -> String {
211 if groups.is_empty() {
212 return String::new();
213 }
214
215 let mut columns: Vec<String> = Vec::new();
216 let mut total_width = 0;
217
218 for group in groups {
219 if !should_render_column(group) {
220 continue;
221 }
222
223 let mut keys: Vec<&str> = Vec::new();
225 let mut descs: Vec<&str> = Vec::new();
226
227 for binding in group {
228 if binding.enabled() {
229 let help = binding.get_help();
230 if !help.key.is_empty() || !help.desc.is_empty() {
231 keys.push(help.key.as_str());
232 descs.push(help.desc.as_str());
233 }
234 }
235 }
236
237 if keys.is_empty() {
238 continue;
239 }
240
241 let sep = if total_width > 0 {
243 self.styles.full_separator.render(&self.full_separator)
244 } else {
245 String::new()
246 };
247
248 let keys_col = self.styles.full_key.render(&keys.join("\n"));
250 let descs_col = self.styles.full_desc.render(&descs.join("\n"));
251 let column = lipgloss::join_horizontal(
253 lipgloss::Position::Top,
254 &[&sep, &keys_col, " ", &descs_col],
255 );
256
257 let max_key_width = keys.iter().map(|k| k.width()).max().unwrap_or(0);
259 let max_desc_width = descs.iter().map(|d| d.width()).max().unwrap_or(0);
260 let col_width = self.full_separator.width() + max_key_width + 1 + max_desc_width;
261
262 if self.width > 0 && total_width + col_width > self.width {
264 break;
265 }
266
267 total_width += col_width;
268 columns.push(column);
269 }
270
271 if columns.len() <= 1 {
273 columns.join("")
274 } else {
275 let refs: Vec<&str> = columns.iter().map(|s| s.as_str()).collect();
276 lipgloss::join_horizontal(lipgloss::Position::Top, &refs)
277 }
278 }
279}
280
281fn should_render_column(bindings: &[&Binding]) -> bool {
283 bindings.iter().any(|b| b.enabled())
284}
285
286pub trait KeyMap {
288 fn short_help(&self) -> Vec<Binding>;
290
291 fn full_help(&self) -> Vec<Vec<Binding>>;
293}
294
295impl Model for Help {
297 fn init(&self) -> Option<Cmd> {
298 None
300 }
301
302 fn update(&mut self, msg: Message) -> Option<Cmd> {
303 if msg.is::<ToggleFullHelpMsg>() {
305 self.show_all = !self.show_all;
306 return None;
307 }
308
309 if let Some(SetWidthMsg(width)) = msg.downcast_ref::<SetWidthMsg>() {
311 self.width = *width;
312 return None;
313 }
314
315 if let Some(set_bindings) = msg.downcast::<SetBindingsMsg>() {
317 self.bindings = set_bindings.0;
318 return None;
319 }
320
321 None
322 }
323
324 fn view(&self) -> String {
325 let binding_refs: Vec<&Binding> = self.bindings.iter().collect();
327 if self.show_all {
328 self.full_help_view(&[binding_refs])
329 } else {
330 self.short_help_view(&binding_refs)
331 }
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_help_new() {
341 let help = Help::new();
342 assert_eq!(help.width, 0);
343 assert!(!help.show_all);
344 assert_eq!(help.short_separator, " • ");
345 }
346
347 #[test]
348 fn test_help_short_view() {
349 let help = Help::new();
350 let quit = Binding::new().keys(&["q"]).help("q", "quit");
351 let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
352
353 let view = help.short_help_view(&[&quit, &save]);
354 assert!(view.contains("q"));
355 assert!(view.contains("quit"));
356 assert!(view.contains("^s"));
357 assert!(view.contains("save"));
358 }
359
360 #[test]
361 fn test_help_short_view_with_width() {
362 let help = Help::new().width(20);
363 let quit = Binding::new().keys(&["q"]).help("q", "quit");
364 let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
365 let other = Binding::new().keys(&["x"]).help("x", "something very long");
366
367 let view = help.short_help_view(&[&quit, &save, &other]);
368 assert!(view.len() <= 25); }
371
372 #[test]
373 fn test_help_full_view() {
374 let help = Help::new();
375 let quit = Binding::new().keys(&["q"]).help("q", "quit");
376 let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
377
378 let view = help.full_help_view(&[vec![&quit, &save]]);
379 assert!(view.contains("q"));
380 assert!(view.contains("quit"));
381 }
382
383 #[test]
384 fn test_help_empty_bindings() {
385 let help = Help::new();
386 assert_eq!(help.short_help_view(&[]), "");
387 assert_eq!(help.full_help_view(&[]), "");
388 }
389
390 #[test]
391 fn test_help_disabled_bindings() {
392 let help = Help::new();
393 let disabled = Binding::new()
394 .keys(&["q"])
395 .help("q", "quit")
396 .set_enabled(false);
397
398 let view = help.short_help_view(&[&disabled]);
399 assert!(!view.contains("quit"));
400 }
401
402 #[test]
403 fn test_help_builder() {
404 let help = Help::new().width(80).show_all(true);
405 assert_eq!(help.width, 80);
406 assert!(help.show_all);
407 }
408
409 #[test]
410 fn test_should_render_column() {
411 let enabled = Binding::new().keys(&["q"]).help("q", "quit");
412 let disabled = Binding::new()
413 .keys(&["x"])
414 .help("x", "exit")
415 .set_enabled(false);
416
417 assert!(should_render_column(&[&enabled]));
418 assert!(!should_render_column(&[&disabled]));
419 assert!(should_render_column(&[&disabled, &enabled]));
420 }
421
422 #[test]
425 fn test_help_model_init_returns_none() {
426 let help = Help::new();
427 assert!(Model::init(&help).is_none());
428 }
429
430 #[test]
431 fn test_help_model_toggle_full_help() {
432 let mut help = Help::new();
433 assert!(!help.show_all);
434
435 Model::update(&mut help, Message::new(ToggleFullHelpMsg));
436 assert!(help.show_all);
437
438 Model::update(&mut help, Message::new(ToggleFullHelpMsg));
439 assert!(!help.show_all);
440 }
441
442 #[test]
443 fn test_help_model_set_width() {
444 let mut help = Help::new();
445 assert_eq!(help.width, 0);
446
447 Model::update(&mut help, Message::new(SetWidthMsg(80)));
448 assert_eq!(help.width, 80);
449
450 Model::update(&mut help, Message::new(SetWidthMsg(120)));
451 assert_eq!(help.width, 120);
452 }
453
454 #[test]
455 fn test_help_model_set_bindings() {
456 let mut help = Help::new();
457 assert!(help.bindings().is_empty());
458
459 let bindings = vec![
460 Binding::new().keys(&["q"]).help("q", "quit"),
461 Binding::new().keys(&["ctrl+s"]).help("^s", "save"),
462 ];
463
464 Model::update(&mut help, Message::new(SetBindingsMsg(bindings)));
465 assert_eq!(help.bindings().len(), 2);
466 }
467
468 #[test]
469 fn test_help_model_view_short_mode() {
470 let quit = Binding::new().keys(&["q"]).help("q", "quit");
471 let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
472
473 let help = Help::new().with_bindings(vec![quit, save]);
474 let view = Model::view(&help);
475
476 assert!(view.contains("q"));
477 assert!(view.contains("quit"));
478 assert!(view.contains("^s"));
479 assert!(view.contains("save"));
480 }
481
482 #[test]
483 fn test_help_model_view_full_mode() {
484 let quit = Binding::new().keys(&["q"]).help("q", "quit");
485 let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
486
487 let help = Help::new().with_bindings(vec![quit, save]).show_all(true);
488 let view = Model::view(&help);
489
490 assert!(view.contains("q"));
491 assert!(view.contains("quit"));
492 }
493
494 #[test]
495 fn test_help_model_view_empty_bindings() {
496 let help = Help::new();
497 let view = Model::view(&help);
498 assert!(view.is_empty());
499 }
500
501 #[test]
502 fn test_help_model_view_respects_width() {
503 let quit = Binding::new().keys(&["q"]).help("q", "quit");
504 let save = Binding::new().keys(&["ctrl+s"]).help("^s", "save");
505 let other = Binding::new()
506 .keys(&["x"])
507 .help("x", "something very very long");
508
509 let help = Help::new().width(20).with_bindings(vec![quit, save, other]);
510 let view = Model::view(&help);
511
512 assert!(view.len() <= 30); }
515
516 #[test]
517 fn test_help_with_bindings_builder() {
518 let bindings = vec![
519 Binding::new().keys(&["q"]).help("q", "quit"),
520 Binding::new().keys(&["ctrl+s"]).help("^s", "save"),
521 ];
522
523 let help = Help::new().with_bindings(bindings);
524 assert_eq!(help.bindings().len(), 2);
525 }
526
527 #[test]
528 fn test_help_set_bindings_method() {
529 let mut help = Help::new();
530 help.set_bindings(vec![Binding::new().keys(&["q"]).help("q", "quit")]);
531 assert_eq!(help.bindings().len(), 1);
532 }
533
534 #[test]
535 fn test_help_model_satisfies_model_bounds() {
536 fn accepts_model<M: Model + Send + 'static>(_model: M) {}
537 let help = Help::new();
538 accepts_model(help);
539 }
540
541 #[test]
542 fn test_help_model_update_returns_none() {
543 let mut help = Help::new();
544 assert!(Model::update(&mut help, Message::new(ToggleFullHelpMsg)).is_none());
546 assert!(Model::update(&mut help, Message::new(SetWidthMsg(80))).is_none());
547 assert!(Model::update(&mut help, Message::new(SetBindingsMsg(vec![]))).is_none());
548 }
549
550 #[test]
553 fn test_help_full_view_multi_group() {
554 let help = Help::new();
556 let nav_up = Binding::new().keys(&["up", "k"]).help("↑/k", "up");
557 let nav_down = Binding::new().keys(&["down", "j"]).help("↓/j", "down");
558 let action_enter = Binding::new().keys(&["enter"]).help("enter", "select");
559 let action_quit = Binding::new().keys(&["q", "ctrl+c"]).help("q", "quit");
560
561 let groups = vec![vec![&nav_up, &nav_down], vec![&action_enter, &action_quit]];
562
563 let view = help.full_help_view(&groups);
564 assert!(view.contains("↑/k"));
566 assert!(view.contains("↓/j"));
567 assert!(view.contains("enter"));
568 assert!(view.contains("quit"));
569 assert!(view.contains('\n'));
571 }
572
573 #[test]
574 fn test_help_full_view_with_width_truncation() {
575 let help = Help::new().width(30);
577 let b1 = Binding::new().keys(&["a"]).help("a", "first action");
578 let b2 = Binding::new().keys(&["b"]).help("b", "second action");
579 let b3 = Binding::new()
580 .keys(&["c"])
581 .help("c", "third action that won't fit");
582
583 let groups = vec![vec![&b1], vec![&b2], vec![&b3]];
584
585 let view = help.full_help_view(&groups);
586 assert!(!view.is_empty());
589 }
590
591 #[test]
592 fn test_help_mixed_enabled_disabled_in_group() {
593 let help = Help::new();
595 let enabled = Binding::new().keys(&["a"]).help("a", "enabled");
596 let disabled = Binding::new()
597 .keys(&["b"])
598 .help("b", "disabled")
599 .set_enabled(false);
600 let enabled2 = Binding::new().keys(&["c"]).help("c", "also enabled");
601
602 let view = help.short_help_view(&[&enabled, &disabled, &enabled2]);
603
604 assert!(view.contains("a"));
605 assert!(!view.contains("b disabled"));
606 assert!(view.contains("c"));
607 }
608
609 #[test]
610 fn test_help_full_view_skips_all_disabled_group() {
611 let help = Help::new();
613 let enabled = Binding::new().keys(&["a"]).help("a", "enabled");
614 let disabled1 = Binding::new()
615 .keys(&["b"])
616 .help("b", "disabled1")
617 .set_enabled(false);
618 let disabled2 = Binding::new()
619 .keys(&["c"])
620 .help("c", "disabled2")
621 .set_enabled(false);
622
623 let groups = vec![vec![&disabled1, &disabled2], vec![&enabled]];
624
625 let view = help.full_help_view(&groups);
626 assert!(view.contains("a"));
627 assert!(view.contains("enabled"));
628 assert!(!view.contains("disabled1"));
629 assert!(!view.contains("disabled2"));
630 }
631
632 #[test]
633 fn test_help_short_view_ellipsis_truncation() {
634 let help = Help::new().width(15);
636 let b1 = Binding::new().keys(&["a"]).help("a", "first");
637 let b2 = Binding::new().keys(&["b"]).help("b", "second");
638 let b3 = Binding::new().keys(&["c"]).help("c", "third");
639
640 let view = help.short_help_view(&[&b1, &b2, &b3]);
641
642 assert!(view.len() <= 20); }
645
646 #[test]
647 fn test_help_separator_styles() {
648 let mut help = Help::new();
650 help.short_separator = " | ".to_string();
651 help.full_separator = " || ".to_string();
652
653 let b1 = Binding::new().keys(&["a"]).help("a", "first");
654 let b2 = Binding::new().keys(&["b"]).help("b", "second");
655
656 let short_view = help.short_help_view(&[&b1, &b2]);
657 assert!(short_view.contains(" | "), "Short view: {}", short_view);
658
659 let full_view = help.full_help_view(&[vec![&b1], vec![&b2]]);
660 assert!(full_view.contains("||"), "Full view: {}", full_view);
661 }
662
663 #[test]
664 fn test_help_unicode_keys() {
665 let help = Help::new();
667 let arrow_up = Binding::new().keys(&["up"]).help("↑", "move up");
668 let arrow_down = Binding::new().keys(&["down"]).help("↓", "move down");
669
670 let view = help.short_help_view(&[&arrow_up, &arrow_down]);
671 assert!(view.contains("↑"));
672 assert!(view.contains("↓"));
673 }
674
675 #[test]
676 fn test_help_empty_key_or_desc() {
677 let help = Help::new();
679 let empty_both = Binding::new().keys(&["a"]).help("", "");
680 let empty_key = Binding::new().keys(&["b"]).help("", "desc only");
681 let empty_desc = Binding::new().keys(&["c"]).help("key only", "");
682 let normal = Binding::new().keys(&["d"]).help("d", "normal");
683
684 let view = help.short_help_view(&[&empty_both, &empty_key, &empty_desc, &normal]);
685
686 assert!(view.contains("desc only") || view.contains("key only"));
690 assert!(view.contains("d normal") || view.contains("d") && view.contains("normal"));
691 }
692
693 #[test]
694 fn test_help_view_method_dispatches_correctly() {
695 let b1 = Binding::new().keys(&["a"]).help("a", "action");
697 let b2 = Binding::new().keys(&["b"]).help("b", "back");
698
699 let help_short = Help::new();
700 let help_full = Help::new().show_all(true);
701
702 let short_view = help_short.view(&[&b1, &b2]);
703 let full_view = help_full.view(&[&b1, &b2]);
704
705 assert!(!short_view.contains('\n') || short_view.lines().count() == 1);
707 assert!(full_view.contains("a"));
709 assert!(full_view.contains("b"));
710 }
711
712 #[test]
713 fn test_help_default_separators() {
714 let help = Help::new();
716 assert_eq!(help.short_separator, " • ");
717 assert_eq!(help.full_separator, " ");
718 assert_eq!(help.ellipsis, "…");
719 }
720
721 #[test]
722 fn test_help_zero_width_no_truncation() {
723 let help = Help::new().width(0);
725 let b1 = Binding::new().keys(&["a"]).help(
726 "a",
727 "a very long description that would normally be truncated",
728 );
729 let b2 = Binding::new()
730 .keys(&["b"])
731 .help("b", "another very long description");
732
733 let view = help.short_help_view(&[&b1, &b2]);
734
735 assert!(view.contains("a very long description"));
737 assert!(view.contains("another very long description"));
738 }
739}