Files
Cagire/src/input/mouse.rs
Raphaël Forment 4743c33916
All checks were successful
Deploy Website / deploy (push) Has been skipped
Feat: begin sample explorer overhaul
2026-03-05 00:42:39 +01:00

1081 lines
36 KiB
Rust

use std::time::Instant;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use crate::commands::AppCommand;
use crate::page::Page;
use crate::state::{
DictFocus, EditorTarget, HelpFocus, MinimapMode, Modal,
OptionsFocus, PatternsColumn,
};
use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view, script_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;
let row = mouse.row;
// Dismiss title screen on any click
if ctx.app.ui.show_title {
if matches!(kind, MouseEventKind::Down(MouseButton::Left)) {
ctx.dispatch(AppCommand::HideTitle);
}
return;
}
match kind {
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);
}
MouseEventKind::Up(MouseButton::Left) => {
ctx.app.editor_ctx.mouse_selecting = false;
ctx.app.script_editor.mouse_selecting = false;
}
MouseEventKind::ScrollUp => handle_scroll(ctx, col, row, term, true),
MouseEventKind::ScrollDown => handle_scroll(ctx, col, row, term, false),
_ => {}
}
}
fn padded(term: Rect) -> Rect {
let h_pad = crate::views::horizontal_padding(term.width);
Rect {
x: term.x + h_pad,
y: term.y + 1,
width: term.width.saturating_sub(h_pad * 2),
height: term.height.saturating_sub(2),
}
}
fn top_level_layout(padded: Rect) -> (Rect, Rect, Rect) {
let header_height = 3u16;
let [header, _pad, body, _bpad, footer] = Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(3),
])
.areas(padded);
(header, body, footer)
}
fn contains(area: Rect, col: u16, row: u16) -> bool {
col >= area.x && col < area.x + area.width && row >= area.y && row < area.y + area.height
}
fn handle_editor_drag(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
if ctx.app.editor_ctx.mouse_selecting {
handle_editor_mouse(ctx, col, row, term, true);
}
}
fn editor_modal_rect(term: Rect) -> Rect {
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let modal_w = width.min(term.width.saturating_sub(4));
let modal_h = height.min(term.height.saturating_sub(4));
let mx = term.x + (term.width.saturating_sub(modal_w)) / 2;
let my = term.y + (term.height.saturating_sub(modal_h)) / 2;
Rect::new(mx, my, modal_w, modal_h)
}
fn handle_editor_mouse(ctx: &mut InputContext, col: u16, row: u16, term: Rect, dragging: bool) {
let modal = editor_modal_rect(term);
let mx = modal.x;
let my = modal.y;
let modal_w = modal.width;
let modal_h = modal.height;
// inner = area inside 1-cell border
let inner_x = mx + 1;
let inner_y = my + 1;
let inner_w = modal_w.saturating_sub(2);
let inner_h = modal_h.saturating_sub(2);
let show_search = ctx.app.editor_ctx.editor.search_active()
|| !ctx.app.editor_ctx.editor.search_query().is_empty();
let reserved = 1 + if show_search { 1 } else { 0 };
let editor_y = inner_y + if show_search { 1 } else { 0 };
let editor_h = inner_h.saturating_sub(reserved);
if col < inner_x || col >= inner_x + inner_w || row < editor_y || row >= editor_y + editor_h {
return;
}
let scroll = ctx.app.editor_ctx.editor.scroll_offset();
let text_row = (row - editor_y) + scroll;
let text_col = col - inner_x;
if dragging {
if !ctx.app.editor_ctx.editor.is_selecting() {
ctx.app.editor_ctx.editor.start_selection();
}
} else {
ctx.app.editor_ctx.mouse_selecting = true;
ctx.app.editor_ctx.editor.cancel_selection();
}
ctx.app
.editor_ctx
.editor
.move_cursor_to(text_row, text_col);
}
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) {
if let Some(page) = Page::at_pos(gc, gr) {
ctx.dispatch(AppCommand::GoToPage(page));
}
}
ctx.app.ui.dismiss_minimap();
return;
}
ctx.dispatch(AppCommand::ClearStatus);
// If a modal is active, clicks outside dismiss it (except Editor)
if !matches!(ctx.app.ui.modal, Modal::None) {
handle_modal_click(ctx, col, row, term);
return;
}
let padded = padded(term);
let (header, body, footer) = top_level_layout(padded);
if contains(header, col, row) {
handle_header_click(ctx, col, row, header);
} 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, kind);
}
}
fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: bool) {
// Modal scroll
if matches!(ctx.app.ui.modal, Modal::KeybindingsHelp { .. }) {
if up {
ctx.dispatch(AppCommand::HelpScrollUp(3));
} else {
ctx.dispatch(AppCommand::HelpScrollDown(3));
}
return;
}
if matches!(ctx.app.ui.modal, Modal::Editor) {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let code = if up { KeyCode::Up } else { KeyCode::Down };
for _ in 0..3 {
ctx.app
.editor_ctx
.editor
.input(KeyEvent::new(code, KeyModifiers::empty()));
}
return;
}
if !matches!(ctx.app.ui.modal, Modal::None) {
return;
}
let padded = padded(term);
let (_header, body, _footer) = top_level_layout(padded);
if !contains(body, col, row) {
return;
}
// Scroll over side panel area
if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
let (_main_area, side_area) = panel_split(body);
if contains(side_area, col, row) {
if let Some(crate::state::SidePanel::SampleBrowser(state)) = &mut ctx.app.panel.side {
if up {
state.move_up();
} else {
state.move_down();
}
}
return;
}
}
match ctx.app.page {
Page::Main => {
if up {
ctx.dispatch(AppCommand::StepUp);
} else {
ctx.dispatch(AppCommand::StepDown);
}
}
Page::Script => {
let [editor_area, _] = script_view::layout(body);
if contains(editor_area, col, row) {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let code = if up { KeyCode::Up } else { KeyCode::Down };
ctx.app.script_editor.editor.input(KeyEvent::new(code, KeyModifiers::empty()));
}
}
Page::Help => {
let [topics_area, content_area] = help_view::layout(body);
if contains(topics_area, col, row) {
if up {
ctx.dispatch(AppCommand::HelpPrevTopic(1));
} else {
ctx.dispatch(AppCommand::HelpNextTopic(1));
}
} else if contains(content_area, col, row) {
if up {
ctx.dispatch(AppCommand::HelpScrollUp(3));
} else {
ctx.dispatch(AppCommand::HelpScrollDown(3));
}
}
}
Page::Dict => {
let [cat_area, words_area] = dict_view::layout(body);
if contains(cat_area, col, row) {
if up {
ctx.dispatch(AppCommand::DictPrevCategory);
} else {
ctx.dispatch(AppCommand::DictNextCategory);
}
} else if contains(words_area, col, row) {
if up {
ctx.dispatch(AppCommand::DictScrollUp(3));
} else {
ctx.dispatch(AppCommand::DictScrollDown(3));
}
}
}
Page::Patterns => {
let [banks_area, patterns_area, _] = patterns_view::layout(body);
if contains(banks_area, col, row) {
if up {
ctx.app.patterns_nav.column = PatternsColumn::Banks;
ctx.app.patterns_nav.move_up_clamped();
} else {
ctx.app.patterns_nav.column = PatternsColumn::Banks;
ctx.app.patterns_nav.move_down_clamped();
}
} else if contains(patterns_area, col, row) {
if up {
ctx.app.patterns_nav.column = PatternsColumn::Patterns;
ctx.app.patterns_nav.move_up_clamped();
} else {
ctx.app.patterns_nav.column = PatternsColumn::Patterns;
ctx.app.patterns_nav.move_down_clamped();
}
}
}
Page::Options => {
if up {
ctx.dispatch(AppCommand::OptionsPrevFocus);
} else {
ctx.dispatch(AppCommand::OptionsNextFocus);
}
}
Page::Engine => {
if up {
ctx.dispatch(AppCommand::AudioPrevSection);
} else {
ctx.dispatch(AppCommand::AudioNextSection);
}
}
}
}
// --- 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(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));
}
}
// --- Footer ---
fn handle_footer_click(ctx: &mut InputContext, col: u16, row: u16, footer: Rect) {
let block_inner = Rect {
x: footer.x + 1,
y: footer.y + 1,
width: footer.width.saturating_sub(2),
height: footer.height.saturating_sub(2),
};
if !contains(block_inner, col, row) {
return;
}
let badge_text = match ctx.app.page {
Page::Main => " MAIN ",
Page::Patterns => " PATTERNS ",
Page::Engine => " ENGINE ",
Page::Options => " OPTIONS ",
Page::Help => " HELP ",
Page::Dict => " DICT ",
Page::Script => " SCRIPT ",
};
let badge_end = block_inner.x + badge_text.len() as u16;
if col < badge_end {
ctx.app.ui.minimap = MinimapMode::Sticky;
}
}
// --- Body ---
fn panel_split(body: Rect) -> (Rect, Rect) {
if body.width >= 120 {
let panel_width = body.width * 35 / 100;
let [main, side] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
.areas(body);
(main, side)
} else {
let panel_height = body.height * 40 / 100;
let [main, side] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
.areas(body);
(main, side)
}
}
fn handle_body_click(ctx: &mut InputContext, col: u16, row: u16, body: Rect, kind: ClickKind) {
use crate::state::PanelFocus;
if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
let (main_area, side_area) = panel_split(body);
if contains(side_area, col, row) {
ctx.app.panel.focus = PanelFocus::Side;
return;
}
// Click on main area: defocus panel
if contains(main_area, col, row) {
if kind == ClickKind::Double {
ctx.dispatch(AppCommand::ClosePanel);
} else {
ctx.app.panel.focus = PanelFocus::Main;
}
}
// Fall through to page-specific handler with main_area
if !contains(main_area, col, row) {
return;
}
match ctx.app.page {
Page::Main => handle_main_click(ctx, col, row, main_area, kind),
Page::Patterns => handle_patterns_click(ctx, col, row, main_area, kind),
Page::Help => handle_help_click(ctx, col, row, main_area),
Page::Dict => handle_dict_click(ctx, col, row, main_area),
Page::Options => handle_options_click(ctx, col, row, main_area),
Page::Engine => handle_engine_click(ctx, col, row, main_area, kind),
Page::Script => handle_script_click(ctx, col, row, main_area),
}
return;
}
let page_area = body;
if !contains(page_area, col, row) {
return;
}
match ctx.app.page {
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, 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, 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) {
return;
}
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;
let spp = ctx.app.editor_ctx.steps_per_page.get();
let page = ctx.app.editor_ctx.step / spp;
let page_start = page * spp;
let steps_on_page = (page_start + spp).min(length) - page_start;
for (tile_rect, step_offset) in main_view::grid_layout(area, steps_on_page) {
if contains(tile_rect, col, row) {
let step_idx = page_start + step_offset;
if step_idx < length {
return Some(step_idx);
}
}
}
None
}
// --- Patterns page ---
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) {
if let Some(bank) = hit_test_patterns_list(ctx, col, row, banks_area, true) {
ctx.app.patterns_nav.column = PatternsColumn::Banks;
ctx.dispatch(AppCommand::PatternsSelectBank(bank));
}
} else if contains(patterns_area, col, row) {
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);
}
}
}
}
fn hit_test_patterns_list(
ctx: &InputContext,
_col: u16,
row: u16,
area: Rect,
is_banks: bool,
) -> Option<usize> {
use crate::model::{MAX_BANKS, MAX_PATTERNS};
use ratatui::widgets::{Block, Borders};
let inner = Block::default().borders(Borders::ALL).inner(area);
let max_items = if is_banks { MAX_BANKS } else { MAX_PATTERNS };
let cursor = if is_banks {
ctx.app.patterns_nav.bank_cursor
} else {
ctx.app.patterns_nav.pattern_cursor
};
let available = inner.height as usize;
// Patterns column: cursor row takes 2 lines
let max_visible = if is_banks {
available.max(1)
} else {
available.saturating_sub(1).max(1)
};
let scroll_offset = if max_items <= max_visible {
0
} else {
cursor
.saturating_sub(max_visible / 2)
.min(max_items - max_visible)
};
let visible_count = max_items.min(max_visible);
if row < inner.y {
return None;
}
if is_banks {
let row_height = (inner.height / visible_count as u16).max(1);
let relative_y = row - inner.y;
let visible_idx = (relative_y / row_height) as usize;
if visible_idx < visible_count {
let idx = scroll_offset + visible_idx;
if idx < max_items {
return Some(idx);
}
}
} else {
let mut y = inner.y;
for visible_idx in 0..visible_count {
let idx = scroll_offset + visible_idx;
let row_h: u16 = if idx == cursor { 2 } else { 1 };
let actual_h = row_h.min(inner.y + inner.height - y);
if row >= y && row < y + actual_h {
return Some(idx);
}
y += actual_h;
if y >= inner.y + inner.height {
break;
}
}
}
None
}
// --- Help page ---
fn handle_help_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
let [topics_area, content_area] = help_view::layout(area);
if contains(topics_area, col, row) {
use crate::model::docs::{self, DocEntry, DOCS};
let items = build_visible_list(
&DOCS
.iter()
.map(|e| matches!(e, DocEntry::Section(_)))
.collect::<Vec<_>>(),
&ctx.app.ui.help_collapsed,
);
let sel = match ctx.app.ui.help_on_section {
Some(s) => VisibleEntry::Section(s),
None => VisibleEntry::Item(ctx.app.ui.help_topic),
};
if let Some(entry) = hit_test_visible_list(&items, &sel, topics_area, row) {
match entry {
VisibleEntry::Item(i) => ctx.dispatch(AppCommand::HelpSelectTopic(i)),
VisibleEntry::Section(s) => {
let collapsed = ctx.app.ui.help_collapsed.get(s).copied().unwrap_or(false);
if collapsed {
if let Some(v) = ctx.app.ui.help_collapsed.get_mut(s) {
*v = false;
}
ctx.app.ui.help_on_section = None;
if let Some(first) = docs::first_topic_in_section(s) {
ctx.app.ui.help_topic = first;
}
} else {
if let Some(v) = ctx.app.ui.help_collapsed.get_mut(s) {
*v = true;
}
ctx.app.ui.help_on_section = Some(s);
}
}
}
}
ctx.app.ui.help_focus = HelpFocus::Topics;
} else if contains(content_area, col, row) {
ctx.app.ui.help_focus = HelpFocus::Content;
}
}
// --- Dict page ---
fn handle_dict_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
let [cat_area, words_area] = dict_view::layout(area);
if contains(cat_area, col, row) {
use crate::model::categories::{self, CatEntry, CATEGORIES};
let items = build_visible_list(
&CATEGORIES
.iter()
.map(|e| matches!(e, CatEntry::Section(_)))
.collect::<Vec<_>>(),
&ctx.app.ui.dict_collapsed,
);
let sel = match ctx.app.ui.dict_on_section {
Some(s) => VisibleEntry::Section(s),
None => VisibleEntry::Item(ctx.app.ui.dict_category),
};
if let Some(entry) = hit_test_visible_list(&items, &sel, cat_area, row) {
match entry {
VisibleEntry::Item(i) => ctx.dispatch(AppCommand::DictSelectCategory(i)),
VisibleEntry::Section(s) => {
let collapsed = ctx.app.ui.dict_collapsed.get(s).copied().unwrap_or(false);
if collapsed {
if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(s) {
*v = false;
}
ctx.app.ui.dict_on_section = None;
if let Some(first) = categories::first_category_in_section(s) {
ctx.app.ui.dict_category = first;
}
} else {
if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(s) {
*v = true;
}
ctx.app.ui.dict_on_section = Some(s);
}
}
}
}
ctx.app.ui.dict_focus = DictFocus::Categories;
} else if contains(words_area, col, row) {
ctx.app.ui.dict_focus = DictFocus::Words;
}
}
// --- CategoryList hit test ---
#[derive(Clone, Copy)]
enum VisibleEntry {
Item(usize),
Section(usize),
}
fn build_visible_list(is_section: &[bool], collapsed: &[bool]) -> Vec<VisibleEntry> {
let mut result = Vec::new();
let mut section_idx = 0usize;
let mut item_idx = 0usize;
let mut skipping = false;
for &is_sec in is_section {
if is_sec {
let is_collapsed = collapsed.get(section_idx).copied().unwrap_or(false);
result.push(VisibleEntry::Section(section_idx));
skipping = is_collapsed;
section_idx += 1;
} else if !skipping {
result.push(VisibleEntry::Item(item_idx));
item_idx += 1;
} else {
item_idx += 1;
}
}
result
}
fn hit_test_visible_list(
items: &[VisibleEntry],
selected: &VisibleEntry,
area: Rect,
click_row: u16,
) -> Option<VisibleEntry> {
let visible_height = area.height.saturating_sub(2) as usize;
if visible_height == 0 {
return None;
}
let total_items = items.len();
let selected_visual_idx = match selected {
VisibleEntry::Item(sel) => items
.iter()
.position(|v| matches!(v, VisibleEntry::Item(i) if *i == *sel))
.unwrap_or(0),
VisibleEntry::Section(sel) => items
.iter()
.position(|v| matches!(v, VisibleEntry::Section(s) if *s == *sel))
.unwrap_or(0),
};
let scroll = if selected_visual_idx < visible_height / 2 {
0
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
total_items.saturating_sub(visible_height)
} else {
selected_visual_idx.saturating_sub(visible_height / 2)
};
let inner_y = area.y + 1;
if click_row < inner_y {
return None;
}
let relative = (click_row - inner_y) as usize;
if relative >= visible_height {
return None;
}
let visual_idx = scroll + relative;
if visual_idx >= total_items {
return None;
}
Some(items[visual_idx])
}
// --- Options page ---
fn handle_options_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
// Replicate options_view layout: Block with borders → inner → padded (+2, +1)
let inner = Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
let padded = Rect {
x: inner.x + 2,
y: inner.y + 1,
width: inner.width.saturating_sub(4),
height: inner.height.saturating_sub(2),
};
if row < padded.y || row >= padded.y + padded.height {
return;
}
let focus = ctx.app.options.focus;
let plugin_mode = ctx.app.plugin_mode;
let focus_line = focus.line_index(plugin_mode);
let total_lines = crate::state::options::total_lines(plugin_mode);
let max_visible = padded.height as usize;
let scroll_offset = if total_lines <= max_visible {
0
} else {
focus_line
.saturating_sub(max_visible / 2)
.min(total_lines.saturating_sub(max_visible))
};
let relative_y = (row - padded.y) as usize;
let abs_line = scroll_offset + relative_y;
if let Some(new_focus) = OptionsFocus::at_line(abs_line, plugin_mode) {
ctx.dispatch(AppCommand::OptionsSetFocus(new_focus));
// Value area starts at prefix(2) + label(20) = offset 22 from padded.x
let value_x = padded.x + 22;
if col >= value_x {
let right = col >= value_x + 4; // past the "< " prefix → right half
super::options_page::cycle_option_value(ctx, right);
}
}
}
// --- Engine page ---
fn handle_script_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
let [editor_area, _] = script_view::layout(area);
if contains(editor_area, col, row) {
ctx.app.script_editor.focused = true;
handle_script_editor_mouse(ctx, col, row, area, false);
} else {
ctx.app.script_editor.focused = false;
}
}
fn script_editor_text_area(area: Rect) -> Rect {
let [editor_area, _] = script_view::layout(area);
// Block with borders → inner
let inner = Rect {
x: editor_area.x + 1,
y: editor_area.y + 1,
width: editor_area.width.saturating_sub(2),
height: editor_area.height.saturating_sub(2),
};
// Editor takes all but last row (hint line)
let editor_height = inner.height.saturating_sub(1);
Rect::new(inner.x, inner.y, inner.width, editor_height)
}
fn handle_script_editor_drag(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
if ctx.app.script_editor.mouse_selecting {
let padded = padded(term);
let (_header, body, _footer) = top_level_layout(padded);
let page_area = if ctx.app.panel.visible && ctx.app.panel.side.is_some() {
if body.width >= 120 {
let panel_width = body.width * 35 / 100;
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
.split(body)[0]
} else {
let panel_height = body.height * 40 / 100;
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
.split(body)[0]
}
} else {
body
};
handle_script_editor_mouse(ctx, col, row, page_area, true);
}
}
fn handle_script_editor_mouse(
ctx: &mut InputContext,
col: u16,
row: u16,
area: Rect,
dragging: bool,
) {
let text_area = script_editor_text_area(area);
if col < text_area.x
|| col >= text_area.x + text_area.width
|| row < text_area.y
|| row >= text_area.y + text_area.height
{
return;
}
let scroll = ctx.app.script_editor.editor.scroll_offset();
let text_row = (row - text_area.y) + scroll;
let text_col = col - text_area.x;
if dragging {
if !ctx.app.script_editor.editor.is_selecting() {
ctx.app.script_editor.editor.start_selection();
}
} else {
ctx.app.script_editor.mouse_selecting = true;
ctx.app.script_editor.editor.cancel_selection();
}
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, _kind: ClickKind) {
// In narrow mode the whole area is a single column, in wide mode it's a 55/45 split.
// Either way, left-column clicks cycle through sections; right column is monitoring (non-interactive).
let is_narrow = area.width < 100;
let left_col = if is_narrow {
area
} else {
let [left, _, _] = engine_view::layout(area);
left
};
if !contains(left_col, col, row) {
return;
}
// Simple: cycle section on click. The complex per-line hit-testing is fragile
// given the scrollable layout. Tab/keyboard is the primary navigation.
ctx.dispatch(AppCommand::AudioNextSection);
}
// --- Modal ---
fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
match &ctx.app.ui.modal {
Modal::Editor => {
let modal_area = editor_modal_rect(term);
if contains(modal_area, col, row) {
handle_editor_mouse(ctx, col, row, term, false);
} else {
match ctx.app.editor_ctx.target {
EditorTarget::Step => {
ctx.dispatch(AppCommand::SaveEditorToStep);
ctx.dispatch(AppCommand::CompileCurrentStep);
}
EditorTarget::Prelude => {
ctx.dispatch(AppCommand::SavePrelude);
ctx.dispatch(AppCommand::EvaluatePrelude);
ctx.dispatch(AppCommand::ClosePreludeEditor);
}
}
ctx.dispatch(AppCommand::CloseModal);
}
}
Modal::Confirm { .. } => {
handle_confirm_click(ctx, col, row, term);
}
Modal::KeybindingsHelp { .. } => {
let padded = padded(term);
let width = (padded.width * 80 / 100).clamp(60, 100);
let height = (padded.height * 80 / 100).max(15);
let modal_area = centered_rect(term, width, height);
if !contains(modal_area, col, row) {
ctx.dispatch(AppCommand::CloseModal);
}
}
_ => {
let (w, h) = match &ctx.app.ui.modal {
Modal::PatternProps { .. } => (50, 18),
Modal::EuclideanDistribution { .. } => (50, 11),
Modal::Onboarding { .. } => (57, 20),
Modal::FileBrowser(_) => (60, 18),
Modal::AddSamplePath(_) => (70, 20),
Modal::Rename { .. } => (40, 5),
Modal::SetPattern { .. } | Modal::SetScript { .. } => (45, 5),
Modal::SetTempo(_) => (30, 5),
Modal::CommandPalette { .. } => (55, 20),
_ => return,
};
let modal_area = centered_rect(term, w, h);
if !contains(modal_area, col, row) {
ctx.dispatch(AppCommand::CloseModal);
}
}
}
}
fn handle_confirm_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) {
// The confirm modal is rendered centered. Approximate its area.
let modal_area = centered_rect(term, 40, 7);
if !contains(modal_area, col, row) {
ctx.dispatch(AppCommand::CloseModal);
return;
}
// The confirm modal has two buttons at the bottom row of the inner area
// Button row is approximately at modal_area.y + modal_area.height - 2
let button_row = modal_area.y + modal_area.height.saturating_sub(3);
if row == button_row || row == button_row + 1 {
if let Modal::Confirm { selected, .. } = &mut ctx.app.ui.modal {
let mid = modal_area.x + modal_area.width / 2;
*selected = col < mid;
}
}
}
fn centered_rect(term: Rect, width: u16, height: u16) -> Rect {
let x = term.x + term.width.saturating_sub(width) / 2;
let y = term.y + term.height.saturating_sub(height) / 2;
Rect {
x,
y,
width: width.min(term.width),
height: height.min(term.height),
}
}