Feat: WIP pattern view redesign
This commit is contained in:
@@ -425,6 +425,14 @@ impl Pattern {
|
||||
let source_idx = self.resolve_source(index);
|
||||
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)]
|
||||
@@ -434,6 +442,15 @@ pub struct Bank {
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -27,8 +27,6 @@ use crate::state::{
|
||||
ProjectState, UiState,
|
||||
};
|
||||
|
||||
const STEPS_PER_PAGE: usize = 32;
|
||||
|
||||
static COMPLETION_CANDIDATES: LazyLock<Vec<CompletionCandidate>> = LazyLock::new(|| {
|
||||
model::WORDS
|
||||
.iter()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{App, STEPS_PER_PAGE};
|
||||
use super::App;
|
||||
|
||||
impl App {
|
||||
pub fn next_step(&mut self) {
|
||||
@@ -15,8 +15,9 @@ impl App {
|
||||
|
||||
pub fn step_up(&mut self) {
|
||||
let len = self.current_edit_pattern().length;
|
||||
let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
|
||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start;
|
||||
let spp = self.editor_ctx.steps_per_page.get();
|
||||
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 steps_per_row = steps_on_page.div_ceil(num_rows);
|
||||
|
||||
@@ -30,8 +31,9 @@ impl App {
|
||||
|
||||
pub fn step_down(&mut self) {
|
||||
let len = self.current_edit_pattern().length;
|
||||
let page_start = (self.editor_ctx.step / STEPS_PER_PAGE) * STEPS_PER_PAGE;
|
||||
let steps_on_page = (page_start + STEPS_PER_PAGE).min(len) - page_start;
|
||||
let spp = self.editor_ctx.steps_per_page.get();
|
||||
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 steps_per_row = steps_on_page.div_ceil(num_rows);
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ pub struct ActivePatternState {
|
||||
pub pattern: usize,
|
||||
pub step_index: usize,
|
||||
pub iter: usize,
|
||||
pub last_step_beat: f64,
|
||||
}
|
||||
|
||||
pub type StepTracesMap = HashMap<(usize, usize, usize), ExecutionTrace>;
|
||||
@@ -208,6 +209,19 @@ impl SequencerSnapshot {
|
||||
.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> {
|
||||
self.active_patterns
|
||||
.iter()
|
||||
@@ -260,6 +274,7 @@ struct ActivePattern {
|
||||
pattern: usize,
|
||||
step_index: usize,
|
||||
iter: usize,
|
||||
last_step_beat: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -821,6 +836,7 @@ impl SequencerState {
|
||||
pattern: pending.id.pattern,
|
||||
step_index: start_step,
|
||||
iter: 0,
|
||||
last_step_beat: beat,
|
||||
},
|
||||
);
|
||||
self.buf_activated.push(pending.id);
|
||||
@@ -902,6 +918,7 @@ impl SequencerState {
|
||||
|
||||
for step_beat in step_beats {
|
||||
result.any_step_fired = true;
|
||||
active.last_step_beat = step_beat;
|
||||
let step_idx = active.step_index % pattern.length;
|
||||
|
||||
let beat_delta = step_beat - beat;
|
||||
@@ -1099,6 +1116,7 @@ impl SequencerState {
|
||||
pattern: a.pattern,
|
||||
step_index: a.step_index,
|
||||
iter: a.iter,
|
||||
last_step_beat: a.last_step_beat,
|
||||
})
|
||||
.collect(),
|
||||
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;
|
||||
|
||||
const STEPS_PER_PAGE: usize = 32;
|
||||
|
||||
pub fn handle_mouse(ctx: &mut InputContext, mouse: MouseEvent, term: Rect) {
|
||||
let kind = mouse.kind;
|
||||
let col = mouse.column;
|
||||
@@ -156,7 +154,7 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
|
||||
}
|
||||
}
|
||||
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 up {
|
||||
@@ -304,13 +302,7 @@ fn handle_main_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pattern = ctx.app.current_edit_pattern();
|
||||
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);
|
||||
let sequencer_area = main_view::sequencer_rect(ctx.app, main_area);
|
||||
|
||||
if !contains(sequencer_area, col, row) {
|
||||
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> {
|
||||
let pattern = ctx.app.current_edit_pattern();
|
||||
let length = pattern.length;
|
||||
let page = ctx.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 = ctx.app.editor_ctx.steps_per_page.get();
|
||||
let page = ctx.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 main_view::grid_layout(area, steps_on_page) {
|
||||
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 ---
|
||||
|
||||
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 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,
|
||||
) -> Option<usize> {
|
||||
use crate::model::{MAX_BANKS, MAX_PATTERNS};
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
|
||||
let [_title, inner] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area);
|
||||
let inner = Block::default().borders(Borders::ALL).inner(area);
|
||||
|
||||
let max_items = if is_banks { MAX_BANKS } else { MAX_PATTERNS };
|
||||
let cursor = if is_banks {
|
||||
@@ -377,7 +370,14 @@ fn hit_test_patterns_list(
|
||||
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 {
|
||||
0
|
||||
} else {
|
||||
@@ -387,21 +387,36 @@ fn hit_test_patterns_list(
|
||||
};
|
||||
|
||||
let visible_count = max_items.min(max_visible);
|
||||
let row_height = (inner.height / visible_count as u16).max(1);
|
||||
|
||||
if row < inner.y {
|
||||
return None;
|
||||
}
|
||||
|
||||
if is_banks {
|
||||
let row_height = (inner.height / visible_count as u16).max(1);
|
||||
let relative_y = row - inner.y;
|
||||
let visible_idx = (relative_y / row_height) as usize;
|
||||
|
||||
if visible_idx < visible_count {
|
||||
let idx = scroll_offset + visible_idx;
|
||||
if idx < max_items {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn for_page(page: Page) -> &'static [(&'static str, &'static [(&'static str,
|
||||
],
|
||||
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"),
|
||||
("Enter", "open in sequencer"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use cagire_ratatui::Editor;
|
||||
@@ -84,6 +84,7 @@ pub struct EditorContext {
|
||||
pub show_stack: bool,
|
||||
pub stack_cache: RefCell<Option<StackCache>>,
|
||||
pub target: EditorTarget,
|
||||
pub steps_per_page: Cell<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -134,6 +135,7 @@ impl Default for EditorContext {
|
||||
show_stack: false,
|
||||
stack_cache: RefCell::new(None),
|
||||
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) {
|
||||
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
|
||||
|| 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);
|
||||
|
||||
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_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),
|
||||
};
|
||||
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 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));
|
||||
frame.render_widget(bg_block, row_area);
|
||||
|
||||
let text_y = if row_height > 1 {
|
||||
row_area.y + (row_height - 1) / 2
|
||||
} else {
|
||||
row_area.y
|
||||
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())
|
||||
} 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