use std::collections::HashSet; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::widgets::{Block, Widget}; use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB}; use crate::keyboard::model::KeyboardModel; use crate::ui::theme::Theme; pub struct KeyboardDiagram<'a> { pub selected_key: Option, pub next_key: Option, pub unlocked_keys: &'a [char], pub depressed_keys: &'a HashSet, pub theme: &'a Theme, pub compact: bool, pub model: &'a KeyboardModel, pub shift_held: bool, pub caps_lock: bool, } impl<'a> KeyboardDiagram<'a> { pub fn new( next_key: Option, unlocked_keys: &'a [char], depressed_keys: &'a HashSet, theme: &'a Theme, model: &'a KeyboardModel, ) -> Self { Self { selected_key: None, next_key, unlocked_keys, depressed_keys, theme, compact: false, model, shift_held: false, caps_lock: false, } } pub fn caps_lock(mut self, caps_lock: bool) -> Self { self.caps_lock = caps_lock; self } pub fn selected_key(mut self, key: Option) -> Self { self.selected_key = key; self } pub fn compact(mut self, compact: bool) -> Self { self.compact = compact; self } pub fn shift_held(mut self, shift_held: bool) -> Self { self.shift_held = shift_held; self } /// Check if a key (by display or base char) matches the selected key. fn is_key_selected(&self, display_char: char, base_char: char) -> bool { self.selected_key == Some(display_char) || self.selected_key == Some(base_char) } /// Check if a sentinel/modifier key matches the selected key. fn is_sentinel_selected(&self, sentinel: char) -> bool { self.selected_key == Some(sentinel) } } fn brighten_color(color: Color) -> Color { match color { Color::Rgb(r, g, b) => Color::Rgb( r.saturating_add(60), g.saturating_add(60), b.saturating_add(60), ), other => other, } } fn color_to_rgb(color: Color) -> (u8, u8, u8) { match color { Color::Reset => (0, 0, 0), Color::Black => (0, 0, 0), Color::Red => (205, 49, 49), Color::Green => (13, 188, 121), Color::Yellow => (229, 229, 16), Color::Blue => (36, 114, 200), Color::Magenta => (188, 63, 188), Color::Cyan => (17, 168, 205), Color::Gray => (229, 229, 229), Color::DarkGray => (102, 102, 102), Color::LightRed => (241, 76, 76), Color::LightGreen => (35, 209, 139), Color::LightYellow => (245, 245, 67), Color::LightBlue => (59, 142, 234), Color::LightMagenta => (214, 112, 214), Color::LightCyan => (41, 184, 219), Color::White => (255, 255, 255), Color::Rgb(r, g, b) => (r, g, b), Color::Indexed(i) => { if i < 16 { const ANSI16: [(u8, u8, u8); 16] = [ (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ]; ANSI16[i as usize] } else if i <= 231 { let idx = i - 16; let r = idx / 36; let g = (idx % 36) / 6; let b = idx % 6; let cv = |n: u8| if n == 0 { 0 } else { 55 + n * 40 }; (cv(r), cv(g), cv(b)) } else { let v = 8 + (i - 232) * 10; (v, v, v) } } } } fn relative_luminance(color: Color) -> f64 { let (r, g, b) = color_to_rgb(color); let to_linear = |c: u8| { let x = c as f64 / 255.0; if x <= 0.03928 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) } }; 0.2126 * to_linear(r) + 0.7152 * to_linear(g) + 0.0722 * to_linear(b) } fn contrast_ratio(a: Color, b: Color) -> f64 { let la = relative_luminance(a); let lb = relative_luminance(b); let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; (hi + 0.05) / (lo + 0.05) } fn readable_fg(bg: Color, preferred: Color) -> Color { let mut best = preferred; let mut best_ratio = contrast_ratio(preferred, bg); for candidate in [Color::White, Color::Black] { let ratio = contrast_ratio(candidate, bg); if ratio > best_ratio { best = candidate; best_ratio = ratio; } } best } /// Blend a color toward the background at the given ratio (0.0 = full bg, 1.0 = full color). fn blend_toward_bg(color: Color, bg: Color, ratio: f32) -> Color { match (color, bg) { (Color::Rgb(r, g, b), Color::Rgb(br, bg_g, bb)) => { let mix = |c: u8, base: u8| -> u8 { (base as f32 + (c as f32 - base as f32) * ratio).round() as u8 }; Color::Rgb(mix(r, br), mix(g, bg_g), mix(b, bb)) } _ => color, } } /// Compute style for a modifier key box (Tab, Enter, Shift, Space, Backspace). fn modifier_key_style( is_depressed: bool, is_next: bool, is_selected: bool, colors: &crate::ui::theme::ThemeColors, ) -> Style { if is_depressed { let bg = brighten_color(colors.accent_dim()); Style::default() .fg(readable_fg(bg, colors.fg())) .bg(bg) .add_modifier(Modifier::BOLD) } else if is_next { let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35); Style::default().fg(readable_fg(bg, colors.accent())).bg(bg) } else if is_selected { let bg = colors.accent_dim(); Style::default().fg(readable_fg(bg, colors.fg())).bg(bg) } else { Style::default().fg(colors.fg()).bg(colors.bg()) } } fn key_style( is_depressed: bool, is_next: bool, is_selected: bool, is_unlocked: bool, colors: &crate::ui::theme::ThemeColors, ) -> Style { if is_depressed { let bg = brighten_color(colors.accent_dim()); Style::default() .fg(readable_fg(bg, colors.fg())) .bg(bg) .add_modifier(Modifier::BOLD) } else if is_next { let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35); Style::default().fg(readable_fg(bg, colors.accent())).bg(bg) } else if is_selected { let bg = colors.accent_dim(); Style::default().fg(readable_fg(bg, colors.fg())).bg(bg) } else if is_unlocked { Style::default().fg(colors.fg()).bg(colors.bg()) } else { Style::default().fg(colors.text_pending()).bg(colors.bg()) } } impl Widget for KeyboardDiagram<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; let block = Block::bordered() .title(" Keyboard ") .border_style(Style::default().fg(colors.border())) .style(Style::default().bg(colors.bg())); let inner = block.inner(area); block.render(area, buf); if self.compact { self.render_compact(inner, buf); } else { self.render_full(inner, buf); } } } impl KeyboardDiagram<'_> { fn render_compact(&self, inner: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; let letter_rows = self.model.letter_rows(); let key_width: u16 = 3; let min_width: u16 = 21; if inner.height < 3 || inner.width < min_width { return; } let offsets: &[u16] = &[3, 4, 6]; for (row_idx, row) in letter_rows.iter().enumerate() { let y = inner.y + row_idx as u16; if y >= inner.y + inner.height { break; } let offset = offsets.get(row_idx).copied().unwrap_or(0); // Render leading modifier key match row_idx { 0 => { let is_dep = self.depressed_keys.contains(&TAB); let is_next = self.next_key == Some(TAB); let is_sel = self.is_sentinel_selected(TAB); let style = modifier_key_style(is_dep, is_next, is_sel, colors); buf.set_string(inner.x, y, "[T]", style); } 2 => { let is_dep = self.shift_held; let style = modifier_key_style(is_dep, false, false, colors); buf.set_string(inner.x, y, "[S]", style); } _ => {} } for (col_idx, physical_key) in row.iter().enumerate() { let x = inner.x + offset + col_idx as u16 * key_width; if x + key_width > inner.x + inner.width { break; } // Caps lock inverts shift for alpha keys only let show_shifted = if physical_key.base.is_ascii_alphabetic() { self.shift_held ^ self.caps_lock } else { self.shift_held }; let display_char = if show_shifted { physical_key.shifted } else { physical_key.base }; let base_char = physical_key.base; let is_depressed = self.depressed_keys.contains(&base_char); let is_unlocked = self.unlocked_keys.contains(&display_char) || self.unlocked_keys.contains(&base_char); let is_next = self.next_key == Some(display_char) || self.next_key == Some(base_char); let is_sel = self.is_key_selected(display_char, base_char); let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors); let display = format!("[{display_char}]"); buf.set_string(x, y, &display, style); } // Render trailing modifier key let row_end_x = inner.x + offset + row.len() as u16 * key_width; match row_idx { 1 => { if row_end_x + 3 <= inner.x + inner.width { let is_dep = self.depressed_keys.contains(&ENTER); let is_next = self.next_key == Some(ENTER); let is_sel = self.is_sentinel_selected(ENTER); let style = modifier_key_style(is_dep, is_next, is_sel, colors); buf.set_string(row_end_x, y, "[E]", style); } } 2 => { if row_end_x + 3 <= inner.x + inner.width { let is_dep = self.shift_held; let style = modifier_key_style(is_dep, false, false, colors); buf.set_string(row_end_x, y, "[S]", style); } } _ => {} } } // Backspace at end of first row if inner.height >= 3 { let y = inner.y; let row_end_x = inner.x + offsets[0] + letter_rows[0].len() as u16 * key_width; if row_end_x + 3 <= inner.x + inner.width { let is_dep = self.depressed_keys.contains(&BACKSPACE); let is_next = self.next_key == Some(BACKSPACE); let is_sel = self.is_sentinel_selected(BACKSPACE); let style = modifier_key_style(is_dep, is_next, is_sel, colors); buf.set_string(row_end_x, y, "[B]", style); } } } fn render_full(&self, inner: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; let key_width: u16 = 5; let min_width: u16 = 75; if inner.height < 4 || inner.width < min_width { self.render_full_fallback(inner, buf); return; } let offsets: &[u16] = &[0, 5, 5, 6]; for (row_idx, row) in self.model.rows.iter().enumerate() { let y = inner.y + row_idx as u16; if y >= inner.y + inner.height { break; } let offset = offsets.get(row_idx).copied().unwrap_or(0); // Render leading modifier keys match row_idx { 1 => { if offset >= 5 { let is_dep = self.depressed_keys.contains(&TAB); let is_next = self.next_key == Some(TAB); let is_sel = self.is_sentinel_selected(TAB); let style = modifier_key_style(is_dep, is_next, is_sel, colors); let label = format!("[{}]", display::key_short_label(TAB)); buf.set_string(inner.x, y, &label, style); } } 2 => { if offset >= 5 { if self.caps_lock { let bg = colors.accent_dim(); let style = Style::default() .fg(readable_fg(bg, colors.warning())) .bg(bg); buf.set_string(inner.x, y, "[Cap]", style); } else { let style = Style::default().fg(colors.text_pending()).bg(colors.bg()); buf.set_string(inner.x, y, "[ ]", style); } } } 3 => { if offset >= 6 { let is_dep = self.shift_held; let style = modifier_key_style(is_dep, false, false, colors); buf.set_string(inner.x, y, "[Shft]", style); } } _ => {} } for (col_idx, physical_key) in row.iter().enumerate() { let x = inner.x + offset + col_idx as u16 * key_width; if x + key_width > inner.x + inner.width { break; } // Caps lock inverts shift for alpha keys only let show_shifted = if physical_key.base.is_ascii_alphabetic() { self.shift_held ^ self.caps_lock } else { self.shift_held }; let display_char = if show_shifted { physical_key.shifted } else { physical_key.base }; let base_char = physical_key.base; let is_depressed = self.depressed_keys.contains(&base_char); let is_unlocked = self.unlocked_keys.contains(&display_char) || self.unlocked_keys.contains(&base_char); let is_next = self.next_key == Some(display_char) || self.next_key == Some(base_char); let is_sel = self.is_key_selected(display_char, base_char); let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors); let display = format!("[ {display_char} ]"); buf.set_string(x, y, &display, style); } // Render trailing modifier keys let after_x = inner.x + offset + row.len() as u16 * key_width; match row_idx { 0 => { if after_x + 6 <= inner.x + inner.width { let is_dep = self.depressed_keys.contains(&BACKSPACE); let is_next = self.next_key == Some(BACKSPACE); let is_sel = self.is_sentinel_selected(BACKSPACE); let style = modifier_key_style(is_dep, is_next, is_sel, colors); let label = format!("[{}]", display::key_short_label(BACKSPACE)); buf.set_string(after_x, y, &label, style); } } 2 => { if after_x + 7 <= inner.x + inner.width { let is_dep = self.depressed_keys.contains(&ENTER); let is_next = self.next_key == Some(ENTER); let is_sel = self.is_sentinel_selected(ENTER); let style = modifier_key_style(is_dep, is_next, is_sel, colors); let label = format!("[{}]", display::key_display_name(ENTER)); buf.set_string(after_x, y, &label, style); } } 3 => { if after_x + 6 <= inner.x + inner.width { let is_dep = self.shift_held; let style = modifier_key_style(is_dep, false, false, colors); buf.set_string(after_x, y, "[Shft]", style); } } _ => {} } } // Compute full keyboard width from rendered rows (including trailing modifier keys), // so the space bar centers relative to the keyboard, not the container. let keyboard_width = self .model .rows .iter() .enumerate() .map(|(row_idx, row)| { let offset = offsets.get(row_idx).copied().unwrap_or(0); let row_end = offset + row.len() as u16 * key_width; match row_idx { 0 => row_end + 6, // [Bksp] 2 => row_end + 7, // [Enter] 3 => row_end + 6, // [Shft] _ => row_end, } }) .max() .unwrap_or(0) .min(inner.width); // Space bar row (row 4) let space_y = inner.y + 4; if space_y < inner.y + inner.height { let space_name = display::key_display_name(SPACE); let space_label = format!("[ {space_name} ]"); let space_width = space_label.len() as u16; let space_x = inner.x + (keyboard_width.saturating_sub(space_width)) / 2; if space_x + space_width <= inner.x + inner.width { let is_dep = self.depressed_keys.contains(&SPACE); let is_next = self.next_key == Some(SPACE); let is_sel = self.is_sentinel_selected(SPACE); let style = modifier_key_style(is_dep, is_next, is_sel, colors); buf.set_string(space_x, space_y, space_label, style); } } } fn render_full_fallback(&self, inner: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; let letter_rows = self.model.letter_rows(); let key_width: u16 = 5; let offsets: &[u16] = &[1, 3, 5]; if inner.height < 3 || inner.width < 30 { return; } for (row_idx, row) in letter_rows.iter().enumerate() { let y = inner.y + row_idx as u16; if y >= inner.y + inner.height { break; } let offset = offsets.get(row_idx).copied().unwrap_or(0); for (col_idx, physical_key) in row.iter().enumerate() { let x = inner.x + offset + col_idx as u16 * key_width; if x + key_width > inner.x + inner.width { break; } // Caps lock inverts shift for alpha keys only let show_shifted = if physical_key.base.is_ascii_alphabetic() { self.shift_held ^ self.caps_lock } else { self.shift_held }; let display_char = if show_shifted { physical_key.shifted } else { physical_key.base }; let base_char = physical_key.base; let is_depressed = self.depressed_keys.contains(&base_char); let is_unlocked = self.unlocked_keys.contains(&display_char) || self.unlocked_keys.contains(&base_char); let is_next = self.next_key == Some(display_char) || self.next_key == Some(base_char); let is_sel = self.is_key_selected(display_char, base_char); let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors); let display = format!("[ {display_char} ]"); buf.set_string(x, y, &display, style); } } } }