Feat: early mouse support
This commit is contained in:
@@ -28,7 +28,7 @@ pub use file_browser::FileBrowserModal;
|
|||||||
pub use hint_bar::hint_line;
|
pub use hint_bar::hint_line;
|
||||||
pub use list_select::ListSelect;
|
pub use list_select::ListSelect;
|
||||||
pub use modal::ModalFrame;
|
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 props_form::render_props_form;
|
||||||
pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind};
|
pub use sample_browser::{SampleBrowser, TreeLine, TreeLineKind};
|
||||||
pub use scope::{Orientation, Scope};
|
pub use scope::{Orientation, Scope};
|
||||||
|
|||||||
@@ -4,6 +4,42 @@ use ratatui::style::Style;
|
|||||||
use ratatui::widgets::{Clear, Paragraph};
|
use ratatui::widgets::{Clear, Paragraph};
|
||||||
use ratatui::Frame;
|
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
|
/// A tile in the navigation grid
|
||||||
pub struct NavTile {
|
pub struct NavTile {
|
||||||
pub col: i8,
|
pub col: i8,
|
||||||
@@ -27,25 +63,7 @@ impl<'a> NavMinimap<'a> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute grid bounds from tiles
|
let area = minimap_area(term);
|
||||||
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);
|
|
||||||
|
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
|
|
||||||
@@ -60,13 +78,13 @@ impl<'a> NavMinimap<'a> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let inner_x = area.x + pad;
|
let inner_x = area.x + PAD;
|
||||||
let inner_y = area.y + pad;
|
let inner_y = area.y + PAD;
|
||||||
|
|
||||||
for tile in self.tiles {
|
for tile in self.tiles {
|
||||||
let tile_x = inner_x + (tile.col as u16) * (tile_w + gap);
|
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_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_area = Rect::new(tile_x, tile_y, TILE_W, TILE_H);
|
||||||
let is_selected = (tile.col, tile.row) == self.selected;
|
let is_selected = (tile.col, tile.row) == self.selected;
|
||||||
self.render_tile(frame, tile_area, tile.name, is_selected);
|
self.render_tile(frame, tile_area, tile.name, is_selected);
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/app.rs
28
src/app.rs
@@ -1251,7 +1251,7 @@ impl App {
|
|||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
AppCommand::ClearMinimap => {
|
AppCommand::ClearMinimap => {
|
||||||
self.ui.minimap_until = None;
|
self.ui.dismiss_minimap();
|
||||||
}
|
}
|
||||||
AppCommand::HideTitle => {
|
AppCommand::HideTitle => {
|
||||||
self.ui.show_title = false;
|
self.ui.show_title = false;
|
||||||
@@ -1294,12 +1294,35 @@ impl App {
|
|||||||
self.panel.focus = crate::state::PanelFocus::Main;
|
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
|
// Selection
|
||||||
AppCommand::SetSelectionAnchor(step) => {
|
AppCommand::SetSelectionAnchor(step) => {
|
||||||
self.editor_ctx.selection_anchor = Some(step);
|
self.editor_ctx.selection_anchor = Some(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio settings (engine page)
|
// Audio settings (engine page)
|
||||||
|
AppCommand::AudioSetSection(section) => {
|
||||||
|
self.audio.section = section;
|
||||||
|
}
|
||||||
AppCommand::AudioNextSection => {
|
AppCommand::AudioNextSection => {
|
||||||
self.audio.next_section();
|
self.audio.next_section();
|
||||||
}
|
}
|
||||||
@@ -1371,6 +1394,9 @@ impl App {
|
|||||||
AppCommand::OptionsPrevFocus => {
|
AppCommand::OptionsPrevFocus => {
|
||||||
self.options.prev_focus();
|
self.options.prev_focus();
|
||||||
}
|
}
|
||||||
|
AppCommand::OptionsSetFocus(focus) => {
|
||||||
|
self.options.focus = focus;
|
||||||
|
}
|
||||||
AppCommand::ToggleRefreshRate => {
|
AppCommand::ToggleRefreshRate => {
|
||||||
self.audio.toggle_refresh_rate();
|
self.audio.toggle_refresh_rate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ use cagire::engine::{
|
|||||||
SequencerHandle, SpectrumBuffer,
|
SequencerHandle, SpectrumBuffer,
|
||||||
};
|
};
|
||||||
use cagire::init::{init, InitArgs};
|
use cagire::init::{init, InitArgs};
|
||||||
use cagire::input::{handle_key, InputContext, InputResult};
|
use cagire::input::{handle_key, handle_mouse, InputContext, InputResult};
|
||||||
use cagire::input_egui::convert_egui_events;
|
use cagire::input_egui::{convert_egui_events, convert_egui_mouse};
|
||||||
use cagire::settings::Settings;
|
use cagire::settings::Settings;
|
||||||
use cagire::views;
|
use cagire::views;
|
||||||
use crossbeam_channel::Receiver;
|
use crossbeam_channel::Receiver;
|
||||||
@@ -286,6 +286,21 @@ impl CagireDesktop {
|
|||||||
};
|
};
|
||||||
let seq_snapshot = sequencer.snapshot();
|
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) {
|
for key in convert_egui_events(ctx) {
|
||||||
let mut input_ctx = InputContext {
|
let mut input_ctx = InputContext {
|
||||||
app: &mut self.app,
|
app: &mut self.app,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||||
use crate::page::Page;
|
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 {
|
pub enum AppCommand {
|
||||||
// Playback
|
// Playback
|
||||||
@@ -198,10 +198,18 @@ pub enum AppCommand {
|
|||||||
// Panel
|
// Panel
|
||||||
ClosePanel,
|
ClosePanel,
|
||||||
|
|
||||||
|
// Direct navigation (mouse)
|
||||||
|
GoToStep(usize),
|
||||||
|
PatternsSelectBank(usize),
|
||||||
|
PatternsSelectPattern(usize),
|
||||||
|
HelpSelectTopic(usize),
|
||||||
|
DictSelectCategory(usize),
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
SetSelectionAnchor(usize),
|
SetSelectionAnchor(usize),
|
||||||
|
|
||||||
// Audio settings (engine page)
|
// Audio settings (engine page)
|
||||||
|
AudioSetSection(EngineSection),
|
||||||
AudioNextSection,
|
AudioNextSection,
|
||||||
AudioPrevSection,
|
AudioPrevSection,
|
||||||
AudioOutputListUp,
|
AudioOutputListUp,
|
||||||
@@ -227,6 +235,7 @@ pub enum AppCommand {
|
|||||||
// Options page
|
// Options page
|
||||||
OptionsNextFocus,
|
OptionsNextFocus,
|
||||||
OptionsPrevFocus,
|
OptionsPrevFocus,
|
||||||
|
OptionsSetFocus(OptionsFocus),
|
||||||
ToggleRefreshRate,
|
ToggleRefreshRate,
|
||||||
ToggleScope,
|
ToggleScope,
|
||||||
ToggleSpectrum,
|
ToggleSpectrum,
|
||||||
|
|||||||
@@ -6,6 +6,31 @@ use crate::commands::AppCommand;
|
|||||||
use crate::engine::{AudioCommand, SeqCommand};
|
use crate::engine::{AudioCommand, SeqCommand};
|
||||||
use crate::state::{ConfirmAction, DeviceKind, EngineSection, Modal, SettingKind};
|
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 {
|
pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => {
|
KeyCode::Char('q') => {
|
||||||
@@ -90,56 +115,14 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
|||||||
EngineSection::Devices => {
|
EngineSection::Devices => {
|
||||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
|
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
|
||||||
}
|
}
|
||||||
EngineSection::Settings => {
|
EngineSection::Settings => cycle_engine_setting(ctx, false),
|
||||||
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::Samples => {}
|
EngineSection::Samples => {}
|
||||||
},
|
},
|
||||||
KeyCode::Right => match ctx.app.audio.section {
|
KeyCode::Right => match ctx.app.audio.section {
|
||||||
EngineSection::Devices => {
|
EngineSection::Devices => {
|
||||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
|
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
|
||||||
}
|
}
|
||||||
EngineSection::Settings => {
|
EngineSection::Settings => cycle_engine_setting(ctx, true),
|
||||||
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::Samples => {}
|
EngineSection::Samples => {}
|
||||||
},
|
},
|
||||||
KeyCode::Char('R') => ctx.dispatch(AppCommand::AudioTriggerRestart),
|
KeyCode::Char('R') => ctx.dispatch(AppCommand::AudioTriggerRestart),
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
mod engine_page;
|
pub(crate) mod engine_page;
|
||||||
mod help_page;
|
mod help_page;
|
||||||
mod main_page;
|
mod main_page;
|
||||||
mod modal;
|
mod modal;
|
||||||
mod options_page;
|
mod mouse;
|
||||||
|
pub(crate) mod options_page;
|
||||||
mod panel;
|
mod panel;
|
||||||
mod patterns_page;
|
mod patterns_page;
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
use crossbeam_channel::Sender;
|
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::atomic::{AtomicBool, AtomicI64};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -17,7 +19,7 @@ use crate::app::App;
|
|||||||
use crate::commands::AppCommand;
|
use crate::commands::AppCommand;
|
||||||
use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
|
use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
|
||||||
use crate::page::Page;
|
use crate::page::Page;
|
||||||
use crate::state::{Modal, PanelFocus};
|
use crate::state::{MinimapMode, Modal, PanelFocus};
|
||||||
|
|
||||||
pub enum InputResult {
|
pub enum InputResult {
|
||||||
Continue,
|
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 {
|
pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
if handle_live_keys(ctx, &key) {
|
if handle_live_keys(ctx, &key) {
|
||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
@@ -54,7 +60,7 @@ pub fn handle_key(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
key.code,
|
key.code,
|
||||||
KeyCode::Left | KeyCode::Right | KeyCode::Up | KeyCode::Down
|
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);
|
ctx.dispatch(AppCommand::ClearMinimap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,25 +97,25 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ctrl {
|
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 {
|
match key.code {
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
ctx.app.ui.minimap_until = minimap_timeout;
|
ctx.app.ui.minimap = minimap_timed;
|
||||||
ctx.dispatch(AppCommand::PageLeft);
|
ctx.dispatch(AppCommand::PageLeft);
|
||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
KeyCode::Right => {
|
KeyCode::Right => {
|
||||||
ctx.app.ui.minimap_until = minimap_timeout;
|
ctx.app.ui.minimap = minimap_timed;
|
||||||
ctx.dispatch(AppCommand::PageRight);
|
ctx.dispatch(AppCommand::PageRight);
|
||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up => {
|
||||||
ctx.app.ui.minimap_until = minimap_timeout;
|
ctx.app.ui.minimap = minimap_timed;
|
||||||
ctx.dispatch(AppCommand::PageUp);
|
ctx.dispatch(AppCommand::PageUp);
|
||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down => {
|
||||||
ctx.app.ui.minimap_until = minimap_timeout;
|
ctx.app.ui.minimap = minimap_timed;
|
||||||
ctx.dispatch(AppCommand::PageDown);
|
ctx.dispatch(AppCommand::PageDown);
|
||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
@@ -126,7 +132,7 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
|||||||
KeyCode::F(6) => Some(Page::Engine),
|
KeyCode::F(6) => Some(Page::Engine),
|
||||||
_ => None,
|
_ => 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));
|
ctx.dispatch(AppCommand::GoToPage(page));
|
||||||
return InputResult::Continue;
|
return InputResult::Continue;
|
||||||
}
|
}
|
||||||
|
|||||||
794
src/input/mouse.rs
Normal file
794
src/input/mouse.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,50 +5,32 @@ use super::{InputContext, InputResult};
|
|||||||
use crate::commands::AppCommand;
|
use crate::commands::AppCommand;
|
||||||
use crate::state::{ConfirmAction, Modal, OptionsFocus};
|
use crate::state::{ConfirmAction, Modal, OptionsFocus};
|
||||||
|
|
||||||
pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
|
||||||
match key.code {
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
|
||||||
action: ConfirmAction::Quit,
|
|
||||||
selected: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
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 {
|
match ctx.app.options.focus {
|
||||||
OptionsFocus::ColorScheme => {
|
OptionsFocus::ColorScheme => {
|
||||||
let new_scheme = if key.code == KeyCode::Left {
|
let new_scheme = if right {
|
||||||
ctx.app.ui.color_scheme.prev()
|
|
||||||
} else {
|
|
||||||
ctx.app.ui.color_scheme.next()
|
ctx.app.ui.color_scheme.next()
|
||||||
|
} else {
|
||||||
|
ctx.app.ui.color_scheme.prev()
|
||||||
};
|
};
|
||||||
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
||||||
}
|
}
|
||||||
OptionsFocus::HueRotation => {
|
OptionsFocus::HueRotation => {
|
||||||
let delta = if key.code == KeyCode::Left { -5.0 } else { 5.0 };
|
let delta = if right { 5.0 } else { -5.0 };
|
||||||
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
|
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
|
||||||
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
|
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
|
||||||
}
|
}
|
||||||
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
||||||
OptionsFocus::RuntimeHighlight => {
|
OptionsFocus::RuntimeHighlight => ctx.dispatch(AppCommand::ToggleRuntimeHighlight),
|
||||||
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope),
|
||||||
}
|
OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum),
|
||||||
OptionsFocus::ShowScope => {
|
OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion),
|
||||||
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::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||||
OptionsFocus::StartStopSync => ctx
|
OptionsFocus::StartStopSync => ctx
|
||||||
.link
|
.link
|
||||||
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
||||||
OptionsFocus::Quantum => {
|
OptionsFocus::Quantum => {
|
||||||
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
|
let delta = if right { 1.0 } else { -1.0 };
|
||||||
ctx.link.set_quantum(ctx.link.quantum() + delta);
|
ctx.link.set_quantum(ctx.link.quantum() + delta);
|
||||||
}
|
}
|
||||||
OptionsFocus::MidiOutput0
|
OptionsFocus::MidiOutput0
|
||||||
@@ -82,14 +64,12 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu
|
|||||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||||
.map(|p| p + 1)
|
.map(|p| p + 1)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let new_pos = if key.code == KeyCode::Left {
|
let new_pos = if right {
|
||||||
if current_pos == 0 {
|
(current_pos + 1) % total_options
|
||||||
|
} else if current_pos == 0 {
|
||||||
total_options - 1
|
total_options - 1
|
||||||
} else {
|
} else {
|
||||||
current_pos - 1
|
current_pos - 1
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(current_pos + 1) % total_options
|
|
||||||
};
|
};
|
||||||
if new_pos == 0 {
|
if new_pos == 0 {
|
||||||
ctx.app.midi.disconnect_output(slot);
|
ctx.app.midi.disconnect_output(slot);
|
||||||
@@ -137,14 +117,12 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu
|
|||||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||||
.map(|p| p + 1)
|
.map(|p| p + 1)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let new_pos = if key.code == KeyCode::Left {
|
let new_pos = if right {
|
||||||
if current_pos == 0 {
|
(current_pos + 1) % total_options
|
||||||
|
} else if current_pos == 0 {
|
||||||
total_options - 1
|
total_options - 1
|
||||||
} else {
|
} else {
|
||||||
current_pos - 1
|
current_pos - 1
|
||||||
}
|
|
||||||
} else {
|
|
||||||
(current_pos + 1) % total_options
|
|
||||||
};
|
};
|
||||||
if new_pos == 0 {
|
if new_pos == 0 {
|
||||||
ctx.app.midi.disconnect_input(slot);
|
ctx.app.midi.disconnect_input(slot);
|
||||||
@@ -164,6 +142,20 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu
|
|||||||
}
|
}
|
||||||
ctx.app.save_settings(ctx.link);
|
ctx.app.save_settings(ctx.link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
ctx.dispatch(AppCommand::OpenModal(Modal::Confirm {
|
||||||
|
action: ConfirmAction::Quit,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Tab => ctx.dispatch(AppCommand::OptionsNextFocus),
|
||||||
|
KeyCode::Up | KeyCode::BackTab => ctx.dispatch(AppCommand::OptionsPrevFocus),
|
||||||
|
KeyCode::Left | KeyCode::Right => {
|
||||||
|
cycle_option_value(ctx, key.code == KeyCode::Right);
|
||||||
|
}
|
||||||
KeyCode::Char(' ') => {
|
KeyCode::Char(' ') => {
|
||||||
ctx.dispatch(AppCommand::TogglePlaying);
|
ctx.dispatch(AppCommand::TogglePlaying);
|
||||||
ctx.playing
|
ctx.playing
|
||||||
|
|||||||
@@ -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<MouseEvent> {
|
||||||
|
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<KeyEvent> {
|
pub fn convert_egui_events(ctx: &egui::Context) -> Vec<KeyEvent> {
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -20,7 +20,7 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event};
|
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, DisableMouseCapture, EnableMouseCapture, Event};
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||||
};
|
};
|
||||||
@@ -30,7 +30,7 @@ use ratatui::Terminal;
|
|||||||
|
|
||||||
use engine::{build_stream, AudioStreamConfig};
|
use engine::{build_stream, AudioStreamConfig};
|
||||||
use init::InitArgs;
|
use init::InitArgs;
|
||||||
use input::{handle_key, InputContext, InputResult};
|
use input::{handle_key, handle_mouse, InputContext, InputResult};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "cagire", version, about = "Forth-based live coding sequencer")]
|
#[command(name = "cagire", version, about = "Forth-based live coding sequencer")]
|
||||||
@@ -86,6 +86,7 @@ fn main() -> io::Result<()> {
|
|||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
io::stdout().execute(EnableBracketedPaste)?;
|
io::stdout().execute(EnableBracketedPaste)?;
|
||||||
|
io::stdout().execute(EnableMouseCapture)?;
|
||||||
io::stdout().execute(EnterAlternateScreen)?;
|
io::stdout().execute(EnterAlternateScreen)?;
|
||||||
let backend = CrosstermBackend::new(io::stdout());
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
@@ -253,6 +254,18 @@ fn main() -> io::Result<()> {
|
|||||||
break;
|
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) => {
|
Event::Paste(text) => {
|
||||||
if matches!(app.ui.modal, state::Modal::Editor) {
|
if matches!(app.ui.modal, state::Modal::Editor) {
|
||||||
app.editor_ctx.editor.insert_str(&text);
|
app.editor_ctx.editor.insert_str(&text);
|
||||||
@@ -282,6 +295,7 @@ fn main() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
|
io::stdout().execute(DisableMouseCapture)?;
|
||||||
io::stdout().execute(DisableBracketedPaste)?;
|
io::stdout().execute(DisableBracketedPaste)?;
|
||||||
io::stdout().execute(LeaveAlternateScreen)?;
|
io::stdout().execute(LeaveAlternateScreen)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
pub fn next_category(ui: &mut UiState) {
|
||||||
let count = categories::category_count();
|
let count = categories::category_count();
|
||||||
ui.dict_category = (ui.dict_category + 1) % count;
|
ui.dict_category = (ui.dict_category + 1) % count;
|
||||||
|
|||||||
@@ -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) {
|
pub fn next_topic(ui: &mut UiState, n: usize) {
|
||||||
let count = docs::topic_count();
|
let count = docs::topic_count();
|
||||||
ui.help_topic = (ui.help_topic + n) % count;
|
ui.help_topic = (ui.help_topic + n) % count;
|
||||||
|
|||||||
@@ -44,4 +44,4 @@ pub use mute::MuteState;
|
|||||||
pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange};
|
pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange};
|
||||||
pub use project::ProjectState;
|
pub use project::ProjectState;
|
||||||
pub use sample_browser::{SampleBrowserState, SampleTree};
|
pub use sample_browser::{SampleBrowserState, SampleTree};
|
||||||
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};
|
pub use ui::{DictFocus, FlashKind, HelpFocus, MinimapMode, UiState};
|
||||||
|
|||||||
@@ -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<OptionsFocus> {
|
||||||
|
FOCUS_LINES
|
||||||
|
.iter()
|
||||||
|
.find(|(_, l)| *l == line)
|
||||||
|
.map(|(f, _)| *f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct OptionsState {
|
pub struct OptionsState {
|
||||||
pub focus: OptionsFocus,
|
pub focus: OptionsFocus,
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ use crate::page::Page;
|
|||||||
use crate::state::effects::FxId;
|
use crate::state::effects::FxId;
|
||||||
use crate::state::{ColorScheme, Modal};
|
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)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub enum FlashKind {
|
pub enum FlashKind {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -49,7 +57,7 @@ pub struct UiState {
|
|||||||
pub show_title: bool,
|
pub show_title: bool,
|
||||||
pub runtime_highlight: bool,
|
pub runtime_highlight: bool,
|
||||||
pub show_completion: bool,
|
pub show_completion: bool,
|
||||||
pub minimap_until: Option<Instant>,
|
pub minimap: MinimapMode,
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
pub hue_rotation: f32,
|
pub hue_rotation: f32,
|
||||||
pub effects: RefCell<EffectManager<FxId>>,
|
pub effects: RefCell<EffectManager<FxId>>,
|
||||||
@@ -81,7 +89,7 @@ impl Default for UiState {
|
|||||||
show_title: true,
|
show_title: true,
|
||||||
runtime_highlight: false,
|
runtime_highlight: false,
|
||||||
show_completion: true,
|
show_completion: true,
|
||||||
minimap_until: None,
|
minimap: MinimapMode::Hidden,
|
||||||
color_scheme: ColorScheme::default(),
|
color_scheme: ColorScheme::default(),
|
||||||
hue_rotation: 0.0,
|
hue_rotation: 0.0,
|
||||||
effects: RefCell::new(EffectManager::default()),
|
effects: RefCell::new(EffectManager::default()),
|
||||||
@@ -138,4 +146,16 @@ impl UiState {
|
|||||||
.map(|t| Instant::now() < t)
|
.map(|t| Instant::now() < t)
|
||||||
.unwrap_or(false)
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ use crate::widgets::{render_search_bar, CategoryItem, CategoryList};
|
|||||||
|
|
||||||
use CatEntry::{Category, Section};
|
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] =
|
let [header_area, body_area] =
|
||||||
Layout::vertical([Constraint::Length(5), Constraint::Fill(1)]).areas(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);
|
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();
|
let is_searching = !app.ui.dict_search_query.is_empty();
|
||||||
render_categories(frame, app, cat_area, is_searching);
|
render_categories(frame, app, cat_area, is_searching);
|
||||||
render_words(frame, app, words_area, is_searching);
|
render_words(frame, app, words_area, is_searching);
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ use crate::widgets::{
|
|||||||
render_scroll_indicators, render_section_header, IndicatorAlign, Orientation, Scope, Spectrum,
|
render_scroll_indicators, render_section_header, IndicatorAlign, Orientation, Scope, Spectrum,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
pub fn layout(area: Rect) -> [Rect; 3] {
|
||||||
let [left_col, _, right_col] = Layout::horizontal([
|
Layout::horizontal([
|
||||||
Constraint::Percentage(55),
|
Constraint::Percentage(55),
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
Constraint::Percentage(45),
|
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_settings_section(frame, app, left_col);
|
||||||
render_visualizers(frame, app, right_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;
|
let visible = item_count.min(5) as u16;
|
||||||
if item_count > 5 {
|
if item_count > 5 {
|
||||||
visible + 1
|
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 output_h = list_height(app.audio.output_devices.len());
|
||||||
let input_h = list_height(app.audio.input_devices.len());
|
let input_h = list_height(app.audio.input_devices.len());
|
||||||
3 + output_h.max(input_h)
|
3 + output_h.max(input_h)
|
||||||
|
|||||||
@@ -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) {
|
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let [topics_area, content_area] =
|
let [topics_area, content_area] = layout(area);
|
||||||
Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area);
|
|
||||||
|
|
||||||
render_topics(frame, app, topics_area);
|
render_topics(frame, app, topics_area);
|
||||||
render_content(frame, app, content_area);
|
render_content(frame, app, content_area);
|
||||||
|
|||||||
@@ -9,15 +9,19 @@ use crate::state::MainLayout;
|
|||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter};
|
use crate::widgets::{ActivePatterns, Orientation, Scope, Spectrum, VuMeter};
|
||||||
|
|
||||||
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
pub fn layout(area: Rect) -> [Rect; 5] {
|
||||||
let [patterns_area, _, main_area, _, vu_area] = Layout::horizontal([
|
Layout::horizontal([
|
||||||
Constraint::Length(13),
|
Constraint::Length(13),
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
Constraint::Length(10),
|
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_scope = app.audio.config.show_scope;
|
||||||
let show_spectrum = app.audio.config.show_spectrum;
|
let show_spectrum = app.audio.config.show_spectrum;
|
||||||
|
|||||||
@@ -206,26 +206,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
|||||||
let total_lines = lines.len();
|
let total_lines = lines.len();
|
||||||
let max_visible = padded.height as usize;
|
let max_visible = padded.height as usize;
|
||||||
|
|
||||||
let focus_line: usize = match focus {
|
let focus_line = focus.line_index();
|
||||||
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 scroll_offset = if total_lines <= max_visible {
|
let scroll_offset = if total_lines <= max_visible {
|
||||||
0
|
0
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ use crate::widgets::{render_scroll_indicators, IndicatorAlign};
|
|||||||
|
|
||||||
const MIN_ROW_HEIGHT: u16 = 1;
|
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) {
|
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let [banks_area, gap, patterns_area] = Layout::horizontal([
|
let [banks_area, gap, patterns_area] = layout(area);
|
||||||
Constraint::Fill(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
])
|
|
||||||
.areas(area);
|
|
||||||
|
|
||||||
render_banks(frame, app, snapshot, banks_area);
|
render_banks(frame, app, snapshot, banks_area);
|
||||||
// gap is just empty space
|
// gap is just empty space
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
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);
|
render_footer(frame, app, footer_area);
|
||||||
let modal_area = render_modal(frame, app, snapshot, term);
|
let modal_area = render_modal(frame, app, snapshot, term);
|
||||||
|
|
||||||
let show_minimap = app
|
if app.ui.show_minimap() {
|
||||||
.ui
|
|
||||||
.minimap_until
|
|
||||||
.map(|until| Instant::now() < until)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if show_minimap {
|
|
||||||
let tiles: Vec<NavTile> = Page::ALL
|
let tiles: Vec<NavTile> = Page::ALL
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
|
|||||||
Reference in New Issue
Block a user