Key milestone overlays + keyboard diagram improvements
Also splits out a separate store for ranked stats from overall key stats.
This commit is contained in:
@@ -5,12 +5,12 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::keyboard::finger::{Finger, Hand};
|
||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct KeyboardDiagram<'a> {
|
||||
pub focused_key: Option<char>,
|
||||
pub selected_key: Option<char>,
|
||||
pub next_key: Option<char>,
|
||||
pub unlocked_keys: &'a [char],
|
||||
pub depressed_keys: &'a HashSet<char>,
|
||||
@@ -18,11 +18,11 @@ pub struct KeyboardDiagram<'a> {
|
||||
pub compact: bool,
|
||||
pub model: &'a KeyboardModel,
|
||||
pub shift_held: bool,
|
||||
pub caps_lock: bool,
|
||||
}
|
||||
|
||||
impl<'a> KeyboardDiagram<'a> {
|
||||
pub fn new(
|
||||
focused_key: Option<char>,
|
||||
next_key: Option<char>,
|
||||
unlocked_keys: &'a [char],
|
||||
depressed_keys: &'a HashSet<char>,
|
||||
@@ -30,7 +30,7 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
model: &'a KeyboardModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
focused_key,
|
||||
selected_key: None,
|
||||
next_key,
|
||||
unlocked_keys,
|
||||
depressed_keys,
|
||||
@@ -38,9 +38,20 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
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<char>) -> Self {
|
||||
self.selected_key = key;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn compact(mut self, compact: bool) -> Self {
|
||||
self.compact = compact;
|
||||
self
|
||||
@@ -50,20 +61,15 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
self.shift_held = shift_held;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn finger_color(model: &KeyboardModel, ch: char) -> Color {
|
||||
let assignment = model.finger_for_char(ch);
|
||||
match (assignment.hand, assignment.finger) {
|
||||
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
|
||||
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
|
||||
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
_ => Color::Rgb(120, 120, 120),
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +84,69 @@ fn brighten_color(color: Color) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(brighten_color(colors.accent_dim()))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
|
||||
Style::default().fg(colors.accent()).bg(bg)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(colors.accent_dim())
|
||||
} 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 {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(brighten_color(colors.accent_dim()))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
|
||||
Style::default().fg(colors.accent()).bg(bg)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(colors.accent_dim())
|
||||
} 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;
|
||||
@@ -90,233 +159,289 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
block.render(area, buf);
|
||||
|
||||
if self.compact {
|
||||
// Compact mode: letter rows only (rows 1-3 of the model)
|
||||
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] = &[0, 1, 3];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
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_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[{display_char}]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
}
|
||||
self.render_compact(inner, buf);
|
||||
} else {
|
||||
// Full mode: all 4 rows
|
||||
let key_width: u16 = 5;
|
||||
let min_width: u16 = 69;
|
||||
|
||||
if inner.height < 4 || inner.width < min_width {
|
||||
// Fallback to compact-style if too narrow for full
|
||||
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;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
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_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Row offsets for full layout (staggered keyboard)
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
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_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
|
||||
// Modifier labels at row edges (visual only)
|
||||
let label_style = Style::default().fg(colors.text_pending());
|
||||
let after_x = inner.x + offset + row.len() as u16 * key_width + 1;
|
||||
match row_idx {
|
||||
0 => {
|
||||
// Backspace after number row
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Bksp", label_style);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Tab before top row, backslash already in row
|
||||
if offset >= 3 {
|
||||
buf.set_string(inner.x, y, "Tab", label_style);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Enter after home row
|
||||
if after_x + 5 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Enter", label_style);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Shift before and after bottom row
|
||||
if offset >= 5 {
|
||||
buf.set_string(inner.x, y, "Shft", label_style);
|
||||
}
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Shft", label_style);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.render_full(inner, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_focused: bool,
|
||||
is_unlocked: bool,
|
||||
base_char: char,
|
||||
model: &KeyboardModel,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
let bg = if is_unlocked {
|
||||
brighten_color(finger_color(model, base_char))
|
||||
} else {
|
||||
brighten_color(colors.accent_dim())
|
||||
};
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
Style::default().fg(colors.bg()).bg(colors.accent())
|
||||
} else if is_focused {
|
||||
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
||||
} else if is_unlocked {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(finger_color(model, base_char))
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
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;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
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 style = Style::default()
|
||||
.fg(colors.warning())
|
||||
.bg(colors.accent_dim());
|
||||
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;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 + (inner.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;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ impl<'a> Menu<'a> {
|
||||
label: "Skill Tree".to_string(),
|
||||
description: "View progression branches and launch drills".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "b".to_string(),
|
||||
label: "Keyboard".to_string(),
|
||||
description: "Explore keyboard layout and key statistics".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "s".to_string(),
|
||||
label: "Statistics".to_string(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||
@@ -176,7 +177,8 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_accuracy_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
@@ -191,7 +193,8 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_timing_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
@@ -676,7 +679,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
@@ -732,6 +735,50 @@ impl StatsDashboard<'_> {
|
||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||
let kbd_width = all_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
let off = offsets.get(i).copied().unwrap_or(0);
|
||||
off + row.len() as u16 * key_step
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(inner.width)
|
||||
.min(inner.width);
|
||||
let mod_y = if show_shifted {
|
||||
inner.y + all_rows.len() as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + all_rows.len() as u16
|
||||
};
|
||||
if mod_y < inner.y + inner.height {
|
||||
let mod_keys: &[(char, &str)] = &[
|
||||
(TAB, display::key_short_label(TAB)),
|
||||
(SPACE, display::key_short_label(SPACE)),
|
||||
(ENTER, display::key_short_label(ENTER)),
|
||||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||||
];
|
||||
let labels: Vec<String> = mod_keys
|
||||
.iter()
|
||||
.map(|&(key, label)| {
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
format_accuracy_cell_label(label, accuracy, key_width)
|
||||
})
|
||||
.collect();
|
||||
let positions = spread_labels(&labels, kbd_width);
|
||||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let fg_color = accuracy_color(accuracy, colors);
|
||||
buf.set_string(
|
||||
inner.x + positions[i],
|
||||
mod_y,
|
||||
&labels[i],
|
||||
Style::default().fg(fg_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +837,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
@@ -842,6 +889,50 @@ impl StatsDashboard<'_> {
|
||||
let display = format_timing_cell(key, time_ms, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||
let kbd_width = all_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
let off = offsets.get(i).copied().unwrap_or(0);
|
||||
off + row.len() as u16 * key_step
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(inner.width)
|
||||
.min(inner.width);
|
||||
let mod_y = if show_shifted {
|
||||
inner.y + all_rows.len() as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + all_rows.len() as u16
|
||||
};
|
||||
if mod_y < inner.y + inner.height {
|
||||
let mod_keys: &[(char, &str)] = &[
|
||||
(TAB, display::key_short_label(TAB)),
|
||||
(SPACE, display::key_short_label(SPACE)),
|
||||
(ENTER, display::key_short_label(ENTER)),
|
||||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||||
];
|
||||
let labels: Vec<String> = mod_keys
|
||||
.iter()
|
||||
.map(|&(key, label)| {
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
format_timing_cell_label(label, time_ms, key_width)
|
||||
})
|
||||
.collect();
|
||||
let positions = spread_labels(&labels, kbd_width);
|
||||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
let fg_color = timing_color(time_ms, colors);
|
||||
buf.set_string(
|
||||
inner.x + positions[i],
|
||||
mod_y,
|
||||
&labels[i],
|
||||
Style::default().fg(fg_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +1044,7 @@ impl StatsDashboard<'_> {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Collect all keys from keyboard model
|
||||
// Collect all keys from keyboard model + modifier keys
|
||||
let mut all_keys = std::collections::HashSet::new();
|
||||
for row in &self.keyboard_model.rows {
|
||||
for pk in row {
|
||||
@@ -961,6 +1052,10 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
// Include modifier/whitespace keys
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1034,6 +1129,9 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1178,6 +1276,21 @@ fn format_accuracy_cell(key: char, accuracy: f64, key_width: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_accuracy_cell_label(label: &str, accuracy: f64, key_width: u16) -> String {
|
||||
if accuracy > 0.0 {
|
||||
let pct = accuracy.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{label}{pct:>3}")
|
||||
} else {
|
||||
format!("{label}{pct:>2}")
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{label} ")
|
||||
} else {
|
||||
format!("{label} ")
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_color(time_ms: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
if time_ms <= 0.0 {
|
||||
colors.text_pending()
|
||||
@@ -1241,6 +1354,51 @@ fn format_timing_cell(key: char, time_ms: f64, key_width: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timing_cell_label(label: &str, time_ms: f64, key_width: u16) -> String {
|
||||
if time_ms > 0.0 {
|
||||
let ms = time_ms.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{label}{ms:>4}")
|
||||
} else {
|
||||
format!("{label}{:>3}", ms.min(999))
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{label} ")
|
||||
} else {
|
||||
format!("{label} ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Distribute labels across `total_width`, with the first flush-left
|
||||
/// and the last flush-right, and equal gaps between the rest.
|
||||
fn spread_labels(labels: &[String], total_width: u16) -> Vec<u16> {
|
||||
let n = labels.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
if n == 1 {
|
||||
return vec![0];
|
||||
}
|
||||
let total_label_width: u16 = labels.iter().map(|l| l.len() as u16).sum();
|
||||
let last_width = labels.last().map(|l| l.len() as u16).unwrap_or(0);
|
||||
let spare = total_width.saturating_sub(total_label_width);
|
||||
let gaps = (n - 1) as u16;
|
||||
let gap = if gaps > 0 { spare / gaps } else { 0 };
|
||||
let remainder = if gaps > 0 { spare % gaps } else { 0 };
|
||||
|
||||
let mut positions = Vec::with_capacity(n);
|
||||
let mut x: u16 = 0;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
if i == n - 1 {
|
||||
// Last label flush-right
|
||||
x = total_width.saturating_sub(last_width);
|
||||
}
|
||||
positions.push(x);
|
||||
x += label.len() as u16 + gap + if (i as u16) < remainder { 1 } else { 0 };
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn render_text_bar(
|
||||
label: &str,
|
||||
ratio: f64,
|
||||
|
||||
@@ -111,7 +111,9 @@ impl Widget for TypingArea<'_> {
|
||||
token.display.clone()
|
||||
}
|
||||
} else if idx == self.drill.cursor && target_ch == ' ' {
|
||||
"\u{00b7}".to_string()
|
||||
// Keep an actual space at cursor position so soft-wrap break opportunities
|
||||
// remain stable at word boundaries.
|
||||
" ".to_string()
|
||||
} else {
|
||||
token.display.clone()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user