Feat: WIP pattern view redesign
This commit is contained in:
@@ -425,6 +425,14 @@ impl Pattern {
|
|||||||
let source_idx = self.resolve_source(index);
|
let source_idx = self.resolve_source(index);
|
||||||
self.steps.get(source_idx).map(|s| s.script.as_str())
|
self.steps.get(source_idx).map(|s| s.script.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn content_step_count(&self) -> usize {
|
||||||
|
self.steps[..self.length]
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.has_content() || s.source.is_some())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
@@ -434,6 +442,15 @@ pub struct Bank {
|
|||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Bank {
|
||||||
|
pub fn content_pattern_count(&self) -> usize {
|
||||||
|
self.patterns
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.content_step_count() > 0)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for Bank {
|
impl Default for Bank {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ use crate::state::{
|
|||||||
ProjectState, UiState,
|
ProjectState, UiState,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STEPS_PER_PAGE: usize = 32;
|
|
||||||
|
|
||||||
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
|
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
|
||||||
model::WORDS
|
model::WORDS
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::{App, STEPS_PER_PAGE};
|
use super::App;
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn next_step(&mut self) {
|
pub fn next_step(&mut self) {
|
||||||
@@ -15,8 +15,9 @@ impl App {
|
|||||||
|
|
||||||
pub fn step_up(&mut self) {
|
pub fn step_up(&mut self) {
|
||||||
let len = self.current_edit_pattern().length;
|
let len = self.current_edit_pattern().length;
|
||||||
let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
|
let spp = self.editor_ctx.steps_per_page.get();
|
||||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start;
|
let page_start = (self.editor_ctx.step / spp) * spp;
|
||||||
|
let steps_on_page = (page_start + spp).min(len) - page_start;
|
||||||
let num_rows = steps_on_page.div_ceil(8);
|
let num_rows = steps_on_page.div_ceil(8);
|
||||||
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
||||||
|
|
||||||
@@ -30,8 +31,9 @@ impl App {
|
|||||||
|
|
||||||
pub fn step_down(&mut self) {
|
pub fn step_down(&mut self) {
|
||||||
let len = self.current_edit_pattern().length;
|
let len = self.current_edit_pattern().length;
|
||||||
let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
|
let spp = self.editor_ctx.steps_per_page.get();
|
||||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start;
|
let page_start = (self.editor_ctx.step / spp) * spp;
|
||||||
|
let steps_on_page = (page_start + spp).min(len) - page_start;
|
||||||
let num_rows = steps_on_page.div_ceil(8);
|
let num_rows = steps_on_page.div_ceil(8);
|
||||||
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
let steps_per_row = steps_on_page.div_ceil(num_rows);
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ pub struct ActivePatternState {
|
|||||||
pub pattern: usize,
|
pub pattern: usize,
|
||||||
pub step_index: usize,
|
pub step_index: usize,
|
||||||
pub iter: usize,
|
pub iter: usize,
|
||||||
|
pub last_step_beat: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type StepTracesMap = HashMap<(usize, usize, usize), ExecutionTrace>;
|
pub type StepTracesMap = HashMap<(usize, usize, usize), ExecutionTrace>;
|
||||||
@@ -208,6 +209,19 @@ impl SequencerSnapshot {
|
|||||||
.map(|p| p.step_index)
|
.map(|p| p.step_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns smooth progress (0.0..1.0) through the pattern by interpolating
|
||||||
|
/// between discrete steps using beat timing.
|
||||||
|
pub fn get_smooth_progress(&self, bank: usize, pattern: usize, length: usize, speed_mult: f64) -> Option<f64> {
|
||||||
|
let p = self.active_patterns.iter().find(|p| p.bank == bank && p.pattern == pattern)?;
|
||||||
|
if length == 0 || self.tempo <= 0.0 {
|
||||||
|
return Some(0.0);
|
||||||
|
}
|
||||||
|
let step_duration_beats = 1.0 / (4.0 * speed_mult);
|
||||||
|
let elapsed = (self.beat - p.last_step_beat).max(0.0);
|
||||||
|
let phase = (elapsed / step_duration_beats).clamp(0.0, 1.0);
|
||||||
|
Some((p.step_index as f64 + phase) / length as f64)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_iter(&self, bank: usize, pattern: usize) -> Option<usize> {
|
pub fn get_iter(&self, bank: usize, pattern: usize) -> Option<usize> {
|
||||||
self.active_patterns
|
self.active_patterns
|
||||||
.iter()
|
.iter()
|
||||||
@@ -260,6 +274,7 @@ struct ActivePattern {
|
|||||||
pattern: usize,
|
pattern: usize,
|
||||||
step_index: usize,
|
step_index: usize,
|
||||||
iter: usize,
|
iter: usize,
|
||||||
|
last_step_beat: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -821,6 +836,7 @@ impl SequencerState {
|
|||||||
pattern: pending.id.pattern,
|
pattern: pending.id.pattern,
|
||||||
step_index: start_step,
|
step_index: start_step,
|
||||||
iter: 0,
|
iter: 0,
|
||||||
|
last_step_beat: beat,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
self.buf_activated.push(pending.id);
|
self.buf_activated.push(pending.id);
|
||||||
@@ -902,6 +918,7 @@ impl SequencerState {
|
|||||||
|
|
||||||
for step_beat in step_beats {
|
for step_beat in step_beats {
|
||||||
result.any_step_fired = true;
|
result.any_step_fired = true;
|
||||||
|
active.last_step_beat = step_beat;
|
||||||
let step_idx = active.step_index % pattern.length;
|
let step_idx = active.step_index % pattern.length;
|
||||||
|
|
||||||
let beat_delta = step_beat - beat;
|
let beat_delta = step_beat - beat;
|
||||||
@@ -1099,6 +1116,7 @@ impl SequencerState {
|
|||||||
pattern: a.pattern,
|
pattern: a.pattern,
|
||||||
step_index: a.step_index,
|
step_index: a.step_index,
|
||||||
iter: a.iter,
|
iter: a.iter,
|
||||||
|
last_step_beat: a.last_step_beat,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
step_traces: Arc::clone(&self.step_traces),
|
step_traces: Arc::clone(&self.step_traces),
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view};
|
|||||||
|
|
||||||
use super::InputContext;
|
use super::InputContext;
|
||||||
|
|
||||||
const STEPS_PER_PAGE: usize = 32;
|
|
||||||
|
|
||||||
pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
|
pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
|
||||||
let kind = mouse.kind;
|
let kind = mouse.kind;
|
||||||
let col = mouse.column;
|
let col = mouse.column;
|
||||||
@@ -156,7 +154,7 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Page::Patterns => {
|
Page::Patterns => {
|
||||||
let [banks_area, _gap, patterns_area] = patterns_view::layout(body);
|
let [banks_area, patterns_area, _] = patterns_view::layout(body);
|
||||||
|
|
||||||
if contains(banks_area, col, row) {
|
if contains(banks_area, col, row) {
|
||||||
if up {
|
if up {
|
||||||
@@ -304,13 +302,7 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pattern = ctx.app.current_edit_pattern();
|
let sequencer_area = main_view::sequencer_rect(ctx.app, main_area);
|
||||||
let has_viz = ctx.app.audio.config.show_scope
|
|
||||||
|| ctx.app.audio.config.show_spectrum
|
|
||||||
|| ctx.app.audio.config.show_preview;
|
|
||||||
let seq_h = main_view::sequencer_height(pattern.length, ctx.app.editor_ctx.step);
|
|
||||||
let (_, sequencer_area) =
|
|
||||||
main_view::viz_seq_split(main_area, ctx.app.audio.config.layout, has_viz, seq_h);
|
|
||||||
|
|
||||||
if !contains(sequencer_area, col, row) {
|
if !contains(sequencer_area, col, row) {
|
||||||
return;
|
return;
|
||||||
@@ -324,9 +316,10 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
|||||||
fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option<usize> {
|
fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option<usize> {
|
||||||
let pattern = ctx.app.current_edit_pattern();
|
let pattern = ctx.app.current_edit_pattern();
|
||||||
let length = pattern.length;
|
let length = pattern.length;
|
||||||
let page = ctx.app.editor_ctx.step / STEPS_PER_PAGE;
|
let spp = ctx.app.editor_ctx.steps_per_page.get();
|
||||||
let page_start = page * STEPS_PER_PAGE;
|
let page = ctx.app.editor_ctx.step / spp;
|
||||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(length) - page_start;
|
let page_start = page * spp;
|
||||||
|
let steps_on_page = (page_start + spp).min(length) - page_start;
|
||||||
|
|
||||||
for (tile_rect, step_offset) in main_view::grid_layout(area, steps_on_page) {
|
for (tile_rect, step_offset) in main_view::grid_layout(area, steps_on_page) {
|
||||||
if contains(tile_rect, col, row) {
|
if contains(tile_rect, col, row) {
|
||||||
@@ -343,7 +336,7 @@ fn hit_test_grid(ctx: &InputContext, col: u16, row: u16, area: Rect) -> Option<u
|
|||||||
// --- Patterns page ---
|
// --- Patterns page ---
|
||||||
|
|
||||||
fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
fn handle_patterns_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
||||||
let [banks_area, _gap, patterns_area] = patterns_view::layout(area);
|
let [banks_area, patterns_area, _] = patterns_view::layout(area);
|
||||||
|
|
||||||
if contains(banks_area, col, row) {
|
if contains(banks_area, col, row) {
|
||||||
if let Some(bank) = hit_test_patterns_list(ctx, col, row, banks_area, true) {
|
if let Some(bank) = hit_test_patterns_list(ctx, col, row, banks_area, true) {
|
||||||
@@ -366,9 +359,9 @@ fn hit_test_patterns_list(
|
|||||||
is_banks: bool,
|
is_banks: bool,
|
||||||
) -> Option<usize> {
|
) -> Option<usize> {
|
||||||
use crate::model::{MAX_BANKS, MAX_PATTERNS};
|
use crate::model::{MAX_BANKS, MAX_PATTERNS};
|
||||||
|
use ratatui::widgets::{Block, Borders};
|
||||||
|
|
||||||
let [_title, inner] =
|
let inner = Block::default().borders(Borders::ALL).inner(area);
|
||||||
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
|
|
||||||
|
|
||||||
let max_items = if is_banks { MAX_BANKS } else { MAX_PATTERNS };
|
let max_items = if is_banks { MAX_BANKS } else { MAX_PATTERNS };
|
||||||
let cursor = if is_banks {
|
let cursor = if is_banks {
|
||||||
@@ -377,7 +370,14 @@ fn hit_test_patterns_list(
|
|||||||
ctx.app.patterns_nav.pattern_cursor
|
ctx.app.patterns_nav.pattern_cursor
|
||||||
};
|
};
|
||||||
|
|
||||||
let max_visible = (inner.height as usize).max(1);
|
let available = inner.height as usize;
|
||||||
|
// Patterns column: cursor row takes 2 lines
|
||||||
|
let max_visible = if is_banks {
|
||||||
|
available.max(1)
|
||||||
|
} else {
|
||||||
|
available.saturating_sub(1).max(1)
|
||||||
|
};
|
||||||
|
|
||||||
let scroll_offset = if max_items <= max_visible {
|
let scroll_offset = if max_items <= max_visible {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -387,21 +387,36 @@ fn hit_test_patterns_list(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let visible_count = max_items.min(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 {
|
if row < inner.y {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if is_banks {
|
||||||
|
let row_height = (inner.height / visible_count as u16).max(1);
|
||||||
let relative_y = row - inner.y;
|
let relative_y = row - inner.y;
|
||||||
let visible_idx = (relative_y / row_height) as usize;
|
let visible_idx = (relative_y / row_height) as usize;
|
||||||
|
|
||||||
if visible_idx < visible_count {
|
if visible_idx < visible_count {
|
||||||
let idx = scroll_offset + visible_idx;
|
let idx = scroll_offset + visible_idx;
|
||||||
if idx < max_items {
|
if idx < max_items {
|
||||||
return Some(idx);
|
return Some(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let mut y = inner.y;
|
||||||
|
for visible_idx in 0..visible_count {
|
||||||
|
let idx = scroll_offset + visible_idx;
|
||||||
|
let row_h: u16 = if idx == cursor { 2 } else { 1 };
|
||||||
|
let actual_h = row_h.min(inner.y + inner.height - y);
|
||||||
|
if row >= y && row < y + actual_h {
|
||||||
|
return Some(idx);
|
||||||
|
}
|
||||||
|
y += actual_h;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
|
|||||||
],
|
],
|
||||||
Page::Patterns => &[
|
Page::Patterns => &[
|
||||||
(
|
(
|
||||||
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. Stage patterns to play or stop, then commit to apply all changes at once.",
|
"Organize your project into banks and patterns. The left column lists 32 banks, the right shows patterns in the selected bank. The bottom strip previews steps and pattern properties. Stage patterns to play or stop, then commit to apply all changes at once.",
|
||||||
&[
|
&[
|
||||||
("Arrows", "navigate"),
|
("Arrows", "navigate"),
|
||||||
("Enter", "open in sequencer"),
|
("Enter", "open in sequencer"),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
use std::ops::RangeInclusive;
|
use std::ops::RangeInclusive;
|
||||||
|
|
||||||
use cagire_ratatui::Editor;
|
use cagire_ratatui::Editor;
|
||||||
@@ -84,6 +84,7 @@ pub struct EditorContext {
|
|||||||
pub show_stack: bool,
|
pub show_stack: bool,
|
||||||
pub stack_cache: RefCell<Option<StackCache>>,
|
pub stack_cache: RefCell<Option<StackCache>>,
|
||||||
pub target: EditorTarget,
|
pub target: EditorTarget,
|
||||||
|
pub steps_per_page: Cell<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -134,6 +135,7 @@ impl Default for EditorContext {
|
|||||||
show_stack: false,
|
show_stack: false,
|
||||||
stack_cache: RefCell::new(None),
|
stack_cache: RefCell::new(None),
|
||||||
target: EditorTarget::default(),
|
target: EditorTarget::default(),
|
||||||
|
steps_per_page: Cell::new(32),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,20 +27,106 @@ pub fn layout(area: Rect) -> [Rect; 3] {
|
|||||||
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 [main_area, _, vu_area] = layout(area);
|
let [main_area, _, vu_area] = layout(area);
|
||||||
|
|
||||||
|
if matches!(app.audio.config.layout, MainLayout::Top) {
|
||||||
|
render_top_layout(frame, app, snapshot, main_area);
|
||||||
|
} else {
|
||||||
let has_viz = app.audio.config.show_scope
|
let has_viz = app.audio.config.show_scope
|
||||||
|| app.audio.config.show_spectrum
|
|| app.audio.config.show_spectrum
|
||||||
|| app.audio.config.show_preview;
|
|| app.audio.config.show_preview;
|
||||||
let seq_h = sequencer_height(app.current_edit_pattern().length, app.editor_ctx.step);
|
let (viz_area, sequencer_area) =
|
||||||
let (viz_area, sequencer_area) = viz_seq_split(main_area, app.audio.config.layout, has_viz, seq_h);
|
viz_seq_split(main_area, app.audio.config.layout, has_viz);
|
||||||
|
|
||||||
if has_viz {
|
if has_viz {
|
||||||
render_viz_area(frame, app, snapshot, viz_area);
|
render_viz_area(frame, app, snapshot, viz_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
render_sequencer(frame, app, snapshot, sequencer_area);
|
render_sequencer(frame, app, snapshot, sequencer_area);
|
||||||
|
}
|
||||||
|
|
||||||
render_vu_meter(frame, app, vu_area);
|
render_vu_meter(frame, app, vu_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_top_layout(
|
||||||
|
frame: &mut Frame,
|
||||||
|
app: &App,
|
||||||
|
snapshot: &SequencerSnapshot,
|
||||||
|
main_area: Rect,
|
||||||
|
) {
|
||||||
|
let has_audio_viz = app.audio.config.show_scope || app.audio.config.show_spectrum;
|
||||||
|
let has_preview = app.audio.config.show_preview;
|
||||||
|
|
||||||
|
let mut constraints = Vec::new();
|
||||||
|
if has_audio_viz {
|
||||||
|
constraints.push(Constraint::Fill(1));
|
||||||
|
}
|
||||||
|
if has_preview {
|
||||||
|
constraints.push(Constraint::Length(preview_height(has_audio_viz)));
|
||||||
|
}
|
||||||
|
constraints.push(Constraint::Fill(1));
|
||||||
|
|
||||||
|
let areas = Layout::vertical(&constraints).split(main_area);
|
||||||
|
|
||||||
|
let mut idx = 0;
|
||||||
|
if has_audio_viz {
|
||||||
|
render_audio_viz(frame, app, areas[idx]);
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
if has_preview {
|
||||||
|
let has_prelude = !app.project_state.project.prelude.trim().is_empty();
|
||||||
|
if has_prelude {
|
||||||
|
let [script_area, prelude_area] =
|
||||||
|
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(areas[idx]);
|
||||||
|
render_script_preview(frame, app, snapshot, script_area);
|
||||||
|
render_prelude_preview(frame, app, prelude_area);
|
||||||
|
} else {
|
||||||
|
render_script_preview(frame, app, snapshot, areas[idx]);
|
||||||
|
}
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
render_sequencer(frame, app, snapshot, areas[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_audio_viz(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
match (app.audio.config.show_scope, app.audio.config.show_spectrum) {
|
||||||
|
(true, true) => {
|
||||||
|
let [scope_area, spectrum_area] =
|
||||||
|
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)]).areas(area);
|
||||||
|
render_scope(frame, app, scope_area, Orientation::Horizontal);
|
||||||
|
render_spectrum(frame, app, spectrum_area);
|
||||||
|
}
|
||||||
|
(true, false) => render_scope(frame, app, area, Orientation::Horizontal),
|
||||||
|
(false, true) => render_spectrum(frame, app, area),
|
||||||
|
(false, false) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview_height(has_audio_viz: bool) -> u16 {
|
||||||
|
if has_audio_viz { 10 } else { 14 }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sequencer_rect(app: &App, main_area: Rect) -> Rect {
|
||||||
|
if matches!(app.audio.config.layout, MainLayout::Top) {
|
||||||
|
let has_audio_viz = app.audio.config.show_scope || app.audio.config.show_spectrum;
|
||||||
|
let has_preview = app.audio.config.show_preview;
|
||||||
|
|
||||||
|
let mut constraints = Vec::new();
|
||||||
|
if has_audio_viz {
|
||||||
|
constraints.push(Constraint::Fill(1));
|
||||||
|
}
|
||||||
|
if has_preview {
|
||||||
|
constraints.push(Constraint::Length(preview_height(has_audio_viz)));
|
||||||
|
}
|
||||||
|
constraints.push(Constraint::Fill(1));
|
||||||
|
|
||||||
|
let areas = Layout::vertical(&constraints).split(main_area);
|
||||||
|
areas[areas.len() - 1]
|
||||||
|
} else {
|
||||||
|
let has_viz = app.audio.config.show_scope
|
||||||
|
|| app.audio.config.show_spectrum
|
||||||
|
|| app.audio.config.show_preview;
|
||||||
|
let (_, seq_area) = viz_seq_split(main_area, app.audio.config.layout, has_viz);
|
||||||
|
seq_area
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum VizPanel {
|
enum VizPanel {
|
||||||
Scope,
|
Scope,
|
||||||
Spectrum,
|
Spectrum,
|
||||||
@@ -81,39 +167,49 @@ fn render_viz_area(
|
|||||||
match panel {
|
match panel {
|
||||||
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
|
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
|
||||||
VizPanel::Spectrum => render_spectrum(frame, app, *panel_area),
|
VizPanel::Spectrum => render_spectrum(frame, app, *panel_area),
|
||||||
VizPanel::Preview => render_script_preview(frame, app, snapshot, *panel_area),
|
VizPanel::Preview => {
|
||||||
|
let has_prelude = !app.project_state.project.prelude.trim().is_empty();
|
||||||
|
if has_prelude {
|
||||||
|
let [script_area, prelude_area] = if is_vertical_layout {
|
||||||
|
Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)])
|
||||||
|
.areas(*panel_area)
|
||||||
|
} else {
|
||||||
|
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)])
|
||||||
|
.areas(*panel_area)
|
||||||
|
};
|
||||||
|
render_script_preview(frame, app, snapshot, script_area);
|
||||||
|
render_prelude_preview(frame, app, prelude_area);
|
||||||
|
} else {
|
||||||
|
render_script_preview(frame, app, snapshot, *panel_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEPS_PER_PAGE: usize = 32;
|
|
||||||
const TILE_HEIGHT: u16 = 3;
|
const TILE_HEIGHT: u16 = 3;
|
||||||
const ROW_GAP: u16 = 1;
|
const ROW_GAP: u16 = 1;
|
||||||
|
|
||||||
pub fn sequencer_height(pattern_length: usize, current_step: usize) -> u16 {
|
pub fn steps_per_page(area_height: u16) -> usize {
|
||||||
let page = current_step / STEPS_PER_PAGE;
|
if area_height < 5 {
|
||||||
let page_start = page * STEPS_PER_PAGE;
|
return 8;
|
||||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(pattern_length) - page_start;
|
|
||||||
if steps_on_page == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
let num_rows = steps_on_page.div_ceil(8);
|
let usable = (area_height - 2) as usize;
|
||||||
let grid_h = (num_rows as u16) * TILE_HEIGHT + (num_rows.saturating_sub(1) as u16) * ROW_GAP;
|
let max_rows = (usable + ROW_GAP as usize) / (TILE_HEIGHT as usize + ROW_GAP as usize);
|
||||||
grid_h + 2
|
(max_rows * 8).clamp(8, 128)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn viz_seq_split(
|
pub fn viz_seq_split(
|
||||||
main_area: Rect,
|
main_area: Rect,
|
||||||
layout: MainLayout,
|
layout: MainLayout,
|
||||||
has_viz: bool,
|
has_viz: bool,
|
||||||
seq_h: u16,
|
|
||||||
) -> (Rect, Rect) {
|
) -> (Rect, Rect) {
|
||||||
match layout {
|
match layout {
|
||||||
MainLayout::Top => {
|
MainLayout::Top => {
|
||||||
if has_viz {
|
if has_viz {
|
||||||
let [viz, seq] = Layout::vertical([
|
let [viz, seq] = Layout::vertical([
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
Constraint::Length(seq_h),
|
Constraint::Fill(1),
|
||||||
])
|
])
|
||||||
.areas(main_area);
|
.areas(main_area);
|
||||||
(viz, seq)
|
(viz, seq)
|
||||||
@@ -124,7 +220,7 @@ pub fn viz_seq_split(
|
|||||||
MainLayout::Bottom => {
|
MainLayout::Bottom => {
|
||||||
if has_viz {
|
if has_viz {
|
||||||
let [seq, viz] = Layout::vertical([
|
let [seq, viz] = Layout::vertical([
|
||||||
Constraint::Length(seq_h),
|
Constraint::Fill(1),
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
])
|
])
|
||||||
.areas(main_area);
|
.areas(main_area);
|
||||||
@@ -226,9 +322,11 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
|
|||||||
|
|
||||||
let pattern = app.current_edit_pattern();
|
let pattern = app.current_edit_pattern();
|
||||||
let length = pattern.length;
|
let length = pattern.length;
|
||||||
let page = app.editor_ctx.step / STEPS_PER_PAGE;
|
let spp = steps_per_page(area.height);
|
||||||
let page_start = page * STEPS_PER_PAGE;
|
app.editor_ctx.steps_per_page.set(spp);
|
||||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(length) - page_start;
|
let page = app.editor_ctx.step / spp;
|
||||||
|
let page_start = page * spp;
|
||||||
|
let steps_on_page = (page_start + spp).min(length) - page_start;
|
||||||
|
|
||||||
for (tile_rect, step_offset) in grid_layout(area, steps_on_page) {
|
for (tile_rect, step_offset) in grid_layout(area, steps_on_page) {
|
||||||
let step_idx = page_start + step_offset;
|
let step_idx = page_start + step_offset;
|
||||||
@@ -470,6 +568,34 @@ fn render_script_preview(
|
|||||||
frame.render_widget(Paragraph::new(lines), inner);
|
frame.render_widget(Paragraph::new(lines), inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_prelude_preview(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let theme = theme::get();
|
||||||
|
let user_words: HashSet<String> = app.dict.lock().keys().cloned().collect();
|
||||||
|
let prelude = &app.project_state.project.prelude;
|
||||||
|
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Prelude ")
|
||||||
|
.border_style(Style::new().fg(theme.ui.border));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
|
let lines: Vec<Line> = prelude
|
||||||
|
.lines()
|
||||||
|
.take(inner.height as usize)
|
||||||
|
.map(|line_str| {
|
||||||
|
let tokens = highlight_line_with_runtime(line_str, &[], &[], &[], &user_words);
|
||||||
|
let spans: Vec<Span> = tokens
|
||||||
|
.into_iter()
|
||||||
|
.map(|(style, text, _)| Span::styled(text, style))
|
||||||
|
.collect();
|
||||||
|
Line::from(spans)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(lines), inner);
|
||||||
|
}
|
||||||
|
|
||||||
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Paragraph};
|
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
@@ -13,31 +13,90 @@ use crate::widgets::{render_scroll_indicators, IndicatorAlign};
|
|||||||
|
|
||||||
const MIN_ROW_HEIGHT: u16 = 1;
|
const MIN_ROW_HEIGHT: u16 = 1;
|
||||||
|
|
||||||
|
/// Replaces the background color of spans beyond `filled_cols` with `unfilled_bg`.
|
||||||
|
fn apply_progress_bg(spans: Vec<Span<'_>>, filled_cols: usize, unfilled_bg: Color) -> Vec<Span<'_>> {
|
||||||
|
let mut result = Vec::with_capacity(spans.len() + 1);
|
||||||
|
let mut col = 0usize;
|
||||||
|
for span in spans {
|
||||||
|
let span_width = span.content.chars().count();
|
||||||
|
if col + span_width <= filled_cols {
|
||||||
|
result.push(span);
|
||||||
|
} else if col >= filled_cols {
|
||||||
|
result.push(Span::styled(span.content, span.style.bg(unfilled_bg)));
|
||||||
|
} else {
|
||||||
|
let split_at = filled_cols - col;
|
||||||
|
let byte_offset = span.content.char_indices()
|
||||||
|
.nth(split_at)
|
||||||
|
.map_or(span.content.len(), |(i, _)| i);
|
||||||
|
let (left, right) = span.content.split_at(byte_offset);
|
||||||
|
result.push(Span::styled(left.to_string(), span.style));
|
||||||
|
result.push(Span::styled(right.to_string(), span.style.bg(unfilled_bg)));
|
||||||
|
}
|
||||||
|
col += span_width;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
pub fn layout(area: Rect) -> [Rect; 3] {
|
pub fn layout(area: Rect) -> [Rect; 3] {
|
||||||
Layout::horizontal([Constraint::Fill(1), Constraint::Length(1), Constraint::Fill(1)]).areas(area)
|
let [top_area, _bottom_area] =
|
||||||
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(8)]).areas(area);
|
||||||
|
let [banks_area, patterns_area] =
|
||||||
|
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(top_area);
|
||||||
|
[banks_area, patterns_area, _bottom_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(area);
|
let theme = theme::get();
|
||||||
|
|
||||||
|
let [banks_area, patterns_area, bottom_area] = layout(area);
|
||||||
|
|
||||||
|
let [steps_area, props_area] =
|
||||||
|
Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]).areas(bottom_area);
|
||||||
|
|
||||||
render_banks(frame, app, snapshot, banks_area);
|
render_banks(frame, app, snapshot, banks_area);
|
||||||
// gap is just empty space
|
|
||||||
let _ = gap;
|
|
||||||
render_patterns(frame, app, snapshot, patterns_area);
|
render_patterns(frame, app, snapshot, patterns_area);
|
||||||
|
|
||||||
|
let bank = app.patterns_nav.bank_cursor;
|
||||||
|
let pattern_idx = app.patterns_nav.pattern_cursor;
|
||||||
|
|
||||||
|
// Steps block
|
||||||
|
let steps_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border))
|
||||||
|
.title(" Steps ")
|
||||||
|
.title_style(Style::new().fg(theme.ui.unfocused));
|
||||||
|
let steps_inner = steps_block.inner(steps_area);
|
||||||
|
frame.render_widget(steps_block, steps_area);
|
||||||
|
render_mini_tile_grid(frame, app, snapshot, steps_inner, bank, pattern_idx);
|
||||||
|
|
||||||
|
// Properties block
|
||||||
|
let props_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(theme.ui.border))
|
||||||
|
.title(" Properties ")
|
||||||
|
.title_style(Style::new().fg(theme.ui.unfocused));
|
||||||
|
let props_inner = props_block.inner(props_area);
|
||||||
|
frame.render_widget(props_block, props_area);
|
||||||
|
render_properties(frame, app, props_inner, bank, pattern_idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
|
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
|
||||||
|
|
||||||
let [title_area, inner] =
|
let border_color = if is_focused { theme.ui.header } else { theme.ui.border };
|
||||||
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
|
let title_style = if is_focused {
|
||||||
|
Style::new().fg(theme.ui.header)
|
||||||
let title_color = if is_focused { theme.ui.header } else { theme.ui.unfocused };
|
} else {
|
||||||
let title = Paragraph::new("Banks")
|
Style::new().fg(theme.ui.unfocused)
|
||||||
.style(Style::new().fg(title_color))
|
};
|
||||||
.alignment(ratatui::layout::Alignment::Center);
|
let block = Block::default()
|
||||||
frame.render_widget(title, title_area);
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(border_color))
|
||||||
|
.title(" Banks ")
|
||||||
|
.title_style(title_style);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let banks_with_playback: Vec<usize> = snapshot
|
let banks_with_playback: Vec<usize> = snapshot
|
||||||
.active_patterns
|
.active_patterns
|
||||||
@@ -96,11 +155,8 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
|||||||
.bank_selection_range()
|
.bank_selection_range()
|
||||||
.is_some_and(|r| r.contains(&idx));
|
.is_some_and(|r| r.contains(&idx));
|
||||||
|
|
||||||
// Check if any pattern in this bank is muted/soloed (applied)
|
|
||||||
let has_muted = (0..MAX_PATTERNS).any(|p| app.mute.is_muted(idx, p));
|
let has_muted = (0..MAX_PATTERNS).any(|p| app.mute.is_muted(idx, p));
|
||||||
let has_soloed = (0..MAX_PATTERNS).any(|p| app.mute.is_soloed(idx, p));
|
let has_soloed = (0..MAX_PATTERNS).any(|p| app.mute.is_soloed(idx, p));
|
||||||
|
|
||||||
// Check if any pattern in this bank has staged mute/solo
|
|
||||||
let has_staged_mute = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_mute(idx, p));
|
let has_staged_mute = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_mute(idx, p));
|
||||||
let has_staged_solo = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_solo(idx, p));
|
let has_staged_solo = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_solo(idx, p));
|
||||||
let has_staged_mute_solo = has_staged_mute || has_staged_solo;
|
let has_staged_mute_solo = has_staged_mute || has_staged_solo;
|
||||||
@@ -135,14 +191,22 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
|||||||
(theme.ui.bg, theme.ui.text_muted, "")
|
(theme.ui.bg, theme.ui.text_muted, "")
|
||||||
};
|
};
|
||||||
|
|
||||||
let name = app.project_state.project.banks[idx]
|
let bank_ref = &app.project_state.project.banks[idx];
|
||||||
.name
|
let name = bank_ref.name.as_deref().unwrap_or("");
|
||||||
.as_deref()
|
let content_count = bank_ref.content_pattern_count();
|
||||||
.unwrap_or("");
|
|
||||||
|
let idx_part = format!("{}{:02}", prefix, idx + 1);
|
||||||
|
let count_part = format!("{}", content_count);
|
||||||
|
let available_for_name = (row_area.width as usize)
|
||||||
|
.saturating_sub(idx_part.len() + 1 + count_part.len());
|
||||||
let label = if name.is_empty() {
|
let label = if name.is_empty() {
|
||||||
format!("{}{:02}", prefix, idx + 1)
|
let pad = " ".repeat(available_for_name);
|
||||||
|
format!("{idx_part}{pad}{count_part}")
|
||||||
} else {
|
} else {
|
||||||
format!("{}{:02} {}", prefix, idx + 1, name)
|
let name_display: String = name.chars().take(available_for_name.saturating_sub(1)).collect();
|
||||||
|
let used = name_display.chars().count() + 1;
|
||||||
|
let pad = " ".repeat(available_for_name.saturating_sub(used));
|
||||||
|
format!("{idx_part} {name_display}{pad}{count_part}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let style = Style::new().bg(bg).fg(fg);
|
let style = Style::new().bg(bg).fg(fg);
|
||||||
@@ -152,7 +216,6 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
|||||||
style
|
style
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fill the entire row with background color
|
|
||||||
let bg_block = Block::default().style(Style::new().bg(bg));
|
let bg_block = Block::default().style(Style::new().bg(bg));
|
||||||
frame.render_widget(bg_block, row_area);
|
frame.render_widget(bg_block, row_area);
|
||||||
|
|
||||||
@@ -188,21 +251,26 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
let theme = theme::get();
|
let theme = theme::get();
|
||||||
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
|
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Patterns);
|
||||||
|
|
||||||
let [title_area, inner] =
|
|
||||||
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
|
|
||||||
|
|
||||||
let title_color = if is_focused { theme.ui.header } else { theme.ui.unfocused };
|
|
||||||
|
|
||||||
let bank = app.patterns_nav.bank_cursor;
|
let bank = app.patterns_nav.bank_cursor;
|
||||||
let bank_name = app.project_state.project.banks[bank].name.as_deref();
|
let bank_name = app.project_state.project.banks[bank].name.as_deref();
|
||||||
let title_text = match bank_name {
|
let title_text = match bank_name {
|
||||||
Some(name) => format!(" Patterns ({name}) "),
|
Some(name) => format!(" Patterns ({name}) "),
|
||||||
None => format!(" Patterns (Bank {:02}) ", bank + 1),
|
None => format!(" Patterns (Bank {:02}) ", bank + 1),
|
||||||
};
|
};
|
||||||
let title = Paragraph::new(title_text)
|
|
||||||
.style(Style::new().fg(title_color))
|
let border_color = if is_focused { theme.ui.header } else { theme.ui.border };
|
||||||
.alignment(ratatui::layout::Alignment::Center);
|
let title_style = if is_focused {
|
||||||
frame.render_widget(title, title_area);
|
Style::new().fg(theme.ui.header)
|
||||||
|
} else {
|
||||||
|
Style::new().fg(theme.ui.unfocused)
|
||||||
|
};
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::new().fg(border_color))
|
||||||
|
.title(title_text)
|
||||||
|
.title_style(title_style);
|
||||||
|
let inner = block.inner(area);
|
||||||
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let playing_patterns: Vec<usize> = snapshot
|
let playing_patterns: Vec<usize> = snapshot
|
||||||
.active_patterns
|
.active_patterns
|
||||||
@@ -243,8 +311,9 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cursor = app.patterns_nav.pattern_cursor;
|
let cursor = app.patterns_nav.pattern_cursor;
|
||||||
let max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
|
let available = inner.height as usize;
|
||||||
let max_visible = max_visible.max(1);
|
// Cursor row takes 2 lines (main + detail); account for 1 extra
|
||||||
|
let max_visible = available.saturating_sub(1).max(1);
|
||||||
|
|
||||||
let scroll_offset = if MAX_PATTERNS <= max_visible {
|
let scroll_offset = if MAX_PATTERNS <= max_visible {
|
||||||
0
|
0
|
||||||
@@ -255,12 +324,12 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
};
|
};
|
||||||
|
|
||||||
let visible_count = MAX_PATTERNS.min(max_visible);
|
let visible_count = MAX_PATTERNS.min(max_visible);
|
||||||
let row_height = inner.height / visible_count as u16;
|
|
||||||
let row_height = row_height.max(MIN_ROW_HEIGHT);
|
|
||||||
|
|
||||||
|
let mut y = inner.y;
|
||||||
for visible_idx in 0..visible_count {
|
for visible_idx in 0..visible_count {
|
||||||
let idx = scroll_offset + visible_idx;
|
let idx = scroll_offset + visible_idx;
|
||||||
let y = inner.y + (visible_idx as u16) * row_height;
|
let is_expanded = idx == cursor;
|
||||||
|
let row_h = if is_expanded { 2u16 } else { 1u16 };
|
||||||
if y >= inner.y + inner.height {
|
if y >= inner.y + inner.height {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -269,7 +338,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
x: inner.x,
|
x: inner.x,
|
||||||
y,
|
y,
|
||||||
width: inner.width,
|
width: inner.width,
|
||||||
height: row_height.min(inner.y + inner.height - y),
|
height: row_h.min(inner.y + inner.height - y),
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
|
let is_cursor = is_focused && idx == app.patterns_nav.pattern_cursor;
|
||||||
@@ -284,16 +353,11 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
.pattern_selection_range()
|
.pattern_selection_range()
|
||||||
.is_some_and(|r| r.contains(&idx));
|
.is_some_and(|r| r.contains(&idx));
|
||||||
|
|
||||||
// Current applied mute/solo state
|
|
||||||
let is_muted = app.mute.is_muted(bank, idx);
|
let is_muted = app.mute.is_muted(bank, idx);
|
||||||
let is_soloed = app.mute.is_soloed(bank, idx);
|
let is_soloed = app.mute.is_soloed(bank, idx);
|
||||||
|
|
||||||
// Staged mute/solo (will toggle on commit)
|
|
||||||
let has_staged_mute = app.playback.has_staged_mute(bank, idx);
|
let has_staged_mute = app.playback.has_staged_mute(bank, idx);
|
||||||
let has_staged_solo = app.playback.has_staged_solo(bank, idx);
|
let has_staged_solo = app.playback.has_staged_solo(bank, idx);
|
||||||
let has_staged_props = app.playback.has_staged_props(bank, idx);
|
let has_staged_props = app.playback.has_staged_props(bank, idx);
|
||||||
|
|
||||||
// Preview state (what it will be after commit)
|
|
||||||
let preview_muted = is_muted ^ has_staged_mute;
|
let preview_muted = is_muted ^ has_staged_mute;
|
||||||
let preview_soloed = is_soloed ^ has_staged_solo;
|
let preview_soloed = is_soloed ^ has_staged_solo;
|
||||||
let is_effectively_muted = app.mute.is_effectively_muted(bank, idx);
|
let is_effectively_muted = app.mute.is_effectively_muted(bank, idx);
|
||||||
@@ -303,18 +367,15 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
} else if is_in_range {
|
} else if is_in_range {
|
||||||
(theme.selection.in_range_bg, theme.selection.in_range_fg, "")
|
(theme.selection.in_range_bg, theme.selection.in_range_fg, "")
|
||||||
} else if is_playing {
|
} else if is_playing {
|
||||||
// Playing patterns
|
|
||||||
if is_staged_stop {
|
if is_staged_stop {
|
||||||
(theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ")
|
(theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ")
|
||||||
} else if has_staged_solo {
|
} else if has_staged_solo {
|
||||||
// Staged solo toggle on playing pattern
|
|
||||||
if preview_soloed {
|
if preview_soloed {
|
||||||
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||||
} else {
|
} else {
|
||||||
(theme.list.playing_bg, theme.list.playing_fg, "-S")
|
(theme.list.playing_bg, theme.list.playing_fg, "-S")
|
||||||
}
|
}
|
||||||
} else if has_staged_mute {
|
} else if has_staged_mute {
|
||||||
// Staged mute toggle on playing pattern
|
|
||||||
if preview_muted {
|
if preview_muted {
|
||||||
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||||
} else {
|
} else {
|
||||||
@@ -332,14 +393,12 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
} else if is_staged_play {
|
} else if is_staged_play {
|
||||||
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
|
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
|
||||||
} else if has_staged_solo {
|
} else if has_staged_solo {
|
||||||
// Staged solo on non-playing pattern
|
|
||||||
if preview_soloed {
|
if preview_soloed {
|
||||||
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||||
} else {
|
} else {
|
||||||
(theme.ui.bg, theme.ui.text_muted, "-S")
|
(theme.ui.bg, theme.ui.text_muted, "-S")
|
||||||
}
|
}
|
||||||
} else if has_staged_mute {
|
} else if has_staged_mute {
|
||||||
// Staged mute on non-playing pattern
|
|
||||||
if preview_muted {
|
if preview_muted {
|
||||||
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||||
} else {
|
} else {
|
||||||
@@ -365,24 +424,29 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
let base_style = Style::new().bg(bg).fg(fg);
|
let base_style = Style::new().bg(bg).fg(fg);
|
||||||
let bold_style = base_style.add_modifier(Modifier::BOLD);
|
let bold_style = base_style.add_modifier(Modifier::BOLD);
|
||||||
|
|
||||||
// Fill the entire row with background color
|
let content_area = if is_expanded {
|
||||||
|
let border_color = if is_focused { theme.selection.cursor } else { theme.ui.unfocused };
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::LEFT | Borders::RIGHT)
|
||||||
|
.border_type(BorderType::QuadrantOutside)
|
||||||
|
.border_style(Style::new().fg(border_color).bg(bg))
|
||||||
|
.style(Style::new().bg(bg));
|
||||||
|
let content = block.inner(row_area);
|
||||||
|
frame.render_widget(block, row_area);
|
||||||
|
content
|
||||||
|
} else {
|
||||||
let bg_block = Block::default().style(Style::new().bg(bg));
|
let bg_block = Block::default().style(Style::new().bg(bg));
|
||||||
frame.render_widget(bg_block, row_area);
|
frame.render_widget(bg_block, row_area);
|
||||||
|
row_area
|
||||||
let text_y = if row_height > 1 {
|
|
||||||
row_area.y + (row_height - 1) / 2
|
|
||||||
} else {
|
|
||||||
row_area.y
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let text_area = Rect {
|
let text_area = Rect {
|
||||||
x: row_area.x,
|
x: content_area.x,
|
||||||
y: text_y,
|
y: content_area.y,
|
||||||
width: row_area.width,
|
width: content_area.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the line: [prefix][idx] [name] ... [length] [speed]
|
|
||||||
let name_style = if is_playing || is_staged_play {
|
let name_style = if is_playing || is_staged_play {
|
||||||
bold_style
|
bold_style
|
||||||
} else {
|
} else {
|
||||||
@@ -395,22 +459,73 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
spans.push(Span::styled(format!(" {name}"), name_style));
|
spans.push(Span::styled(format!(" {name}"), name_style));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-aligned info: length, speed, and staged props indicator
|
let content_count = pattern.content_step_count();
|
||||||
let speed_str = if speed != PatternSpeed::NORMAL {
|
let speed_str = if speed != PatternSpeed::NORMAL {
|
||||||
format!(" {}", speed.label())
|
format!(" {}", speed.label())
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
let props_indicator = if has_staged_props { "~" } else { "" };
|
let props_indicator = if has_staged_props { "~" } else { "" };
|
||||||
let right_info = format!("{props_indicator}{length}{speed_str}");
|
let right_info = if content_count > 0 {
|
||||||
|
format!("{props_indicator}{content_count}/{length}{speed_str}")
|
||||||
|
} else {
|
||||||
|
format!("{props_indicator} {length}{speed_str}")
|
||||||
|
};
|
||||||
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
|
let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum();
|
||||||
let right_width = right_info.chars().count();
|
let right_width = right_info.chars().count();
|
||||||
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
|
let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);
|
||||||
|
|
||||||
spans.push(Span::raw(" ".repeat(padding)));
|
spans.push(Span::styled(" ".repeat(padding), dim_style));
|
||||||
spans.push(Span::styled(right_info, dim_style));
|
spans.push(Span::styled(right_info, dim_style));
|
||||||
|
|
||||||
|
let spans = if is_playing && !is_cursor && !is_in_range {
|
||||||
|
let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0);
|
||||||
|
let filled = (ratio * text_area.width as f64).min(text_area.width as f64) as usize;
|
||||||
|
apply_progress_bg(spans, filled, theme.ui.bg)
|
||||||
|
} else {
|
||||||
|
spans
|
||||||
|
};
|
||||||
|
|
||||||
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
|
frame.render_widget(Paragraph::new(Line::from(spans)), text_area);
|
||||||
|
|
||||||
|
if is_expanded && content_area.height >= 2 {
|
||||||
|
let detail_area = Rect {
|
||||||
|
x: content_area.x,
|
||||||
|
y: content_area.y + 1,
|
||||||
|
width: content_area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = format!(
|
||||||
|
"{} · {}",
|
||||||
|
pattern.quantization.label(),
|
||||||
|
pattern.sync_mode.label()
|
||||||
|
);
|
||||||
|
let w = detail_area.width as usize;
|
||||||
|
let padded_label = format!("{label:>w$}");
|
||||||
|
|
||||||
|
let filled_width = if is_playing {
|
||||||
|
let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0);
|
||||||
|
(ratio * detail_area.width as f64).min(detail_area.width as f64) as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let dim_fg = theme.ui.text_muted;
|
||||||
|
let progress_bg = theme.list.playing_bg;
|
||||||
|
let byte_offset = padded_label
|
||||||
|
.char_indices()
|
||||||
|
.nth(filled_width)
|
||||||
|
.map_or(padded_label.len(), |(i, _)| i);
|
||||||
|
let (left, right) = padded_label.split_at(byte_offset);
|
||||||
|
let detail_spans = vec![
|
||||||
|
Span::styled(left.to_string(), Style::new().bg(progress_bg).fg(dim_fg)),
|
||||||
|
Span::styled(right.to_string(), Style::new().bg(theme.ui.bg).fg(dim_fg)),
|
||||||
|
];
|
||||||
|
frame.render_widget(Paragraph::new(Line::from(detail_spans)), detail_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
y += row_area.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
render_scroll_indicators(
|
render_scroll_indicators(
|
||||||
@@ -423,3 +538,219 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
|||||||
IndicatorAlign::Center,
|
IndicatorAlign::Center,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_mini_tile_grid(
|
||||||
|
frame: &mut Frame,
|
||||||
|
app: &App,
|
||||||
|
snapshot: &SequencerSnapshot,
|
||||||
|
area: Rect,
|
||||||
|
bank: usize,
|
||||||
|
pattern_idx: usize,
|
||||||
|
) {
|
||||||
|
let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx];
|
||||||
|
let length = pattern.length;
|
||||||
|
if length == 0 || area.height == 0 || area.width < 8 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let playing_step = snapshot.get_step(bank, pattern_idx);
|
||||||
|
|
||||||
|
let num_rows = length.div_ceil(8);
|
||||||
|
let row_gap: u16 = 1;
|
||||||
|
let max_tile_height: u16 = 4;
|
||||||
|
|
||||||
|
let available_for_rows =
|
||||||
|
area.height.saturating_sub((num_rows.saturating_sub(1) as u16) * row_gap);
|
||||||
|
let tile_height = (available_for_rows / num_rows as u16).min(max_tile_height).max(1);
|
||||||
|
|
||||||
|
let total_grid_height =
|
||||||
|
(num_rows as u16) * tile_height + (num_rows.saturating_sub(1) as u16) * row_gap;
|
||||||
|
let y_offset = area.height.saturating_sub(total_grid_height) / 2;
|
||||||
|
|
||||||
|
let grid_area = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: area.y + y_offset,
|
||||||
|
width: area.width,
|
||||||
|
height: total_grid_height.min(area.height),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut row_constraints: Vec<Constraint> = Vec::new();
|
||||||
|
for i in 0..num_rows {
|
||||||
|
row_constraints.push(Constraint::Length(tile_height));
|
||||||
|
if i < num_rows - 1 {
|
||||||
|
row_constraints.push(Constraint::Length(row_gap));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let row_areas = Layout::vertical(row_constraints).split(grid_area);
|
||||||
|
|
||||||
|
for row_idx in 0..num_rows {
|
||||||
|
if row_idx * 2 >= row_areas.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let row_area = row_areas[row_idx * 2];
|
||||||
|
let start_step = row_idx * 8;
|
||||||
|
let end_step = (start_step + 8).min(length);
|
||||||
|
let cols_in_row = end_step - start_step;
|
||||||
|
|
||||||
|
let mut col_constraints: Vec<Constraint> = Vec::new();
|
||||||
|
for col in 0..cols_in_row {
|
||||||
|
col_constraints.push(Constraint::Fill(1));
|
||||||
|
if col < cols_in_row - 1 {
|
||||||
|
if (col + 1) % 4 == 0 {
|
||||||
|
col_constraints.push(Constraint::Length(2));
|
||||||
|
} else {
|
||||||
|
col_constraints.push(Constraint::Length(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let col_areas = Layout::horizontal(col_constraints).split(row_area);
|
||||||
|
|
||||||
|
for col_idx in 0..cols_in_row {
|
||||||
|
let step_idx = start_step + col_idx;
|
||||||
|
let cell_area = col_areas[col_idx * 2];
|
||||||
|
render_mini_tile(frame, pattern, cell_area, step_idx, playing_step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_mini_tile(
|
||||||
|
frame: &mut Frame,
|
||||||
|
pattern: &cagire_project::Pattern,
|
||||||
|
area: Rect,
|
||||||
|
step_idx: usize,
|
||||||
|
playing_step: Option<usize>,
|
||||||
|
) {
|
||||||
|
let theme = theme::get();
|
||||||
|
let step = pattern.step(step_idx);
|
||||||
|
let is_active = step.map(|s| s.active).unwrap_or(false);
|
||||||
|
let has_content = step.map(|s| s.has_content()).unwrap_or(false);
|
||||||
|
let source_idx = step.and_then(|s| s.source);
|
||||||
|
let is_playing = playing_step == Some(step_idx);
|
||||||
|
|
||||||
|
let (bg, fg) = if is_playing {
|
||||||
|
if is_active {
|
||||||
|
(theme.tile.playing_active_bg, theme.tile.playing_active_fg)
|
||||||
|
} else {
|
||||||
|
(theme.tile.playing_inactive_bg, theme.tile.playing_inactive_fg)
|
||||||
|
}
|
||||||
|
} else if let Some(src) = source_idx {
|
||||||
|
let i = src as usize % 5;
|
||||||
|
let (r, g, b) = theme.tile.link_dim[i];
|
||||||
|
(Color::Rgb(r, g, b), theme.tile.active_fg)
|
||||||
|
} else if has_content {
|
||||||
|
(theme.tile.content_bg, theme.tile.active_fg)
|
||||||
|
} else if is_active {
|
||||||
|
(theme.tile.active_bg, theme.tile.active_fg)
|
||||||
|
} else {
|
||||||
|
(theme.tile.inactive_bg, theme.tile.inactive_fg)
|
||||||
|
};
|
||||||
|
|
||||||
|
let symbol = if is_playing {
|
||||||
|
"\u{25b6}".to_string()
|
||||||
|
} else if let Some(src) = source_idx {
|
||||||
|
format!("\u{2192}{:02}", src + 1)
|
||||||
|
} else if has_content {
|
||||||
|
format!("\u{00b7}{:02}\u{00b7}", step_idx + 1)
|
||||||
|
} else {
|
||||||
|
format!("{:02}", step_idx + 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let bg_fill = Paragraph::new("").style(Style::new().bg(bg));
|
||||||
|
frame.render_widget(bg_fill, area);
|
||||||
|
|
||||||
|
let center_y = area.y + area.height / 2;
|
||||||
|
|
||||||
|
let step_name = if let Some(src) = source_idx {
|
||||||
|
pattern.step(src as usize).and_then(|s| s.name.as_ref())
|
||||||
|
} else {
|
||||||
|
step.and_then(|s| s.name.as_ref())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(name) = step_name {
|
||||||
|
if center_y > area.y {
|
||||||
|
let name_area = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: center_y - 1,
|
||||||
|
width: area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let name_widget = Paragraph::new(name.as_str())
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
|
||||||
|
frame.render_widget(name_widget, name_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbol_area = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: center_y,
|
||||||
|
width: area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let symbol_widget = Paragraph::new(symbol)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().bg(bg).fg(fg).add_modifier(Modifier::BOLD));
|
||||||
|
frame.render_widget(symbol_widget, symbol_area);
|
||||||
|
|
||||||
|
if has_content && center_y + 1 < area.y + area.height {
|
||||||
|
let script = pattern.resolve_script(step_idx).unwrap_or("");
|
||||||
|
if let Some(first_token) = script.split_whitespace().next() {
|
||||||
|
let hint_area = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: center_y + 1,
|
||||||
|
width: area.width,
|
||||||
|
height: 1,
|
||||||
|
};
|
||||||
|
let hint_widget = Paragraph::new(first_token)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.style(Style::new().bg(bg).fg(theme.ui.text_dim));
|
||||||
|
frame.render_widget(hint_widget, hint_area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_properties(
|
||||||
|
frame: &mut Frame,
|
||||||
|
app: &App,
|
||||||
|
area: Rect,
|
||||||
|
bank: usize,
|
||||||
|
pattern_idx: usize,
|
||||||
|
) {
|
||||||
|
let theme = theme::get();
|
||||||
|
let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx];
|
||||||
|
|
||||||
|
let name = pattern.name.as_deref().unwrap_or("-");
|
||||||
|
let content_count = pattern.content_step_count();
|
||||||
|
let steps_label = format!("{}/{}", content_count, pattern.length);
|
||||||
|
let speed_label = pattern.speed.label();
|
||||||
|
let quant_label = pattern.quantization.label();
|
||||||
|
let sync_label = pattern.sync_mode.label();
|
||||||
|
|
||||||
|
let label_style = Style::new().fg(theme.ui.text_muted);
|
||||||
|
let value_style = Style::new().fg(theme.ui.text_primary);
|
||||||
|
|
||||||
|
let rows: Vec<Line> = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Name ", label_style),
|
||||||
|
Span::styled(name, value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Steps ", label_style),
|
||||||
|
Span::styled(steps_label, value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Speed ", label_style),
|
||||||
|
Span::styled(speed_label, value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Quant ", label_style),
|
||||||
|
Span::styled(quant_label, value_style),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Sync ", label_style),
|
||||||
|
Span::styled(sync_label, value_style),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
frame.render_widget(Paragraph::new(rows), area);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user