Euclidean + hue rotation
Some checks failed
Deploy Website / deploy (push) Has been cancelled

This commit is contained in:
2026-02-02 13:25:27 +01:00
parent c396c39b6b
commit 4396147a8b
21 changed files with 1338 additions and 53 deletions

View File

@@ -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"));

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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(" "))
}