Mouse input improvements

This commit is contained in:
2026-02-28 17:56:09 +00:00
parent 7c1aad84af
commit 8b8703b9b9
4 changed files with 381 additions and 41 deletions

View File

@@ -2688,8 +2688,7 @@ mod tests {
drill.started_at = Some(now);
drill.finished_at = Some(now + Duration::from_millis(200 * (target.len() as u64)));
drill.cursor = drill.target.len();
drill.input =
vec![crate::session::input::CharStatus::Correct; drill.target.len()];
drill.input = vec![crate::session::input::CharStatus::Correct; drill.target.len()];
}
}

View File

@@ -1177,18 +1177,32 @@ mod tests {
found_available = true;
// Should contain exactly the 5 non-lowercase branches
assert_eq!(result.branches_newly_available.len(), 5);
assert!(!result.branches_newly_available.contains(&BranchId::Lowercase));
assert!(result.branches_newly_available.contains(&BranchId::Capitals));
assert!(
!result
.branches_newly_available
.contains(&BranchId::Lowercase)
);
assert!(
result
.branches_newly_available
.contains(&BranchId::Capitals)
);
assert!(result.branches_newly_available.contains(&BranchId::Numbers));
assert!(result
assert!(
result
.branches_newly_available
.contains(&BranchId::ProsePunctuation));
assert!(result
.contains(&BranchId::ProsePunctuation)
);
assert!(
result
.branches_newly_available
.contains(&BranchId::Whitespace));
assert!(result
.contains(&BranchId::Whitespace)
);
assert!(
result
.branches_newly_available
.contains(&BranchId::CodeSymbols));
.contains(&BranchId::CodeSymbols)
);
break;
}
}
@@ -1218,7 +1232,10 @@ mod tests {
let mut found_complete = false;
for _ in 0..5 {
let result = tree.update(&stats, None);
if result.branches_newly_completed.contains(&BranchId::Capitals) {
if result
.branches_newly_completed
.contains(&BranchId::Capitals)
{
found_complete = true;
break;
}
@@ -1231,7 +1248,9 @@ mod tests {
// Second update should not re-report
let result2 = tree.update(&stats, None);
assert!(
!result2.branches_newly_completed.contains(&BranchId::Capitals),
!result2
.branches_newly_completed
.contains(&BranchId::Capitals),
"should not re-report Capitals as newly completed"
);
}

View File

@@ -39,8 +39,8 @@ use keyboard::finger::Hand;
use ui::components::dashboard::Dashboard;
use ui::components::keyboard_diagram::KeyboardDiagram;
use ui::components::skill_tree::{
SkillTreeWidget, detail_line_count_with_level_spacing, selectable_branches,
use_expanded_level_spacing, use_side_by_side_layout,
SkillTreeWidget, branch_list_spacing_flags, detail_line_count_with_level_spacing,
selectable_branches, use_expanded_level_spacing, use_side_by_side_layout,
};
use ui::components::stats_dashboard::{
AnomalyBigramRow, NgramTabData, StatsDashboard, history_page_size_for_terminal,
@@ -1844,7 +1844,14 @@ fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
}
}
fn skill_tree_interactive_areas(app: &App, area: Rect) -> (Rect, Rect) {
struct SkillTreeMouseLayout {
branch_area: Rect,
detail_area: Rect,
inter_branch_spacing: bool,
separator_padding: bool,
}
fn skill_tree_interactive_areas(app: &App, area: Rect) -> SkillTreeMouseLayout {
let centered = skill_tree_popup_rect(area);
let inner = Block::bordered().inner(centered);
let branches = selectable_branches();
@@ -1914,7 +1921,14 @@ fn skill_tree_interactive_areas(app: &App, area: Rect) -> (Rect, Rect) {
Constraint::Percentage(58),
])
.split(layout[0]);
(main[0], main[2])
let (inter_branch_spacing, separator_padding) =
branch_list_spacing_flags(main[0].height, branches.len());
SkillTreeMouseLayout {
branch_area: main[0],
detail_area: main[2],
inter_branch_spacing,
separator_padding,
}
} else {
let branch_list_height = branches.len() as u16 * 2 + 1;
let main = Layout::default()
@@ -1925,9 +1939,50 @@ fn skill_tree_interactive_areas(app: &App, area: Rect) -> (Rect, Rect) {
Constraint::Min(3),
])
.split(layout[0]);
(main[0], main[2])
SkillTreeMouseLayout {
branch_area: main[0],
detail_area: main[2],
inter_branch_spacing: false,
separator_padding: false,
}
}
}
fn skill_tree_branch_index_from_y(
branch_area: Rect,
y: u16,
branch_count: usize,
inter_branch_spacing: bool,
separator_padding: bool,
) -> Option<usize> {
if y < branch_area.y || y >= branch_area.y + branch_area.height {
return None;
}
let rel_y = (y - branch_area.y) as usize;
let mut line = 0usize;
for idx in 0..branch_count {
if idx > 0 && inter_branch_spacing {
line += 1;
}
let title_line = line;
let progress_line = line + 1;
if rel_y == title_line || rel_y == progress_line {
return Some(idx);
}
line += 2;
if idx == 0 {
if separator_padding {
line += 1;
}
line += 1;
if separator_padding && !inter_branch_spacing {
line += 1;
}
}
}
None
}
fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) {
const DETAIL_SCROLL_STEP: usize = 3;
@@ -1975,10 +2030,15 @@ fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) {
}
MouseEventKind::Down(MouseButton::Left) => {
let branches = selectable_branches();
let (branch_area, detail_area) = skill_tree_interactive_areas(app, terminal_area());
if point_in_rect(mouse.column, mouse.row, branch_area) {
let relative = (mouse.row - branch_area.y) as usize;
let idx = (relative / 2).min(branches.len().saturating_sub(1));
let layout = skill_tree_interactive_areas(app, terminal_area());
if point_in_rect(mouse.column, mouse.row, layout.branch_area) {
if let Some(idx) = skill_tree_branch_index_from_y(
layout.branch_area,
mouse.row,
branches.len(),
layout.inter_branch_spacing,
layout.separator_padding,
) {
let already_selected = idx == app.skill_tree_selected;
app.skill_tree_selected = idx;
app.skill_tree_detail_scroll = 0;
@@ -1991,9 +2051,10 @@ fn handle_skill_tree_mouse(app: &mut App, mouse: MouseEvent) {
app.start_branch_drill(branch_id);
}
}
} else if point_in_rect(mouse.column, mouse.row, detail_area) {
}
} else if point_in_rect(mouse.column, mouse.row, layout.detail_area) {
// Click in detail pane focuses selected branch; scroll wheel handles movement.
let _ = detail_area;
let _ = layout.detail_area;
}
}
_ => {}
@@ -2439,7 +2500,10 @@ fn render_milestone_overlay(
let area = frame.area();
let colors = &app.theme.colors;
let is_key_milestone = matches!(milestone.kind, MilestoneKind::Unlock | MilestoneKind::Mastery);
let is_key_milestone = matches!(
milestone.kind,
MilestoneKind::Unlock | MilestoneKind::Mastery
);
// Determine overlay size based on terminal height:
// Key milestones get keyboard diagrams; other milestones are text-only
@@ -2610,10 +2674,7 @@ fn render_milestone_overlay(
.map(|&id| get_branch_definition(id).name)
.collect();
let branches_text = if branch_names.len() == 1 {
format!(
" You've fully mastered the {} branch!",
branch_names[0]
)
format!(" You've fully mastered the {} branch!", branch_names[0])
} else {
let all_but_last = &branch_names[..branch_names.len() - 1];
let last = branch_names[branch_names.len() - 1];
@@ -5211,6 +5272,21 @@ fn handle_keyboard_explorer_mouse(app: &mut App, mouse: MouseEvent) {
.split(area);
if point_in_rect(mouse.column, mouse.row, layout[3]) {
app.go_to_menu();
return;
}
if point_in_rect(mouse.column, mouse.row, layout[1])
&& let Some(ch) = KeyboardDiagram::key_at_position(
layout[1],
&app.keyboard_model,
false,
mouse.column,
mouse.row,
)
{
app.keyboard_explorer_selected = Some(ch);
app.key_accuracy(ch, false);
app.key_accuracy(ch, true);
}
}

View File

@@ -256,6 +256,25 @@ impl Widget for KeyboardDiagram<'_> {
}
impl KeyboardDiagram<'_> {
pub fn key_at_position(
area: Rect,
model: &KeyboardModel,
compact: bool,
x: u16,
y: u16,
) -> Option<char> {
let inner = Block::bordered().inner(area);
if compact {
return key_at_compact_position(inner, model, x, y);
}
if inner.height >= 4 && inner.width >= 75 {
key_at_full_position(inner, model, x, y)
} else {
key_at_full_fallback_position(inner, model, x, y)
}
}
fn render_compact(&self, inner: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let letter_rows = self.model.letter_rows();
@@ -597,3 +616,230 @@ impl KeyboardDiagram<'_> {
}
}
}
fn rect_contains(area: Rect, x: u16, y: u16) -> bool {
x >= area.x && x < area.x + area.width && y >= area.y && y < area.y + area.height
}
fn key_at_compact_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
let letter_rows = model.letter_rows();
let key_width: u16 = 3;
let min_width: u16 = 21;
if inner.height < 3 || inner.width < min_width {
return None;
}
let offsets: &[u16] = &[3, 4, 6];
let keyboard_width = letter_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 + 3,
1 => row_end + 3,
2 => row_end + 3,
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
for (row_idx, row) in letter_rows.iter().enumerate() {
let row_y = inner.y + row_idx as u16;
if y != row_y {
continue;
}
match row_idx {
0 => {
let tab_rect = Rect::new(start_x, row_y, 3, 1);
if rect_contains(tab_rect, x, y) {
return Some(TAB);
}
}
2 => {
let shft_rect = Rect::new(start_x, row_y, 3, 1);
if rect_contains(shft_rect, x, y) {
return None;
}
}
_ => {}
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, key) in row.iter().enumerate() {
let key_x = start_x + offset + col_idx as u16 * key_width;
let key_rect = Rect::new(key_x, row_y, 3, 1);
if rect_contains(key_rect, x, y) {
return Some(key.base);
}
}
let row_end_x = start_x + offset + row.len() as u16 * key_width;
match row_idx {
1 => {
let enter_rect = Rect::new(row_end_x, row_y, 3, 1);
if rect_contains(enter_rect, x, y) {
return Some(ENTER);
}
}
2 => {
let shft_rect = Rect::new(row_end_x, row_y, 3, 1);
if rect_contains(shft_rect, x, y) {
return None;
}
}
_ => {}
}
}
if inner.height >= 3 {
let y0 = inner.y;
let row_end_x = start_x + offsets[0] + letter_rows[0].len() as u16 * key_width;
let back_rect = Rect::new(row_end_x, y0, 3, 1);
if rect_contains(back_rect, x, y) {
return Some(BACKSPACE);
}
}
None
}
fn key_at_full_position(inner: Rect, model: &KeyboardModel, x: u16, y: u16) -> Option<char> {
let key_width: u16 = 5;
let offsets: &[u16] = &[0, 5, 5, 6];
let keyboard_width = 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,
2 => row_end + 7,
3 => row_end + 6,
_ => row_end,
}
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
for (row_idx, row) in model.rows.iter().enumerate() {
let row_y = inner.y + row_idx as u16;
if y != row_y {
continue;
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
match row_idx {
1 => {
let tab_rect = Rect::new(start_x, row_y, 5, 1);
if rect_contains(tab_rect, x, y) {
return Some(TAB);
}
}
2 => {
let cap_rect = Rect::new(start_x, row_y, 5, 1);
if rect_contains(cap_rect, x, y) {
return None;
}
}
3 => {
let shft_rect = Rect::new(start_x, row_y, 6, 1);
if rect_contains(shft_rect, x, y) {
return None;
}
}
_ => {}
}
for (col_idx, key) in row.iter().enumerate() {
let key_x = start_x + offset + col_idx as u16 * key_width;
let key_rect = Rect::new(key_x, row_y, key_width, 1);
if rect_contains(key_rect, x, y) {
return Some(key.base);
}
}
let after_x = start_x + offset + row.len() as u16 * key_width;
match row_idx {
0 => {
let rect = Rect::new(after_x, row_y, 6, 1);
if rect_contains(rect, x, y) {
return Some(BACKSPACE);
}
}
2 => {
let rect = Rect::new(after_x, row_y, 7, 1);
if rect_contains(rect, x, y) {
return Some(ENTER);
}
}
3 => {
let rect = Rect::new(after_x, row_y, 6, 1);
if rect_contains(rect, x, y) {
return None;
}
}
_ => {}
}
}
let space_y = inner.y + 4;
if y == space_y {
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 = start_x + (keyboard_width.saturating_sub(space_width)) / 2;
let space_rect = Rect::new(space_x, space_y, space_width, 1);
if rect_contains(space_rect, x, y) {
return Some(SPACE);
}
}
None
}
fn key_at_full_fallback_position(
inner: Rect,
model: &KeyboardModel,
x: u16,
y: u16,
) -> Option<char> {
let letter_rows = model.letter_rows();
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
let keyboard_width = letter_rows
.iter()
.enumerate()
.map(|(row_idx, row)| {
let offset = offsets.get(row_idx).copied().unwrap_or(0);
offset + row.len() as u16 * key_width
})
.max()
.unwrap_or(0);
let start_x = inner.x + inner.width.saturating_sub(keyboard_width) / 2;
if inner.height < 3 || inner.width < 30 {
return None;
}
for (row_idx, row) in letter_rows.iter().enumerate() {
let row_y = inner.y + row_idx as u16;
if y != row_y {
continue;
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, key) in row.iter().enumerate() {
let key_x = start_x + offset + col_idx as u16 * key_width;
let key_rect = Rect::new(key_x, row_y, key_width, 1);
if rect_contains(key_rect, x, y) {
return Some(key.base);
}
}
}
None
}