use std::collections::BTreeMap;
use crate::event::Rect;
use crate::types::{Decoration, StyledSpan, StyledText, WidgetId};
use crate::{Backend, Key, NamedKey, PaletteItem, Panel, TextDisplay, TextDisplayLine, UiEvent};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelpNote {
pub label: String,
pub description: String,
}
impl HelpNote {
pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
Self {
label: label.into(),
description: description.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelpAction {
pub id: WidgetId,
pub label: String,
pub description: String,
pub accelerator: Option<String>,
}
impl HelpAction {
pub fn new(
id: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
id: WidgetId::new(id.into()),
label: label.into(),
description: description.into(),
accelerator: None,
}
}
pub fn with_accelerator(mut self, accelerator: impl Into<String>) -> Self {
self.accelerator = Some(accelerator.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ViewHelp {
pub title: String,
pub notes: Vec<HelpNote>,
pub actions: Vec<HelpAction>,
}
impl ViewHelp {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
notes: Vec::new(),
actions: Vec::new(),
}
}
pub fn with_notes(mut self, notes: Vec<HelpNote>) -> Self {
self.notes = notes;
self
}
pub fn with_actions(mut self, actions: Vec<HelpAction>) -> Self {
self.actions = actions;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct HelpRegistry {
views: BTreeMap<String, ViewHelp>,
}
impl HelpRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, view_id: impl Into<String>, help: ViewHelp) {
self.views.insert(view_id.into(), help);
}
pub fn get(&self, view_id: &str) -> Option<&ViewHelp> {
self.views.get(view_id)
}
pub fn actions_for(&self, view_id: &str) -> &[HelpAction] {
self.views
.get(view_id)
.map(|v| v.actions.as_slice())
.unwrap_or(&[])
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpOverlayEvent {
Opened,
Closed,
Consumed,
Ignored,
}
#[derive(Debug, Clone)]
pub struct HelpOverlayController {
id: WidgetId,
open: bool,
}
impl HelpOverlayController {
pub fn new() -> Self {
Self {
id: WidgetId::new("help-cheatsheet"),
open: false,
}
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = WidgetId::new(id.into());
self
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self) -> HelpOverlayEvent {
self.open = true;
HelpOverlayEvent::Opened
}
pub fn close(&mut self) -> HelpOverlayEvent {
self.open = false;
HelpOverlayEvent::Closed
}
pub fn toggle(&mut self) -> HelpOverlayEvent {
if self.open {
self.close()
} else {
self.open()
}
}
pub fn handle(&mut self, event: &UiEvent) -> HelpOverlayEvent {
let UiEvent::KeyPressed { key, modifiers, .. } = event else {
return if self.open {
HelpOverlayEvent::Consumed
} else {
HelpOverlayEvent::Ignored
};
};
let plain = !modifiers.ctrl && !modifiers.alt;
if self.open {
match key {
Key::Named(NamedKey::Escape) => self.close(),
Key::Char('?') if plain => self.close(),
_ => HelpOverlayEvent::Consumed,
}
} else if plain && matches!(key, Key::Char('?')) {
self.open()
} else {
HelpOverlayEvent::Ignored
}
}
pub fn render(&self, container: Rect, backend: &mut dyn Backend, help: Option<&ViewHelp>) {
if !self.open {
return;
}
let rect = Self::popup_rect(container, backend);
let title = match help {
Some(h) => format!("Help — {} (Esc to close)", h.title),
None => "Help (Esc to close)".to_string(),
};
let panel = Panel {
id: self.id,
title: Some(StyledText::plain(title)),
actions: vec![],
accent: None,
collapsed: false,
};
let layout = backend.draw_panel(rect, &panel);
let lines = match help {
Some(h) => build_cheatsheet_lines(h),
None => vec![TextDisplayLine {
spans: vec![StyledSpan::plain(
"No help available for this view. (Esc to close)",
)],
decoration: Decoration::Normal,
timestamp: None,
}],
};
let td = TextDisplay {
id: WidgetId::new(format!("{}-content", self.id.as_str())),
lines,
scroll_offset: 0,
auto_scroll: false,
max_lines: 0,
has_focus: false,
title: None,
show_scrollbar: false,
};
backend.draw_text_display(layout.content_bounds, &td);
}
fn popup_rect(container: Rect, backend: &dyn Backend) -> Rect {
let lh = backend.chrome_line_height();
let cw = backend.char_width();
let w = (container.width * 0.7).max(cw * 44.0).min(container.width);
let h = (container.height * 0.7)
.max(lh * 10.0)
.min(container.height);
let x = container.x + (container.width - w) * 0.5;
let y = container.y + (container.height - h) * 0.5;
Rect::new(x, y, w, h)
}
}
impl Default for HelpOverlayController {
fn default() -> Self {
Self::new()
}
}
fn build_cheatsheet_lines(help: &ViewHelp) -> Vec<TextDisplayLine> {
let mut lines = Vec::new();
if !help.notes.is_empty() {
lines.push(header_line("Reference"));
for note in &help.notes {
lines.push(entry_line(¬e.label, None, ¬e.description));
}
}
if !help.actions.is_empty() {
if !lines.is_empty() {
lines.push(blank_line());
}
lines.push(header_line("Actions"));
for action in &help.actions {
lines.push(entry_line(
&action.label,
action.accelerator.as_deref(),
&action.description,
));
}
}
lines
}
fn header_line(text: &str) -> TextDisplayLine {
TextDisplayLine {
spans: vec![StyledSpan {
bold: true,
..StyledSpan::plain(text)
}],
decoration: Decoration::Header,
timestamp: None,
}
}
fn blank_line() -> TextDisplayLine {
TextDisplayLine {
spans: vec![StyledSpan::plain("")],
decoration: Decoration::Normal,
timestamp: None,
}
}
fn entry_line(label: &str, accelerator: Option<&str>, description: &str) -> TextDisplayLine {
let text = format!(
" {:<20}{:<14}{}",
label,
accelerator.unwrap_or(""),
description
);
TextDisplayLine {
spans: vec![StyledSpan::plain(text)],
decoration: Decoration::Normal,
timestamp: None,
}
}
pub fn help_actions_to_palette_items<'a, I>(actions: I, query: &str) -> Vec<PaletteItem>
where
I: IntoIterator<Item = &'a HelpAction>,
{
let query_lower = query.to_lowercase();
actions
.into_iter()
.map(|action| {
let mut spans = vec![StyledSpan {
bold: true,
..StyledSpan::plain(action.label.clone())
}];
if !action.description.is_empty() {
spans.push(StyledSpan::plain(format!(" — {}", action.description)));
}
let match_positions = if query_lower.is_empty() {
Vec::new()
} else {
let concatenated: String = spans.iter().map(|s| s.text.as_str()).collect();
match_byte_positions(&concatenated, &query_lower)
};
PaletteItem {
text: StyledText { spans },
detail: action
.accelerator
.as_ref()
.map(|a| StyledText::plain(a.clone())),
icon: None,
match_positions,
depth: 0,
expandable: false,
expanded: false,
}
})
.collect()
}
fn match_byte_positions(haystack: &str, needle_lower: &str) -> Vec<usize> {
if needle_lower.is_empty() {
return Vec::new();
}
let haystack_lower = haystack.to_lowercase();
let mut positions = Vec::new();
let mut search_start = 0;
while search_start <= haystack_lower.len() {
let Some(found) = haystack_lower[search_start..].find(needle_lower) else {
break;
};
let match_start = search_start + found;
positions.extend(match_start..match_start + needle_lower.len());
search_start = match_start + needle_lower.len().max(1);
}
positions
}
pub fn filter_help_actions<'a>(actions: &'a [HelpAction], query: &str) -> Vec<&'a HelpAction> {
if query.is_empty() {
return actions.iter().collect();
}
let q = query.to_lowercase();
actions
.iter()
.filter(|a| {
let haystack = format!("{} {}", a.label, a.description).to_lowercase();
crate::text_util::fuzzy_score(&haystack, &q).is_some()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Modifiers;
fn key_ev(k: Key) -> UiEvent {
UiEvent::KeyPressed {
key: k,
modifiers: Modifiers::default(),
repeat: false,
}
}
fn ctrl_ev(k: Key) -> UiEvent {
UiEvent::KeyPressed {
key: k,
modifiers: Modifiers {
ctrl: true,
..Modifiers::default()
},
repeat: false,
}
}
#[test]
fn registry_get_returns_none_for_unknown_view() {
let reg = HelpRegistry::new();
assert!(reg.get("nope").is_none());
assert!(reg.actions_for("nope").is_empty());
}
#[test]
fn registry_register_then_get_roundtrips() {
let mut reg = HelpRegistry::new();
let help = ViewHelp::new("Explorer").with_notes(vec![HelpNote::new("●", "modified")]);
reg.register("panel:explorer", help.clone());
assert_eq!(reg.get("panel:explorer"), Some(&help));
}
#[test]
fn registry_register_replaces_existing() {
let mut reg = HelpRegistry::new();
reg.register("v", ViewHelp::new("First"));
reg.register("v", ViewHelp::new("Second"));
assert_eq!(reg.get("v").unwrap().title, "Second");
}
#[test]
fn registry_actions_for_returns_registered_actions() {
let mut reg = HelpRegistry::new();
let action = HelpAction::new("a.save", "Save", "Write to disk").with_accelerator("Ctrl+S");
reg.register("v", ViewHelp::new("V").with_actions(vec![action.clone()]));
assert_eq!(reg.actions_for("v"), &[action]);
}
#[test]
fn starts_closed() {
let ctrl = HelpOverlayController::new();
assert!(!ctrl.is_open());
}
#[test]
fn question_mark_opens_when_closed() {
let mut ctrl = HelpOverlayController::new();
let ev = ctrl.handle(&key_ev(Key::Char('?')));
assert_eq!(ev, HelpOverlayEvent::Opened);
assert!(ctrl.is_open());
}
#[test]
fn other_keys_ignored_when_closed() {
let mut ctrl = HelpOverlayController::new();
let ev = ctrl.handle(&key_ev(Key::Char('x')));
assert_eq!(ev, HelpOverlayEvent::Ignored);
assert!(!ctrl.is_open());
}
#[test]
fn escape_closes_when_open() {
let mut ctrl = HelpOverlayController::new();
ctrl.open();
let ev = ctrl.handle(&key_ev(Key::Named(NamedKey::Escape)));
assert_eq!(ev, HelpOverlayEvent::Closed);
assert!(!ctrl.is_open());
}
#[test]
fn question_mark_closes_when_open() {
let mut ctrl = HelpOverlayController::new();
ctrl.open();
let ev = ctrl.handle(&key_ev(Key::Char('?')));
assert_eq!(ev, HelpOverlayEvent::Closed);
}
#[test]
fn other_keys_consumed_when_open() {
let mut ctrl = HelpOverlayController::new();
ctrl.open();
let ev = ctrl.handle(&key_ev(Key::Char('x')));
assert_eq!(ev, HelpOverlayEvent::Consumed);
assert!(ctrl.is_open(), "unrelated keys must not close the overlay");
}
#[test]
fn ctrl_question_mark_does_not_open() {
let mut ctrl = HelpOverlayController::new();
let ev = ctrl.handle(&ctrl_ev(Key::Char('?')));
assert_eq!(ev, HelpOverlayEvent::Ignored);
assert!(!ctrl.is_open());
}
#[test]
fn toggle_flips_state() {
let mut ctrl = HelpOverlayController::new();
assert_eq!(ctrl.toggle(), HelpOverlayEvent::Opened);
assert_eq!(ctrl.toggle(), HelpOverlayEvent::Closed);
}
#[test]
fn non_key_event_consumed_when_open_ignored_when_closed() {
let mut ctrl = HelpOverlayController::new();
let mouse = UiEvent::MouseDown {
widget: None,
button: crate::MouseButton::Left,
position: crate::Point::new(0.0, 0.0),
modifiers: Modifiers::default(),
};
assert_eq!(ctrl.handle(&mouse), HelpOverlayEvent::Ignored);
ctrl.open();
assert_eq!(ctrl.handle(&mouse), HelpOverlayEvent::Consumed);
}
#[test]
fn cheatsheet_lines_include_notes_and_actions() {
let help = ViewHelp::new("Explorer")
.with_notes(vec![HelpNote::new("●", "modified file")])
.with_actions(vec![
HelpAction::new("a.open", "Open", "Open the file").with_accelerator("Enter")
]);
let lines = build_cheatsheet_lines(&help);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.text.as_str()))
.collect::<Vec<_>>()
.join("\n");
assert!(joined.contains("Reference"));
assert!(joined.contains("modified file"));
assert!(joined.contains("Actions"));
assert!(joined.contains("Open"));
assert!(joined.contains("Open the file"));
assert!(joined.contains("Enter"));
}
#[test]
fn cheatsheet_lines_empty_for_empty_help() {
let help = ViewHelp::new("Empty");
assert!(build_cheatsheet_lines(&help).is_empty());
}
#[test]
fn palette_items_include_label_description_and_accelerator() {
let actions = vec![
HelpAction::new("a.save", "Save", "Write current file to disk")
.with_accelerator("Ctrl+S"),
];
let items = help_actions_to_palette_items(&actions, "");
assert_eq!(items.len(), 1);
let text: String = items[0]
.text
.spans
.iter()
.map(|s| s.text.as_str())
.collect();
assert!(text.contains("Save"));
assert!(text.contains("Write current file to disk"));
assert_eq!(items[0].detail.as_ref().unwrap().spans[0].text, "Ctrl+S");
}
#[test]
fn palette_items_omit_detail_when_no_accelerator() {
let actions = vec![HelpAction::new("a.x", "X", "does x")];
let items = help_actions_to_palette_items(&actions, "");
assert!(items[0].detail.is_none());
}
#[test]
fn palette_items_match_positions_empty_for_empty_query() {
let actions = vec![HelpAction::new("a.save", "Save", "Write to disk")];
let items = help_actions_to_palette_items(&actions, "");
assert!(items[0].match_positions.is_empty());
}
#[test]
fn palette_items_match_positions_highlight_label_match() {
let actions = vec![HelpAction::new("a.save", "Save", "Write to disk")];
let items = help_actions_to_palette_items(&actions, "sav");
assert_eq!(items[0].match_positions, vec![0, 1, 2]);
}
#[test]
fn palette_items_match_positions_highlight_description_match() {
let actions = vec![HelpAction::new("a.save", "Save", "Write to disk")];
let items = help_actions_to_palette_items(&actions, "disk");
let concatenated = "Save — Write to disk";
let start = concatenated.find("disk").unwrap();
assert_eq!(
items[0].match_positions,
(start..start + "disk".len()).collect::<Vec<_>>()
);
}
#[test]
fn palette_items_match_positions_case_insensitive() {
let actions = vec![HelpAction::new("a.save", "Save", "write to disk")];
let items = help_actions_to_palette_items(&actions, "SAVE");
assert_eq!(items[0].match_positions, vec![0, 1, 2, 3]);
}
#[test]
fn palette_items_match_positions_multiple_occurrences() {
let actions = vec![HelpAction::new("a.x", "Cat", "concatenate files")];
let items = help_actions_to_palette_items(&actions, "cat");
let concatenated_lower = "cat — concatenate files";
let expected: Vec<usize> = concatenated_lower
.match_indices("cat")
.flat_map(|(i, m)| i..i + m.len())
.collect();
assert!(
expected.len() > 3,
"sanity check: query should match in two places"
);
assert_eq!(items[0].match_positions, expected);
}
#[test]
fn filter_empty_query_returns_all() {
let actions = vec![
HelpAction::new("a", "Alpha", "first"),
HelpAction::new("b", "Beta", "second"),
];
assert_eq!(filter_help_actions(&actions, "").len(), 2);
}
#[test]
fn filter_matches_label() {
let actions = vec![
HelpAction::new("a", "Alpha", "first"),
HelpAction::new("b", "Beta", "second"),
];
let matched = filter_help_actions(&actions, "alp");
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].label, "Alpha");
}
#[test]
fn filter_matches_description_when_label_does_not_match() {
let actions = vec![
HelpAction::new("a", "Save", "write current file to disk"),
HelpAction::new("b", "Quit", "exit the application"),
];
let matched = filter_help_actions(&actions, "disk");
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].label, "Save");
}
#[test]
fn filter_no_match_returns_empty() {
let actions = vec![HelpAction::new("a", "Save", "write to disk")];
assert!(filter_help_actions(&actions, "zzz").is_empty());
}
#[test]
fn filter_matches_non_contiguous_subsequence() {
let actions = vec![
HelpAction::new("a", "Save", "write current file to disk"),
HelpAction::new("b", "Quit", "exit the application"),
];
let matched = filter_help_actions(&actions, "sv");
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].label, "Save");
}
}