Feat: UI redesign and UX
All checks were successful
Deploy Website / deploy (push) Has been skipped

This commit is contained in:
2026-03-01 01:50:34 +01:00
parent d30ef8bb5b
commit 6cd20732ed
11 changed files with 938 additions and 727 deletions

View File

@@ -4,7 +4,7 @@ use std::sync::atomic::Ordering;
use super::{InputContext, InputResult};
use crate::commands::AppCommand;
use crate::engine::{AudioCommand, SeqCommand};
use crate::state::{ConfirmAction, DeviceKind, EngineSection, Modal, SettingKind};
use crate::state::{ConfirmAction, DeviceKind, EngineSection, LinkSetting, Modal, SettingKind};
pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) {
let sign = if right { 1 } else { -1 };
@@ -31,6 +31,112 @@ pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) {
ctx.app.save_settings(ctx.link);
}
pub(crate) fn cycle_link_setting(ctx: &mut InputContext, right: bool) {
match ctx.app.audio.link_setting {
LinkSetting::Enabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
LinkSetting::StartStopSync => ctx
.link
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
LinkSetting::Quantum => {
let delta = if right { 1.0 } else { -1.0 };
ctx.link.set_quantum(ctx.link.quantum() + delta);
}
}
ctx.app.save_settings(ctx.link);
}
pub(crate) fn cycle_midi_output(ctx: &mut InputContext, right: bool) {
let slot = ctx.app.audio.midi_output_slot;
let all_devices = crate::midi::list_midi_outputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|| !ctx
.app
.midi
.selected_outputs
.iter()
.enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_outputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if right {
(current_pos + 1) % total_options
} else if current_pos == 0 {
total_options - 1
} else {
current_pos - 1
};
if new_pos == 0 {
ctx.app.midi.disconnect_output(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {}: {}",
slot, device.name
)));
}
}
ctx.app.save_settings(ctx.link);
}
pub(crate) fn cycle_midi_input(ctx: &mut InputContext, right: bool) {
let slot = ctx.app.audio.midi_input_slot;
let all_devices = crate::midi::list_midi_inputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|| !ctx
.app
.midi
.selected_inputs
.iter()
.enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_inputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if right {
(current_pos + 1) % total_options
} else if current_pos == 0 {
total_options - 1
} else {
current_pos - 1
};
if new_pos == 0 {
ctx.app.midi.disconnect_input(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {}: {}",
slot, device.name
)));
}
}
ctx.app.save_settings(ctx.link);
}
pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match key.code {
KeyCode::Char('q') if !ctx.app.plugin_mode => {
@@ -49,6 +155,15 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingPrev);
}
EngineSection::Link => {
ctx.app.audio.prev_link_setting();
}
EngineSection::MidiOutput => {
ctx.app.audio.prev_midi_output_slot();
}
EngineSection::MidiInput => {
ctx.app.audio.prev_midi_input_slot();
}
EngineSection::Samples => {
ctx.app.audio.sample_list.move_up();
}
@@ -68,6 +183,15 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingNext);
}
EngineSection::Link => {
ctx.app.audio.next_link_setting();
}
EngineSection::MidiOutput => {
ctx.app.audio.next_midi_output_slot();
}
EngineSection::MidiInput => {
ctx.app.audio.next_midi_input_slot();
}
EngineSection::Samples => {
let count = ctx.app.audio.config.sample_paths.len();
ctx.app.audio.sample_list.move_down(count);
@@ -123,6 +247,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
}
EngineSection::Settings => cycle_engine_setting(ctx, false),
EngineSection::Link => cycle_link_setting(ctx, false),
EngineSection::MidiOutput => cycle_midi_output(ctx, false),
EngineSection::MidiInput => cycle_midi_input(ctx, false),
_ => {}
},
KeyCode::Right => match ctx.app.audio.section {
@@ -130,6 +257,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
}
EngineSection::Settings => cycle_engine_setting(ctx, true),
EngineSection::Link => cycle_link_setting(ctx, true),
EngineSection::MidiOutput => cycle_midi_output(ctx, true),
EngineSection::MidiInput => cycle_midi_input(ctx, true),
_ => {}
},
KeyCode::Char('R') if !ctx.app.plugin_mode => {
@@ -158,7 +288,6 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
if !ctx.app.plugin_mode {
let _ = ctx.audio_tx.load().send(AudioCommand::Hush);
}
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
}
KeyCode::Char('p') => {
if !ctx.app.plugin_mode {

View File

@@ -6,8 +6,8 @@ use ratatui::layout::{Constraint, Layout, Rect};
use crate::commands::AppCommand;
use crate::page::Page;
use crate::state::{
DeviceKind, DictFocus, EditorTarget, EngineSection, HelpFocus, MinimapMode, Modal,
OptionsFocus, PatternsColumn, SettingKind,
DictFocus, EditorTarget, HelpFocus, MinimapMode, Modal,
OptionsFocus, PatternsColumn,
};
use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view, script_view};
@@ -288,31 +288,10 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
}
}
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 => {}
}
if up {
ctx.dispatch(AppCommand::AudioPrevSection);
} else {
ctx.dispatch(AppCommand::AudioNextSection);
}
}
}
@@ -944,132 +923,24 @@ fn handle_script_editor_mouse(
ctx.app.script_editor.editor.move_cursor_to(text_row, text_col);
}
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) {
let [left_col, _, right_col] = engine_view::layout(area);
// Viz panel clicks (right column)
if contains(right_col, col, row) {
let [scope_area, _, lissajous_area, _, spectrum_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(right_col);
if contains(scope_area, col, row) {
if kind == ClickKind::Double {
ctx.dispatch(AppCommand::FlipScopeOrientation);
} else {
ctx.dispatch(AppCommand::CycleScopeMode);
}
} else if contains(lissajous_area, col, row) {
ctx.dispatch(AppCommand::ToggleLissajousTrails);
} else if contains(spectrum_area, col, row) {
if kind == ClickKind::Double {
ctx.dispatch(AppCommand::ToggleSpectrumPeaks);
} else {
ctx.dispatch(AppCommand::CycleSpectrumMode);
}
}
return;
}
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, _kind: ClickKind) {
// In narrow mode the whole area is a single column, in wide mode it's a 55/45 split.
// Either way, left-column clicks cycle through sections; right column is monitoring (non-interactive).
let is_narrow = area.width < 100;
let left_col = if is_narrow {
area
} else {
let [left, _, _] = engine_view::layout(area);
left
};
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));
}
// Simple: cycle section on click. The complex per-line hit-testing is fragile
// given the scrollable layout. Tab/keyboard is the primary navigation.
ctx.dispatch(AppCommand::AudioNextSection);
}
// --- Modal ---

View File

@@ -89,126 +89,12 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
let (w, h) = WINDOW_SIZES[new_pos];
ctx.dispatch(AppCommand::SetWindowSize(w, h));
}
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
OptionsFocus::StartStopSync => ctx
.link
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
OptionsFocus::Quantum => {
let delta = if right { 1.0 } else { -1.0 };
ctx.link.set_quantum(ctx.link.quantum() + delta);
}
OptionsFocus::MidiOutput0
| OptionsFocus::MidiOutput1
| OptionsFocus::MidiOutput2
| OptionsFocus::MidiOutput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiOutput0 => 0,
OptionsFocus::MidiOutput1 => 1,
OptionsFocus::MidiOutput2 => 2,
OptionsFocus::MidiOutput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_outputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|| !ctx
.app
.midi
.selected_outputs
.iter()
.enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_outputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if right {
(current_pos + 1) % total_options
} else if current_pos == 0 {
total_options - 1
} else {
current_pos - 1
};
if new_pos == 0 {
ctx.app.midi.disconnect_output(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI output {}: {}",
slot, device.name
)));
}
}
}
OptionsFocus::ResetOnboarding => {
ctx.dispatch(AppCommand::ResetOnboarding);
}
OptionsFocus::LoadDemoOnStartup => {
ctx.app.ui.load_demo_on_startup = !ctx.app.ui.load_demo_on_startup;
}
OptionsFocus::MidiInput0
| OptionsFocus::MidiInput1
| OptionsFocus::MidiInput2
| OptionsFocus::MidiInput3 => {
let slot = match ctx.app.options.focus {
OptionsFocus::MidiInput0 => 0,
OptionsFocus::MidiInput1 => 1,
OptionsFocus::MidiInput2 => 2,
OptionsFocus::MidiInput3 => 3,
_ => 0,
};
let all_devices = crate::midi::list_midi_inputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|| !ctx
.app
.midi
.selected_inputs
.iter()
.enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_inputs[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
let new_pos = if right {
(current_pos + 1) % total_options
} else if current_pos == 0 {
total_options - 1
} else {
current_pos - 1
};
if new_pos == 0 {
ctx.app.midi.disconnect_input(slot);
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {slot}: disconnected"
)));
} else {
let (device_idx, device) = available[new_pos - 1];
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
ctx.dispatch(AppCommand::SetStatus(format!(
"MIDI input {}: {}",
slot, device.name
)));
}
}
}
}
ctx.app.save_settings(ctx.link);
}