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

@@ -379,7 +379,6 @@ impl App {
} }
// Audio settings (engine page) // Audio settings (engine page)
AppCommand::AudioSetSection(section) => self.audio.section = section,
AppCommand::AudioNextSection => self.audio.next_section(self.plugin_mode), AppCommand::AudioNextSection => self.audio.next_section(self.plugin_mode),
AppCommand::AudioPrevSection => self.audio.prev_section(self.plugin_mode), AppCommand::AudioPrevSection => self.audio.prev_section(self.plugin_mode),
AppCommand::AudioOutputListUp => self.audio.output_list.move_up(), AppCommand::AudioOutputListUp => self.audio.output_list.move_up(),

View File

@@ -4,7 +4,7 @@ use std::path::PathBuf;
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode}; use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use crate::page::Page; use crate::page::Page;
use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, ScriptField, SettingKind}; use crate::state::{ColorScheme, DeviceKind, Modal, OptionsFocus, PatternField, ScriptField, SettingKind};
pub enum AppCommand { pub enum AppCommand {
// Undo/Redo // Undo/Redo
@@ -247,7 +247,6 @@ pub enum AppCommand {
SetSelectionAnchor(usize), SetSelectionAnchor(usize),
// Audio settings (engine page) // Audio settings (engine page)
AudioSetSection(EngineSection),
AudioNextSection, AudioNextSection,
AudioPrevSection, AudioPrevSection,
AudioOutputListUp, AudioOutputListUp,

View File

@@ -4,7 +4,7 @@ use std::sync::atomic::Ordering;
use super::{InputContext, InputResult}; use super::{InputContext, InputResult};
use crate::commands::AppCommand; 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, LinkSetting, Modal, SettingKind};
pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) { pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) {
let sign = if right { 1 } else { -1 }; 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); 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 { pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
match key.code { match key.code {
KeyCode::Char('q') if !ctx.app.plugin_mode => { 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 => { EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingPrev); 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 => { EngineSection::Samples => {
ctx.app.audio.sample_list.move_up(); 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 => { EngineSection::Settings => {
ctx.dispatch(AppCommand::AudioSettingNext); 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 => { EngineSection::Samples => {
let count = ctx.app.audio.config.sample_paths.len(); let count = ctx.app.audio.config.sample_paths.len();
ctx.app.audio.sample_list.move_down(count); 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)); ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
} }
EngineSection::Settings => cycle_engine_setting(ctx, false), 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 { 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)); ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
} }
EngineSection::Settings => cycle_engine_setting(ctx, true), 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 => { 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 { if !ctx.app.plugin_mode {
let _ = ctx.audio_tx.load().send(AudioCommand::Hush); let _ = ctx.audio_tx.load().send(AudioCommand::Hush);
} }
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
} }
KeyCode::Char('p') => { KeyCode::Char('p') => {
if !ctx.app.plugin_mode { if !ctx.app.plugin_mode {

View File

@@ -6,8 +6,8 @@ use ratatui::layout::{Constraint, Layout, Rect};
use crate::commands::AppCommand; use crate::commands::AppCommand;
use crate::page::Page; use crate::page::Page;
use crate::state::{ use crate::state::{
DeviceKind, DictFocus, EditorTarget, EngineSection, HelpFocus, MinimapMode, Modal, DictFocus, EditorTarget, HelpFocus, MinimapMode, Modal,
OptionsFocus, PatternsColumn, SettingKind, OptionsFocus, PatternsColumn,
}; };
use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view, script_view}; 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 => { Page::Engine => {
let [left_col, _, _] = engine_view::layout(body); if up {
if contains(left_col, col, row) { ctx.dispatch(AppCommand::AudioPrevSection);
match ctx.app.audio.section { } else {
EngineSection::Devices => { ctx.dispatch(AppCommand::AudioNextSection);
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 => {}
}
} }
} }
} }
@@ -944,132 +923,24 @@ fn handle_script_editor_mouse(
ctx.app.script_editor.editor.move_cursor_to(text_row, text_col); 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) { fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, _kind: ClickKind) {
let [left_col, _, right_col] = engine_view::layout(area); // 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).
// Viz panel clicks (right column) let is_narrow = area.width < 100;
if contains(right_col, col, row) { let left_col = if is_narrow {
let [scope_area, _, lissajous_area, _, spectrum_area] = Layout::vertical([ area
Constraint::Fill(1), } else {
Constraint::Length(1), let [left, _, _] = engine_view::layout(area);
Constraint::Fill(1), left
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;
}
if !contains(left_col, col, row) { if !contains(left_col, col, row) {
return; return;
} }
// Replicate engine_view render_settings_section layout // Simple: cycle section on click. The complex per-line hit-testing is fragile
let inner = Rect { // given the scrollable layout. Tab/keyboard is the primary navigation.
x: left_col.x + 1, ctx.dispatch(AppCommand::AudioNextSection);
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 --- // --- 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]; let (w, h) = WINDOW_SIZES[new_pos];
ctx.dispatch(AppCommand::SetWindowSize(w, h)); 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 => { OptionsFocus::ResetOnboarding => {
ctx.dispatch(AppCommand::ResetOnboarding); ctx.dispatch(AppCommand::ResetOnboarding);
} }
OptionsFocus::LoadDemoOnStartup => { OptionsFocus::LoadDemoOnStartup => {
ctx.app.ui.load_demo_on_startup = !ctx.app.ui.load_demo_on_startup; 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); ctx.app.save_settings(ctx.link);
} }

View File

@@ -202,11 +202,33 @@ pub enum EngineSection {
#[default] #[default]
Devices, Devices,
Settings, Settings,
Link,
MidiOutput,
MidiInput,
Samples, Samples,
} }
impl CyclicEnum for EngineSection { impl CyclicEnum for EngineSection {
const VARIANTS: &'static [Self] = &[Self::Devices, Self::Settings, Self::Samples]; const VARIANTS: &'static [Self] = &[
Self::Devices,
Self::Settings,
Self::Link,
Self::MidiOutput,
Self::MidiInput,
Self::Samples,
];
}
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum LinkSetting {
#[default]
Enabled,
StartStopSync,
Quantum,
}
impl CyclicEnum for LinkSetting {
const VARIANTS: &'static [Self] = &[Self::Enabled, Self::StartStopSync, Self::Quantum];
} }
#[derive(Clone, Copy, PartialEq, Eq, Default)] #[derive(Clone, Copy, PartialEq, Eq, Default)]
@@ -271,6 +293,9 @@ pub struct AudioSettings {
pub section: EngineSection, pub section: EngineSection,
pub device_kind: DeviceKind, pub device_kind: DeviceKind,
pub setting_kind: SettingKind, pub setting_kind: SettingKind,
pub link_setting: LinkSetting,
pub midi_output_slot: usize,
pub midi_input_slot: usize,
pub output_devices: Vec<AudioDeviceInfo>, pub output_devices: Vec<AudioDeviceInfo>,
pub input_devices: Vec<AudioDeviceInfo>, pub input_devices: Vec<AudioDeviceInfo>,
pub output_list: ListSelectState, pub output_list: ListSelectState,
@@ -288,6 +313,9 @@ impl Default for AudioSettings {
section: EngineSection::default(), section: EngineSection::default(),
device_kind: DeviceKind::default(), device_kind: DeviceKind::default(),
setting_kind: SettingKind::default(), setting_kind: SettingKind::default(),
link_setting: LinkSetting::default(),
midi_output_slot: 0,
midi_input_slot: 0,
output_devices: doux::audio::list_output_devices(), output_devices: doux::audio::list_output_devices(),
input_devices: doux::audio::list_input_devices(), input_devices: doux::audio::list_input_devices(),
output_list: ListSelectState { output_list: ListSelectState {
@@ -313,9 +341,12 @@ impl AudioSettings {
pub fn new_plugin() -> Self { pub fn new_plugin() -> Self {
Self { Self {
config: AudioConfig::default(), config: AudioConfig::default(),
section: EngineSection::default(), section: EngineSection::Settings,
device_kind: DeviceKind::default(), device_kind: DeviceKind::default(),
setting_kind: SettingKind::default(), setting_kind: SettingKind::Polyphony,
link_setting: LinkSetting::default(),
midi_output_slot: 0,
midi_input_slot: 0,
output_devices: Vec::new(), output_devices: Vec::new(),
input_devices: Vec::new(), input_devices: Vec::new(),
output_list: ListSelectState { output_list: ListSelectState {
@@ -346,7 +377,7 @@ impl AudioSettings {
match self.section { match self.section {
EngineSection::Settings => EngineSection::Samples, EngineSection::Settings => EngineSection::Samples,
EngineSection::Samples => EngineSection::Settings, EngineSection::Samples => EngineSection::Settings,
EngineSection::Devices => EngineSection::Settings, _ => EngineSection::Settings,
} }
} else { } else {
self.section.next() self.section.next()
@@ -358,7 +389,7 @@ impl AudioSettings {
match self.section { match self.section {
EngineSection::Settings => EngineSection::Samples, EngineSection::Settings => EngineSection::Samples,
EngineSection::Samples => EngineSection::Settings, EngineSection::Samples => EngineSection::Settings,
EngineSection::Devices => EngineSection::Settings, _ => EngineSection::Settings,
} }
} else { } else {
self.section.prev() self.section.prev()
@@ -389,6 +420,30 @@ impl AudioSettings {
}; };
} }
pub fn next_link_setting(&mut self) {
self.link_setting = self.link_setting.next();
}
pub fn prev_link_setting(&mut self) {
self.link_setting = self.link_setting.prev();
}
pub fn next_midi_output_slot(&mut self) {
self.midi_output_slot = (self.midi_output_slot + 1).min(3);
}
pub fn prev_midi_output_slot(&mut self) {
self.midi_output_slot = self.midi_output_slot.saturating_sub(1);
}
pub fn next_midi_input_slot(&mut self) {
self.midi_input_slot = (self.midi_input_slot + 1).min(3);
}
pub fn prev_midi_input_slot(&mut self) {
self.midi_input_slot = self.midi_input_slot.saturating_sub(1);
}
pub fn current_output_device_index(&self) -> usize { pub fn current_output_device_index(&self) -> usize {
match &self.config.output_device { match &self.config.output_device {
Some(name) => self Some(name) => self

View File

@@ -30,7 +30,7 @@ pub mod sample_browser;
pub mod undo; pub mod undo;
pub mod ui; pub mod ui;
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, ScopeMode, SettingKind, SpectrumMode}; pub use audio::{AudioSettings, DeviceKind, EngineSection, LinkSetting, MainLayout, Metrics, ScopeMode, SettingKind, SpectrumMode};
pub use color_scheme::ColorScheme; pub use color_scheme::ColorScheme;
pub use editor::{ pub use editor::{
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField, CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,

View File

@@ -18,17 +18,6 @@ pub enum OptionsFocus {
Font, Font,
ZoomFactor, ZoomFactor,
WindowSize, WindowSize,
LinkEnabled,
StartStopSync,
Quantum,
MidiOutput0,
MidiOutput1,
MidiOutput2,
MidiOutput3,
MidiInput0,
MidiInput1,
MidiInput2,
MidiInput3,
ResetOnboarding, ResetOnboarding,
LoadDemoOnStartup, LoadDemoOnStartup,
} }
@@ -50,17 +39,6 @@ impl CyclicEnum for OptionsFocus {
Self::Font, Self::Font,
Self::ZoomFactor, Self::ZoomFactor,
Self::WindowSize, Self::WindowSize,
Self::LinkEnabled,
Self::StartStopSync,
Self::Quantum,
Self::MidiOutput0,
Self::MidiOutput1,
Self::MidiOutput2,
Self::MidiOutput3,
Self::MidiInput0,
Self::MidiInput1,
Self::MidiInput2,
Self::MidiInput3,
Self::ResetOnboarding, Self::ResetOnboarding,
Self::LoadDemoOnStartup, Self::LoadDemoOnStartup,
]; ];
@@ -72,25 +50,7 @@ const PLUGIN_ONLY: &[OptionsFocus] = &[
OptionsFocus::WindowSize, OptionsFocus::WindowSize,
]; ];
const STANDALONE_ONLY: &[OptionsFocus] = &[
OptionsFocus::LinkEnabled,
OptionsFocus::StartStopSync,
OptionsFocus::Quantum,
OptionsFocus::MidiOutput0,
OptionsFocus::MidiOutput1,
OptionsFocus::MidiOutput2,
OptionsFocus::MidiOutput3,
OptionsFocus::MidiInput0,
OptionsFocus::MidiInput1,
OptionsFocus::MidiInput2,
OptionsFocus::MidiInput3,
OptionsFocus::ResetOnboarding,
OptionsFocus::LoadDemoOnStartup,
];
/// Section layout: header line, divider line, then option lines. /// Section layout: header line, divider line, then option lines.
/// Each entry gives the raw line offsets assuming ALL sections are visible
/// (plugin mode with Font/Zoom/Window shown).
const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[ const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[
// DISPLAY section: header=0, divider=1 // DISPLAY section: header=0, divider=1
(OptionsFocus::ColorScheme, 2), (OptionsFocus::ColorScheme, 2),
@@ -108,24 +68,9 @@ const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[
(OptionsFocus::Font, 14), (OptionsFocus::Font, 14),
(OptionsFocus::ZoomFactor, 15), (OptionsFocus::ZoomFactor, 15),
(OptionsFocus::WindowSize, 16), (OptionsFocus::WindowSize, 16),
// blank=17, ABLETON LINK header=18, divider=19 // blank=17, ONBOARDING header=18, divider=19
(OptionsFocus::LinkEnabled, 20), (OptionsFocus::ResetOnboarding, 20),
(OptionsFocus::StartStopSync, 21), (OptionsFocus::LoadDemoOnStartup, 21),
(OptionsFocus::Quantum, 22),
// blank=23, SESSION header=24, divider=25, Tempo=26, Beat=27, Phase=28
// blank=29, MIDI OUTPUTS header=30, divider=31
(OptionsFocus::MidiOutput0, 32),
(OptionsFocus::MidiOutput1, 33),
(OptionsFocus::MidiOutput2, 34),
(OptionsFocus::MidiOutput3, 35),
// blank=36, MIDI INPUTS header=37, divider=38
(OptionsFocus::MidiInput0, 39),
(OptionsFocus::MidiInput1, 40),
(OptionsFocus::MidiInput2, 41),
(OptionsFocus::MidiInput3, 42),
// blank=43, ONBOARDING header=44, divider=45
(OptionsFocus::ResetOnboarding, 46),
(OptionsFocus::LoadDemoOnStartup, 47),
]; ];
impl OptionsFocus { impl OptionsFocus {
@@ -133,17 +78,10 @@ impl OptionsFocus {
PLUGIN_ONLY.contains(&self) PLUGIN_ONLY.contains(&self)
} }
fn is_standalone_only(self) -> bool {
STANDALONE_ONLY.contains(&self)
}
fn is_visible(self, plugin_mode: bool) -> bool { fn is_visible(self, plugin_mode: bool) -> bool {
if self.is_plugin_only() && !plugin_mode { if self.is_plugin_only() && !plugin_mode {
return false; return false;
} }
if self.is_standalone_only() && plugin_mode {
return false;
}
true true
} }
@@ -171,26 +109,12 @@ pub fn total_lines(plugin_mode: bool) -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
/// Compute (focus, line_index) pairs for only the visible options,
/// with line indices adjusted to account for hidden sections.
fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> { fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> {
// Start from the full layout and compute adjusted line numbers.
// Hidden items + their section headers/dividers/blanks shrink the layout.
// We know the exact section structure, so we compute the offset to subtract
// based on which sections are hidden.
let mut offset: usize = 0; let mut offset: usize = 0;
// Font/Zoom/Window lines (14,15,16) hidden when !plugin_mode // Font/Zoom/Window lines (14,15,16) hidden when !plugin_mode
if !plugin_mode { if !plugin_mode {
offset += 3; // 3 lines for Font, ZoomFactor, WindowSize offset += 3;
}
// Link + Session + MIDI sections hidden when plugin_mode
// These span from blank(17) through MidiInput3(42) = 26 lines
if plugin_mode {
let link_section_lines = 26;
offset += link_section_lines;
} }
let mut result = Vec::new(); let mut result = Vec::new();
@@ -198,11 +122,9 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> {
if !focus.is_visible(plugin_mode) { if !focus.is_visible(plugin_mode) {
continue; continue;
} }
// Lines at or below index 13 (PerformanceMode) are never shifted
let adjusted = if raw_line <= 13 { let adjusted = if raw_line <= 13 {
raw_line raw_line
} else if !plugin_mode && raw_line <= 16 { } else if !plugin_mode && raw_line <= 16 {
// Font/Zoom/Window — these are hidden, skip
continue; continue;
} else { } else {
raw_line - offset raw_line - offset

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,11 @@ use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::app::App; use crate::app::App;
use crate::engine::LinkState;
use crate::midi;
use crate::state::OptionsFocus; use crate::state::OptionsFocus;
use crate::theme::{self, ThemeColors}; use crate::theme::{self, ThemeColors};
use crate::widgets::{render_scroll_indicators, IndicatorAlign}; use crate::widgets::{render_scroll_indicators, IndicatorAlign};
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get(); let theme = theme::get();
let block = Block::default() let block = Block::default()
@@ -141,144 +139,24 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
&theme, &theme,
)); ));
} }
if !app.plugin_mode {
let enabled = link.is_enabled();
let peers = link.peers();
let (status_text, status_color) = if !enabled {
("DISABLED", theme.link_status.disabled)
} else if peers > 0 {
("CONNECTED", theme.link_status.connected)
} else {
("LISTENING", theme.link_status.listening)
};
let peer_text = if enabled && peers > 0 {
if peers == 1 {
" · 1 peer".to_string()
} else {
format!(" · {peers} peers")
}
} else {
String::new()
};
let link_header = Line::from(vec![
Span::styled(
"ABLETON LINK",
Style::new().fg(theme.ui.header).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
status_text,
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
),
Span::styled(peer_text, Style::new().fg(theme.ui.text_muted)),
]);
let quantum_str = format!("{:.0}", link.quantum());
let tempo_str = format!("{:.1} BPM", link.tempo());
let beat_str = format!("{:.2}", link.beat());
let phase_str = format!("{:.2}", link.phase());
let tempo_style = Style::new().fg(theme.values.tempo).add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(theme.values.value);
lines.push(Line::from("")); lines.push(Line::from(""));
lines.extend([ lines.extend([
link_header, render_section_header("ONBOARDING", &theme),
render_divider(content_width, &theme), render_divider(content_width, &theme),
render_option_line( render_option_line(
"Enabled", "Reset guides",
if link.is_enabled() { "On" } else { "Off" }, &onboarding_str,
focus == OptionsFocus::LinkEnabled, focus == OptionsFocus::ResetOnboarding,
&theme, &theme,
), ),
render_option_line( render_option_line(
"Start/Stop sync", "Demo on startup",
if link.is_start_stop_sync_enabled() { if app.ui.load_demo_on_startup { "On" } else { "Off" },
"On" focus == OptionsFocus::LoadDemoOnStartup,
} else { &theme,
"Off" ),
}, ]);
focus == OptionsFocus::StartStopSync,
&theme,
),
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme),
Line::from(""),
render_section_header("SESSION", &theme),
render_divider(content_width, &theme),
render_readonly_line("Tempo", &tempo_str, tempo_style, &theme),
render_readonly_line("Beat", &beat_str, value_style, &theme),
render_readonly_line("Phase", &phase_str, value_style, &theme),
]);
let midi_outputs = midi::list_midi_outputs();
let midi_inputs = midi::list_midi_inputs();
let midi_out_display = |slot: usize| -> String {
if let Some(idx) = app.midi.selected_outputs[slot] {
midi_outputs
.get(idx)
.map(|d| d.name.clone())
.unwrap_or_else(|| "(disconnected)".to_string())
} else if midi_outputs.is_empty() {
"(none found)".to_string()
} else {
"(not connected)".to_string()
}
};
let midi_in_display = |slot: usize| -> String {
if let Some(idx) = app.midi.selected_inputs[slot] {
midi_inputs
.get(idx)
.map(|d| d.name.clone())
.unwrap_or_else(|| "(disconnected)".to_string())
} else if midi_inputs.is_empty() {
"(none found)".to_string()
} else {
"(not connected)".to_string()
}
};
let midi_out_0 = midi_out_display(0);
let midi_out_1 = midi_out_display(1);
let midi_out_2 = midi_out_display(2);
let midi_out_3 = midi_out_display(3);
let midi_in_0 = midi_in_display(0);
let midi_in_1 = midi_in_display(1);
let midi_in_2 = midi_in_display(2);
let midi_in_3 = midi_in_display(3);
lines.push(Line::from(""));
lines.extend([
render_section_header("MIDI OUTPUTS", &theme),
render_divider(content_width, &theme),
render_option_line("Output 0", &midi_out_0, focus == OptionsFocus::MidiOutput0, &theme),
render_option_line("Output 1", &midi_out_1, focus == OptionsFocus::MidiOutput1, &theme),
render_option_line("Output 2", &midi_out_2, focus == OptionsFocus::MidiOutput2, &theme),
render_option_line("Output 3", &midi_out_3, focus == OptionsFocus::MidiOutput3, &theme),
Line::from(""),
render_section_header("MIDI INPUTS", &theme),
render_divider(content_width, &theme),
render_option_line("Input 0", &midi_in_0, focus == OptionsFocus::MidiInput0, &theme),
render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme),
render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme),
render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme),
]);
}
if !app.plugin_mode {
lines.push(Line::from(""));
lines.extend([
render_section_header("ONBOARDING", &theme),
render_divider(content_width, &theme),
render_option_line(
"Reset guides",
&onboarding_str,
focus == OptionsFocus::ResetOnboarding,
&theme,
),
render_option_line(
"Demo on startup",
if app.ui.load_demo_on_startup { "On" } else { "Off" },
focus == OptionsFocus::LoadDemoOnStartup,
&theme,
),
]);
}
// Insert description below focused option // Insert description below focused option
let focus_vec_idx = focus.line_index(app.plugin_mode); let focus_vec_idx = focus.line_index(app.plugin_mode);
@@ -330,7 +208,7 @@ fn render_section_header(title: &str, theme: &theme::ThemeColors) -> Line<'stati
fn render_divider(width: usize, theme: &theme::ThemeColors) -> Line<'static> { fn render_divider(width: usize, theme: &theme::ThemeColors) -> Line<'static> {
Line::from(Span::styled( Line::from(Span::styled(
"".repeat(width), "\u{2500}".repeat(width),
Style::new().fg(theme.ui.border), Style::new().fg(theme.ui.border),
)) ))
} }
@@ -377,17 +255,6 @@ fn option_description(focus: OptionsFocus) -> Option<&'static str> {
OptionsFocus::Font => Some("Bitmap font for the plugin window"), OptionsFocus::Font => Some("Bitmap font for the plugin window"),
OptionsFocus::ZoomFactor => Some("Scale factor for the plugin window"), OptionsFocus::ZoomFactor => Some("Scale factor for the plugin window"),
OptionsFocus::WindowSize => Some("Default size for the plugin window"), OptionsFocus::WindowSize => Some("Default size for the plugin window"),
OptionsFocus::LinkEnabled => Some("Join an Ableton Link session on the local network"),
OptionsFocus::StartStopSync => Some("Sync transport start/stop with other Link peers"),
OptionsFocus::Quantum => Some("Number of beats per phase cycle"),
OptionsFocus::MidiOutput0 => Some("MIDI output device for channel group 1"),
OptionsFocus::MidiOutput1 => Some("MIDI output device for channel group 2"),
OptionsFocus::MidiOutput2 => Some("MIDI output device for channel group 3"),
OptionsFocus::MidiOutput3 => Some("MIDI output device for channel group 4"),
OptionsFocus::MidiInput0 => Some("MIDI input device for channel group 1"),
OptionsFocus::MidiInput1 => Some("MIDI input device for channel group 2"),
OptionsFocus::MidiInput2 => Some("MIDI input device for channel group 3"),
OptionsFocus::MidiInput3 => Some("MIDI input device for channel group 4"),
OptionsFocus::ResetOnboarding => Some("Re-enable all dismissed guide popups"), OptionsFocus::ResetOnboarding => Some("Re-enable all dismissed guide popups"),
OptionsFocus::LoadDemoOnStartup => Some("Load a rotating demo song on fresh startup"), OptionsFocus::LoadDemoOnStartup => Some("Load a rotating demo song on fresh startup"),
} }
@@ -403,15 +270,3 @@ fn render_description_line(desc: &str, theme: &ThemeColors) -> Line<'static> {
fn gain_boost_label(gain: f32) -> String { fn gain_boost_label(gain: f32) -> String {
format!("{:.0}x", gain) format!("{:.0}x", gain)
} }
fn render_readonly_line(label: &str, value: &str, value_style: Style, theme: &theme::ThemeColors) -> Line<'static> {
let label_style = Style::new().fg(theme.ui.text_muted);
let label_width = 20;
let padded_label = format!("{label:<label_width$}");
Line::from(vec![
Span::styled(" ".to_string(), Style::new()),
Span::styled(padded_label, label_style),
Span::styled(format!(" {value}"), value_style),
])
}

View File

@@ -185,8 +185,8 @@ pub fn render(
match app.page { match app.page {
Page::Main => main_view::render(frame, app, snapshot, page_area), Page::Main => main_view::render(frame, app, snapshot, page_area),
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area), Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
Page::Engine => engine_view::render(frame, app, page_area), Page::Engine => engine_view::render(frame, app, link, page_area),
Page::Options => options_view::render(frame, app, link, page_area), Page::Options => options_view::render(frame, app, page_area),
Page::Help => help_view::render(frame, app, page_area), Page::Help => help_view::render(frame, app, page_area),
Page::Dict => dict_view::render(frame, app, page_area), Page::Dict => dict_view::render(frame, app, page_area),
Page::Script => script_view::render(frame, app, snapshot, page_area), Page::Script => script_view::render(frame, app, snapshot, page_area),
@@ -557,10 +557,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
], ],
Page::Engine => vec![ Page::Engine => vec![
("Tab", "Section"), ("Tab", "Section"),
("←→", "Switch/Adjust"), ("←→", "Adjust"),
("A", "Add Samples"),
("D", "Remove"),
("R", "Restart"), ("R", "Restart"),
("t", "Test"),
("h/p", "Hush/Panic"),
("?", "Keys"), ("?", "Keys"),
], ],
Page::Options => vec![ Page::Options => vec![