use crate::{StatefulWidget, Widget, clear_text_area, draw_text_span};
use ftui_core::geometry::Rect;
use ftui_render::budget::DegradationLevel;
use ftui_render::buffer::Buffer;
use ftui_render::cell::{Cell, PackedRgba};
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_style::StyleFlags;
use ftui_text::wrap::display_width;
use std::hash::{Hash, Hasher};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub enum HelpCategory {
#[default]
General,
Navigation,
Editing,
File,
View,
Global,
Custom(String),
}
impl HelpCategory {
#[must_use]
pub fn label(&self) -> &str {
match self {
Self::General => "General",
Self::Navigation => "Navigation",
Self::Editing => "Editing",
Self::File => "File",
Self::View => "View",
Self::Global => "Global",
Self::Custom(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelpEntry {
pub key: String,
pub desc: String,
pub enabled: bool,
pub category: HelpCategory,
}
impl HelpEntry {
#[must_use]
pub fn new(key: impl Into<String>, desc: impl Into<String>) -> Self {
Self {
key: key.into(),
desc: desc.into(),
enabled: true,
category: HelpCategory::default(),
}
}
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub fn with_category(mut self, category: HelpCategory) -> Self {
self.category = category;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum HelpMode {
#[default]
Short,
Full,
}
#[derive(Debug, Clone)]
pub struct Help {
entries: Vec<HelpEntry>,
mode: HelpMode,
separator: String,
ellipsis: String,
key_style: Style,
desc_style: Style,
separator_style: Style,
}
#[derive(Debug, Default)]
pub struct HelpRenderState {
cache: Option<HelpCache>,
enabled_indices: Vec<usize>,
dirty_indices: Vec<usize>,
dirty_rects: Vec<Rect>,
stats: HelpCacheStats,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct HelpCacheStats {
pub hits: u64,
pub misses: u64,
pub dirty_updates: u64,
pub layout_rebuilds: u64,
}
impl HelpRenderState {
#[must_use]
pub fn stats(&self) -> HelpCacheStats {
self.stats
}
pub fn clear_dirty_rects(&mut self) {
self.dirty_rects.clear();
}
#[must_use]
pub fn take_dirty_rects(&mut self) -> Vec<Rect> {
std::mem::take(&mut self.dirty_rects)
}
#[must_use]
pub fn dirty_rects(&self) -> &[Rect] {
&self.dirty_rects
}
pub fn reset_stats(&mut self) {
self.stats = HelpCacheStats::default();
}
}
#[derive(Debug)]
struct HelpCache {
buffer: Buffer,
layout: HelpLayout,
key: LayoutKey,
entry_hashes: Vec<u64>,
enabled_count: usize,
}
#[derive(Debug, Clone)]
struct HelpLayout {
mode: HelpMode,
width: u16,
entries: Vec<EntrySlot>,
ellipsis: Option<EllipsisSlot>,
max_key_width: usize,
separator_width: usize,
}
#[derive(Debug, Clone)]
struct EntrySlot {
x: u16,
y: u16,
width: u16,
key_width: usize,
}
#[derive(Debug, Clone)]
struct EllipsisSlot {
x: u16,
width: u16,
prefix_space: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct StyleKey {
fg: Option<PackedRgba>,
bg: Option<PackedRgba>,
attrs: Option<StyleFlags>,
}
impl From<Style> for StyleKey {
fn from(style: Style) -> Self {
Self {
fg: style.fg,
bg: style.bg,
attrs: style.attrs,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct LayoutKey {
mode: HelpMode,
width: u16,
height: u16,
separator_hash: u64,
ellipsis_hash: u64,
key_style: StyleKey,
desc_style: StyleKey,
separator_style: StyleKey,
degradation: DegradationLevel,
}
impl Default for Help {
fn default() -> Self {
Self::new()
}
}
impl Help {
#[must_use]
pub fn new() -> Self {
Self {
entries: Vec::new(),
mode: HelpMode::Short,
separator: " • ".to_string(),
ellipsis: "…".to_string(),
key_style: Style::new().bold(),
desc_style: Style::default(),
separator_style: Style::default(),
}
}
#[must_use]
pub fn entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
self.entries.push(HelpEntry::new(key, desc));
self
}
#[must_use]
pub fn with_entry(mut self, entry: HelpEntry) -> Self {
self.entries.push(entry);
self
}
#[must_use]
pub fn with_entries(mut self, entries: Vec<HelpEntry>) -> Self {
self.entries = entries;
self
}
#[must_use]
pub fn with_mode(mut self, mode: HelpMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
self.separator = sep.into();
self
}
#[must_use]
pub fn with_ellipsis(mut self, ellipsis: impl Into<String>) -> Self {
self.ellipsis = ellipsis.into();
self
}
#[must_use]
pub fn with_key_style(mut self, style: Style) -> Self {
self.key_style = style;
self
}
#[must_use]
pub fn with_desc_style(mut self, style: Style) -> Self {
self.desc_style = style;
self
}
#[must_use]
pub fn with_separator_style(mut self, style: Style) -> Self {
self.separator_style = style;
self
}
#[must_use]
pub fn entries(&self) -> &[HelpEntry] {
&self.entries
}
#[must_use]
pub fn mode(&self) -> HelpMode {
self.mode
}
pub fn toggle_mode(&mut self) {
self.mode = match self.mode {
HelpMode::Short => HelpMode::Full,
HelpMode::Full => HelpMode::Short,
};
}
pub fn push_entry(&mut self, entry: HelpEntry) {
self.entries.push(entry);
}
fn enabled_entries(&self) -> Vec<&HelpEntry> {
self.entries.iter().filter(|e| e.enabled).collect()
}
fn render_short(&self, area: Rect, frame: &mut Frame) {
let entries = self.enabled_entries();
if entries.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let deg = frame.buffer.degradation;
let sep_width = display_width(&self.separator);
let ellipsis_width = display_width(&self.ellipsis);
let max_x = area.right();
let y = area.y;
let mut x = area.x;
for (i, entry) in entries.iter().enumerate() {
if entry.key.is_empty() && entry.desc.is_empty() {
continue;
}
let sep_w = if i > 0 { sep_width } else { 0 };
let key_w = display_width(&entry.key);
let desc_w = display_width(&entry.desc);
let item_w = key_w + 1 + desc_w;
let total_item_w = sep_w + item_w;
let space_left = (max_x as usize).saturating_sub(x as usize);
if total_item_w > space_left {
let ell_total = if i > 0 {
1 + ellipsis_width
} else {
ellipsis_width
};
if ell_total <= space_left {
let ellipsis_style = if deg.apply_styling() {
self.separator_style
} else {
Style::default()
};
if i > 0 {
x = draw_text_span(frame, x, y, " ", ellipsis_style, max_x);
}
draw_text_span(frame, x, y, &self.ellipsis, ellipsis_style, max_x);
}
break;
}
if i > 0 {
if deg.apply_styling() {
x = draw_text_span(frame, x, y, &self.separator, self.separator_style, max_x);
} else {
x = draw_text_span(frame, x, y, &self.separator, Style::default(), max_x);
}
}
if deg.apply_styling() {
x = draw_text_span(frame, x, y, &entry.key, self.key_style, max_x);
x = draw_text_span(frame, x, y, " ", self.desc_style, max_x);
x = draw_text_span(frame, x, y, &entry.desc, self.desc_style, max_x);
} else {
let text = format!("{} {}", entry.key, entry.desc);
x = draw_text_span(frame, x, y, &text, Style::default(), max_x);
}
}
}
fn render_full(&self, area: Rect, frame: &mut Frame) {
let entries = self.enabled_entries();
if entries.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let deg = frame.buffer.degradation;
let max_key_w = entries
.iter()
.filter(|e| !e.key.is_empty() || !e.desc.is_empty())
.map(|e| display_width(&e.key))
.max()
.unwrap_or(0);
let max_x = area.right();
let mut row: u16 = 0;
let key_style = if deg.apply_styling() {
self.key_style
} else {
Style::default()
};
let desc_style = if deg.apply_styling() {
self.desc_style
} else {
Style::default()
};
for entry in &entries {
if entry.key.is_empty() && entry.desc.is_empty() {
continue;
}
if row >= area.height {
break;
}
let y = area.y.saturating_add(row);
let mut x = area.x;
let key_w = display_width(&entry.key);
x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
let pad = max_key_w.saturating_sub(key_w);
for _ in 0..pad {
x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
}
x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
row += 1;
}
}
fn entry_hash(entry: &HelpEntry) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
entry.key.hash(&mut hasher);
entry.desc.hash(&mut hasher);
entry.enabled.hash(&mut hasher);
entry.category.hash(&mut hasher);
hasher.finish()
}
fn hash_str(value: &str) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value.hash(&mut hasher);
hasher.finish()
}
fn layout_key(&self, area: Rect, degradation: DegradationLevel) -> LayoutKey {
LayoutKey {
mode: self.mode,
width: area.width,
height: area.height,
separator_hash: Self::hash_str(&self.separator),
ellipsis_hash: Self::hash_str(&self.ellipsis),
key_style: StyleKey::from(self.key_style),
desc_style: StyleKey::from(self.desc_style),
separator_style: StyleKey::from(self.separator_style),
degradation,
}
}
fn build_layout(&self, area: Rect) -> HelpLayout {
match self.mode {
HelpMode::Short => self.build_short_layout(area),
HelpMode::Full => self.build_full_layout(area),
}
}
fn build_short_layout(&self, area: Rect) -> HelpLayout {
let mut entries = Vec::new();
let mut ellipsis = None;
let sep_width = display_width(&self.separator);
let ellipsis_width = display_width(&self.ellipsis);
let max_x = area.width;
let mut x: u16 = 0;
let mut first = true;
for entry in self
.entries
.iter()
.filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
{
let key_width = display_width(&entry.key);
let desc_width = display_width(&entry.desc);
let item_width = key_width + 1 + desc_width;
let total_width = if first {
item_width
} else {
sep_width + item_width
};
let space_left = (max_x as usize).saturating_sub(x as usize);
if total_width > space_left {
let ell_total = if first {
ellipsis_width
} else {
1 + ellipsis_width
};
if ell_total <= space_left {
ellipsis = Some(EllipsisSlot {
x,
width: ell_total as u16,
prefix_space: !first,
});
}
break;
}
entries.push(EntrySlot {
x,
y: 0,
width: total_width as u16,
key_width,
});
x = x.saturating_add(total_width as u16);
first = false;
}
HelpLayout {
mode: HelpMode::Short,
width: area.width,
entries,
ellipsis,
max_key_width: 0,
separator_width: sep_width,
}
}
fn build_full_layout(&self, area: Rect) -> HelpLayout {
let mut max_key_width = 0usize;
for entry in self
.entries
.iter()
.filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
{
let key_width = display_width(&entry.key);
max_key_width = max_key_width.max(key_width);
}
let mut entries = Vec::new();
let mut row: u16 = 0;
for entry in self
.entries
.iter()
.filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
{
if row >= area.height {
break;
}
let key_width = display_width(&entry.key);
let desc_width = display_width(&entry.desc);
let entry_width = max_key_width.saturating_add(2).saturating_add(desc_width);
let slot_width = entry_width.min(area.width as usize) as u16;
entries.push(EntrySlot {
x: 0,
y: row,
width: slot_width,
key_width,
});
row = row.saturating_add(1);
}
HelpLayout {
mode: HelpMode::Full,
width: area.width,
entries,
ellipsis: None,
max_key_width,
separator_width: 0,
}
}
fn render_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
match layout.mode {
HelpMode::Short => self.render_short_cached(area, frame, layout),
HelpMode::Full => self.render_full_cached(area, frame, layout),
}
}
fn render_short_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let deg = frame.buffer.degradation;
let max_x = area.right();
let mut enabled_iter = self
.entries
.iter()
.filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
for (idx, slot) in layout.entries.iter().enumerate() {
let Some(entry) = enabled_iter.next() else {
break;
};
let mut x = area.x.saturating_add(slot.x);
let y = area.y.saturating_add(slot.y);
if idx > 0 {
let sep_style = if deg.apply_styling() {
self.separator_style
} else {
Style::default()
};
x = draw_text_span(frame, x, y, &self.separator, sep_style, max_x);
}
let key_style = if deg.apply_styling() {
self.key_style
} else {
Style::default()
};
let desc_style = if deg.apply_styling() {
self.desc_style
} else {
Style::default()
};
x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
x = draw_text_span(frame, x, y, " ", desc_style, max_x);
draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
}
if let Some(ellipsis) = &layout.ellipsis {
let y = area.y.saturating_add(0);
let mut x = area.x.saturating_add(ellipsis.x);
let ellipsis_style = if deg.apply_styling() {
self.separator_style
} else {
Style::default()
};
if ellipsis.prefix_space {
x = draw_text_span(frame, x, y, " ", ellipsis_style, max_x);
}
draw_text_span(frame, x, y, &self.ellipsis, ellipsis_style, max_x);
}
}
fn render_full_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
return;
}
let deg = frame.buffer.degradation;
let max_x = area.right();
let mut enabled_iter = self
.entries
.iter()
.filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
for slot in layout.entries.iter() {
let Some(entry) = enabled_iter.next() else {
break;
};
let y = area.y.saturating_add(slot.y);
let mut x = area.x.saturating_add(slot.x);
let key_style = if deg.apply_styling() {
self.key_style
} else {
Style::default()
};
let desc_style = if deg.apply_styling() {
self.desc_style
} else {
Style::default()
};
x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
let pad = layout.max_key_width.saturating_sub(slot.key_width);
for _ in 0..pad {
x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
}
x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
}
}
fn render_short_entry(&self, slot: &EntrySlot, entry: &HelpEntry, frame: &mut Frame) {
let deg = frame.buffer.degradation;
let max_x = slot.x.saturating_add(slot.width);
let rect = Rect::new(slot.x, slot.y, slot.width, 1);
frame.buffer.fill(rect, Cell::default());
let mut x = slot.x;
if slot.x > 0 {
let sep_style = if deg.apply_styling() {
self.separator_style
} else {
Style::default()
};
x = draw_text_span(frame, x, slot.y, &self.separator, sep_style, max_x);
}
let key_style = if deg.apply_styling() {
self.key_style
} else {
Style::default()
};
let desc_style = if deg.apply_styling() {
self.desc_style
} else {
Style::default()
};
x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
x = draw_text_span(frame, x, slot.y, " ", desc_style, max_x);
draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
}
fn render_full_entry(
&self,
slot: &EntrySlot,
entry: &HelpEntry,
layout: &HelpLayout,
frame: &mut Frame,
) {
let deg = frame.buffer.degradation;
let max_x = slot.x.saturating_add(slot.width);
let rect = Rect::new(slot.x, slot.y, slot.width, 1);
frame.buffer.fill(rect, Cell::default());
let mut x = slot.x;
let key_style = if deg.apply_styling() {
self.key_style
} else {
Style::default()
};
let desc_style = if deg.apply_styling() {
self.desc_style
} else {
Style::default()
};
x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
let pad = layout.max_key_width.saturating_sub(slot.key_width);
for _ in 0..pad {
x = draw_text_span(frame, x, slot.y, " ", Style::default(), max_x);
}
x = draw_text_span(frame, x, slot.y, " ", Style::default(), max_x);
draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
}
}
impl Widget for Help {
fn render(&self, area: Rect, frame: &mut Frame) {
if area.is_empty() || area.width == 0 || area.height == 0 {
return;
}
clear_text_area(frame, area, Style::default());
match self.mode {
HelpMode::Short => self.render_short(area, frame),
HelpMode::Full => self.render_full(area, frame),
}
}
fn is_essential(&self) -> bool {
false
}
}
impl StatefulWidget for Help {
type State = HelpRenderState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut HelpRenderState) {
if area.is_empty() || area.width == 0 || area.height == 0 {
state.cache = None;
state.dirty_rects.clear();
state.dirty_indices.clear();
state.enabled_indices.clear();
return;
}
state.dirty_rects.clear();
state.dirty_indices.clear();
let layout_key = self.layout_key(area, frame.buffer.degradation);
let enabled_count = collect_enabled_indices(&self.entries, &mut state.enabled_indices);
let cache_miss = state
.cache
.as_ref()
.is_none_or(|cache| cache.key != layout_key);
if cache_miss {
rebuild_cache(self, area, frame, state, layout_key, enabled_count);
blit_cache(state.cache.as_ref(), area, frame);
return;
}
let cache = state
.cache
.as_mut()
.expect("cache present after miss check");
if enabled_count != cache.enabled_count {
rebuild_cache(self, area, frame, state, layout_key, enabled_count);
blit_cache(state.cache.as_ref(), area, frame);
return;
}
let mut layout_changed = false;
let visible_count = cache.layout.entries.len();
for (pos, entry_idx) in state.enabled_indices.iter().enumerate() {
let entry = &self.entries[*entry_idx];
let hash = Help::entry_hash(entry);
if pos >= cache.entry_hashes.len() {
layout_changed = true;
break;
}
if hash != cache.entry_hashes[pos] {
if pos >= visible_count || !entry_fits_slot(entry, pos, &cache.layout) {
layout_changed = true;
break;
}
cache.entry_hashes[pos] = hash;
state.dirty_indices.push(pos);
}
}
if layout_changed {
rebuild_cache(self, area, frame, state, layout_key, enabled_count);
blit_cache(state.cache.as_ref(), area, frame);
return;
}
if state.dirty_indices.is_empty() {
state.stats.hits += 1;
blit_cache(state.cache.as_ref(), area, frame);
return;
}
state.stats.dirty_updates += 1;
let cache = state
.cache
.as_mut()
.expect("cache present for dirty update");
let mut cache_buffer = std::mem::take(&mut cache.buffer);
cache_buffer.degradation = frame.buffer.degradation;
{
let mut cache_frame = Frame::from_buffer(cache_buffer, frame.pool);
cache_frame.widget_budget = frame.widget_budget.clone();
cache_frame.set_degradation(frame.buffer.degradation);
for idx in &state.dirty_indices {
if let Some(entry_idx) = state.enabled_indices.get(*idx)
&& let Some(slot) = cache.layout.entries.get(*idx)
{
let entry = &self.entries[*entry_idx];
match cache.layout.mode {
HelpMode::Short => self.render_short_entry(slot, entry, &mut cache_frame),
HelpMode::Full => {
self.render_full_entry(slot, entry, &cache.layout, &mut cache_frame)
}
}
state
.dirty_rects
.push(Rect::new(slot.x, slot.y, slot.width, 1));
}
}
cache_buffer = cache_frame.buffer;
}
cache.buffer = cache_buffer;
blit_cache(state.cache.as_ref(), area, frame);
}
}
fn collect_enabled_indices(entries: &[HelpEntry], out: &mut Vec<usize>) -> usize {
out.clear();
for (idx, entry) in entries.iter().enumerate() {
if entry.enabled && (!entry.key.is_empty() || !entry.desc.is_empty()) {
out.push(idx);
}
}
out.len()
}
fn entry_fits_slot(entry: &HelpEntry, index: usize, layout: &HelpLayout) -> bool {
match layout.mode {
HelpMode::Short => {
let entry_width = display_width(&entry.key) + 1 + display_width(&entry.desc);
let slot = match layout.entries.get(index) {
Some(slot) => slot,
None => return false,
};
let sep_width = layout.separator_width;
let max_width = if slot.x == 0 {
slot.width as usize
} else {
slot.width.saturating_sub(sep_width as u16) as usize
};
entry_width <= max_width
}
HelpMode::Full => {
let key_width = display_width(&entry.key);
let desc_width = display_width(&entry.desc);
let entry_width = layout
.max_key_width
.saturating_add(2)
.saturating_add(desc_width);
let slot = match layout.entries.get(index) {
Some(slot) => slot,
None => return false,
};
if slot.width == layout.width {
key_width <= layout.max_key_width
} else {
key_width <= layout.max_key_width && entry_width <= slot.width as usize
}
}
}
}
fn rebuild_cache(
help: &Help,
area: Rect,
frame: &mut Frame,
state: &mut HelpRenderState,
layout_key: LayoutKey,
enabled_count: usize,
) {
state.stats.misses += 1;
state.stats.layout_rebuilds += 1;
let layout_area = Rect::new(0, 0, area.width, area.height);
let layout = help.build_layout(layout_area);
let mut buffer = Buffer::new(area.width, area.height);
buffer.degradation = frame.buffer.degradation;
{
let mut cache_frame = Frame::from_buffer(buffer, frame.pool);
cache_frame.widget_budget = frame.widget_budget.clone();
cache_frame.set_degradation(frame.buffer.degradation);
help.render_cached(layout_area, &mut cache_frame, &layout);
buffer = cache_frame.buffer;
}
let mut entry_hashes = Vec::with_capacity(state.enabled_indices.len());
for idx in &state.enabled_indices {
entry_hashes.push(Help::entry_hash(&help.entries[*idx]));
}
state.cache = Some(HelpCache {
buffer,
layout,
key: layout_key,
entry_hashes,
enabled_count,
});
}
fn blit_cache(cache: Option<&HelpCache>, area: Rect, frame: &mut Frame) {
let Some(cache) = cache else {
return;
};
for slot in &cache.layout.entries {
let src = Rect::new(slot.x, slot.y, slot.width, 1);
frame
.buffer
.copy_from(&cache.buffer, src, area.x + slot.x, area.y + slot.y);
}
if let Some(ellipsis) = &cache.layout.ellipsis {
let src = Rect::new(ellipsis.x, 0, ellipsis.width, 1);
frame
.buffer
.copy_from(&cache.buffer, src, area.x + ellipsis.x, area.y);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum KeyFormat {
#[default]
Plain,
Bracketed,
}
#[derive(Debug, Clone)]
pub struct KeybindingHints {
global_entries: Vec<HelpEntry>,
contextual_entries: Vec<HelpEntry>,
key_format: KeyFormat,
mode: HelpMode,
key_style: Style,
desc_style: Style,
separator_style: Style,
category_style: Style,
separator: String,
ellipsis: String,
show_categories: bool,
show_context: bool,
}
impl Default for KeybindingHints {
fn default() -> Self {
Self::new()
}
}
impl KeybindingHints {
#[must_use]
pub fn new() -> Self {
Self {
global_entries: Vec::new(),
contextual_entries: Vec::new(),
key_format: KeyFormat::default(),
mode: HelpMode::Short,
key_style: Style::new().bold(),
desc_style: Style::default(),
separator_style: Style::default(),
category_style: Style::new().bold().underline(),
separator: " • ".to_string(),
ellipsis: "…".to_string(),
show_categories: true,
show_context: false,
}
}
#[must_use]
pub fn global_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
self.global_entries
.push(HelpEntry::new(key, desc).with_category(HelpCategory::Global));
self
}
#[must_use]
pub fn global_entry_categorized(
mut self,
key: impl Into<String>,
desc: impl Into<String>,
category: HelpCategory,
) -> Self {
self.global_entries
.push(HelpEntry::new(key, desc).with_category(category));
self
}
#[must_use]
pub fn contextual_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
self.contextual_entries.push(HelpEntry::new(key, desc));
self
}
#[must_use]
pub fn contextual_entry_categorized(
mut self,
key: impl Into<String>,
desc: impl Into<String>,
category: HelpCategory,
) -> Self {
self.contextual_entries
.push(HelpEntry::new(key, desc).with_category(category));
self
}
#[must_use]
pub fn with_global_entry(mut self, entry: HelpEntry) -> Self {
self.global_entries.push(entry);
self
}
#[must_use]
pub fn with_contextual_entry(mut self, entry: HelpEntry) -> Self {
self.contextual_entries.push(entry);
self
}
#[must_use]
pub fn with_key_format(mut self, format: KeyFormat) -> Self {
self.key_format = format;
self
}
#[must_use]
pub fn with_mode(mut self, mode: HelpMode) -> Self {
self.mode = mode;
self
}
#[must_use]
pub fn with_show_context(mut self, show: bool) -> Self {
self.show_context = show;
self
}
#[must_use]
pub fn with_show_categories(mut self, show: bool) -> Self {
self.show_categories = show;
self
}
#[must_use]
pub fn with_key_style(mut self, style: Style) -> Self {
self.key_style = style;
self
}
#[must_use]
pub fn with_desc_style(mut self, style: Style) -> Self {
self.desc_style = style;
self
}
#[must_use]
pub fn with_separator_style(mut self, style: Style) -> Self {
self.separator_style = style;
self
}
#[must_use]
pub fn with_category_style(mut self, style: Style) -> Self {
self.category_style = style;
self
}
#[must_use]
pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
self.separator = sep.into();
self
}
#[must_use]
pub fn global_entries(&self) -> &[HelpEntry] {
&self.global_entries
}
#[must_use]
pub fn contextual_entries(&self) -> &[HelpEntry] {
&self.contextual_entries
}
#[must_use]
pub fn mode(&self) -> HelpMode {
self.mode
}
#[must_use]
pub fn key_format(&self) -> KeyFormat {
self.key_format
}
pub fn toggle_mode(&mut self) {
self.mode = match self.mode {
HelpMode::Short => HelpMode::Full,
HelpMode::Full => HelpMode::Short,
};
}
pub fn set_show_context(&mut self, show: bool) {
self.show_context = show;
}
fn format_key(&self, key: &str) -> String {
match self.key_format {
KeyFormat::Plain => key.to_string(),
KeyFormat::Bracketed => format!("[{key}]"),
}
}
#[must_use]
pub fn visible_entries(&self) -> Vec<HelpEntry> {
let mut entries = Vec::new();
for e in &self.global_entries {
if e.enabled {
entries.push(HelpEntry {
key: self.format_key(&e.key),
desc: e.desc.clone(),
enabled: true,
category: e.category.clone(),
});
}
}
if self.show_context {
for e in &self.contextual_entries {
if e.enabled {
entries.push(HelpEntry {
key: self.format_key(&e.key),
desc: e.desc.clone(),
enabled: true,
category: e.category.clone(),
});
}
}
}
entries
}
fn grouped_entries(entries: &[HelpEntry]) -> Vec<(&HelpCategory, Vec<&HelpEntry>)> {
let mut groups: Vec<(&HelpCategory, Vec<&HelpEntry>)> = Vec::new();
for entry in entries {
if let Some(group) = groups.iter_mut().find(|(cat, _)| **cat == entry.category) {
group.1.push(entry);
} else {
groups.push((&entry.category, vec![entry]));
}
}
groups
}
fn render_full_grouped(&self, entries: &[HelpEntry], area: Rect, frame: &mut Frame) {
let groups = Self::grouped_entries(entries);
let deg = frame.buffer.degradation;
let max_x = area.right();
let mut y = area.y;
let max_key_w = entries
.iter()
.map(|e| display_width(&e.key))
.max()
.unwrap_or(0);
for (i, (cat, group_entries)) in groups.iter().enumerate() {
if y >= area.bottom() {
break;
}
let cat_style = if deg.apply_styling() {
self.category_style
} else {
Style::default()
};
draw_text_span(frame, area.x, y, cat.label(), cat_style, max_x);
y += 1;
for entry in group_entries {
if y >= area.bottom() {
break;
}
let key_style = if deg.apply_styling() {
self.key_style
} else {
Style::default()
};
let desc_style = if deg.apply_styling() {
self.desc_style
} else {
Style::default()
};
let mut x = area.x;
x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
let pad = max_key_w.saturating_sub(display_width(&entry.key));
for _ in 0..pad {
x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
}
x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
y += 1;
}
if i + 1 < groups.len() {
y += 1;
}
}
}
}
impl Widget for KeybindingHints {
fn render(&self, area: Rect, frame: &mut Frame) {
if area.is_empty() || area.width == 0 || area.height == 0 {
return;
}
clear_text_area(frame, area, Style::default());
let entries = self.visible_entries();
if entries.is_empty() {
return;
}
match self.mode {
HelpMode::Short => {
let help = Help::new()
.with_mode(HelpMode::Short)
.with_key_style(self.key_style)
.with_desc_style(self.desc_style)
.with_separator_style(self.separator_style)
.with_separator(self.separator.clone())
.with_ellipsis(self.ellipsis.clone())
.with_entries(entries);
Widget::render(&help, area, frame);
}
HelpMode::Full => {
if self.show_categories {
self.render_full_grouped(&entries, area, frame);
} else {
let help = Help::new()
.with_mode(HelpMode::Full)
.with_key_style(self.key_style)
.with_desc_style(self.desc_style)
.with_entries(entries);
Widget::render(&help, area, frame);
}
}
}
}
fn is_essential(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::buffer::Buffer;
use ftui_render::frame::Frame;
use ftui_render::grapheme_pool::GraphemePool;
use proptest::prelude::*;
use proptest::string::string_regex;
use std::time::Instant;
fn row_text(buf: &Buffer, y: u16, width: u16) -> String {
(0..width)
.map(|x| {
buf.get(x, y)
.and_then(|cell| cell.content.as_char())
.unwrap_or(' ')
})
.collect()
}
fn find_char_column(buf: &Buffer, y: u16, width: u16, target: char) -> Option<usize> {
row_text(buf, y, width).chars().position(|ch| ch == target)
}
#[test]
fn new_help_is_empty() {
let help = Help::new();
assert!(help.entries().is_empty());
assert_eq!(help.mode(), HelpMode::Short);
}
#[test]
fn entry_builder() {
let help = Help::new().entry("q", "quit").entry("^s", "save");
assert_eq!(help.entries().len(), 2);
assert_eq!(help.entries()[0].key, "q");
assert_eq!(help.entries()[0].desc, "quit");
}
#[test]
fn with_entries_replaces() {
let help = Help::new()
.entry("old", "old")
.with_entries(vec![HelpEntry::new("new", "new")]);
assert_eq!(help.entries().len(), 1);
assert_eq!(help.entries()[0].key, "new");
}
#[test]
fn disabled_entries_hidden() {
let help = Help::new()
.with_entry(HelpEntry::new("a", "shown"))
.with_entry(HelpEntry::new("b", "hidden").with_enabled(false))
.with_entry(HelpEntry::new("c", "also shown"));
assert_eq!(help.enabled_entries().len(), 2);
}
#[test]
fn toggle_mode() {
let mut help = Help::new();
assert_eq!(help.mode(), HelpMode::Short);
help.toggle_mode();
assert_eq!(help.mode(), HelpMode::Full);
help.toggle_mode();
assert_eq!(help.mode(), HelpMode::Short);
}
#[test]
fn push_entry() {
let mut help = Help::new();
help.push_entry(HelpEntry::new("x", "action"));
assert_eq!(help.entries().len(), 1);
}
#[test]
fn render_short_basic() {
let help = Help::new().entry("q", "quit").entry("^s", "save");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
Widget::render(&help, area, &mut frame);
let cell_q = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell_q.content.as_char(), Some('q'));
}
#[test]
fn render_short_truncation() {
let help = Help::new()
.entry("q", "quit")
.entry("^s", "save")
.entry("^x", "something very long that should not fit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('q'));
}
#[test]
fn render_short_truncation_keeps_ellipsis_without_styling() {
let help = Help::new()
.entry("q", "quit")
.entry("^s", "save")
.entry("^x", "something very long that should not fit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
let area = Rect::new(0, 0, 20, 1);
Widget::render(&help, area, &mut frame);
let saw_ellipsis = (area.x..area.right()).any(|x| {
frame
.buffer
.get(x, area.y)
.and_then(|cell| cell.content.as_char())
== Some('…')
});
assert!(saw_ellipsis);
}
#[test]
fn render_short_empty_entries() {
let help = Help::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
}
#[test]
fn render_short_shrinking_clears_stale_suffix() {
let long = Help::new().entry("^x", "explode").entry("^s", "save");
let short = Help::new().entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(24, 1, &mut pool);
let area = Rect::new(0, 0, 24, 1);
Widget::render(&long, area, &mut frame);
Widget::render(&short, area, &mut frame);
assert_eq!(row_text(&frame.buffer, 0, 24), "q quit ");
}
#[test]
fn render_short_empty_entries_clear_stale_row() {
let populated = Help::new().entry("q", "quit").entry("^s", "save");
let empty = Help::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&populated, area, &mut frame);
Widget::render(&empty, area, &mut frame);
assert_eq!(row_text(&frame.buffer, 0, 20), " ".repeat(20));
}
#[test]
fn render_full_basic() {
let help = Help::new()
.with_mode(HelpMode::Full)
.entry("q", "quit")
.entry("^s", "save file");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 5, &mut pool);
let area = Rect::new(0, 0, 30, 5);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(cell.content.as_char() == Some(' ') || cell.content.as_char() == Some('q'));
let cell_row2 = frame.buffer.get(0, 1).unwrap();
assert!(
cell_row2.content.as_char() == Some('^') || cell_row2.content.as_char() == Some(' ')
);
}
#[test]
fn render_full_to_short_clears_stale_lower_rows() {
let full = Help::new()
.with_mode(HelpMode::Full)
.entry("a", "alpha")
.entry("b", "beta");
let short = Help::new().entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 2, &mut pool);
let area = Rect::new(0, 0, 20, 2);
Widget::render(&full, area, &mut frame);
Widget::render(&short, area, &mut frame);
assert_eq!(row_text(&frame.buffer, 0, 20), "q quit ");
assert_eq!(row_text(&frame.buffer, 1, 20), " ".repeat(20));
}
#[test]
fn render_full_respects_height() {
let help = Help::new()
.with_mode(HelpMode::Full)
.entry("a", "first")
.entry("b", "second")
.entry("c", "third");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 2, &mut pool);
let area = Rect::new(0, 0, 30, 2);
Widget::render(&help, area, &mut frame);
}
#[test]
fn help_entry_equality() {
let a = HelpEntry::new("q", "quit");
let b = HelpEntry::new("q", "quit");
let c = HelpEntry::new("x", "exit");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn help_entry_disabled() {
let entry = HelpEntry::new("q", "quit").with_enabled(false);
assert!(!entry.enabled);
}
#[test]
fn with_separator() {
let help = Help::new().with_separator(" | ");
assert_eq!(help.separator, " | ");
}
#[test]
fn with_ellipsis() {
let help = Help::new().with_ellipsis("...");
assert_eq!(help.ellipsis, "...");
}
#[test]
fn render_zero_area() {
let help = Help::new().entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 0, 0);
Widget::render(&help, area, &mut frame); }
#[test]
fn is_not_essential() {
let help = Help::new();
assert!(!help.is_essential());
}
#[test]
fn render_full_alignment() {
let help = Help::new()
.with_mode(HelpMode::Full)
.entry("q", "quit")
.entry("ctrl+s", "save");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 3, &mut pool);
let area = Rect::new(0, 0, 30, 3);
Widget::render(&help, area, &mut frame);
}
#[test]
fn render_full_no_styling_keeps_left_aligned_key_column() {
let help = Help::new()
.with_mode(HelpMode::Full)
.entry("q", "quit")
.entry("ctrl+s", "save");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 3, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
let area = Rect::new(0, 0, 30, 3);
Widget::render(&help, area, &mut frame);
assert_eq!(
frame
.buffer
.get(0, 0)
.and_then(|cell| cell.content.as_char()),
Some('q'),
"short key should stay left-aligned in degraded full mode"
);
assert_eq!(
find_char_column(&frame.buffer, 0, area.width, 'q'),
Some(0),
"short key drifted right in degraded full mode"
);
assert_eq!(
find_char_column(&frame.buffer, 0, area.width, 'q'),
find_char_column(&frame.buffer, 1, area.width, 'c')
);
}
#[test]
fn render_full_no_styling_uses_display_width_for_wide_keys() {
let help = Help::new()
.with_mode(HelpMode::Full)
.entry("🦀", "crab")
.entry("ctrl+s", "write");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 3, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
let area = Rect::new(0, 0, 30, 3);
Widget::render(&help, area, &mut frame);
let crab_desc_col = find_char_column(&frame.buffer, 0, area.width, 'c');
let save_desc_col = find_char_column(&frame.buffer, 1, area.width, 'w');
assert_eq!(
crab_desc_col, save_desc_col,
"wide-key descriptions should align to the same display column in degraded full mode"
);
}
#[test]
fn default_impl() {
let help = Help::default();
assert!(help.entries().is_empty());
}
#[test]
fn cache_hit_same_hints() {
let help = Help::new().entry("q", "quit").entry("^s", "save");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
StatefulWidget::render(&help, area, &mut frame, &mut state);
let stats_after_first = state.stats();
StatefulWidget::render(&help, area, &mut frame, &mut state);
let stats_after_second = state.stats();
assert!(
stats_after_second.hits > stats_after_first.hits,
"Second render should be a cache hit"
);
assert!(state.dirty_rects().is_empty(), "No dirty rects on hit");
}
#[test]
fn dirty_rect_only_changes() {
let mut help = Help::new()
.with_mode(HelpMode::Full)
.entry("q", "quit")
.entry("w", "write")
.entry("e", "edit");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 3, &mut pool);
let area = Rect::new(0, 0, 40, 3);
StatefulWidget::render(&help, area, &mut frame, &mut state);
help.entries[1].desc.clear();
help.entries[1].desc.push_str("save");
StatefulWidget::render(&help, area, &mut frame, &mut state);
let dirty = state.take_dirty_rects();
assert_eq!(dirty.len(), 1, "Only one row should be dirty");
assert_eq!(dirty[0].y, 1, "Second entry row should be dirty");
}
proptest! {
#[test]
fn prop_cache_hits_on_stable_entries(entries in prop::collection::vec(
(string_regex("[a-z]{1,6}").unwrap(), string_regex("[a-z]{1,10}").unwrap()),
1..6
)) {
let mut help = Help::new();
for (key, desc) in entries {
help = help.entry(key, desc);
}
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 1, &mut pool);
let area = Rect::new(0, 0, 80, 1);
StatefulWidget::render(&help, area, &mut frame, &mut state);
let stats_after_first = state.stats();
StatefulWidget::render(&help, area, &mut frame, &mut state);
let stats_after_second = state.stats();
prop_assert!(stats_after_second.hits > stats_after_first.hits);
prop_assert!(state.dirty_rects().is_empty());
}
}
#[test]
fn perf_micro_hint_update() {
let mut help = Help::new()
.with_mode(HelpMode::Short)
.entry("^T", "Theme")
.entry("^C", "Quit")
.entry("?", "Help")
.entry("F12", "Debug");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(120, 1, &mut pool);
let area = Rect::new(0, 0, 120, 1);
StatefulWidget::render(&help, area, &mut frame, &mut state);
let iterations = 200u32;
let mut times_us = Vec::with_capacity(iterations as usize);
for i in 0..iterations {
let label = if i % 2 == 0 { "Close" } else { "Open" };
help.entries[1].desc.clear();
help.entries[1].desc.push_str(label);
let start = Instant::now();
StatefulWidget::render(&help, area, &mut frame, &mut state);
let elapsed = start.elapsed();
times_us.push(elapsed.as_micros() as u64);
}
times_us.sort();
let len = times_us.len();
let p50 = times_us[len / 2];
let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
let updates_per_sec = 1_000_000u64.checked_div(p50).unwrap_or(0);
eprintln!(
"{{\"ts\":\"2026-02-03T00:00:00Z\",\"case\":\"help_hint_update\",\"iterations\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"updates_per_sec\":{},\"hits\":{},\"misses\":{},\"dirty_updates\":{}}}",
iterations,
p50,
p95,
p99,
updates_per_sec,
state.stats().hits,
state.stats().misses,
state.stats().dirty_updates
);
assert!(p95 <= 2000, "p95 too slow: {p95}us");
}
#[test]
fn help_category_default_is_general() {
assert_eq!(HelpCategory::default(), HelpCategory::General);
}
#[test]
fn help_category_labels() {
assert_eq!(HelpCategory::General.label(), "General");
assert_eq!(HelpCategory::Navigation.label(), "Navigation");
assert_eq!(HelpCategory::Editing.label(), "Editing");
assert_eq!(HelpCategory::File.label(), "File");
assert_eq!(HelpCategory::View.label(), "View");
assert_eq!(HelpCategory::Global.label(), "Global");
assert_eq!(
HelpCategory::Custom("My Section".into()).label(),
"My Section"
);
}
#[test]
fn help_entry_with_category() {
let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
assert_eq!(entry.category, HelpCategory::Navigation);
}
#[test]
fn help_entry_default_category_is_general() {
let entry = HelpEntry::new("q", "quit");
assert_eq!(entry.category, HelpCategory::General);
}
#[test]
fn category_changes_entry_hash() {
let a = HelpEntry::new("q", "quit");
let b = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
}
#[test]
fn key_format_default_is_plain() {
assert_eq!(KeyFormat::default(), KeyFormat::Plain);
}
#[test]
fn keybinding_hints_new_is_empty() {
let hints = KeybindingHints::new();
assert!(hints.global_entries().is_empty());
assert!(hints.contextual_entries().is_empty());
assert_eq!(hints.mode(), HelpMode::Short);
assert_eq!(hints.key_format(), KeyFormat::Plain);
}
#[test]
fn keybinding_hints_default() {
let hints = KeybindingHints::default();
assert!(hints.global_entries().is_empty());
}
#[test]
fn keybinding_hints_global_entry() {
let hints = KeybindingHints::new()
.global_entry("q", "quit")
.global_entry("^s", "save");
assert_eq!(hints.global_entries().len(), 2);
assert_eq!(hints.global_entries()[0].key, "q");
assert_eq!(hints.global_entries()[0].category, HelpCategory::Global);
}
#[test]
fn keybinding_hints_categorized_entries() {
let hints = KeybindingHints::new()
.global_entry_categorized("Tab", "next", HelpCategory::Navigation)
.global_entry_categorized("q", "quit", HelpCategory::Global);
assert_eq!(hints.global_entries()[0].category, HelpCategory::Navigation);
assert_eq!(hints.global_entries()[1].category, HelpCategory::Global);
}
#[test]
fn keybinding_hints_contextual_entry() {
let hints = KeybindingHints::new()
.contextual_entry("^s", "save")
.contextual_entry_categorized("^f", "find", HelpCategory::Editing);
assert_eq!(hints.contextual_entries().len(), 2);
assert_eq!(
hints.contextual_entries()[0].category,
HelpCategory::General
);
assert_eq!(
hints.contextual_entries()[1].category,
HelpCategory::Editing
);
}
#[test]
fn keybinding_hints_with_prebuilt_entries() {
let global = HelpEntry::new("q", "quit").with_category(HelpCategory::Global);
let ctx = HelpEntry::new("^s", "save").with_category(HelpCategory::File);
let hints = KeybindingHints::new()
.with_global_entry(global)
.with_contextual_entry(ctx);
assert_eq!(hints.global_entries().len(), 1);
assert_eq!(hints.contextual_entries().len(), 1);
}
#[test]
fn keybinding_hints_toggle_mode() {
let mut hints = KeybindingHints::new();
assert_eq!(hints.mode(), HelpMode::Short);
hints.toggle_mode();
assert_eq!(hints.mode(), HelpMode::Full);
hints.toggle_mode();
assert_eq!(hints.mode(), HelpMode::Short);
}
#[test]
fn keybinding_hints_set_show_context() {
let mut hints = KeybindingHints::new()
.global_entry("q", "quit")
.contextual_entry("^s", "save");
let visible = hints.visible_entries();
assert_eq!(visible.len(), 1);
hints.set_show_context(true);
let visible = hints.visible_entries();
assert_eq!(visible.len(), 2);
}
#[test]
fn keybinding_hints_bracketed_format() {
let hints = KeybindingHints::new()
.with_key_format(KeyFormat::Bracketed)
.global_entry("q", "quit");
let visible = hints.visible_entries();
assert_eq!(visible[0].key, "[q]");
}
#[test]
fn keybinding_hints_plain_format() {
let hints = KeybindingHints::new()
.with_key_format(KeyFormat::Plain)
.global_entry("q", "quit");
let visible = hints.visible_entries();
assert_eq!(visible[0].key, "q");
}
#[test]
fn keybinding_hints_disabled_entries_hidden() {
let hints = KeybindingHints::new()
.with_global_entry(HelpEntry::new("a", "shown"))
.with_global_entry(HelpEntry::new("b", "hidden").with_enabled(false));
let visible = hints.visible_entries();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].key, "a");
}
#[test]
fn keybinding_hints_grouped_entries() {
let entries = vec![
HelpEntry::new("Tab", "next").with_category(HelpCategory::Navigation),
HelpEntry::new("q", "quit").with_category(HelpCategory::Global),
HelpEntry::new("S-Tab", "prev").with_category(HelpCategory::Navigation),
];
let groups = KeybindingHints::grouped_entries(&entries);
assert_eq!(groups.len(), 2);
assert_eq!(*groups[0].0, HelpCategory::Navigation);
assert_eq!(groups[0].1.len(), 2);
assert_eq!(*groups[1].0, HelpCategory::Global);
assert_eq!(groups[1].1.len(), 1);
}
#[test]
fn keybinding_hints_render_short() {
let hints = KeybindingHints::new()
.global_entry("q", "quit")
.global_entry("^s", "save");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
Widget::render(&hints, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('q'));
}
#[test]
fn keybinding_hints_render_short_bracketed() {
let hints = KeybindingHints::new()
.with_key_format(KeyFormat::Bracketed)
.global_entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
Widget::render(&hints, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('['));
}
#[test]
fn keybinding_hints_render_full_grouped() {
let hints = KeybindingHints::new()
.with_mode(HelpMode::Full)
.with_show_categories(true)
.global_entry_categorized("Tab", "next", HelpCategory::Navigation)
.global_entry_categorized("q", "quit", HelpCategory::Global);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
let area = Rect::new(0, 0, 40, 10);
Widget::render(&hints, area, &mut frame);
let mut row0 = String::new();
for x in 0..40u16 {
if let Some(cell) = frame.buffer.get(x, 0)
&& let Some(ch) = cell.content.as_char()
{
row0.push(ch);
}
}
assert!(
row0.contains("Navigation"),
"First row should be Navigation header: {row0}"
);
}
#[test]
fn keybinding_hints_render_full_no_categories() {
let hints = KeybindingHints::new()
.with_mode(HelpMode::Full)
.with_show_categories(false)
.global_entry("q", "quit")
.global_entry("^s", "save");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 5, &mut pool);
let area = Rect::new(0, 0, 40, 5);
Widget::render(&hints, area, &mut frame);
}
#[test]
fn keybinding_hints_render_empty() {
let hints = KeybindingHints::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&hints, area, &mut frame);
assert_eq!(row_text(&frame.buffer, 0, 20), " ".repeat(20));
}
#[test]
fn keybinding_hints_empty_clears_stale_row() {
let populated = KeybindingHints::new()
.global_entry("q", "quit")
.global_entry("^s", "save");
let empty = KeybindingHints::new();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&populated, area, &mut frame);
Widget::render(&empty, area, &mut frame);
assert_eq!(row_text(&frame.buffer, 0, 20), " ".repeat(20));
}
#[test]
fn keybinding_hints_full_to_short_clears_stale_lower_rows() {
let full = KeybindingHints::new()
.with_mode(HelpMode::Full)
.with_show_categories(true)
.global_entry("q", "quit")
.global_entry("^s", "save");
let short = KeybindingHints::new().global_entry("x", "exit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(24, 4, &mut pool);
let area = Rect::new(0, 0, 24, 4);
Widget::render(&full, area, &mut frame);
Widget::render(&short, area, &mut frame);
assert_eq!(row_text(&frame.buffer, 0, 24), "x exit ");
assert_eq!(row_text(&frame.buffer, 1, 24), " ".repeat(24));
assert_eq!(row_text(&frame.buffer, 2, 24), " ".repeat(24));
assert_eq!(row_text(&frame.buffer, 3, 24), " ".repeat(24));
}
#[test]
fn keybinding_hints_render_zero_area() {
let hints = KeybindingHints::new().global_entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 0, 0);
Widget::render(&hints, area, &mut frame);
}
#[test]
fn keybinding_hints_is_not_essential() {
let hints = KeybindingHints::new();
assert!(!hints.is_essential());
}
proptest! {
#[test]
fn prop_visible_entries_count(
n_global in 0..5usize,
n_ctx in 0..5usize,
show_ctx in proptest::bool::ANY,
) {
let mut hints = KeybindingHints::new().with_show_context(show_ctx);
for i in 0..n_global {
hints = hints.global_entry(format!("g{i}"), format!("global {i}"));
}
for i in 0..n_ctx {
hints = hints.contextual_entry(format!("c{i}"), format!("ctx {i}"));
}
let visible = hints.visible_entries();
let expected = if show_ctx { n_global + n_ctx } else { n_global };
prop_assert_eq!(visible.len(), expected);
}
#[test]
fn prop_bracketed_keys_wrapped(
keys in prop::collection::vec(string_regex("[a-z]{1,4}").unwrap(), 1..5),
) {
let mut hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
for key in &keys {
hints = hints.global_entry(key.clone(), "action");
}
let visible = hints.visible_entries();
for entry in &visible {
prop_assert!(entry.key.starts_with('['), "Key should start with [: {}", entry.key);
prop_assert!(entry.key.ends_with(']'), "Key should end with ]: {}", entry.key);
}
}
#[test]
fn prop_grouped_preserves_count(
entries in prop::collection::vec(
(string_regex("[a-z]{1,4}").unwrap(), 0..3u8),
1..8
),
) {
let help_entries: Vec<HelpEntry> = entries.into_iter().map(|(key, cat_idx)| {
let cat = match cat_idx {
0 => HelpCategory::Navigation,
1 => HelpCategory::Editing,
_ => HelpCategory::Global,
};
HelpEntry::new(key, "action").with_category(cat)
}).collect();
let total = help_entries.len();
let groups = KeybindingHints::grouped_entries(&help_entries);
let grouped_total: usize = groups.iter().map(|(_, v)| v.len()).sum();
prop_assert_eq!(total, grouped_total, "Grouping should preserve total entry count");
}
#[test]
fn prop_render_no_panic(
n_global in 0..5usize,
n_ctx in 0..5usize,
width in 1..80u16,
height in 1..20u16,
show_ctx in proptest::bool::ANY,
use_full in proptest::bool::ANY,
use_brackets in proptest::bool::ANY,
show_cats in proptest::bool::ANY,
) {
let mode = if use_full { HelpMode::Full } else { HelpMode::Short };
let fmt = if use_brackets { KeyFormat::Bracketed } else { KeyFormat::Plain };
let mut hints = KeybindingHints::new()
.with_mode(mode)
.with_key_format(fmt)
.with_show_context(show_ctx)
.with_show_categories(show_cats);
for i in 0..n_global {
hints = hints.global_entry(format!("g{i}"), format!("global action {i}"));
}
for i in 0..n_ctx {
hints = hints.contextual_entry(format!("c{i}"), format!("ctx action {i}"));
}
let mut pool = GraphemePool::new();
let mut frame = Frame::new(width, height, &mut pool);
let area = Rect::new(0, 0, width, height);
Widget::render(&hints, area, &mut frame);
}
}
#[test]
fn help_category_custom_empty_string() {
let cat = HelpCategory::Custom(String::new());
assert_eq!(cat.label(), "");
}
#[test]
fn help_category_custom_eq() {
let a = HelpCategory::Custom("Foo".into());
let b = HelpCategory::Custom("Foo".into());
let c = HelpCategory::Custom("Bar".into());
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn help_category_clone() {
let cat = HelpCategory::Navigation;
let cloned = cat.clone();
assert_eq!(cat, cloned);
}
#[test]
fn help_category_hash_consistency() {
use std::collections::hash_map::DefaultHasher;
let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
HelpCategory::File.hash(&mut h1);
HelpCategory::File.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn help_category_debug_format() {
let dbg = format!("{:?}", HelpCategory::General);
assert!(dbg.contains("General"));
let dbg_custom = format!("{:?}", HelpCategory::Custom("X".into()));
assert!(dbg_custom.contains("Custom"));
}
#[test]
fn help_entry_empty_key_and_desc() {
let entry = HelpEntry::new("", "");
assert!(entry.key.is_empty());
assert!(entry.desc.is_empty());
assert!(entry.enabled);
}
#[test]
fn help_entry_clone() {
let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
let cloned = entry.clone();
assert_eq!(entry, cloned);
}
#[test]
fn help_entry_debug_format() {
let entry = HelpEntry::new("^s", "save");
let dbg = format!("{:?}", entry);
assert!(dbg.contains("HelpEntry"));
assert!(dbg.contains("save"));
}
#[test]
fn help_mode_default_is_short() {
assert_eq!(HelpMode::default(), HelpMode::Short);
}
#[test]
fn help_mode_eq_and_hash() {
use std::collections::hash_map::DefaultHasher;
assert_eq!(HelpMode::Short, HelpMode::Short);
assert_ne!(HelpMode::Short, HelpMode::Full);
let mut h = DefaultHasher::new();
HelpMode::Full.hash(&mut h);
}
#[test]
fn help_mode_copy() {
let m = HelpMode::Full;
let m2 = m; assert_eq!(m, m2);
}
#[test]
fn render_short_all_disabled() {
let help = Help::new()
.with_entry(HelpEntry::new("a", "first").with_enabled(false))
.with_entry(HelpEntry::new("b", "second").with_enabled(false));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
}
#[test]
fn render_short_empty_key_desc_entries_skipped() {
let help = Help::new()
.with_entry(HelpEntry::new("", ""))
.entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
Widget::render(&help, area, &mut frame);
let mut found_q = false;
for x in 0..40 {
if let Some(cell) = frame.buffer.get(x, 0)
&& cell.content.as_char() == Some('q')
{
found_q = true;
break;
}
}
assert!(found_q, "'q' should appear in the rendered row");
}
#[test]
fn render_short_width_one() {
let help = Help::new().entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
let area = Rect::new(0, 0, 1, 1);
Widget::render(&help, area, &mut frame);
}
#[test]
fn render_full_width_one() {
let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 5, &mut pool);
let area = Rect::new(0, 0, 1, 5);
Widget::render(&help, area, &mut frame);
}
#[test]
fn render_full_height_one() {
let help = Help::new()
.with_mode(HelpMode::Full)
.entry("a", "first")
.entry("b", "second")
.entry("c", "third");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
Widget::render(&help, area, &mut frame);
}
#[test]
fn render_short_single_entry_exact_fit() {
let help = Help::new().entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(6, 1, &mut pool);
let area = Rect::new(0, 0, 6, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('q'));
}
#[test]
fn render_short_empty_separator() {
let help = Help::new()
.with_separator("")
.entry("a", "x")
.entry("b", "y");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('a'));
}
#[test]
fn help_with_mode_full() {
let help = Help::new().with_mode(HelpMode::Full);
assert_eq!(help.mode(), HelpMode::Full);
}
#[test]
fn help_clone() {
let help = Help::new()
.entry("q", "quit")
.with_separator(" | ")
.with_ellipsis("...");
let cloned = help.clone();
assert_eq!(cloned.entries().len(), 1);
assert_eq!(cloned.separator, " | ");
assert_eq!(cloned.ellipsis, "...");
}
#[test]
fn help_debug_format() {
let help = Help::new().entry("q", "quit");
let dbg = format!("{:?}", help);
assert!(dbg.contains("Help"));
}
#[test]
fn help_render_state_default() {
let state = HelpRenderState::default();
assert!(state.cache.is_none());
assert!(state.dirty_rects().is_empty());
assert_eq!(state.stats().hits, 0);
assert_eq!(state.stats().misses, 0);
}
#[test]
fn help_render_state_clear_dirty_rects() {
let mut state = HelpRenderState::default();
state.dirty_rects.push(Rect::new(0, 0, 10, 1));
assert_eq!(state.dirty_rects().len(), 1);
state.clear_dirty_rects();
assert!(state.dirty_rects().is_empty());
}
#[test]
fn help_render_state_take_dirty_rects() {
let mut state = HelpRenderState::default();
state.dirty_rects.push(Rect::new(0, 0, 5, 1));
state.dirty_rects.push(Rect::new(0, 1, 5, 1));
let taken = state.take_dirty_rects();
assert_eq!(taken.len(), 2);
assert!(state.dirty_rects().is_empty()); }
#[test]
fn help_render_state_reset_stats() {
let mut state = HelpRenderState::default();
state.stats.hits = 42;
state.stats.misses = 7;
state.stats.dirty_updates = 3;
state.stats.layout_rebuilds = 2;
state.reset_stats();
assert_eq!(state.stats(), HelpCacheStats::default());
}
#[test]
fn help_cache_stats_default() {
let stats = HelpCacheStats::default();
assert_eq!(stats.hits, 0);
assert_eq!(stats.misses, 0);
assert_eq!(stats.dirty_updates, 0);
assert_eq!(stats.layout_rebuilds, 0);
}
#[test]
fn help_cache_stats_clone_eq() {
let a = HelpCacheStats {
hits: 5,
misses: 2,
dirty_updates: 1,
layout_rebuilds: 3,
};
let b = a;
assert_eq!(a, b);
}
#[test]
fn stateful_render_empty_area_clears_cache() {
let help = Help::new().entry("q", "quit");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
StatefulWidget::render(&help, area, &mut frame, &mut state);
assert!(state.cache.is_some());
state.dirty_rects.push(Rect::new(0, 0, 3, 1));
state.dirty_indices.push(0);
state.enabled_indices.push(0);
let empty = Rect::new(0, 0, 0, 0);
StatefulWidget::render(&help, empty, &mut frame, &mut state);
assert!(state.cache.is_none());
assert!(state.dirty_rects().is_empty());
assert!(state.dirty_indices.is_empty());
assert!(state.enabled_indices.is_empty());
}
#[test]
fn stateful_render_cache_miss_on_area_change() {
let help = Help::new().entry("q", "quit").entry("^s", "save");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 5, &mut pool);
StatefulWidget::render(&help, Rect::new(0, 0, 40, 1), &mut frame, &mut state);
let misses1 = state.stats().misses;
StatefulWidget::render(&help, Rect::new(0, 0, 60, 1), &mut frame, &mut state);
let misses2 = state.stats().misses;
assert!(misses2 > misses1, "Area change should cause cache miss");
}
#[test]
fn stateful_render_cache_miss_on_mode_change() {
let mut help = Help::new().entry("q", "quit");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 5, &mut pool);
let area = Rect::new(0, 0, 40, 5);
StatefulWidget::render(&help, area, &mut frame, &mut state);
let misses1 = state.stats().misses;
help.toggle_mode();
StatefulWidget::render(&help, area, &mut frame, &mut state);
let misses2 = state.stats().misses;
assert!(misses2 > misses1, "Mode change should cause cache miss");
}
#[test]
fn stateful_render_layout_rebuild_on_enabled_count_change() {
let mut help = Help::new()
.entry("q", "quit")
.entry("^s", "save")
.entry("^x", "exit");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(80, 1, &mut pool);
let area = Rect::new(0, 0, 80, 1);
StatefulWidget::render(&help, area, &mut frame, &mut state);
let rebuilds1 = state.stats().layout_rebuilds;
help.entries[1].enabled = false;
StatefulWidget::render(&help, area, &mut frame, &mut state);
let rebuilds2 = state.stats().layout_rebuilds;
assert!(
rebuilds2 > rebuilds1,
"Enabled count change should trigger layout rebuild"
);
}
#[test]
fn key_format_eq_and_hash() {
use std::collections::hash_map::DefaultHasher;
assert_eq!(KeyFormat::Plain, KeyFormat::Plain);
assert_ne!(KeyFormat::Plain, KeyFormat::Bracketed);
let mut h = DefaultHasher::new();
KeyFormat::Bracketed.hash(&mut h);
}
#[test]
fn key_format_copy() {
let f = KeyFormat::Bracketed;
let f2 = f;
assert_eq!(f, f2);
}
#[test]
fn key_format_debug() {
let dbg = format!("{:?}", KeyFormat::Bracketed);
assert!(dbg.contains("Bracketed"));
}
#[test]
fn keybinding_hints_clone() {
let hints = KeybindingHints::new()
.global_entry("q", "quit")
.contextual_entry("^s", "save");
let cloned = hints.clone();
assert_eq!(cloned.global_entries().len(), 1);
assert_eq!(cloned.contextual_entries().len(), 1);
}
#[test]
fn keybinding_hints_debug() {
let hints = KeybindingHints::new().global_entry("q", "quit");
let dbg = format!("{:?}", hints);
assert!(dbg.contains("KeybindingHints"));
}
#[test]
fn keybinding_hints_with_separator() {
let hints = KeybindingHints::new().with_separator(" | ");
assert_eq!(hints.separator, " | ");
}
#[test]
fn keybinding_hints_with_styles() {
let hints = KeybindingHints::new()
.with_key_style(Style::new().bold())
.with_desc_style(Style::default())
.with_separator_style(Style::default())
.with_category_style(Style::new().underline());
assert_eq!(hints.mode(), HelpMode::Short);
}
#[test]
fn keybinding_hints_visible_entries_disabled_contextual() {
let hints = KeybindingHints::new()
.with_show_context(true)
.global_entry("q", "quit")
.with_contextual_entry(HelpEntry::new("^s", "save").with_enabled(false));
let visible = hints.visible_entries();
assert_eq!(visible.len(), 1);
assert_eq!(visible[0].desc, "quit");
}
#[test]
fn keybinding_hints_empty_global_nonempty_ctx_hidden() {
let hints = KeybindingHints::new()
.contextual_entry("^s", "save")
.contextual_entry("^f", "find");
let visible = hints.visible_entries();
assert!(visible.is_empty());
}
#[test]
fn keybinding_hints_render_full_grouped_height_limit() {
let hints = KeybindingHints::new()
.with_mode(HelpMode::Full)
.with_show_categories(true)
.global_entry_categorized("a", "first", HelpCategory::Navigation)
.global_entry_categorized("b", "second", HelpCategory::Navigation)
.global_entry_categorized("c", "third", HelpCategory::Navigation)
.global_entry_categorized("d", "fourth", HelpCategory::Global)
.global_entry_categorized("e", "fifth", HelpCategory::Global);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 3, &mut pool);
let area = Rect::new(0, 0, 40, 3);
Widget::render(&hints, area, &mut frame);
}
#[test]
fn keybinding_hints_render_empty_area() {
let hints = KeybindingHints::new().global_entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
Widget::render(&hints, Rect::new(0, 0, 0, 0), &mut frame);
}
#[test]
fn entry_hash_differs_for_different_keys() {
let a = HelpEntry::new("q", "quit");
let b = HelpEntry::new("x", "quit");
assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
}
#[test]
fn entry_hash_differs_for_different_descs() {
let a = HelpEntry::new("q", "quit");
let b = HelpEntry::new("q", "exit");
assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
}
#[test]
fn entry_hash_differs_for_enabled_flag() {
let a = HelpEntry::new("q", "quit");
let b = HelpEntry::new("q", "quit").with_enabled(false);
assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
}
#[test]
fn entry_hash_same_for_equal_entries() {
let a = HelpEntry::new("q", "quit");
let b = HelpEntry::new("q", "quit");
assert_eq!(Help::entry_hash(&a), Help::entry_hash(&b));
}
#[test]
fn help_category_custom_general_not_eq_general() {
assert_ne!(
HelpCategory::Custom("General".into()),
HelpCategory::General
);
}
#[test]
fn help_category_all_variants_distinct() {
let variants: Vec<HelpCategory> = vec![
HelpCategory::General,
HelpCategory::Navigation,
HelpCategory::Editing,
HelpCategory::File,
HelpCategory::View,
HelpCategory::Global,
HelpCategory::Custom("X".into()),
];
for (i, a) in variants.iter().enumerate() {
for (j, b) in variants.iter().enumerate() {
if i != j {
assert_ne!(a, b, "Variant {i} should differ from variant {j}");
}
}
}
}
#[test]
fn help_entry_hash_differs_by_category() {
let a = HelpEntry::new("q", "quit");
let b = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
}
#[test]
fn help_entry_only_key_no_desc_renders() {
let help = Help::new().with_entry(HelpEntry::new("q", ""));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('q'));
}
#[test]
fn help_entry_only_desc_no_key_renders() {
let help = Help::new().with_entry(HelpEntry::new("", "quit"));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(1, 0).unwrap();
assert_eq!(cell.content.as_char(), Some('q'));
}
#[test]
fn help_entry_unicode_key_and_desc() {
let help = Help::new().with_entry(HelpEntry::new("\u{2191}", "up arrow"));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
let area = Rect::new(0, 0, 20, 1);
Widget::render(&help, area, &mut frame);
}
#[test]
fn help_entry_chained_builder_overrides() {
let entry = HelpEntry::new("q", "quit")
.with_enabled(false)
.with_category(HelpCategory::File)
.with_enabled(true)
.with_category(HelpCategory::View);
assert!(entry.enabled);
assert_eq!(entry.category, HelpCategory::View);
}
#[test]
fn render_short_area_offset() {
let help = Help::new().entry("x", "action");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 5, &mut pool);
let area = Rect::new(5, 2, 20, 1);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(5, 2).unwrap();
assert_eq!(cell.content.as_char(), Some('x'));
let cell_origin = frame.buffer.get(0, 0).unwrap();
assert!(cell_origin.content.is_empty() || cell_origin.content.as_char() == Some(' '));
}
#[test]
fn render_full_area_offset() {
let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 5, &mut pool);
let area = Rect::new(3, 1, 20, 3);
Widget::render(&help, area, &mut frame);
let cell = frame.buffer.get(3, 1).unwrap();
assert_eq!(cell.content.as_char(), Some('q'));
}
#[test]
fn render_full_all_disabled() {
let help = Help::new()
.with_mode(HelpMode::Full)
.with_entry(HelpEntry::new("a", "first").with_enabled(false))
.with_entry(HelpEntry::new("b", "second").with_enabled(false));
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 3, &mut pool);
let area = Rect::new(0, 0, 30, 3);
Widget::render(&help, area, &mut frame);
}
#[test]
fn render_short_empty_ellipsis_string() {
let help = Help::new()
.with_ellipsis("")
.entry("q", "quit")
.entry("w", "this is a very long description that overflows");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(12, 1, &mut pool);
let area = Rect::new(0, 0, 12, 1);
Widget::render(&help, area, &mut frame);
}
#[test]
fn render_short_entry_wider_than_area() {
let help = Help::new().entry("verylongkey", "very long description text");
let mut pool = GraphemePool::new();
let mut frame = Frame::new(3, 1, &mut pool);
let area = Rect::new(0, 0, 3, 1);
Widget::render(&help, area, &mut frame);
}
#[test]
fn stateful_cache_invalidated_on_style_change() {
let help1 = Help::new().entry("q", "quit");
let help2 = Help::new()
.entry("q", "quit")
.with_key_style(Style::new().italic());
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
StatefulWidget::render(&help1, area, &mut frame, &mut state);
let misses_1 = state.stats().misses;
StatefulWidget::render(&help2, area, &mut frame, &mut state);
assert!(
state.stats().misses > misses_1,
"Style change should cause cache miss"
);
}
#[test]
fn stateful_entry_addition_rebuilds_layout() {
let mut help = Help::new().entry("q", "quit");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 3, &mut pool);
let area = Rect::new(0, 0, 40, 3);
StatefulWidget::render(&help, area, &mut frame, &mut state);
let rebuilds_1 = state.stats().layout_rebuilds;
help.push_entry(HelpEntry::new("w", "write"));
StatefulWidget::render(&help, area, &mut frame, &mut state);
assert!(
state.stats().layout_rebuilds > rebuilds_1,
"Entry addition should rebuild layout"
);
}
#[test]
fn stateful_separator_change_invalidates_cache() {
let help1 = Help::new()
.with_separator(" | ")
.entry("q", "quit")
.entry("w", "write");
let help2 = Help::new()
.with_separator(" - ")
.entry("q", "quit")
.entry("w", "write");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
StatefulWidget::render(&help1, area, &mut frame, &mut state);
let misses_1 = state.stats().misses;
StatefulWidget::render(&help2, area, &mut frame, &mut state);
assert!(
state.stats().misses > misses_1,
"Separator change should cause cache miss"
);
}
#[test]
fn stateful_full_mode_dirty_update_multiple() {
let mut help = Help::new()
.with_mode(HelpMode::Full)
.entry("q", "quit")
.entry("w", "save")
.entry("e", "edit");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 5, &mut pool);
let area = Rect::new(0, 0, 40, 5);
StatefulWidget::render(&help, area, &mut frame, &mut state);
help.entries[0].desc = "exit".to_string();
help.entries[2].desc = "view".to_string();
StatefulWidget::render(&help, area, &mut frame, &mut state);
let dirty = state.take_dirty_rects();
assert_eq!(dirty.len(), 2, "Two changed entries produce 2 dirty rects");
}
#[test]
fn stateful_short_mode_dirty_update() {
let mut help = Help::new()
.with_mode(HelpMode::Short)
.entry("q", "quit")
.entry("w", "write");
let mut state = HelpRenderState::default();
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 1, &mut pool);
let area = Rect::new(0, 0, 40, 1);
StatefulWidget::render(&help, area, &mut frame, &mut state);
help.entries[0].desc = "exit".to_string();
StatefulWidget::render(&help, area, &mut frame, &mut state);
assert!(
state.stats().dirty_updates > 0,
"Changed desc should trigger dirty update"
);
}
#[test]
fn build_short_layout_no_enabled_entries() {
let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
assert!(layout.entries.is_empty());
assert!(layout.ellipsis.is_none());
}
#[test]
fn build_full_layout_no_enabled_entries() {
let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
let layout = help.build_full_layout(Rect::new(0, 0, 40, 5));
assert!(layout.entries.is_empty());
assert_eq!(layout.max_key_width, 0);
}
#[test]
fn build_short_layout_triggers_ellipsis() {
let help = Help::new()
.entry("longkey", "long description text here")
.entry("another", "even longer description text");
let layout = help.build_short_layout(Rect::new(0, 0, 20, 1));
assert!(
!layout.entries.is_empty() || layout.ellipsis.is_some(),
"Should have entries or ellipsis"
);
}
#[test]
fn build_full_layout_respects_height() {
let help = Help::new()
.entry("a", "first")
.entry("b", "second")
.entry("c", "third")
.entry("d", "fourth");
let layout = help.build_full_layout(Rect::new(0, 0, 40, 2));
assert_eq!(layout.entries.len(), 2, "Should respect height=2 limit");
}
#[test]
fn build_short_layout_zero_width() {
let help = Help::new().entry("q", "quit");
let layout = help.build_short_layout(Rect::new(0, 0, 0, 1));
assert!(layout.entries.is_empty());
}
#[test]
fn build_full_layout_zero_height() {
let help = Help::new().entry("q", "quit");
let layout = help.build_full_layout(Rect::new(0, 0, 40, 0));
assert!(layout.entries.is_empty());
}
#[test]
fn entry_fits_slot_out_of_bounds_index_short() {
let help = Help::new().entry("q", "quit");
let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
let entry = &help.entries[0];
assert!(!entry_fits_slot(entry, 999, &layout));
}
#[test]
fn entry_fits_slot_out_of_bounds_index_full() {
let help = Help::new().entry("q", "quit");
let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
let entry = &help.entries[0];
assert!(!entry_fits_slot(entry, 999, &layout));
}
#[test]
fn entry_fits_slot_full_key_too_wide() {
let help = Help::new().entry("x", "d");
let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
if !layout.entries.is_empty() {
let wide_entry = HelpEntry::new("verylongkeyname", "d");
assert!(!entry_fits_slot(&wide_entry, 0, &layout));
}
}
#[test]
fn collect_enabled_indices_all_disabled() {
let entries = vec![
HelpEntry::new("a", "b").with_enabled(false),
HelpEntry::new("c", "d").with_enabled(false),
];
let mut out = Vec::new();
let count = collect_enabled_indices(&entries, &mut out);
assert_eq!(count, 0);
assert!(out.is_empty());
}
#[test]
fn collect_enabled_indices_empty_entries_filtered() {
let entries = vec![
HelpEntry::new("", ""),
HelpEntry::new("q", "quit"),
HelpEntry::new("", ""),
];
let mut out = Vec::new();
let count = collect_enabled_indices(&entries, &mut out);
assert_eq!(count, 1);
assert_eq!(out, vec![1]);
}
#[test]
fn collect_enabled_indices_mixed() {
let entries = vec![
HelpEntry::new("a", "first"),
HelpEntry::new("b", "second").with_enabled(false),
HelpEntry::new("", ""),
HelpEntry::new("d", "fourth"),
];
let mut out = Vec::new();
let count = collect_enabled_indices(&entries, &mut out);
assert_eq!(count, 2);
assert_eq!(out, vec![0, 3]);
}
#[test]
fn collect_enabled_indices_clears_previous_data() {
let entries = vec![HelpEntry::new("a", "b")];
let mut out = vec![99, 100, 101];
let count = collect_enabled_indices(&entries, &mut out);
assert_eq!(count, 1);
assert_eq!(out, vec![0]);
}
#[test]
fn blit_cache_none_is_noop() {
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
let area = Rect::new(0, 0, 10, 1);
blit_cache(None, area, &mut frame);
}
#[test]
fn style_key_from_default_style() {
let sk = StyleKey::from(Style::default());
assert!(sk.fg.is_none());
assert!(sk.bg.is_none());
assert!(sk.attrs.is_none());
}
#[test]
fn style_key_from_styled() {
let style = Style::new().bold();
let sk = StyleKey::from(style);
assert!(sk.attrs.is_some());
}
#[test]
fn style_key_equality_and_hash() {
use std::collections::hash_map::DefaultHasher;
let a = StyleKey::from(Style::new().italic());
let b = StyleKey::from(Style::new().italic());
assert_eq!(a, b);
let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
a.hash(&mut h1);
b.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn style_key_different_styles_ne() {
let a = StyleKey::from(Style::new().bold());
let b = StyleKey::from(Style::new().italic());
assert_ne!(a, b);
}
#[test]
fn hash_str_empty_deterministic() {
assert_eq!(Help::hash_str(""), Help::hash_str(""));
}
#[test]
fn hash_str_different_strings_differ() {
assert_ne!(Help::hash_str("abc"), Help::hash_str("def"));
}
#[test]
fn keybinding_hints_custom_categories_grouped() {
let entries = vec![
HelpEntry::new("a", "one").with_category(HelpCategory::Custom("Alpha".into())),
HelpEntry::new("b", "two").with_category(HelpCategory::Custom("Beta".into())),
HelpEntry::new("c", "three").with_category(HelpCategory::Custom("Alpha".into())),
];
let groups = KeybindingHints::grouped_entries(&entries);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].1.len(), 2); assert_eq!(groups[1].1.len(), 1); }
#[test]
fn keybinding_hints_all_contextual_context_on() {
let hints = KeybindingHints::new()
.with_show_context(true)
.contextual_entry("^s", "save")
.contextual_entry("^f", "find");
let visible = hints.visible_entries();
assert_eq!(visible.len(), 2);
}
#[test]
fn keybinding_hints_format_key_plain_empty() {
let hints = KeybindingHints::new().with_key_format(KeyFormat::Plain);
assert_eq!(hints.format_key(""), "");
}
#[test]
fn keybinding_hints_format_key_bracketed_empty() {
let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
assert_eq!(hints.format_key(""), "[]");
}
#[test]
fn keybinding_hints_format_key_bracketed_unicode() {
let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
assert_eq!(hints.format_key("\u{2191}"), "[\u{2191}]");
}
#[test]
fn keybinding_hints_render_full_grouped_single_category() {
let hints = KeybindingHints::new()
.with_mode(HelpMode::Full)
.with_show_categories(true)
.global_entry_categorized("a", "first", HelpCategory::Navigation)
.global_entry_categorized("b", "second", HelpCategory::Navigation);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(40, 10, &mut pool);
let area = Rect::new(0, 0, 40, 10);
Widget::render(&hints, area, &mut frame);
}
#[test]
fn help_cache_stats_ne() {
let a = HelpCacheStats::default();
let b = HelpCacheStats {
hits: 1,
..Default::default()
};
assert_ne!(a, b);
}
#[test]
fn help_cache_stats_debug() {
let stats = HelpCacheStats {
hits: 5,
misses: 2,
dirty_updates: 1,
layout_rebuilds: 3,
};
let dbg = format!("{stats:?}");
assert!(dbg.contains("hits"));
assert!(dbg.contains("misses"));
assert!(dbg.contains("dirty_updates"));
assert!(dbg.contains("layout_rebuilds"));
}
#[test]
fn layout_key_copy_and_eq() {
let help = Help::new().entry("q", "quit");
let area = Rect::new(0, 0, 40, 1);
let key1 = help.layout_key(area, DegradationLevel::Full);
let key2 = key1; assert_eq!(key1, key2);
}
#[test]
fn layout_key_differs_by_mode() {
let help_s = Help::new().entry("q", "quit");
let help_f = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
let area = Rect::new(0, 0, 40, 1);
let deg = DegradationLevel::Full;
assert_ne!(help_s.layout_key(area, deg), help_f.layout_key(area, deg));
}
#[test]
fn layout_key_differs_by_dimensions() {
let help = Help::new().entry("q", "quit");
let deg = DegradationLevel::Full;
let k1 = help.layout_key(Rect::new(0, 0, 40, 1), deg);
let k2 = help.layout_key(Rect::new(0, 0, 80, 1), deg);
assert_ne!(k1, k2);
}
#[test]
fn layout_key_hash_consistent() {
use std::collections::hash_map::DefaultHasher;
let help = Help::new().entry("q", "quit");
let key = help.layout_key(Rect::new(0, 0, 40, 1), DegradationLevel::Full);
let mut h1 = DefaultHasher::new();
let mut h2 = DefaultHasher::new();
key.hash(&mut h1);
key.hash(&mut h2);
assert_eq!(h1.finish(), h2.finish());
}
}