use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Layout, Rect}; use crate::commands::AppCommand; use crate::page::Page; use crate::state::{ DeviceKind, DictFocus, EngineSection, HelpFocus, MinimapMode, Modal, OptionsFocus, PatternsColumn, SettingKind, }; use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view}; use super::InputContext; 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) => handle_click(ctx, col, row, term), 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_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { // 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/Preview) 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); } } 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::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::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 => { let [left_col, _, _] = engine_view::layout(body); if contains(left_col, col, row) { match ctx.app.audio.section { EngineSection::Devices => { if ctx.app.audio.device_kind == DeviceKind::Input { if up { ctx.dispatch(AppCommand::AudioInputListUp); } else { ctx.dispatch(AppCommand::AudioInputListDown(1)); } } else if up { ctx.dispatch(AppCommand::AudioOutputListUp); } else { ctx.dispatch(AppCommand::AudioOutputListDown(1)); } } EngineSection::Settings => { if up { ctx.dispatch(AppCommand::AudioSettingPrev); } else { ctx.dispatch(AppCommand::AudioSettingNext); } } EngineSection::Samples => {} } } } } } // --- 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); if contains(transport_area, col, _row) { ctx.dispatch(AppCommand::TogglePlaying); } } // --- 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 ", }; 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) { // 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), Page::Patterns => handle_patterns_click(ctx, col, row, page_area), 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), } } // --- Main page (grid) --- fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { let [main_area, _, _vu_area] = main_view::layout(area); if !contains(main_area, col, row) { 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)); } } 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) { 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)); } } } 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_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) { let [left_col, _, _] = engine_view::layout(area); if !contains(left_col, col, row) { return; } // Replicate engine_view render_settings_section layout let inner = Rect { x: left_col.x + 1, y: left_col.y + 1, width: left_col.width.saturating_sub(2), height: left_col.height.saturating_sub(2), }; let padded = Rect { x: inner.x + 1, y: inner.y + 1, width: inner.width.saturating_sub(2), height: inner.height.saturating_sub(1), }; if row < padded.y || row >= padded.y + padded.height { return; } let devices_lines = engine_view::devices_section_height(ctx.app) as usize; let settings_lines: usize = 8; let samples_lines: usize = 6; let total_lines = devices_lines + 1 + settings_lines + 1 + samples_lines; let max_visible = padded.height as usize; let (focus_start, focus_height) = match ctx.app.audio.section { EngineSection::Devices => (0, devices_lines), EngineSection::Settings => (devices_lines + 1, settings_lines), EngineSection::Samples => (devices_lines + 1 + settings_lines + 1, samples_lines), }; let scroll_offset = if total_lines <= max_visible { 0 } else { let focus_end = focus_start + focus_height; if focus_end <= max_visible { 0 } else { focus_start.min(total_lines.saturating_sub(max_visible)) } }; let relative_y = (row - padded.y) as usize; let abs_line = scroll_offset + relative_y; let devices_end = devices_lines; let settings_start = devices_lines + 1; let settings_end = settings_start + settings_lines; let samples_start = settings_end + 1; if abs_line < devices_end { ctx.dispatch(AppCommand::AudioSetSection(EngineSection::Devices)); // Determine output vs input sub-column let [output_col, _sep, input_col] = Layout::horizontal([ Constraint::Percentage(48), Constraint::Length(3), Constraint::Percentage(48), ]) .areas(padded); if contains(input_col, col, row) { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input)); } else if contains(output_col, col, row) { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output)); } } else if abs_line >= settings_start && abs_line < settings_end { ctx.dispatch(AppCommand::AudioSetSection(EngineSection::Settings)); // Settings section: 2 header lines + 6 table rows // Rows 0-3 are adjustable (Channels, Buffer, Voices, Nudge) let row_in_section = abs_line - settings_start; if row_in_section >= 2 { let table_row = row_in_section - 2; let setting = match table_row { 0 => Some(SettingKind::Channels), 1 => Some(SettingKind::BufferSize), 2 => Some(SettingKind::Polyphony), 3 => Some(SettingKind::Nudge), _ => None, }; if let Some(kind) = setting { ctx.app.audio.setting_kind = kind; // Table columns: [Length(14), Fill(1)] — value starts at padded.x + 14 let value_x = padded.x + 14; if col >= value_x { let right = col >= value_x + 4; super::engine_page::cycle_engine_setting(ctx, right); } } } } else if abs_line >= samples_start { ctx.dispatch(AppCommand::AudioSetSection(EngineSection::Samples)); } } // --- Modal --- fn handle_modal_click(ctx: &mut InputContext, col: u16, row: u16, term: Rect) { match &ctx.app.ui.modal { Modal::Editor | Modal::Preview => { // Don't dismiss editor/preview on click } Modal::Confirm { .. } => { handle_confirm_click(ctx, col, row, term); } Modal::KeybindingsHelp { .. } => { // Click outside keybindings help to dismiss 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); } } _ => { // For other modals, don't dismiss on click (they have their own input) } } } 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), } }