859 lines
28 KiB
Rust
859 lines
28 KiB
Rust
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
|
|
use crate::commands::AppCommand;
|
|
use crate::page::Page;
|
|
use crate::state::{
|
|
DeviceKind, DictFocus, EngineSection, HelpFocus, 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 [cat_area, words_area] = dict_view::layout(body);
|
|
if contains(cat_area, col, row) {
|
|
if up {
|
|
ctx.dispatch(AppCommand::DictPrevCategory);
|
|
} else {
|
|
ctx.dispatch(AppCommand::DictNextCategory);
|
|
}
|
|
} else if contains(words_area, col, row) {
|
|
if up {
|
|
ctx.dispatch(AppCommand::DictScrollUp(3));
|
|
} else {
|
|
ctx.dispatch(AppCommand::DictScrollDown(3));
|
|
}
|
|
}
|
|
}
|
|
Page::Patterns => {
|
|
let [banks_area, _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 [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 show_preview = ctx.app.audio.config.show_preview;
|
|
let has_viz = show_scope || show_spectrum || show_preview;
|
|
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::{self, DocEntry, DOCS};
|
|
let items = build_visible_list(
|
|
&DOCS
|
|
.iter()
|
|
.map(|e| matches!(e, DocEntry::Section(_)))
|
|
.collect::<Vec<_>>(),
|
|
&ctx.app.ui.help_collapsed,
|
|
);
|
|
let sel = match ctx.app.ui.help_on_section {
|
|
Some(s) => VisibleEntry::Section(s),
|
|
None => VisibleEntry::Item(ctx.app.ui.help_topic),
|
|
};
|
|
if let Some(entry) = hit_test_visible_list(&items, &sel, topics_area, row) {
|
|
match entry {
|
|
VisibleEntry::Item(i) => ctx.dispatch(AppCommand::HelpSelectTopic(i)),
|
|
VisibleEntry::Section(s) => {
|
|
let collapsed = ctx.app.ui.help_collapsed.get(s).copied().unwrap_or(false);
|
|
if collapsed {
|
|
if let Some(v) = ctx.app.ui.help_collapsed.get_mut(s) {
|
|
*v = false;
|
|
}
|
|
ctx.app.ui.help_on_section = None;
|
|
if let Some(first) = docs::first_topic_in_section(s) {
|
|
ctx.app.ui.help_topic = first;
|
|
}
|
|
} else {
|
|
if let Some(v) = ctx.app.ui.help_collapsed.get_mut(s) {
|
|
*v = true;
|
|
}
|
|
ctx.app.ui.help_on_section = Some(s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ctx.app.ui.help_focus = HelpFocus::Topics;
|
|
} else if contains(content_area, col, row) {
|
|
ctx.app.ui.help_focus = HelpFocus::Content;
|
|
}
|
|
}
|
|
|
|
// --- Dict page ---
|
|
|
|
fn handle_dict_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
|
let [cat_area, words_area] = dict_view::layout(area);
|
|
|
|
if contains(cat_area, col, row) {
|
|
use crate::model::categories::{self, CatEntry, CATEGORIES};
|
|
let items = build_visible_list(
|
|
&CATEGORIES
|
|
.iter()
|
|
.map(|e| matches!(e, CatEntry::Section(_)))
|
|
.collect::<Vec<_>>(),
|
|
&ctx.app.ui.dict_collapsed,
|
|
);
|
|
let sel = match ctx.app.ui.dict_on_section {
|
|
Some(s) => VisibleEntry::Section(s),
|
|
None => VisibleEntry::Item(ctx.app.ui.dict_category),
|
|
};
|
|
if let Some(entry) = hit_test_visible_list(&items, &sel, cat_area, row) {
|
|
match entry {
|
|
VisibleEntry::Item(i) => ctx.dispatch(AppCommand::DictSelectCategory(i)),
|
|
VisibleEntry::Section(s) => {
|
|
let collapsed = ctx.app.ui.dict_collapsed.get(s).copied().unwrap_or(false);
|
|
if collapsed {
|
|
if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(s) {
|
|
*v = false;
|
|
}
|
|
ctx.app.ui.dict_on_section = None;
|
|
if let Some(first) = categories::first_category_in_section(s) {
|
|
ctx.app.ui.dict_category = first;
|
|
}
|
|
} else {
|
|
if let Some(v) = ctx.app.ui.dict_collapsed.get_mut(s) {
|
|
*v = true;
|
|
}
|
|
ctx.app.ui.dict_on_section = Some(s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ctx.app.ui.dict_focus = DictFocus::Categories;
|
|
} else if contains(words_area, col, row) {
|
|
ctx.app.ui.dict_focus = DictFocus::Words;
|
|
}
|
|
}
|
|
|
|
// --- CategoryList hit test ---
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum VisibleEntry {
|
|
Item(usize),
|
|
Section(usize),
|
|
}
|
|
|
|
fn build_visible_list(is_section: &[bool], collapsed: &[bool]) -> Vec<VisibleEntry> {
|
|
let mut result = Vec::new();
|
|
let mut section_idx = 0usize;
|
|
let mut item_idx = 0usize;
|
|
let mut skipping = false;
|
|
for &is_sec in is_section {
|
|
if is_sec {
|
|
let is_collapsed = collapsed.get(section_idx).copied().unwrap_or(false);
|
|
result.push(VisibleEntry::Section(section_idx));
|
|
skipping = is_collapsed;
|
|
section_idx += 1;
|
|
} else if !skipping {
|
|
result.push(VisibleEntry::Item(item_idx));
|
|
item_idx += 1;
|
|
} else {
|
|
item_idx += 1;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn hit_test_visible_list(
|
|
items: &[VisibleEntry],
|
|
selected: &VisibleEntry,
|
|
area: Rect,
|
|
click_row: u16,
|
|
) -> Option<VisibleEntry> {
|
|
let visible_height = area.height.saturating_sub(2) as usize;
|
|
if visible_height == 0 {
|
|
return None;
|
|
}
|
|
let total_items = items.len();
|
|
|
|
let selected_visual_idx = match selected {
|
|
VisibleEntry::Item(sel) => items
|
|
.iter()
|
|
.position(|v| matches!(v, VisibleEntry::Item(i) if *i == *sel))
|
|
.unwrap_or(0),
|
|
VisibleEntry::Section(sel) => items
|
|
.iter()
|
|
.position(|v| matches!(v, VisibleEntry::Section(s) if *s == *sel))
|
|
.unwrap_or(0),
|
|
};
|
|
|
|
let scroll = if selected_visual_idx < visible_height / 2 {
|
|
0
|
|
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
|
|
total_items.saturating_sub(visible_height)
|
|
} else {
|
|
selected_visual_idx.saturating_sub(visible_height / 2)
|
|
};
|
|
|
|
let inner_y = area.y + 1;
|
|
if click_row < inner_y {
|
|
return None;
|
|
}
|
|
let relative = (click_row - inner_y) as usize;
|
|
if relative >= visible_height {
|
|
return None;
|
|
}
|
|
|
|
let visual_idx = scroll + relative;
|
|
if visual_idx >= total_items {
|
|
return None;
|
|
}
|
|
|
|
Some(items[visual_idx])
|
|
}
|
|
|
|
// --- Options page ---
|
|
|
|
fn handle_options_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
|
// Replicate options_view layout: Block with borders → inner → padded (+2, +1)
|
|
let inner = Rect {
|
|
x: area.x + 1,
|
|
y: area.y + 1,
|
|
width: area.width.saturating_sub(2),
|
|
height: area.height.saturating_sub(2),
|
|
};
|
|
let padded = Rect {
|
|
x: inner.x + 2,
|
|
y: inner.y + 1,
|
|
width: inner.width.saturating_sub(4),
|
|
height: inner.height.saturating_sub(2),
|
|
};
|
|
|
|
if row < padded.y || row >= padded.y + padded.height {
|
|
return;
|
|
}
|
|
|
|
let focus = ctx.app.options.focus;
|
|
let plugin_mode = ctx.app.plugin_mode;
|
|
let focus_line = focus.line_index(plugin_mode);
|
|
let total_lines = crate::state::options::total_lines(plugin_mode);
|
|
let max_visible = padded.height as usize;
|
|
|
|
let scroll_offset = if total_lines <= max_visible {
|
|
0
|
|
} else {
|
|
focus_line
|
|
.saturating_sub(max_visible / 2)
|
|
.min(total_lines.saturating_sub(max_visible))
|
|
};
|
|
|
|
let relative_y = (row - padded.y) as usize;
|
|
let abs_line = scroll_offset + relative_y;
|
|
|
|
if let Some(new_focus) = OptionsFocus::at_line(abs_line, plugin_mode) {
|
|
ctx.dispatch(AppCommand::OptionsSetFocus(new_focus));
|
|
|
|
// Value area starts at prefix(2) + label(20) = offset 22 from padded.x
|
|
let value_x = padded.x + 22;
|
|
if col >= value_x {
|
|
let right = col >= value_x + 4; // past the "< " prefix → right half
|
|
super::options_page::cycle_option_value(ctx, right);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Engine page ---
|
|
|
|
fn handle_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),
|
|
}
|
|
}
|