This commit is contained in:
88
src/app.rs
88
src/app.rs
@@ -111,6 +111,7 @@ impl App {
|
||||
flash_brightness: self.ui.flash_brightness,
|
||||
color_scheme: self.ui.color_scheme,
|
||||
layout: self.audio.config.layout,
|
||||
hue_rotation: self.ui.hue_rotation,
|
||||
..Default::default()
|
||||
},
|
||||
link: crate::settings::LinkSettings {
|
||||
@@ -1311,7 +1312,15 @@ impl App {
|
||||
}
|
||||
AppCommand::SetColorScheme(scheme) => {
|
||||
self.ui.color_scheme = scheme;
|
||||
crate::theme::set(scheme.to_theme());
|
||||
let base_theme = scheme.to_theme();
|
||||
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, self.ui.hue_rotation);
|
||||
crate::theme::set(rotated);
|
||||
}
|
||||
AppCommand::SetHueRotation(degrees) => {
|
||||
self.ui.hue_rotation = degrees;
|
||||
let base_theme = self.ui.color_scheme.to_theme();
|
||||
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, degrees);
|
||||
crate::theme::set(rotated);
|
||||
}
|
||||
AppCommand::ToggleRuntimeHighlight => {
|
||||
self.ui.runtime_highlight = !self.ui.runtime_highlight;
|
||||
@@ -1429,6 +1438,65 @@ impl App {
|
||||
AppCommand::ResetPeakVoices => {
|
||||
self.metrics.peak_voices = 0;
|
||||
}
|
||||
|
||||
// Euclidean distribution
|
||||
AppCommand::ApplyEuclideanDistribution {
|
||||
bank,
|
||||
pattern,
|
||||
source_step,
|
||||
pulses,
|
||||
steps,
|
||||
rotation,
|
||||
} => {
|
||||
let pat_len = self.project_state.project.pattern_at(bank, pattern).length;
|
||||
let rhythm = euclidean_rhythm(pulses, steps, rotation);
|
||||
|
||||
let mut created_count = 0;
|
||||
for (i, &is_hit) in rhythm.iter().enumerate() {
|
||||
if !is_hit {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = (source_step + i) % pat_len;
|
||||
|
||||
if target == source_step {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(step) = self
|
||||
.project_state
|
||||
.project
|
||||
.pattern_at_mut(bank, pattern)
|
||||
.step_mut(target)
|
||||
{
|
||||
step.source = Some(source_step);
|
||||
step.script.clear();
|
||||
step.command = None;
|
||||
step.active = true;
|
||||
}
|
||||
created_count += 1;
|
||||
}
|
||||
|
||||
self.project_state.mark_dirty(bank, pattern);
|
||||
|
||||
for (i, &is_hit) in rhythm.iter().enumerate() {
|
||||
if !is_hit || i == 0 {
|
||||
continue;
|
||||
}
|
||||
let target = (source_step + i) % pat_len;
|
||||
let saved = self.editor_ctx.step;
|
||||
self.editor_ctx.step = target;
|
||||
self.compile_current_step(link);
|
||||
self.editor_ctx.step = saved;
|
||||
}
|
||||
|
||||
self.load_step_to_editor();
|
||||
self.ui.flash(
|
||||
&format!("Created {} linked steps (E({pulses},{steps}))", created_count),
|
||||
200,
|
||||
FlashKind::Success,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,3 +1549,21 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn euclidean_rhythm(pulses: usize, steps: usize, rotation: usize) -> Vec<bool> {
|
||||
if pulses == 0 || steps == 0 || pulses > steps {
|
||||
return vec![false; steps];
|
||||
}
|
||||
|
||||
let mut pattern = vec![false; steps];
|
||||
for i in 0..pulses {
|
||||
let pos = (i * steps) / pulses;
|
||||
pattern[pos] = true;
|
||||
}
|
||||
|
||||
if rotation > 0 {
|
||||
pattern.rotate_left(rotation % steps);
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ pub enum AppCommand {
|
||||
HideTitle,
|
||||
ToggleEditorStack,
|
||||
SetColorScheme(ColorScheme),
|
||||
SetHueRotation(f32),
|
||||
ToggleRuntimeHighlight,
|
||||
ToggleCompletion,
|
||||
AdjustFlashBrightness(f32),
|
||||
@@ -207,4 +208,13 @@ pub enum AppCommand {
|
||||
// Metrics
|
||||
ResetPeakVoices,
|
||||
|
||||
// Euclidean distribution
|
||||
ApplyEuclideanDistribution {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
source_step: usize,
|
||||
pulses: usize,
|
||||
steps: usize,
|
||||
rotation: usize,
|
||||
},
|
||||
}
|
||||
|
||||
101
src/input.rs
101
src/input.rs
@@ -11,8 +11,8 @@ use crate::engine::{AudioCommand, LinkState, SeqCommand, SequencerSnapshot};
|
||||
use crate::model::PatternSpeed;
|
||||
use crate::page::Page;
|
||||
use crate::state::{
|
||||
CyclicEnum, DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField,
|
||||
PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
|
||||
CyclicEnum, DeviceKind, EngineSection, EuclideanField, Modal, OptionsFocus, PanelFocus,
|
||||
PatternField, PatternPropsField, SampleBrowserState, SettingKind, SidePanel,
|
||||
};
|
||||
|
||||
pub enum InputResult {
|
||||
@@ -641,6 +641,79 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Modal::EuclideanDistribution {
|
||||
bank,
|
||||
pattern,
|
||||
source_step,
|
||||
field,
|
||||
pulses,
|
||||
steps,
|
||||
rotation,
|
||||
} => {
|
||||
let (bank_val, pattern_val, source_step_val) = (*bank, *pattern, *source_step);
|
||||
match key.code {
|
||||
KeyCode::Up => *field = field.prev(),
|
||||
KeyCode::Down | KeyCode::Tab => *field = field.next(),
|
||||
KeyCode::Left => {
|
||||
let target = match field {
|
||||
EuclideanField::Pulses => pulses,
|
||||
EuclideanField::Steps => steps,
|
||||
EuclideanField::Rotation => rotation,
|
||||
};
|
||||
if let Ok(val) = target.parse::<usize>() {
|
||||
*target = val.saturating_sub(1).to_string();
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
let target = match field {
|
||||
EuclideanField::Pulses => pulses,
|
||||
EuclideanField::Steps => steps,
|
||||
EuclideanField::Rotation => rotation,
|
||||
};
|
||||
if let Ok(val) = target.parse::<usize>() {
|
||||
*target = (val + 1).min(128).to_string();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) if c.is_ascii_digit() => match field {
|
||||
EuclideanField::Pulses => pulses.push(c),
|
||||
EuclideanField::Steps => steps.push(c),
|
||||
EuclideanField::Rotation => rotation.push(c),
|
||||
},
|
||||
KeyCode::Backspace => match field {
|
||||
EuclideanField::Pulses => {
|
||||
pulses.pop();
|
||||
}
|
||||
EuclideanField::Steps => {
|
||||
steps.pop();
|
||||
}
|
||||
EuclideanField::Rotation => {
|
||||
rotation.pop();
|
||||
}
|
||||
},
|
||||
KeyCode::Enter => {
|
||||
let pulses_val: usize = pulses.parse().unwrap_or(0);
|
||||
let steps_val: usize = steps.parse().unwrap_or(0);
|
||||
let rotation_val: usize = rotation.parse().unwrap_or(0);
|
||||
if pulses_val > 0 && steps_val > 0 && pulses_val <= steps_val {
|
||||
ctx.dispatch(AppCommand::ApplyEuclideanDistribution {
|
||||
bank: bank_val,
|
||||
pattern: pattern_val,
|
||||
source_step: source_step_val,
|
||||
pulses: pulses_val,
|
||||
steps: steps_val,
|
||||
rotation: rotation_val,
|
||||
});
|
||||
ctx.dispatch(AppCommand::CloseModal);
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::SetStatus(
|
||||
"Invalid: pulses must be > 0 and <= steps".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => ctx.dispatch(AppCommand::CloseModal),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Modal::None => unreachable!(),
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -962,6 +1035,25 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||
let (bank, pattern, step) = (
|
||||
ctx.app.editor_ctx.bank,
|
||||
ctx.app.editor_ctx.pattern,
|
||||
ctx.app.editor_ctx.step,
|
||||
);
|
||||
let pattern_len = ctx.app.current_edit_pattern().length;
|
||||
let default_steps = pattern_len.min(32);
|
||||
let default_pulses = (default_steps / 2).max(1).min(default_steps);
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::EuclideanDistribution {
|
||||
bank,
|
||||
pattern,
|
||||
source_step: step,
|
||||
field: EuclideanField::Pulses,
|
||||
pulses: default_pulses.to_string(),
|
||||
steps: default_steps.to_string(),
|
||||
rotation: "0".to_string(),
|
||||
}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -1292,6 +1384,11 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
};
|
||||
ctx.dispatch(AppCommand::SetColorScheme(new_scheme));
|
||||
}
|
||||
OptionsFocus::HueRotation => {
|
||||
let delta = if key.code == KeyCode::Left { -1.0 } else { 1.0 };
|
||||
let new_rotation = (ctx.app.ui.hue_rotation + delta).rem_euclid(360.0);
|
||||
ctx.dispatch(AppCommand::SetHueRotation(new_rotation));
|
||||
}
|
||||
OptionsFocus::RefreshRate => ctx.dispatch(AppCommand::ToggleRefreshRate),
|
||||
OptionsFocus::RuntimeHighlight => {
|
||||
ctx.dispatch(AppCommand::ToggleRuntimeHighlight);
|
||||
|
||||
@@ -100,8 +100,11 @@ fn main() -> io::Result<()> {
|
||||
app.ui.show_completion = settings.display.show_completion;
|
||||
app.ui.flash_brightness = settings.display.flash_brightness;
|
||||
app.ui.color_scheme = settings.display.color_scheme;
|
||||
app.ui.hue_rotation = settings.display.hue_rotation;
|
||||
app.audio.config.layout = settings.display.layout;
|
||||
theme::set(settings.display.color_scheme.to_theme());
|
||||
let base_theme = settings.display.color_scheme.to_theme();
|
||||
let rotated = cagire_ratatui::theme::transform::rotate_theme(base_theme, settings.display.hue_rotation);
|
||||
theme::set(rotated);
|
||||
|
||||
// Load MIDI settings
|
||||
let outputs = midi::list_midi_outputs();
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct DisplaySettings {
|
||||
pub color_scheme: ColorScheme,
|
||||
#[serde(default)]
|
||||
pub layout: MainLayout,
|
||||
#[serde(default)]
|
||||
pub hue_rotation: f32,
|
||||
}
|
||||
|
||||
fn default_font() -> String {
|
||||
@@ -94,6 +96,7 @@ impl Default for DisplaySettings {
|
||||
font: default_font(),
|
||||
color_scheme: ColorScheme::default(),
|
||||
layout: MainLayout::default(),
|
||||
hue_rotation: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,32 @@ impl PatternPropsField {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EuclideanField {
|
||||
#[default]
|
||||
Pulses,
|
||||
Steps,
|
||||
Rotation,
|
||||
}
|
||||
|
||||
impl EuclideanField {
|
||||
pub fn next(&self) -> Self {
|
||||
match self {
|
||||
Self::Pulses => Self::Steps,
|
||||
Self::Steps => Self::Rotation,
|
||||
Self::Rotation => Self::Rotation,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&self) -> Self {
|
||||
match self {
|
||||
Self::Pulses => Self::Pulses,
|
||||
Self::Steps => Self::Pulses,
|
||||
Self::Rotation => Self::Steps,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EditorContext {
|
||||
pub bank: usize,
|
||||
pub pattern: usize,
|
||||
|
||||
@@ -30,7 +30,8 @@ pub mod ui;
|
||||
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, SettingKind};
|
||||
pub use color_scheme::ColorScheme;
|
||||
pub use editor::{
|
||||
CopiedStepData, CopiedSteps, EditorContext, PatternField, PatternPropsField, StackCache,
|
||||
CopiedStepData, CopiedSteps, EditorContext, EuclideanField, PatternField, PatternPropsField,
|
||||
StackCache,
|
||||
};
|
||||
pub use live_keys::LiveKeyState;
|
||||
pub use modal::Modal;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::model::{LaunchQuantization, PatternSpeed, SyncMode};
|
||||
use crate::state::editor::{PatternField, PatternPropsField};
|
||||
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField};
|
||||
use crate::state::file_browser::FileBrowserState;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
@@ -66,4 +66,13 @@ pub enum Modal {
|
||||
KeybindingsHelp {
|
||||
scroll: usize,
|
||||
},
|
||||
EuclideanDistribution {
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
source_step: usize,
|
||||
field: EuclideanField,
|
||||
pulses: String,
|
||||
steps: String,
|
||||
rotation: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::CyclicEnum;
|
||||
pub enum OptionsFocus {
|
||||
#[default]
|
||||
ColorScheme,
|
||||
HueRotation,
|
||||
RefreshRate,
|
||||
RuntimeHighlight,
|
||||
ShowScope,
|
||||
@@ -26,6 +27,7 @@ pub enum OptionsFocus {
|
||||
impl CyclicEnum for OptionsFocus {
|
||||
const VARIANTS: &'static [Self] = &[
|
||||
Self::ColorScheme,
|
||||
Self::HueRotation,
|
||||
Self::RefreshRate,
|
||||
Self::RuntimeHighlight,
|
||||
Self::ShowScope,
|
||||
|
||||
@@ -50,6 +50,7 @@ pub struct UiState {
|
||||
pub event_flash: f32,
|
||||
pub flash_brightness: f32,
|
||||
pub color_scheme: ColorScheme,
|
||||
pub hue_rotation: f32,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -78,6 +79,7 @@ impl Default for UiState {
|
||||
event_flash: 0.0,
|
||||
flash_brightness: 1.0,
|
||||
color_scheme: ColorScheme::default(),
|
||||
hue_rotation: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
|
||||
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
|
||||
bindings.push(("r", "Rename", "Rename current step"));
|
||||
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
|
||||
bindings.push(("e", "Euclidean", "Distribute linked steps using Euclidean rhythm"));
|
||||
}
|
||||
Page::Patterns => {
|
||||
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
|
||||
|
||||
@@ -136,22 +136,15 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
|
||||
let num_rows = steps_on_page.div_ceil(8);
|
||||
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
||||
|
||||
let spacing = num_rows.saturating_sub(1) as u16;
|
||||
let row_height = area.height.saturating_sub(spacing) / num_rows as u16;
|
||||
let row_height = area.height / num_rows as u16;
|
||||
|
||||
let row_constraints: Vec<Constraint> = (0..num_rows * 2 - 1)
|
||||
.map(|i| {
|
||||
if i % 2 == 0 {
|
||||
Constraint::Length(row_height)
|
||||
} else {
|
||||
Constraint::Length(1)
|
||||
}
|
||||
})
|
||||
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 * 2];
|
||||
let row_area = rows[row_idx];
|
||||
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;
|
||||
@@ -226,6 +219,12 @@ fn render_tile(
|
||||
(false, false, false, _, _) => (theme.tile.inactive_bg, theme.tile.inactive_fg),
|
||||
};
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::new().fg(theme.ui.border));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let source_idx = step.and_then(|s| s.source);
|
||||
let symbol = if is_playing {
|
||||
"▶".to_string()
|
||||
@@ -243,17 +242,17 @@ fn render_tile(
|
||||
};
|
||||
let num_lines = if step_name.is_some() { 2u16 } else { 1u16 };
|
||||
let content_height = num_lines;
|
||||
let y_offset = area.height.saturating_sub(content_height) / 2;
|
||||
let y_offset = inner.height.saturating_sub(content_height) / 2;
|
||||
|
||||
// Fill background for entire tile
|
||||
// Fill background for inner area
|
||||
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
|
||||
frame.render_widget(bg_fill, area);
|
||||
frame.render_widget(bg_fill, inner);
|
||||
|
||||
if let Some(name) = step_name {
|
||||
let name_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + y_offset,
|
||||
width: area.width,
|
||||
x: inner.x,
|
||||
y: inner.y + y_offset,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
let name_widget = Paragraph::new(name.as_str())
|
||||
@@ -262,9 +261,9 @@ fn render_tile(
|
||||
frame.render_widget(name_widget, name_area);
|
||||
|
||||
let symbol_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + y_offset + 1,
|
||||
width: area.width,
|
||||
x: inner.x,
|
||||
y: inner.y + y_offset + 1,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
let symbol_widget = Paragraph::new(symbol)
|
||||
@@ -273,9 +272,9 @@ fn render_tile(
|
||||
frame.render_widget(symbol_widget, symbol_area);
|
||||
} else {
|
||||
let centered_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + y_offset,
|
||||
width: area.width,
|
||||
x: inner.x,
|
||||
y: inner.y + y_offset,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
};
|
||||
let tile = Paragraph::new(symbol)
|
||||
|
||||
@@ -110,6 +110,8 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let midi_in_2 = midi_in_display(2);
|
||||
let midi_in_3 = midi_in_display(3);
|
||||
|
||||
let hue_str = format!("{}°", app.ui.hue_rotation as i32);
|
||||
|
||||
let lines: Vec<Line> = vec![
|
||||
render_section_header("DISPLAY", &theme),
|
||||
render_divider(content_width, &theme),
|
||||
@@ -119,6 +121,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
focus == OptionsFocus::ColorScheme,
|
||||
&theme,
|
||||
),
|
||||
render_option_line(
|
||||
"Hue rotation",
|
||||
&hue_str,
|
||||
focus == OptionsFocus::HueRotation,
|
||||
&theme,
|
||||
),
|
||||
render_option_line(
|
||||
"Refresh rate",
|
||||
app.audio.config.refresh_rate.label(),
|
||||
@@ -201,23 +209,24 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
|
||||
let focus_line: usize = match focus {
|
||||
OptionsFocus::ColorScheme => 2,
|
||||
OptionsFocus::RefreshRate => 3,
|
||||
OptionsFocus::RuntimeHighlight => 4,
|
||||
OptionsFocus::ShowScope => 5,
|
||||
OptionsFocus::ShowSpectrum => 6,
|
||||
OptionsFocus::ShowCompletion => 7,
|
||||
OptionsFocus::FlashBrightness => 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,
|
||||
OptionsFocus::HueRotation => 3,
|
||||
OptionsFocus::RefreshRate => 4,
|
||||
OptionsFocus::RuntimeHighlight => 5,
|
||||
OptionsFocus::ShowScope => 6,
|
||||
OptionsFocus::ShowSpectrum => 7,
|
||||
OptionsFocus::ShowCompletion => 8,
|
||||
OptionsFocus::FlashBrightness => 9,
|
||||
OptionsFocus::LinkEnabled => 13,
|
||||
OptionsFocus::StartStopSync => 14,
|
||||
OptionsFocus::Quantum => 15,
|
||||
OptionsFocus::MidiOutput0 => 26,
|
||||
OptionsFocus::MidiOutput1 => 27,
|
||||
OptionsFocus::MidiOutput2 => 28,
|
||||
OptionsFocus::MidiOutput3 => 29,
|
||||
OptionsFocus::MidiInput0 => 33,
|
||||
OptionsFocus::MidiInput1 => 34,
|
||||
OptionsFocus::MidiInput2 => 35,
|
||||
OptionsFocus::MidiInput3 => 36,
|
||||
};
|
||||
|
||||
let scroll_offset = if total_lines <= max_visible {
|
||||
|
||||
@@ -16,7 +16,9 @@ use crate::app::App;
|
||||
use crate::engine::{LinkState, SequencerSnapshot};
|
||||
use crate::model::{SourceSpan, StepContext, Value};
|
||||
use crate::page::Page;
|
||||
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache};
|
||||
use crate::state::{
|
||||
EuclideanField, FlashKind, Modal, PanelFocus, PatternField, SidePanel, StackCache,
|
||||
};
|
||||
use crate::theme;
|
||||
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
|
||||
use crate::widgets::{
|
||||
@@ -1038,5 +1040,131 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
|
||||
hint_area,
|
||||
);
|
||||
}
|
||||
Modal::EuclideanDistribution {
|
||||
source_step,
|
||||
field,
|
||||
pulses,
|
||||
steps,
|
||||
rotation,
|
||||
..
|
||||
} => {
|
||||
let width = 50u16;
|
||||
let height = 11u16;
|
||||
let x = (term.width.saturating_sub(width)) / 2;
|
||||
let y = (term.height.saturating_sub(height)) / 2;
|
||||
let area = Rect::new(x, y, width, height);
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(format!(" Euclidean Distribution (Step {:02}) ", source_step + 1))
|
||||
.border_style(Style::default().fg(theme.modal.input));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
|
||||
// Fill background with theme color
|
||||
let bg_fill = " ".repeat(area.width as usize);
|
||||
for row in 0..area.height {
|
||||
let line_area = Rect::new(area.x, area.y + row, area.width, 1);
|
||||
frame.render_widget(
|
||||
Paragraph::new(bg_fill.clone()).style(Style::new().bg(theme.ui.bg)),
|
||||
line_area,
|
||||
);
|
||||
}
|
||||
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let fields = [
|
||||
(
|
||||
"Pulses",
|
||||
pulses.as_str(),
|
||||
*field == EuclideanField::Pulses,
|
||||
),
|
||||
("Steps", steps.as_str(), *field == EuclideanField::Steps),
|
||||
(
|
||||
"Rotation",
|
||||
rotation.as_str(),
|
||||
*field == EuclideanField::Rotation,
|
||||
),
|
||||
];
|
||||
|
||||
for (i, (label, value, selected)) in fields.iter().enumerate() {
|
||||
let row_y = inner.y + i as u16;
|
||||
if row_y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let (label_style, value_style) = if *selected {
|
||||
(
|
||||
Style::default()
|
||||
.fg(theme.hint.key)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(theme.ui.text_primary)
|
||||
.bg(theme.ui.surface),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(theme.ui.text_muted),
|
||||
Style::default().fg(theme.ui.text_primary),
|
||||
)
|
||||
};
|
||||
|
||||
let label_area = Rect::new(inner.x + 1, row_y, 14, 1);
|
||||
let value_area = Rect::new(inner.x + 16, row_y, inner.width.saturating_sub(18), 1);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!("{label}:")).style(label_style),
|
||||
label_area,
|
||||
);
|
||||
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
|
||||
}
|
||||
|
||||
let preview_y = inner.y + 4;
|
||||
if preview_y < inner.y + inner.height {
|
||||
let pulses_val: usize = pulses.parse().unwrap_or(0);
|
||||
let steps_val: usize = steps.parse().unwrap_or(0);
|
||||
let rotation_val: usize = rotation.parse().unwrap_or(0);
|
||||
let preview = format_euclidean_preview(pulses_val, steps_val, rotation_val);
|
||||
let preview_line = Line::from(vec![
|
||||
Span::styled("Preview: ", Style::default().fg(theme.ui.text_muted)),
|
||||
Span::styled(preview, Style::default().fg(theme.modal.input)),
|
||||
]);
|
||||
let preview_area =
|
||||
Rect::new(inner.x + 1, preview_y, inner.width.saturating_sub(2), 1);
|
||||
frame.render_widget(Paragraph::new(preview_line), preview_area);
|
||||
}
|
||||
|
||||
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);
|
||||
let hint_line = Line::from(vec![
|
||||
Span::styled("↑↓", Style::default().fg(theme.hint.key)),
|
||||
Span::styled(" nav ", Style::default().fg(theme.hint.text)),
|
||||
Span::styled("←→", Style::default().fg(theme.hint.key)),
|
||||
Span::styled(" adjust ", Style::default().fg(theme.hint.text)),
|
||||
Span::styled("Enter", Style::default().fg(theme.hint.key)),
|
||||
Span::styled(" apply ", Style::default().fg(theme.hint.text)),
|
||||
Span::styled("Esc", Style::default().fg(theme.hint.key)),
|
||||
Span::styled(" cancel", Style::default().fg(theme.hint.text)),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint_line), hint_area);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
|
||||
if pulses == 0 || steps == 0 || pulses > steps {
|
||||
return "[invalid]".to_string();
|
||||
}
|
||||
|
||||
let mut pattern = vec![false; steps];
|
||||
for i in 0..pulses {
|
||||
let pos = (i * steps) / pulses;
|
||||
pattern[pos] = true;
|
||||
}
|
||||
|
||||
if rotation > 0 {
|
||||
pattern.rotate_left(rotation % steps);
|
||||
}
|
||||
|
||||
let chars: Vec<&str> = pattern.iter().map(|&h| if h { "x" } else { "." }).collect();
|
||||
format!("[{}]", chars.join(" "))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user