diff --git a/crates/ratatui/src/lib.rs b/crates/ratatui/src/lib.rs index 04c2f0c..ac3c318 100644 --- a/crates/ratatui/src/lib.rs +++ b/crates/ratatui/src/lib.rs @@ -28,7 +28,7 @@ pub use file_browser::FileBrowserModal; pub use hint_bar::hint_line; pub use list_select::ListSelect; pub use modal::ModalFrame; -pub use nav_minimap::{NavMinimap, NavTile}; +pub use nav_minimap::{hit_test_tile, minimap_area, NavMinimap, NavTile}; pub use props_form::render_props_form; pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind}; pub use scope::{Orientation, Scope}; diff --git a/crates/ratatui/src/nav_minimap.rs b/crates/ratatui/src/nav_minimap.rs index 2c84a2b..f04232d 100644 --- a/crates/ratatui/src/nav_minimap.rs +++ b/crates/ratatui/src/nav_minimap.rs @@ -4,6 +4,42 @@ use ratatui::style::Style; use ratatui::widgets::{Clear, Paragraph}; use ratatui::Frame; +const TILE_W: u16 = 12; +const TILE_H: u16 = 3; +const GAP: u16 = 1; +const PAD: u16 = 2; +const GRID_COLS: u16 = 3; +const GRID_ROWS: u16 = 2; + +/// Compute the centered minimap area for a 3x2 grid. +pub fn minimap_area(term: Rect) -> Rect { + let content_w = TILE_W * GRID_COLS + GAP * (GRID_COLS - 1); + let content_h = TILE_H * GRID_ROWS + GAP * (GRID_ROWS - 1); + let modal_w = content_w + PAD * 2; + let modal_h = content_h + PAD * 2; + let x = term.x + (term.width.saturating_sub(modal_w)) / 2; + let y = term.y + (term.height.saturating_sub(modal_h)) / 2; + Rect::new(x, y, modal_w, modal_h) +} + +/// Hit-test: returns `(grid_col, grid_row)` if the click lands on a tile. +pub fn hit_test_tile(col: u16, row: u16, term: Rect) -> Option<(i8, i8)> { + let area = minimap_area(term); + let inner_x = area.x + PAD; + let inner_y = area.y + PAD; + + for grid_row in 0..GRID_ROWS { + for grid_col in 0..GRID_COLS { + let tx = inner_x + grid_col * (TILE_W + GAP); + let ty = inner_y + grid_row * (TILE_H + GAP); + if col >= tx && col < tx + TILE_W && row >= ty && row < ty + TILE_H { + return Some((grid_col as i8, grid_row as i8)); + } + } + } + None +} + /// A tile in the navigation grid pub struct NavTile { pub col: i8, @@ -27,25 +63,7 @@ impl<'a> NavMinimap<'a> { return; } - // Compute grid bounds from tiles - let max_col = self.tiles.iter().map(|t| t.col).max().unwrap_or(0); - let max_row = self.tiles.iter().map(|t| t.row).max().unwrap_or(0); - let cols = (max_col + 1) as u16; - let rows = (max_row + 1) as u16; - - let tile_w: u16 = 12; - let tile_h: u16 = 3; - let gap: u16 = 1; - let pad: u16 = 2; - - let content_w = tile_w * cols + gap * (cols.saturating_sub(1)); - let content_h = tile_h * rows + gap * (rows.saturating_sub(1)); - let modal_w = content_w + pad * 2; - let modal_h = content_h + pad * 2; - - let x = term.x + (term.width.saturating_sub(modal_w)) / 2; - let y = term.y + (term.height.saturating_sub(modal_h)) / 2; - let area = Rect::new(x, y, modal_w, modal_h); + let area = minimap_area(term); frame.render_widget(Clear, area); @@ -60,13 +78,13 @@ impl<'a> NavMinimap<'a> { ); } - let inner_x = area.x + pad; - let inner_y = area.y + pad; + let inner_x = area.x + PAD; + let inner_y = area.y + PAD; for tile in self.tiles { - let tile_x = inner_x + (tile.col as u16) * (tile_w + gap); - let tile_y = inner_y + (tile.row as u16) * (tile_h + gap); - let tile_area = Rect::new(tile_x, tile_y, tile_w, tile_h); + let tile_x = inner_x + (tile.col as u16) * (TILE_W + GAP); + let tile_y = inner_y + (tile.row as u16) * (TILE_H + GAP); + let tile_area = Rect::new(tile_x, tile_y, TILE_W, TILE_H); let is_selected = (tile.col, tile.row) == self.selected; self.render_tile(frame, tile_area, tile.name, is_selected); } diff --git a/src/app.rs b/src/app.rs index cd2d845..35b51c3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1251,7 +1251,7 @@ impl App { // UI state AppCommand::ClearMinimap => { - self.ui.minimap_until = None; + self.ui.dismiss_minimap(); } AppCommand::HideTitle => { self.ui.show_title = false; @@ -1294,12 +1294,35 @@ impl App { self.panel.focus = crate::state::PanelFocus::Main; } + // Direct navigation (mouse) + AppCommand::GoToStep(step) => { + let len = self.current_edit_pattern().length; + if step < len { + self.editor_ctx.step = step; + self.editor_ctx.clear_selection(); + self.load_step_to_editor(); + } + } + AppCommand::PatternsSelectBank(bank) => { + self.patterns_nav.bank_cursor = bank; + self.patterns_nav.clear_selection(); + } + AppCommand::PatternsSelectPattern(pattern) => { + self.patterns_nav.pattern_cursor = pattern; + self.patterns_nav.clear_selection(); + } + AppCommand::HelpSelectTopic(i) => help_nav::select_topic(&mut self.ui, i), + AppCommand::DictSelectCategory(i) => dict_nav::select_category(&mut self.ui, i), + // Selection AppCommand::SetSelectionAnchor(step) => { self.editor_ctx.selection_anchor = Some(step); } // Audio settings (engine page) + AppCommand::AudioSetSection(section) => { + self.audio.section = section; + } AppCommand::AudioNextSection => { self.audio.next_section(); } @@ -1371,6 +1394,9 @@ impl App { AppCommand::OptionsPrevFocus => { self.options.prev_focus(); } + AppCommand::OptionsSetFocus(focus) => { + self.options.focus = focus; + } AppCommand::ToggleRefreshRate => { self.audio.toggle_refresh_rate(); } diff --git a/src/bin/desktop.rs b/src/bin/desktop.rs index 8642ad0..b666e1f 100644 --- a/src/bin/desktop.rs +++ b/src/bin/desktop.rs @@ -20,8 +20,8 @@ use cagire::engine::{ SequencerHandle, SpectrumBuffer, }; use cagire::init::{init, InitArgs}; -use cagire::input::{handle_key, InputContext, InputResult}; -use cagire::input_egui::convert_egui_events; +use cagire::input::{handle_key, handle_mouse, InputContext, InputResult}; +use cagire::input_egui::{convert_egui_events, convert_egui_mouse}; use cagire::settings::Settings; use cagire::views; use crossbeam_channel::Receiver; @@ -286,6 +286,21 @@ impl CagireDesktop { }; let seq_snapshot = sequencer.snapshot(); + let term = self.terminal.get_frame().area(); + let widget_rect = ctx.content_rect(); + for mouse in convert_egui_mouse(ctx, widget_rect, term) { + let mut input_ctx = InputContext { + app: &mut self.app, + link: &self.link, + snapshot: &seq_snapshot, + playing: &self.playing, + audio_tx: &sequencer.audio_tx, + seq_cmd_tx: &sequencer.cmd_tx, + nudge_us: &self.nudge_us, + }; + handle_mouse(&mut input_ctx, mouse, term); + } + for key in convert_egui_events(ctx) { let mut input_ctx = InputContext { app: &mut self.app, diff --git a/src/commands.rs b/src/commands.rs index 3955214..a40b67e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; use crate::page::Page; -use crate::state::{ColorScheme, DeviceKind, Modal, PatternField, SettingKind}; +use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, SettingKind}; pub enum AppCommand { // Playback @@ -198,10 +198,18 @@ pub enum AppCommand { // Panel ClosePanel, + // Direct navigation (mouse) + GoToStep(usize), + PatternsSelectBank(usize), + PatternsSelectPattern(usize), + HelpSelectTopic(usize), + DictSelectCategory(usize), + // Selection SetSelectionAnchor(usize), // Audio settings (engine page) + AudioSetSection(EngineSection), AudioNextSection, AudioPrevSection, AudioOutputListUp, @@ -227,6 +235,7 @@ pub enum AppCommand { // Options page OptionsNextFocus, OptionsPrevFocus, + OptionsSetFocus(OptionsFocus), ToggleRefreshRate, ToggleScope, ToggleSpectrum, diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index e069bea..4fd3e53 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -6,6 +6,31 @@ use crate::commands::AppCommand; use crate::engine::{AudioCommand, SeqCommand}; use crate::state::{ConfirmAction, DeviceKind, EngineSection, Modal, SettingKind}; +pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) { + let sign = if right { 1 } else { -1 }; + match ctx.app.audio.setting_kind { + SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting { + setting: SettingKind::Channels, + delta: sign, + }), + SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting { + setting: SettingKind::BufferSize, + delta: sign * 64, + }), + SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting { + setting: SettingKind::Polyphony, + delta: sign, + }), + SettingKind::Nudge => { + let prev = ctx.nudge_us.load(Ordering::Relaxed); + let new_val = prev + sign as i64 * 1000; + ctx.nudge_us + .store(new_val.clamp(-100_000, 100_000), Ordering::Relaxed); + } + } + ctx.app.save_settings(ctx.link); +} + pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { @@ -90,56 +115,14 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input EngineSection::Devices => { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output)); } - EngineSection::Settings => { - match ctx.app.audio.setting_kind { - SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting { - setting: SettingKind::Channels, - delta: -1, - }), - SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting { - setting: SettingKind::BufferSize, - delta: -64, - }), - SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting { - setting: SettingKind::Polyphony, - delta: -1, - }), - SettingKind::Nudge => { - let prev = ctx.nudge_us.load(Ordering::Relaxed); - ctx.nudge_us - .store((prev - 1000).max(-100_000), Ordering::Relaxed); - } - } - ctx.app.save_settings(ctx.link); - } + EngineSection::Settings => cycle_engine_setting(ctx, false), EngineSection::Samples => {} }, KeyCode::Right => match ctx.app.audio.section { EngineSection::Devices => { ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input)); } - EngineSection::Settings => { - match ctx.app.audio.setting_kind { - SettingKind::Channels => ctx.dispatch(AppCommand::AdjustAudioSetting { - setting: SettingKind::Channels, - delta: 1, - }), - SettingKind::BufferSize => ctx.dispatch(AppCommand::AdjustAudioSetting { - setting: SettingKind::BufferSize, - delta: 64, - }), - SettingKind::Polyphony => ctx.dispatch(AppCommand::AdjustAudioSetting { - setting: SettingKind::Polyphony, - delta: 1, - }), - SettingKind::Nudge => { - let prev = ctx.nudge_us.load(Ordering::Relaxed); - ctx.nudge_us - .store((prev + 1000).min(100_000), Ordering::Relaxed); - } - } - ctx.app.save_settings(ctx.link); - } + EngineSection::Settings => cycle_engine_setting(ctx, true), EngineSection::Samples => {} }, KeyCode::Char('R') => ctx.dispatch(AppCommand::AudioTriggerRestart), diff --git a/src/input/mod.rs b/src/input/mod.rs index 1531d38..55fce72 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,14 +1,16 @@ -mod engine_page; +pub(crate) mod engine_page; mod help_page; mod main_page; mod modal; -mod options_page; +mod mouse; +pub(crate) mod options_page; mod panel; mod patterns_page; use arc_swap::ArcSwap; use crossbeam_channel::Sender; -use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent}; +use ratatui::layout::Rect; use std::sync::atomic::{AtomicBool, AtomicI64}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -17,7 +19,7 @@ use crate::app::App; use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot}; use crate::page::Page; -use crate::state::{Modal, PanelFocus}; +use crate::state::{MinimapMode, Modal, PanelFocus}; pub enum InputResult { Continue, @@ -40,6 +42,10 @@ impl<'a> InputContext<'a> { } } +pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) { + mouse::handle_mouse(ctx, mouse, term); +} + pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult { if handle_live_keys(ctx, &key) { return InputResult::Continue; @@ -54,7 +60,7 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult { key.code, KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down ); - if ctx.app.ui.minimap_until.is_some() && !(ctrl && is_arrow) { + if !(matches!(ctx.app.ui.minimap, MinimapMode::Hidden) || ctrl && is_arrow) { ctx.dispatch(AppCommand::ClearMinimap); } @@ -91,25 +97,25 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { } if ctrl { - let minimap_timeout = Some(Instant::now() + Duration::from_millis(250)); + let minimap_timed = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); match key.code { KeyCode::Left => { - ctx.app.ui.minimap_until = minimap_timeout; + ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageLeft); return InputResult::Continue; } KeyCode::Right => { - ctx.app.ui.minimap_until = minimap_timeout; + ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageRight); return InputResult::Continue; } KeyCode::Up => { - ctx.app.ui.minimap_until = minimap_timeout; + ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageUp); return InputResult::Continue; } KeyCode::Down => { - ctx.app.ui.minimap_until = minimap_timeout; + ctx.app.ui.minimap = minimap_timed; ctx.dispatch(AppCommand::PageDown); return InputResult::Continue; } @@ -126,7 +132,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::F(6) => Some(Page::Engine), _ => None, } { - ctx.app.ui.minimap_until = Some(Instant::now() + Duration::from_millis(250)); + ctx.app.ui.minimap = MinimapMode::Timed(Instant::now() + Duration::from_millis(250)); ctx.dispatch(AppCommand::GoToPage(page)); return InputResult::Continue; } diff --git a/src/input/mouse.rs b/src/input/mouse.rs new file mode 100644 index 0000000..371a185 --- /dev/null +++ b/src/input/mouse.rs @@ -0,0 +1,794 @@ +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::{Constraint, Layout, Rect}; + +use crate::commands::AppCommand; +use crate::page::Page; +use crate::state::{ + DictFocus, DeviceKind, EngineSection, HelpFocus, MainLayout, MinimapMode, Modal, + OptionsFocus, PatternsColumn, SettingKind, +}; +use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view}; + +use super::InputContext; + +const STEPS_PER_PAGE: usize = 32; + +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 { + Rect { + x: term.x + 4, + y: term.y + 1, + width: term.width.saturating_sub(8), + 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 (header_area, [cat_area, words_area]) = dict_view::layout(body); + if contains(header_area, col, row) { + return; + } + 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, _gap, 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 [_patterns_area, _, main_area, _, _vu_area] = main_view::layout(area); + + if !contains(main_area, col, row) { + return; + } + + // Replay viz/sequencer split + let show_scope = ctx.app.audio.config.show_scope; + let show_spectrum = ctx.app.audio.config.show_spectrum; + let has_viz = show_scope || show_spectrum; + let layout = ctx.app.audio.config.layout; + + let sequencer_area = match layout { + MainLayout::Top => { + let viz_height = if has_viz { 16 } else { 0 }; + let [_viz, seq] = Layout::vertical([ + Constraint::Length(viz_height), + Constraint::Fill(1), + ]) + .areas(main_area); + seq + } + MainLayout::Bottom => { + let viz_height = if has_viz { 16 } else { 0 }; + let [seq, _viz] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(viz_height), + ]) + .areas(main_area); + seq + } + MainLayout::Left => { + let viz_width = if has_viz { 33 } else { 0 }; + let [_viz, _spacer, seq] = Layout::horizontal([ + Constraint::Percentage(viz_width), + Constraint::Length(2), + Constraint::Fill(1), + ]) + .areas(main_area); + seq + } + MainLayout::Right => { + let viz_width = if has_viz { 33 } else { 0 }; + let [seq, _spacer, _viz] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(2), + Constraint::Percentage(viz_width), + ]) + .areas(main_area); + seq + } + }; + + if !contains(sequencer_area, col, row) { + return; + } + + // Replay grid layout to find which step was clicked + 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 { + if area.width < 50 { + return None; + } + + let pattern = ctx.app.current_edit_pattern(); + let length = pattern.length; + let page = ctx.app.editor_ctx.step / STEPS_PER_PAGE; + let page_start = page * STEPS_PER_PAGE; + let steps_on_page = (page_start + STEPS_PER_PAGE).min(length) - page_start; + + let num_rows = steps_on_page.div_ceil(8); + let steps_per_row = steps_on_page.div_ceil(num_rows); + + let row_height = area.height / num_rows as u16; + + let row_constraints: Vec = (0..num_rows) + .map(|_| Constraint::Length(row_height)) + .collect(); + let rows = Layout::vertical(row_constraints).split(area); + + for row_idx in 0..num_rows { + let row_area = rows[row_idx]; + if !contains(row_area, col, row) { + continue; + } + + let start_step = row_idx * steps_per_row; + let end_step = (start_step + steps_per_row).min(steps_on_page); + let cols_in_row = end_step - start_step; + + let col_constraints: Vec = (0..cols_in_row * 2 - 1) + .map(|i| { + if i % 2 == 0 { + Constraint::Fill(1) + } else if i == cols_in_row - 1 { + Constraint::Length(2) + } else { + Constraint::Length(1) + } + }) + .collect(); + let cols = Layout::horizontal(col_constraints).split(row_area); + + for col_idx in 0..cols_in_row { + let tile_area = cols[col_idx * 2]; + if contains(tile_area, col, row) { + let step_idx = page_start + start_step + col_idx; + 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, _gap, 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}; + + let [_title, inner] = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(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 max_visible = (inner.height as usize).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); + let row_height = (inner.height / visible_count as u16).max(1); + + if row < inner.y { + return None; + } + + 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); + } + } + + 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::{DocEntry, DOCS}; + let is_section: Vec = DOCS.iter().map(|e| matches!(e, DocEntry::Section(_))).collect(); + if let Some(i) = hit_test_category_list(&is_section, ctx.app.ui.help_topic, topics_area, row) { + ctx.dispatch(AppCommand::HelpSelectTopic(i)); + } + 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 (_header_area, [cat_area, words_area]) = dict_view::layout(area); + + if contains(cat_area, col, row) { + use crate::model::categories::{CatEntry, CATEGORIES}; + let is_section: Vec = CATEGORIES.iter().map(|e| matches!(e, CatEntry::Section(_))).collect(); + if let Some(i) = hit_test_category_list(&is_section, ctx.app.ui.dict_category, cat_area, row) { + ctx.dispatch(AppCommand::DictSelectCategory(i)); + } + ctx.app.ui.dict_focus = DictFocus::Categories; + } else if contains(words_area, col, row) { + ctx.app.ui.dict_focus = DictFocus::Words; + } +} + +// --- CategoryList hit test --- + +fn hit_test_category_list( + is_section: &[bool], + selected: usize, + 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 = is_section.len(); + + // Compute visual index of the selected item (same as CategoryList::render) + let selected_visual_idx = { + let mut visual = 0; + let mut selectable_count = 0; + for &is_sec in is_section { + if !is_sec { + if selectable_count == selected { + break; + } + selectable_count += 1; + } + visual += 1; + } + visual + }; + + 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) + }; + + // Inner area starts at area.y + 1 (border top), height is area.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; + } + + // If it's a section header, not clickable + if is_section[visual_idx] { + return None; + } + + // Count selectable items before this visual index to get the selectable index + let selectable_idx = is_section[..visual_idx].iter().filter(|&&s| !s).count(); + Some(selectable_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 focus_line = focus.line_index(); + let total_lines = 35; + 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) { + 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), + } +} diff --git a/src/input/options_page.rs b/src/input/options_page.rs index 3b3ffc5..c1bd4c7 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -5,6 +5,144 @@ use super::{InputContext, InputResult}; use crate::commands::AppCommand; use crate::state::{ConfirmAction, Modal, OptionsFocus}; +pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) { + match ctx.app.options.focus { + OptionsFocus::ColorScheme => { + let new_scheme = if right { + ctx.app.ui.color_scheme.next() + } else { + ctx.app.ui.color_scheme.prev() + }; + ctx.dispatch(AppCommand::SetColorScheme(new_scheme)); + } + OptionsFocus::HueRotation => { + let delta = if right { 5.0 } else { -5.0 }; + let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0); + ctx.dispatch(AppCommand::SetHueRotation(new_rotation)); + } + OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate), + OptionsFocus::RuntimeHighlight => ctx.dispatch(AppCommand::ToggleRuntimeHighlight), + OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope), + OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum), + OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion), + OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), + OptionsFocus::StartStopSync => ctx + .link + .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), + OptionsFocus::Quantum => { + let delta = if right { 1.0 } else { -1.0 }; + ctx.link.set_quantum(ctx.link.quantum() + delta); + } + OptionsFocus::MidiOutput0 + | OptionsFocus::MidiOutput1 + | OptionsFocus::MidiOutput2 + | OptionsFocus::MidiOutput3 => { + let slot = match ctx.app.options.focus { + OptionsFocus::MidiOutput0 => 0, + OptionsFocus::MidiOutput1 => 1, + OptionsFocus::MidiOutput2 => 2, + OptionsFocus::MidiOutput3 => 3, + _ => 0, + }; + let all_devices = crate::midi::list_midi_outputs(); + let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices + .iter() + .enumerate() + .filter(|(idx, _)| { + ctx.app.midi.selected_outputs[slot] == Some(*idx) + || !ctx + .app + .midi + .selected_outputs + .iter() + .enumerate() + .any(|(s, sel)| s != slot && *sel == Some(*idx)) + }) + .collect(); + let total_options = available.len() + 1; + let current_pos = ctx.app.midi.selected_outputs[slot] + .and_then(|idx| available.iter().position(|(i, _)| *i == idx)) + .map(|p| p + 1) + .unwrap_or(0); + let new_pos = if right { + (current_pos + 1) % total_options + } else if current_pos == 0 { + total_options - 1 + } else { + current_pos - 1 + }; + if new_pos == 0 { + ctx.app.midi.disconnect_output(slot); + ctx.dispatch(AppCommand::SetStatus(format!( + "MIDI output {slot}: disconnected" + ))); + } else { + let (device_idx, device) = available[new_pos - 1]; + if ctx.app.midi.connect_output(slot, device_idx).is_ok() { + ctx.dispatch(AppCommand::SetStatus(format!( + "MIDI output {}: {}", + slot, device.name + ))); + } + } + } + OptionsFocus::MidiInput0 + | OptionsFocus::MidiInput1 + | OptionsFocus::MidiInput2 + | OptionsFocus::MidiInput3 => { + let slot = match ctx.app.options.focus { + OptionsFocus::MidiInput0 => 0, + OptionsFocus::MidiInput1 => 1, + OptionsFocus::MidiInput2 => 2, + OptionsFocus::MidiInput3 => 3, + _ => 0, + }; + let all_devices = crate::midi::list_midi_inputs(); + let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices + .iter() + .enumerate() + .filter(|(idx, _)| { + ctx.app.midi.selected_inputs[slot] == Some(*idx) + || !ctx + .app + .midi + .selected_inputs + .iter() + .enumerate() + .any(|(s, sel)| s != slot && *sel == Some(*idx)) + }) + .collect(); + let total_options = available.len() + 1; + let current_pos = ctx.app.midi.selected_inputs[slot] + .and_then(|idx| available.iter().position(|(i, _)| *i == idx)) + .map(|p| p + 1) + .unwrap_or(0); + let new_pos = if right { + (current_pos + 1) % total_options + } else if current_pos == 0 { + total_options - 1 + } else { + current_pos - 1 + }; + if new_pos == 0 { + ctx.app.midi.disconnect_input(slot); + ctx.dispatch(AppCommand::SetStatus(format!( + "MIDI input {slot}: disconnected" + ))); + } else { + let (device_idx, device) = available[new_pos - 1]; + if ctx.app.midi.connect_input(slot, device_idx).is_ok() { + ctx.dispatch(AppCommand::SetStatus(format!( + "MIDI input {}: {}", + slot, device.name + ))); + } + } + } + } + ctx.app.save_settings(ctx.link); +} + pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { @@ -16,153 +154,7 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu KeyCode::Down | KeyCode::Tab => ctx.dispatch(AppCommand::OptionsNextFocus), KeyCode::Up | KeyCode::BackTab => ctx.dispatch(AppCommand::OptionsPrevFocus), KeyCode::Left | KeyCode::Right => { - match ctx.app.options.focus { - OptionsFocus::ColorScheme => { - let new_scheme = if key.code == KeyCode::Left { - ctx.app.ui.color_scheme.prev() - } else { - ctx.app.ui.color_scheme.next() - }; - ctx.dispatch(AppCommand::SetColorScheme(new_scheme)); - } - OptionsFocus::HueRotation => { - let delta = if key.code == KeyCode::Left { -5.0 } else { 5.0 }; - let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0); - ctx.dispatch(AppCommand::SetHueRotation(new_rotation)); - } - OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate), - OptionsFocus::RuntimeHighlight => { - ctx.dispatch(AppCommand::ToggleRuntimeHighlight); - } - OptionsFocus::ShowScope => { - ctx.dispatch(AppCommand::ToggleScope); - } - OptionsFocus::ShowSpectrum => { - ctx.dispatch(AppCommand::ToggleSpectrum); - } - OptionsFocus::ShowCompletion => { - ctx.dispatch(AppCommand::ToggleCompletion); - } - OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), - OptionsFocus::StartStopSync => ctx - .link - .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), - OptionsFocus::Quantum => { - let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 }; - ctx.link.set_quantum(ctx.link.quantum() + delta); - } - OptionsFocus::MidiOutput0 - | OptionsFocus::MidiOutput1 - | OptionsFocus::MidiOutput2 - | OptionsFocus::MidiOutput3 => { - let slot = match ctx.app.options.focus { - OptionsFocus::MidiOutput0 => 0, - OptionsFocus::MidiOutput1 => 1, - OptionsFocus::MidiOutput2 => 2, - OptionsFocus::MidiOutput3 => 3, - _ => 0, - }; - let all_devices = crate::midi::list_midi_outputs(); - let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices - .iter() - .enumerate() - .filter(|(idx, _)| { - ctx.app.midi.selected_outputs[slot] == Some(*idx) - || !ctx - .app - .midi - .selected_outputs - .iter() - .enumerate() - .any(|(s, sel)| s != slot && *sel == Some(*idx)) - }) - .collect(); - let total_options = available.len() + 1; - let current_pos = ctx.app.midi.selected_outputs[slot] - .and_then(|idx| available.iter().position(|(i, _)| *i == idx)) - .map(|p| p + 1) - .unwrap_or(0); - let new_pos = if key.code == KeyCode::Left { - if current_pos == 0 { - total_options - 1 - } else { - current_pos - 1 - } - } else { - (current_pos + 1) % total_options - }; - if new_pos == 0 { - ctx.app.midi.disconnect_output(slot); - ctx.dispatch(AppCommand::SetStatus(format!( - "MIDI output {slot}: disconnected" - ))); - } else { - let (device_idx, device) = available[new_pos - 1]; - if ctx.app.midi.connect_output(slot, device_idx).is_ok() { - ctx.dispatch(AppCommand::SetStatus(format!( - "MIDI output {}: {}", - slot, device.name - ))); - } - } - } - OptionsFocus::MidiInput0 - | OptionsFocus::MidiInput1 - | OptionsFocus::MidiInput2 - | OptionsFocus::MidiInput3 => { - let slot = match ctx.app.options.focus { - OptionsFocus::MidiInput0 => 0, - OptionsFocus::MidiInput1 => 1, - OptionsFocus::MidiInput2 => 2, - OptionsFocus::MidiInput3 => 3, - _ => 0, - }; - let all_devices = crate::midi::list_midi_inputs(); - let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices - .iter() - .enumerate() - .filter(|(idx, _)| { - ctx.app.midi.selected_inputs[slot] == Some(*idx) - || !ctx - .app - .midi - .selected_inputs - .iter() - .enumerate() - .any(|(s, sel)| s != slot && *sel == Some(*idx)) - }) - .collect(); - let total_options = available.len() + 1; - let current_pos = ctx.app.midi.selected_inputs[slot] - .and_then(|idx| available.iter().position(|(i, _)| *i == idx)) - .map(|p| p + 1) - .unwrap_or(0); - let new_pos = if key.code == KeyCode::Left { - if current_pos == 0 { - total_options - 1 - } else { - current_pos - 1 - } - } else { - (current_pos + 1) % total_options - }; - if new_pos == 0 { - ctx.app.midi.disconnect_input(slot); - ctx.dispatch(AppCommand::SetStatus(format!( - "MIDI input {slot}: disconnected" - ))); - } else { - let (device_idx, device) = available[new_pos - 1]; - if ctx.app.midi.connect_input(slot, device_idx).is_ok() { - ctx.dispatch(AppCommand::SetStatus(format!( - "MIDI input {}: {}", - slot, device.name - ))); - } - } - } - } - ctx.app.save_settings(ctx.link); + cycle_option_value(ctx, key.code == KeyCode::Right); } KeyCode::Char(' ') => { ctx.dispatch(AppCommand::TogglePlaying); diff --git a/src/input_egui.rs b/src/input_egui.rs index da6f0c6..f499a9d 100644 --- a/src/input_egui.rs +++ b/src/input_egui.rs @@ -1,4 +1,60 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +pub fn convert_egui_mouse( + ctx: &egui::Context, + widget_rect: egui::Rect, + term: Rect, +) -> Vec { + let mut events = Vec::new(); + if widget_rect.width() < 1.0 || widget_rect.height() < 1.0 || term.width == 0 || term.height == 0 { + return events; + } + + ctx.input(|i| { + let Some(pos) = i.pointer.latest_pos() else { + return; + }; + if !widget_rect.contains(pos) { + return; + } + + let col = + ((pos.x - widget_rect.left()) / widget_rect.width() * term.width as f32) as u16; + let row = + ((pos.y - widget_rect.top()) / widget_rect.height() * term.height as f32) as u16; + let col = col.min(term.width.saturating_sub(1)); + let row = row.min(term.height.saturating_sub(1)); + + if i.pointer.button_clicked(egui::PointerButton::Primary) { + events.push(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: col, + row, + modifiers: KeyModifiers::empty(), + }); + } + + let scroll = i.raw_scroll_delta.y; + if scroll > 1.0 { + events.push(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: col, + row, + modifiers: KeyModifiers::empty(), + }); + } else if scroll < -1.0 { + events.push(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: col, + row, + modifiers: KeyModifiers::empty(), + }); + } + }); + + events +} pub fn convert_egui_events(ctx: &egui::Context) -> Vec { let mut events = Vec::new(); diff --git a/src/main.rs b/src/main.rs index 72dbd73..8cf844f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use clap::Parser; -use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event}; +use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, DisableMouseCapture, EnableMouseCapture, Event}; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; @@ -30,7 +30,7 @@ use ratatui::Terminal; use engine::{build_stream, AudioStreamConfig}; use init::InitArgs; -use input::{handle_key, InputContext, InputResult}; +use input::{handle_key, handle_mouse, InputContext, InputResult}; #[derive(Parser)] #[command(name = "cagire", version, about = "Forth-based live coding sequencer")] @@ -86,6 +86,7 @@ fn main() -> io::Result<()> { enable_raw_mode()?; io::stdout().execute(EnableBracketedPaste)?; + io::stdout().execute(EnableMouseCapture)?; io::stdout().execute(EnterAlternateScreen)?; let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; @@ -253,6 +254,18 @@ fn main() -> io::Result<()> { break; } } + Event::Mouse(mouse) => { + let mut ctx = InputContext { + app: &mut app, + link: &link, + snapshot: &seq_snapshot, + playing: &playing, + audio_tx: &sequencer.audio_tx, + seq_cmd_tx: &sequencer.cmd_tx, + nudge_us: &nudge_us, + }; + handle_mouse(&mut ctx, mouse, terminal.get_frame().area()); + } Event::Paste(text) => { if matches!(app.ui.modal, state::Modal::Editor) { app.editor_ctx.editor.insert_str(&text); @@ -282,6 +295,7 @@ fn main() -> io::Result<()> { } disable_raw_mode()?; + io::stdout().execute(DisableMouseCapture)?; io::stdout().execute(DisableBracketedPaste)?; io::stdout().execute(LeaveAlternateScreen)?; diff --git a/src/services/dict_nav.rs b/src/services/dict_nav.rs index 21e49e9..7cd78c4 100644 --- a/src/services/dict_nav.rs +++ b/src/services/dict_nav.rs @@ -8,6 +8,12 @@ pub fn toggle_focus(ui: &mut UiState) { }; } +pub fn select_category(ui: &mut UiState, index: usize) { + if index < categories::category_count() { + ui.dict_category = index; + } +} + pub fn next_category(ui: &mut UiState) { let count = categories::category_count(); ui.dict_category = (ui.dict_category + 1) % count; diff --git a/src/services/help_nav.rs b/src/services/help_nav.rs index 918b2a9..8549684 100644 --- a/src/services/help_nav.rs +++ b/src/services/help_nav.rs @@ -8,6 +8,12 @@ pub fn toggle_focus(ui: &mut UiState) { }; } +pub fn select_topic(ui: &mut UiState, index: usize) { + if index < docs::topic_count() { + ui.help_topic = index; + } +} + pub fn next_topic(ui: &mut UiState, n: usize) { let count = docs::topic_count(); ui.help_topic = (ui.help_topic + n) % count; diff --git a/src/state/mod.rs b/src/state/mod.rs index 13c2cfc..3c0bed0 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -44,4 +44,4 @@ pub use mute::MuteState; pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange}; pub use project::ProjectState; pub use sample_browser::{SampleBrowserState, SampleTree}; -pub use ui::{DictFocus, FlashKind, HelpFocus, UiState}; +pub use ui::{DictFocus, FlashKind, HelpFocus, MinimapMode, UiState}; diff --git a/src/state/options.rs b/src/state/options.rs index 79452c1..e9216f3 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -46,6 +46,44 @@ impl CyclicEnum for OptionsFocus { ]; } +const FOCUS_LINES: &[(OptionsFocus, usize)] = &[ + (OptionsFocus::ColorScheme, 2), + (OptionsFocus::HueRotation, 3), + (OptionsFocus::RefreshRate, 4), + (OptionsFocus::RuntimeHighlight, 5), + (OptionsFocus::ShowScope, 6), + (OptionsFocus::ShowSpectrum, 7), + (OptionsFocus::ShowCompletion, 8), + (OptionsFocus::LinkEnabled, 12), + (OptionsFocus::StartStopSync, 13), + (OptionsFocus::Quantum, 14), + (OptionsFocus::MidiOutput0, 24), + (OptionsFocus::MidiOutput1, 25), + (OptionsFocus::MidiOutput2, 26), + (OptionsFocus::MidiOutput3, 27), + (OptionsFocus::MidiInput0, 31), + (OptionsFocus::MidiInput1, 32), + (OptionsFocus::MidiInput2, 33), + (OptionsFocus::MidiInput3, 34), +]; + +impl OptionsFocus { + pub fn line_index(self) -> usize { + FOCUS_LINES + .iter() + .find(|(f, _)| *f == self) + .map(|(_, l)| *l) + .unwrap_or(0) + } + + pub fn at_line(line: usize) -> Option { + FOCUS_LINES + .iter() + .find(|(_, l)| *l == line) + .map(|(f, _)| *f) + } +} + #[derive(Default)] pub struct OptionsState { pub focus: OptionsFocus, diff --git a/src/state/ui.rs b/src/state/ui.rs index 48ebaa6..914885c 100644 --- a/src/state/ui.rs +++ b/src/state/ui.rs @@ -8,6 +8,14 @@ use crate::page::Page; use crate::state::effects::FxId; use crate::state::{ColorScheme, Modal}; +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum MinimapMode { + #[default] + Hidden, + Timed(Instant), + Sticky, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum FlashKind { #[default] @@ -49,7 +57,7 @@ pub struct UiState { pub show_title: bool, pub runtime_highlight: bool, pub show_completion: bool, - pub minimap_until: Option, + pub minimap: MinimapMode, pub color_scheme: ColorScheme, pub hue_rotation: f32, pub effects: RefCell>, @@ -81,7 +89,7 @@ impl Default for UiState { show_title: true, runtime_highlight: false, show_completion: true, - minimap_until: None, + minimap: MinimapMode::Hidden, color_scheme: ColorScheme::default(), hue_rotation: 0.0, effects: RefCell::new(EffectManager::default()), @@ -138,4 +146,16 @@ impl UiState { .map(|t| Instant::now() < t) .unwrap_or(false) } + + pub fn show_minimap(&self) -> bool { + match self.minimap { + MinimapMode::Hidden => false, + MinimapMode::Timed(until) => Instant::now() < until, + MinimapMode::Sticky => true, + } + } + + pub fn dismiss_minimap(&mut self) { + self.minimap = MinimapMode::Hidden; + } } diff --git a/src/views/dict_view.rs b/src/views/dict_view.rs index 4062757..a30055d 100644 --- a/src/views/dict_view.rs +++ b/src/views/dict_view.rs @@ -13,15 +13,18 @@ use crate::widgets::{render_search_bar, CategoryItem, CategoryList}; use CatEntry::{Category, Section}; -pub fn render(frame: &mut Frame, app: &App, area: Rect) { +pub fn layout(area: Rect) -> (Rect, [Rect; 2]) { let [header_area, body_area] = Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(area); + let body = Layout::horizontal([Constraint::Length(16), Constraint::Fill(1)]).areas(body_area); + (header_area, body) +} + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + let (header_area, [cat_area, words_area]) = layout(area); render_header(frame, header_area); - let [cat_area, words_area] = - Layout::horizontal([Constraint::Length(16), Constraint::Fill(1)]).areas(body_area); - let is_searching = !app.ui.dict_search_query.is_empty(); render_categories(frame, app, cat_area, is_searching); render_words(frame, app, words_area, is_searching); diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index d1f9a62..dae14a9 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -12,13 +12,17 @@ use crate::widgets::{ render_scroll_indicators, render_section_header, IndicatorAlign, Orientation, Scope, Spectrum, }; -pub fn render(frame: &mut Frame, app: &App, area: Rect) { - let [left_col, _, right_col] = Layout::horizontal([ +pub fn layout(area: Rect) -> [Rect; 3] { + Layout::horizontal([ Constraint::Percentage(55), Constraint::Length(2), Constraint::Percentage(45), ]) - .areas(area); + .areas(area) +} + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + let [left_col, _, right_col] = layout(area); render_settings_section(frame, app, left_col); render_visualizers(frame, app, right_col); @@ -185,7 +189,7 @@ fn truncate_name(name: &str, max_len: usize) -> String { } } -fn list_height(item_count: usize) -> u16 { +pub fn list_height(item_count: usize) -> u16 { let visible = item_count.min(5) as u16; if item_count > 5 { visible + 1 @@ -194,7 +198,7 @@ fn list_height(item_count: usize) -> u16 { } } -fn devices_section_height(app: &App) -> u16 { +pub fn devices_section_height(app: &App) -> u16 { let output_h = list_height(app.audio.output_devices.len()); let input_h = list_height(app.audio.input_devices.len()); 3 + output_h.max(input_h) diff --git a/src/views/help_view.rs b/src/views/help_view.rs index 2549dac..15a8a69 100644 --- a/src/views/help_view.rs +++ b/src/views/help_view.rs @@ -91,9 +91,12 @@ impl CodeHighlighter for ForthHighlighter { } } +pub fn layout(area: Rect) -> [Rect; 2] { + Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area) +} + pub fn render(frame: &mut Frame, app: &App, area: Rect) { - let [topics_area, content_area] = - Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area); + let [topics_area, content_area] = layout(area); render_topics(frame, app, topics_area); render_content(frame, app, content_area); diff --git a/src/views/main_view.rs b/src/views/main_view.rs index c218d5b..038ff70 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -9,15 +9,19 @@ use crate::state::MainLayout; use crate::theme; use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter}; -pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { - let [patterns_area, _, main_area, _, vu_area] = Layout::horizontal([ +pub fn layout(area: Rect) -> [Rect; 5] { + Layout::horizontal([ Constraint::Length(13), Constraint::Length(2), Constraint::Fill(1), Constraint::Length(2), Constraint::Length(10), ]) - .areas(area); + .areas(area) +} + +pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { + let [patterns_area, _, main_area, _, vu_area] = layout(area); let show_scope = app.audio.config.show_scope; let show_spectrum = app.audio.config.show_spectrum; diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 707a67d..d647773 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -206,26 +206,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let total_lines = lines.len(); let max_visible = padded.height as usize; - let focus_line: usize = match focus { - OptionsFocus::ColorScheme => 2, - OptionsFocus::HueRotation => 3, - OptionsFocus::RefreshRate => 4, - OptionsFocus::RuntimeHighlight => 5, - OptionsFocus::ShowScope => 6, - OptionsFocus::ShowSpectrum => 7, - OptionsFocus::ShowCompletion => 8, - OptionsFocus::LinkEnabled => 12, - OptionsFocus::StartStopSync => 13, - OptionsFocus::Quantum => 14, - OptionsFocus::MidiOutput0 => 25, - OptionsFocus::MidiOutput1 => 26, - OptionsFocus::MidiOutput2 => 27, - OptionsFocus::MidiOutput3 => 28, - OptionsFocus::MidiInput0 => 32, - OptionsFocus::MidiInput1 => 33, - OptionsFocus::MidiInput2 => 34, - OptionsFocus::MidiInput3 => 35, - }; + let focus_line = focus.line_index(); let scroll_offset = if total_lines <= max_visible { 0 diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 941df7a..05dbcd7 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -13,13 +13,12 @@ use crate::widgets::{render_scroll_indicators, IndicatorAlign}; const MIN_ROW_HEIGHT: u16 = 1; +pub fn layout(area: Rect) -> [Rect; 3] { + Layout::horizontal([Constraint::Fill(1), Constraint::Length(1), Constraint::Fill(1)]).areas(area) +} + pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) { - let [banks_area, gap, patterns_area] = Layout::horizontal([ - Constraint::Fill(1), - Constraint::Length(1), - Constraint::Fill(1), - ]) - .areas(area); + let [banks_area, gap, patterns_area] = layout(area); render_banks(frame, app, snapshot, banks_area); // gap is just empty space diff --git a/src/views/render.rs b/src/views/render.rs index c004a8a..dd65722 100644 --- a/src/views/render.rs +++ b/src/views/render.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use std::time::{Duration, Instant}; +use std::time::Duration; use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; @@ -134,13 +134,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc render_footer(frame, app, footer_area); let modal_area = render_modal(frame, app, snapshot, term); - let show_minimap = app - .ui - .minimap_until - .map(|until| Instant::now() < until) - .unwrap_or(false); - - if show_minimap { + if app.ui.show_minimap() { let tiles: Vec = Page::ALL .iter() .map(|p| {