Feat: WIP pattern view redesign
This commit is contained in:
@@ -27,20 +27,106 @@ pub fn layout(area: Rect) -> [Rect; 3] {
|
||||
pub fn render(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
let [main_area, _, vu_area] = layout(area);
|
||||
|
||||
let has_viz = app.audio.config.show_scope
|
||||
|| app.audio.config.show_spectrum
|
||||
|| app.audio.config.show_preview;
|
||||
let seq_h = sequencer_height(app.current_edit_pattern().length, app.editor_ctx.step);
|
||||
let (viz_area, sequencer_area) = viz_seq_split(main_area, app.audio.config.layout, has_viz, seq_h);
|
||||
|
||||
if has_viz {
|
||||
render_viz_area(frame, app, snapshot, viz_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
|
||||
|| app.audio.config.show_spectrum
|
||||
|| app.audio.config.show_preview;
|
||||
let (viz_area, sequencer_area) =
|
||||
viz_seq_split(main_area, app.audio.config.layout, has_viz);
|
||||
if has_viz {
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
Scope,
|
||||
Spectrum,
|
||||
@@ -81,39 +167,49 @@ fn render_viz_area(
|
||||
match panel {
|
||||
VizPanel::Scope => render_scope(frame, app, *panel_area, orientation),
|
||||
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 ROW_GAP: u16 = 1;
|
||||
|
||||
pub fn sequencer_height(pattern_length: usize, current_step: usize) -> u16 {
|
||||
let page = current_step / STEPS_PER_PAGE;
|
||||
let page_start = page * STEPS_PER_PAGE;
|
||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(pattern_length) - page_start;
|
||||
if steps_on_page == 0 {
|
||||
return 0;
|
||||
pub fn steps_per_page(area_height: u16) -> usize {
|
||||
if area_height < 5 {
|
||||
return 8;
|
||||
}
|
||||
let num_rows = steps_on_page.div_ceil(8);
|
||||
let grid_h = (num_rows as u16) * TILE_HEIGHT + (num_rows.saturating_sub(1) as u16) * ROW_GAP;
|
||||
grid_h + 2
|
||||
let usable = (area_height - 2) as usize;
|
||||
let max_rows = (usable + ROW_GAP as usize) / (TILE_HEIGHT as usize + ROW_GAP as usize);
|
||||
(max_rows * 8).clamp(8, 128)
|
||||
}
|
||||
|
||||
pub fn viz_seq_split(
|
||||
main_area: Rect,
|
||||
layout: MainLayout,
|
||||
has_viz: bool,
|
||||
seq_h: u16,
|
||||
) -> (Rect, Rect) {
|
||||
match layout {
|
||||
MainLayout::Top => {
|
||||
if has_viz {
|
||||
let [viz, seq] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(seq_h),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(main_area);
|
||||
(viz, seq)
|
||||
@@ -124,7 +220,7 @@ pub fn viz_seq_split(
|
||||
MainLayout::Bottom => {
|
||||
if has_viz {
|
||||
let [seq, viz] = Layout::vertical([
|
||||
Constraint::Length(seq_h),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(main_area);
|
||||
@@ -226,9 +322,11 @@ fn render_sequencer(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot,
|
||||
|
||||
let pattern = app.current_edit_pattern();
|
||||
let length = pattern.length;
|
||||
let page = app.editor_ctx.step / STEPS_PER_PAGE;
|
||||
let page_start = page * STEPS_PER_PAGE;
|
||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(length) - page_start;
|
||||
let spp = steps_per_page(area.height);
|
||||
app.editor_ctx.steps_per_page.set(spp);
|
||||
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) {
|
||||
let step_idx = page_start + step_offset;
|
||||
@@ -470,6 +568,34 @@ fn render_script_preview(
|
||||
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) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
@@ -13,31 +13,90 @@ use crate::widgets::{render_scroll_indicators, IndicatorAlign};
|
||||
|
||||
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] {
|
||||
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) {
|
||||
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);
|
||||
// gap is just empty space
|
||||
let _ = gap;
|
||||
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) {
|
||||
let theme = theme::get();
|
||||
let is_focused = matches!(app.patterns_nav.column, PatternsColumn::Banks);
|
||||
|
||||
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 title = Paragraph::new("Banks")
|
||||
.style(Style::new().fg(title_color))
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(title, title_area);
|
||||
let border_color = if is_focused { theme.ui.header } else { theme.ui.border };
|
||||
let title_style = if is_focused {
|
||||
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(" Banks ")
|
||||
.title_style(title_style);
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let banks_with_playback: Vec<usize> = snapshot
|
||||
.active_patterns
|
||||
@@ -96,11 +155,8 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
||||
.bank_selection_range()
|
||||
.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_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_solo = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_solo(idx, p));
|
||||
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, "")
|
||||
};
|
||||
|
||||
let name = app.project_state.project.banks[idx]
|
||||
.name
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let bank_ref = &app.project_state.project.banks[idx];
|
||||
let name = bank_ref.name.as_deref().unwrap_or("");
|
||||
let content_count = bank_ref.content_pattern_count();
|
||||
|
||||
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() {
|
||||
format!("{}{:02}", prefix, idx + 1)
|
||||
let pad = " ".repeat(available_for_name);
|
||||
format!("{idx_part}{pad}{count_part}")
|
||||
} 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);
|
||||
@@ -152,7 +216,6 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
||||
style
|
||||
};
|
||||
|
||||
// Fill the entire row with background color
|
||||
let bg_block = Block::default().style(Style::new().bg(bg));
|
||||
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 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_name = app.project_state.project.banks[bank].name.as_deref();
|
||||
let title_text = match bank_name {
|
||||
Some(name) => format!("Patterns ({name})"),
|
||||
None => format!("Patterns (Bank {:02})", bank + 1),
|
||||
Some(name) => format!(" Patterns ({name}) "),
|
||||
None => format!(" Patterns (Bank {:02}) ", bank + 1),
|
||||
};
|
||||
let title = Paragraph::new(title_text)
|
||||
.style(Style::new().fg(title_color))
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(title, title_area);
|
||||
|
||||
let border_color = if is_focused { theme.ui.header } else { theme.ui.border };
|
||||
let title_style = if is_focused {
|
||||
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
|
||||
.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 max_visible = (inner.height / MIN_ROW_HEIGHT) as usize;
|
||||
let max_visible = max_visible.max(1);
|
||||
let available = inner.height as usize;
|
||||
// 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 {
|
||||
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 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 {
|
||||
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 {
|
||||
break;
|
||||
}
|
||||
@@ -269,7 +338,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
x: inner.x,
|
||||
y,
|
||||
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;
|
||||
@@ -284,16 +353,11 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
.pattern_selection_range()
|
||||
.is_some_and(|r| r.contains(&idx));
|
||||
|
||||
// Current applied mute/solo state
|
||||
let is_muted = app.mute.is_muted(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_solo = app.playback.has_staged_solo(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_soloed = is_soloed ^ has_staged_solo;
|
||||
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 {
|
||||
(theme.selection.in_range_bg, theme.selection.in_range_fg, "")
|
||||
} else if is_playing {
|
||||
// Playing patterns
|
||||
if is_staged_stop {
|
||||
(theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ")
|
||||
} else if has_staged_solo {
|
||||
// Staged solo toggle on playing pattern
|
||||
if preview_soloed {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||
} else {
|
||||
(theme.list.playing_bg, theme.list.playing_fg, "-S")
|
||||
}
|
||||
} else if has_staged_mute {
|
||||
// Staged mute toggle on playing pattern
|
||||
if preview_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||
} else {
|
||||
@@ -332,14 +393,12 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
} else if is_staged_play {
|
||||
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
|
||||
} else if has_staged_solo {
|
||||
// Staged solo on non-playing pattern
|
||||
if preview_soloed {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||
} else {
|
||||
(theme.ui.bg, theme.ui.text_muted, "-S")
|
||||
}
|
||||
} else if has_staged_mute {
|
||||
// Staged mute on non-playing pattern
|
||||
if preview_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||
} 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 bold_style = base_style.add_modifier(Modifier::BOLD);
|
||||
|
||||
// Fill the entire row with background color
|
||||
let bg_block = Block::default().style(Style::new().bg(bg));
|
||||
frame.render_widget(bg_block, row_area);
|
||||
|
||||
let text_y = if row_height > 1 {
|
||||
row_area.y + (row_height - 1) / 2
|
||||
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 {
|
||||
row_area.y
|
||||
let bg_block = Block::default().style(Style::new().bg(bg));
|
||||
frame.render_widget(bg_block, row_area);
|
||||
row_area
|
||||
};
|
||||
|
||||
let text_area = Rect {
|
||||
x: row_area.x,
|
||||
y: text_y,
|
||||
width: row_area.width,
|
||||
x: content_area.x,
|
||||
y: content_area.y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
};
|
||||
|
||||
// Build the line: [prefix][idx] [name] ... [length] [speed]
|
||||
let name_style = if is_playing || is_staged_play {
|
||||
bold_style
|
||||
} else {
|
||||
@@ -395,22 +459,73 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
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 {
|
||||
format!(" {}", speed.label())
|
||||
format!(" {}", speed.label())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
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 right_width = right_info.chars().count();
|
||||
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));
|
||||
|
||||
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);
|
||||
|
||||
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(
|
||||
@@ -423,3 +538,219 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
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