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; } 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 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 { let panel_width = body.width * 35 / 100; let [main, _side] = Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)]) .areas(body); main } else { let panel_height = body.height * 40 / 100; let [main, _side] = Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)]) .areas(body); main } } else { 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 { 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 = panels.iter().map(|_| Constraint::Fill(1)).collect(); let areas: Vec = 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 { 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 { 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::>(), &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::>(), &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 { 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 { 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(_) | Modal::AddSamplePath(_) => (60, 18), 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), } }