Feat: UI / UX improvements once more (mouse)

This commit is contained in:
2026-02-26 23:29:07 +01:00
parent 6b56655661
commit 0ecc4dae11
16 changed files with 680 additions and 99 deletions

View File

@@ -1,3 +1,5 @@
use std::time::Instant;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
@@ -11,6 +13,14 @@ use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view,
use super::InputContext;
#[derive(Clone, Copy, PartialEq, Eq)]
enum ClickKind {
Single,
Double,
}
const DOUBLE_CLICK_MS: u128 = 300;
pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
let kind = mouse.kind;
let col = mouse.column;
@@ -25,7 +35,18 @@ pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
}
match kind {
MouseEventKind::Down(MouseButton::Left) => handle_click(ctx, col, row, term),
MouseEventKind::Down(MouseButton::Left) => {
let now = Instant::now();
let click_kind = match ctx.app.ui.last_click.take() {
Some((t, c, r)) if now.duration_since(t).as_millis() < DOUBLE_CLICK_MS
&& c == col && r == row => ClickKind::Double,
_ => {
ctx.app.ui.last_click = Some((now, col, row));
ClickKind::Single
}
};
handle_click(ctx, col, row, term, click_kind);
}
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Moved => {
handle_editor_drag(ctx, col, row, term);
handle_script_editor_drag(ctx, col, row, term);
@@ -116,7 +137,7 @@ fn handle_editor_mouse(ctx: &mut InputContext, col: u16, row: u16, term: Rect, d
.move_cursor_to(text_row, text_col);
}
fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect, kind: ClickKind) {
// Sticky minimap intercepts all clicks
if matches!(ctx.app.ui.minimap, MinimapMode::Sticky) {
if let Some((gc, gr)) = cagire_ratatui::hit_test_tile(col, row, term) {
@@ -144,7 +165,7 @@ fn handle_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
} else if contains(footer, col, row) {
handle_footer_click(ctx, col, row, footer);
} else if contains(body, col, row) {
handle_body_click(ctx, col, row, body);
handle_body_click(ctx, col, row, body, kind);
}
}
@@ -279,19 +300,30 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
// --- Header ---
fn handle_header_click(ctx: &mut InputContext, col: u16, _row: u16, header: Rect) {
let [transport_area, _live, _tempo, _bank, _pattern, _stats] = Layout::horizontal([
Constraint::Min(12),
Constraint::Length(9),
Constraint::Min(14),
Constraint::Fill(1),
Constraint::Fill(2),
Constraint::Min(20),
])
.areas(header);
fn handle_header_click(ctx: &mut InputContext, col: u16, row: u16, header: Rect) {
let [logo_area, transport_area, _live, tempo_area, _bank, pattern_area, stats_area] =
Layout::horizontal([
Constraint::Length(5),
Constraint::Min(12),
Constraint::Length(9),
Constraint::Min(14),
Constraint::Fill(1),
Constraint::Fill(2),
Constraint::Min(20),
])
.areas(header);
if contains(transport_area, col, _row) {
if contains(logo_area, col, row) {
ctx.app.ui.minimap = MinimapMode::Sticky;
} else if contains(transport_area, col, row) {
ctx.dispatch(AppCommand::TogglePlaying);
} else if contains(tempo_area, col, row) {
let tempo = format!("{:.1}", ctx.link.tempo());
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(tempo)));
} else if contains(pattern_area, col, row) {
ctx.dispatch(AppCommand::GoToPage(Page::Patterns));
} else if contains(stats_area, col, row) {
ctx.dispatch(AppCommand::GoToPage(Page::Engine));
}
}
@@ -325,7 +357,7 @@ fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect)
// --- Body ---
fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect) {
fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) {
// Account for side panel splitting
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
if body.width >= 120 {
@@ -350,25 +382,31 @@ fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect) {
}
match ctx.app.page {
Page::Main => handle_main_click(ctx, col, row, page_area),
Page::Patterns => handle_patterns_click(ctx, col, row, page_area),
Page::Main => handle_main_click(ctx, col, row, page_area, kind),
Page::Patterns => handle_patterns_click(ctx, col, row, page_area, kind),
Page::Help => handle_help_click(ctx, col, row, page_area),
Page::Dict => handle_dict_click(ctx, col, row, page_area),
Page::Options => handle_options_click(ctx, col, row, page_area),
Page::Engine => handle_engine_click(ctx, col, row, page_area),
Page::Engine => handle_engine_click(ctx, col, row, page_area, kind),
Page::Script => handle_script_click(ctx, col, row, page_area),
}
}
// --- Main page (grid) ---
fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) {
let [main_area, _, _vu_area] = main_view::layout(area);
if !contains(main_area, col, row) {
return;
}
// Check viz area clicks before sequencer
if let Some(cmd) = hit_test_main_viz(ctx, col, row, main_area, kind) {
ctx.dispatch(cmd);
return;
}
let sequencer_area = main_view::sequencer_rect(ctx.app, main_area);
if !contains(sequencer_area, col, row) {
@@ -377,9 +415,105 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
if let Some(step) = hit_test_grid(ctx, col, row, sequencer_area) {
ctx.dispatch(AppCommand::GoToStep(step));
if kind == ClickKind::Double {
ctx.dispatch(AppCommand::OpenModal(Modal::Editor));
}
}
}
fn hit_test_main_viz(
ctx: &InputContext,
col: u16,
row: u16,
main_area: Rect,
kind: ClickKind,
) -> Option<AppCommand> {
use crate::state::MainLayout;
let layout = ctx.app.audio.config.layout;
let show_scope = ctx.app.audio.config.show_scope;
let show_spectrum = ctx.app.audio.config.show_spectrum;
let show_lissajous = ctx.app.audio.config.show_lissajous;
let show_preview = ctx.app.audio.config.show_preview;
let has_viz = show_scope || show_spectrum || show_lissajous || show_preview;
if !has_viz {
return None;
}
// Determine viz area based on layout
let viz_area = if matches!(layout, MainLayout::Top) {
// Top layout: render_audio_viz uses only audio panels (no preview)
let has_audio_viz = show_scope || show_spectrum || show_lissajous;
if !has_audio_viz {
return None;
}
let mut constraints = Vec::new();
if has_audio_viz {
constraints.push(Constraint::Fill(1));
}
if show_preview {
let ph = if has_audio_viz { 10u16 } else { 14 };
constraints.push(Constraint::Length(ph));
}
constraints.push(Constraint::Fill(1));
let areas = Layout::vertical(&constraints).split(main_area);
areas[0]
} else {
let (viz, _) = main_view::viz_seq_split(main_area, layout, has_viz);
viz
};
if !contains(viz_area, col, row) {
return None;
}
// Build panel list matching render order
let is_vertical_layout = matches!(layout, MainLayout::Left | MainLayout::Right);
let mut panels: Vec<&str> = Vec::new();
if show_scope { panels.push("scope"); }
if show_spectrum { panels.push("spectrum"); }
if show_lissajous { panels.push("lissajous"); }
// Top layout uses render_audio_viz (horizontal only, no preview)
// Other layouts use render_viz_area (includes preview, vertical if Left/Right)
if !matches!(layout, MainLayout::Top) && show_preview {
panels.push("preview");
}
if panels.is_empty() {
return None;
}
let constraints: Vec<Constraint> = panels.iter().map(|_| Constraint::Fill(1)).collect();
let areas: Vec<Rect> = if is_vertical_layout && !matches!(layout, MainLayout::Top) {
Layout::vertical(&constraints).split(viz_area).to_vec()
} else {
Layout::horizontal(&constraints).split(viz_area).to_vec()
};
for (panel, panel_area) in panels.iter().zip(areas.iter()) {
if contains(*panel_area, col, row) {
return match *panel {
"scope" => Some(if kind == ClickKind::Double {
AppCommand::FlipScopeOrientation
} else {
AppCommand::CycleScopeMode
}),
"lissajous" => Some(AppCommand::ToggleLissajousTrails),
"spectrum" => Some(if kind == ClickKind::Double {
AppCommand::ToggleSpectrumPeaks
} else {
AppCommand::CycleSpectrumMode
}),
_ => None,
};
}
}
None
}
fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option<usize> {
let pattern = ctx.app.current_edit_pattern();
let length = pattern.length;
@@ -402,7 +536,7 @@ fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option<u
// --- Patterns page ---
fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) {
let [banks_area, patterns_area, _] = patterns_view::layout(area);
if contains(banks_area, col, row) {
@@ -414,6 +548,9 @@ fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect)
if let Some(pattern) = hit_test_patterns_list(ctx, col, row, patterns_area, false) {
ctx.app.patterns_nav.column = PatternsColumn::Patterns;
ctx.dispatch(AppCommand::PatternsSelectPattern(pattern));
if kind == ClickKind::Double {
ctx.dispatch(AppCommand::PatternsEnter);
}
}
}
}
@@ -787,8 +924,37 @@ fn handle_script_editor_mouse(
ctx.app.script_editor.editor.move_cursor_to(text_row, text_col);
}
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
let [left_col, _, _] = engine_view::layout(area);
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) {
let [left_col, _, right_col] = engine_view::layout(area);
// Viz panel clicks (right column)
if contains(right_col, col, row) {
let [scope_area, _, lissajous_area, _, spectrum_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(right_col);
if contains(scope_area, col, row) {
if kind == ClickKind::Double {
ctx.dispatch(AppCommand::FlipScopeOrientation);
} else {
ctx.dispatch(AppCommand::CycleScopeMode);
}
} else if contains(lissajous_area, col, row) {
ctx.dispatch(AppCommand::ToggleLissajousTrails);
} else if contains(spectrum_area, col, row) {
if kind == ClickKind::Double {
ctx.dispatch(AppCommand::ToggleSpectrumPeaks);
} else {
ctx.dispatch(AppCommand::CycleSpectrumMode);
}
}
return;
}
if !contains(left_col, col, row) {
return;