Feat: early mouse support

This commit is contained in:
2026-02-14 16:26:29 +01:00
parent 5e7fd8b79c
commit cfaadd9d33
23 changed files with 1256 additions and 285 deletions

View File

@@ -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),

View File

@@ -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;
}

794
src/input/mouse.rs Normal file
View File

@@ -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<usize> {
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<Constraint> = (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<Constraint> = (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<usize> {
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<bool> = 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<bool> = 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<usize> {
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),
}
}

View File

@@ -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);