This commit is contained in:
2026-02-10 23:51:17 +01:00
parent 7d670dacb9
commit 8ba89f91a0
12 changed files with 2312 additions and 2280 deletions

View File

@@ -26,24 +26,26 @@ use super::{
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
};
fn clip_span(span: SourceSpan, line_start: usize, line_len: usize) -> Option<SourceSpan> {
let ls = line_start as u32;
let ll = line_len as u32;
if span.end <= ls || span.start >= ls + ll {
return None;
}
Some(SourceSpan {
start: span.start.max(ls) - ls,
end: span.end.min(ls + ll) - ls,
})
}
fn adjust_spans_for_line(
spans: &[SourceSpan],
line_start: usize,
line_len: usize,
) -> Vec<SourceSpan> {
let ls = line_start as u32;
let ll = line_len as u32;
spans
.iter()
.filter_map(|s| {
if s.end <= ls || s.start >= ls + ll {
return None;
}
Some(SourceSpan {
start: s.start.max(ls) - ls,
end: (s.end.min(ls + ll)) - ls,
})
})
.filter_map(|s| clip_span(*s, line_start, line_len))
.collect()
}
@@ -52,22 +54,9 @@ fn adjust_resolved_for_line(
line_start: usize,
line_len: usize,
) -> Vec<(SourceSpan, String)> {
let ls = line_start as u32;
let ll = line_len as u32;
resolved
.iter()
.filter_map(|(s, display)| {
if s.end <= ls || s.start >= ls + ll {
return None;
}
Some((
SourceSpan {
start: s.start.max(ls) - ls,
end: (s.end.min(ls + ll)) - ls,
},
display.clone(),
))
})
.filter_map(|(s, display)| clip_span(*s, line_start, line_len).map(|cs| (cs, display.clone())))
.collect()
}
@@ -564,252 +553,8 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
.height(18)
.render_centered(frame, term)
}
Modal::Preview => {
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 80 / 100).max(10);
let pattern = app.current_edit_pattern();
let step_idx = app.editor_ctx.step;
let step = pattern.step(step_idx);
let source_idx = step.and_then(|s| s.source);
let step_name = step.and_then(|s| s.name.as_ref());
let title = match (source_idx, step_name) {
(Some(src), Some(name)) => {
format!("Step {:02}: {}{:02}", step_idx + 1, name, src + 1)
}
(None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name),
(Some(src), None) => format!("Step {:02}{:02}", step_idx + 1, src + 1),
(None, None) => format!("Step {:02}", step_idx + 1),
};
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(theme.modal.preview)
.render_centered(frame, term);
let script = pattern.resolve_script(step_idx).unwrap_or("");
if script.is_empty() {
let empty = Paragraph::new("(empty)")
.alignment(Alignment::Center)
.style(Style::new().fg(theme.ui.text_dim));
let centered_area = Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
};
frame.render_widget(empty, centered_area);
} else {
let trace = if app.ui.runtime_highlight && app.playback.playing {
let source = pattern.resolve_source(step_idx);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let resolved_display: Vec<(SourceSpan, String)> = trace
.map(|t| {
t.resolved
.iter()
.map(|(s, v)| (*s, v.display()))
.collect()
})
.unwrap_or_default();
let mut line_start = 0usize;
let lines: Vec<Line> = script
.lines()
.map(|line_str| {
let tokens = if let Some(t) = trace {
let exec = adjust_spans_for_line(
&t.executed_spans,
line_start,
line_str.len(),
);
let sel = adjust_spans_for_line(
&t.selected_spans,
line_start,
line_str.len(),
);
let res = adjust_resolved_for_line(
&resolved_display,
line_start,
line_str.len(),
);
highlight_line_with_runtime(line_str, &exec, &sel, &res, &user_words)
} else {
highlight_line_with_runtime(line_str, &[], &[], &[], &user_words)
};
line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text, _)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
inner
}
Modal::Editor => {
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let flash_kind = app.ui.flash_kind();
let border_color = match flash_kind {
Some(FlashKind::Error) => theme.flash.error_fg,
Some(FlashKind::Info) => theme.ui.text_primary,
Some(FlashKind::Success) => theme.flash.success_fg,
None => theme.modal.editor,
};
let title = match app.editor_ctx.target {
EditorTarget::Prelude => "Prelude".to_string(),
EditorTarget::Step => {
let step_num = app.editor_ctx.step + 1;
let step = app.current_edit_pattern().step(app.editor_ctx.step);
if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
format!("Step {step_num:02}: {name}")
} else {
format!("Step {step_num:02} Script")
}
}
};
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(border_color)
.render_centered(frame, term);
let trace = if app.ui.runtime_highlight
&& app.playback.playing
&& app.editor_ctx.target == EditorTarget::Step
{
let source = app
.current_edit_pattern()
.resolve_source(app.editor_ctx.step);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let text_lines = app.editor_ctx.editor.lines();
let mut line_offsets: Vec<usize> = Vec::with_capacity(text_lines.len());
let mut offset = 0;
for line in text_lines.iter() {
line_offsets.push(offset);
offset += line.len() + 1;
}
let resolved_display: Vec<(SourceSpan, String)> = trace
.map(|t| {
t.resolved
.iter()
.map(|(s, v)| (*s, v.display()))
.collect()
})
.unwrap_or_default();
let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> {
let line_start = line_offsets[row];
let (exec, sel, res) = match trace {
Some(t) => (
adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
adjust_resolved_for_line(&resolved_display, line_start, line.len()),
),
None => (Vec::new(), Vec::new(), Vec::new()),
};
highlight::highlight_line_with_runtime(line, &exec, &sel, &res, &user_words)
};
let show_search = app.editor_ctx.editor.search_active()
|| !app.editor_ctx.editor.search_query().is_empty();
let reserved_lines = 1 + if show_search { 1 } else { 0 };
let editor_height = inner.height.saturating_sub(reserved_lines);
let mut y = inner.y;
let search_area = if show_search {
let area = Rect::new(inner.x, y, inner.width, 1);
y += 1;
Some(area)
} else {
None
};
let editor_area = Rect::new(inner.x, y, inner.width, editor_height);
y += editor_height;
let hint_area = Rect::new(inner.x, y, inner.width, 1);
if let Some(sa) = search_area {
render_search_bar(
frame,
sa,
app.editor_ctx.editor.search_query(),
app.editor_ctx.editor.search_active(),
);
}
if let Some(kind) = flash_kind {
let bg = match kind {
FlashKind::Error => theme.flash.error_bg,
FlashKind::Info => theme.flash.info_bg,
FlashKind::Success => theme.flash.success_bg,
};
let flash_block = Block::default().style(Style::default().bg(bg));
frame.render_widget(flash_block, editor_area);
}
app.editor_ctx
.editor
.render(frame, editor_area, &highlighter);
if app.editor_ctx.editor.search_active() {
let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]);
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
} else if app.editor_ctx.show_stack {
let stack_text = app
.editor_ctx
.stack_cache
.borrow()
.as_ref()
.map(|c| c.result.clone())
.unwrap_or_else(|| "Stack: []".to_string());
let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]);
let [hint_left, stack_right] = Layout::horizontal([
Constraint::Length(hints.width() as u16),
Constraint::Fill(1),
])
.areas(hint_area);
frame.render_widget(Paragraph::new(hints), hint_left);
let dim = Style::default().fg(theme.hint.text);
frame.render_widget(
Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
stack_right,
);
} else {
let hints = hint_line(&[
("Esc", "save"),
("C-e", "eval"),
("C-f", "find"),
("C-b", "samples"),
("C-s", "stack"),
("C-u", "/"),
("C-r", "undo/redo"),
]);
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
}
inner
}
Modal::Preview => render_modal_preview(frame, app, snapshot, &user_words, term),
Modal::Editor => render_modal_editor(frame, app, snapshot, &user_words, term),
Modal::PatternProps {
bank,
pattern,
@@ -846,72 +591,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
inner
}
Modal::KeybindingsHelp { scroll } => {
let width = (term.width * 80 / 100).clamp(60, 100);
let height = (term.height * 80 / 100).max(15);
let title = format!("Keybindings — {}", app.page.name());
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(theme.modal.editor)
.render_centered(frame, term);
let bindings = super::keybindings::bindings_for(app.page);
let visible_rows = inner.height.saturating_sub(2) as usize;
let rows: Vec<Row> = bindings
.iter()
.enumerate()
.skip(*scroll)
.take(visible_rows)
.map(|(i, (key, name, desc))| {
let bg = if i % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
};
Row::new(vec![
Cell::from(*key).style(Style::default().fg(theme.modal.confirm)),
Cell::from(*name).style(Style::default().fg(theme.modal.input)),
Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)),
])
.style(Style::default().bg(bg))
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(14),
Constraint::Length(12),
Constraint::Fill(1),
],
)
.column_spacing(2);
let table_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1),
};
frame.render_widget(table, table_area);
let hint_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]);
frame.render_widget(
Paragraph::new(hints).alignment(Alignment::Right),
hint_area,
);
inner
}
Modal::KeybindingsHelp { scroll } => render_modal_keybindings(frame, app, *scroll, term),
Modal::EuclideanDistribution {
source_step,
field,
@@ -969,6 +649,336 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
))
}
fn render_modal_preview(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
user_words: &HashSet<String>,
term: Rect,
) -> Rect {
let theme = theme::get();
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 80 / 100).max(10);
let pattern = app.current_edit_pattern();
let step_idx = app.editor_ctx.step;
let step = pattern.step(step_idx);
let source_idx = step.and_then(|s| s.source);
let step_name = step.and_then(|s| s.name.as_ref());
let title = match (source_idx, step_name) {
(Some(src), Some(name)) => {
format!("Step {:02}: {}{:02}", step_idx + 1, name, src + 1)
}
(None, Some(name)) => format!("Step {:02}: {}", step_idx + 1, name),
(Some(src), None) => format!("Step {:02}{:02}", step_idx + 1, src + 1),
(None, None) => format!("Step {:02}", step_idx + 1),
};
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(theme.modal.preview)
.render_centered(frame, term);
let script = pattern.resolve_script(step_idx).unwrap_or("");
if script.is_empty() {
let empty = Paragraph::new("(empty)")
.alignment(Alignment::Center)
.style(Style::new().fg(theme.ui.text_dim));
let centered_area = Rect {
y: inner.y + inner.height / 2,
height: 1,
..inner
};
frame.render_widget(empty, centered_area);
} else {
let trace = if app.ui.runtime_highlight && app.playback.playing {
let source = pattern.resolve_source(step_idx);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let resolved_display: Vec<(SourceSpan, String)> = trace
.map(|t| {
t.resolved
.iter()
.map(|(s, v)| (*s, v.display()))
.collect()
})
.unwrap_or_default();
let mut line_start = 0usize;
let lines: Vec<Line> = script
.lines()
.map(|line_str| {
let tokens = if let Some(t) = trace {
let exec = adjust_spans_for_line(
&t.executed_spans,
line_start,
line_str.len(),
);
let sel = adjust_spans_for_line(
&t.selected_spans,
line_start,
line_str.len(),
);
let res = adjust_resolved_for_line(
&resolved_display,
line_start,
line_str.len(),
);
highlight_line_with_runtime(line_str, &exec, &sel, &res, user_words)
} else {
highlight_line_with_runtime(line_str, &[], &[], &[], user_words)
};
line_start += line_str.len() + 1;
let spans: Vec<Span> = tokens
.into_iter()
.map(|(style, text, _)| Span::styled(text, style))
.collect();
Line::from(spans)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
inner
}
fn render_modal_editor(
frame: &mut Frame,
app: &App,
snapshot: &SequencerSnapshot,
user_words: &HashSet<String>,
term: Rect,
) -> Rect {
let theme = theme::get();
let width = (term.width * 80 / 100).max(40);
let height = (term.height * 60 / 100).max(10);
let flash_kind = app.ui.flash_kind();
let border_color = match flash_kind {
Some(FlashKind::Error) => theme.flash.error_fg,
Some(FlashKind::Info) => theme.ui.text_primary,
Some(FlashKind::Success) => theme.flash.success_fg,
None => theme.modal.editor,
};
let title = match app.editor_ctx.target {
EditorTarget::Prelude => "Prelude".to_string(),
EditorTarget::Step => {
let step_num = app.editor_ctx.step + 1;
let step = app.current_edit_pattern().step(app.editor_ctx.step);
if let Some(ref name) = step.and_then(|s| s.name.as_ref()) {
format!("Step {step_num:02}: {name}")
} else {
format!("Step {step_num:02} Script")
}
}
};
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(border_color)
.render_centered(frame, term);
let trace = if app.ui.runtime_highlight
&& app.playback.playing
&& app.editor_ctx.target == EditorTarget::Step
{
let source = app
.current_edit_pattern()
.resolve_source(app.editor_ctx.step);
snapshot.get_trace(app.editor_ctx.bank, app.editor_ctx.pattern, source)
} else {
None
};
let text_lines = app.editor_ctx.editor.lines();
let mut line_offsets: Vec<usize> = Vec::with_capacity(text_lines.len());
let mut offset = 0;
for line in text_lines.iter() {
line_offsets.push(offset);
offset += line.len() + 1;
}
let resolved_display: Vec<(SourceSpan, String)> = trace
.map(|t| {
t.resolved
.iter()
.map(|(s, v)| (*s, v.display()))
.collect()
})
.unwrap_or_default();
let highlighter = |row: usize, line: &str| -> Vec<(Style, String, bool)> {
let line_start = line_offsets[row];
let (exec, sel, res) = match trace {
Some(t) => (
adjust_spans_for_line(&t.executed_spans, line_start, line.len()),
adjust_spans_for_line(&t.selected_spans, line_start, line.len()),
adjust_resolved_for_line(&resolved_display, line_start, line.len()),
),
None => (Vec::new(), Vec::new(), Vec::new()),
};
highlight::highlight_line_with_runtime(line, &exec, &sel, &res, user_words)
};
let show_search = app.editor_ctx.editor.search_active()
|| !app.editor_ctx.editor.search_query().is_empty();
let reserved_lines = 1 + if show_search { 1 } else { 0 };
let editor_height = inner.height.saturating_sub(reserved_lines);
let mut y = inner.y;
let search_area = if show_search {
let area = Rect::new(inner.x, y, inner.width, 1);
y += 1;
Some(area)
} else {
None
};
let editor_area = Rect::new(inner.x, y, inner.width, editor_height);
y += editor_height;
let hint_area = Rect::new(inner.x, y, inner.width, 1);
if let Some(sa) = search_area {
render_search_bar(
frame,
sa,
app.editor_ctx.editor.search_query(),
app.editor_ctx.editor.search_active(),
);
}
if let Some(kind) = flash_kind {
let bg = match kind {
FlashKind::Error => theme.flash.error_bg,
FlashKind::Info => theme.flash.info_bg,
FlashKind::Success => theme.flash.success_bg,
};
let flash_block = Block::default().style(Style::default().bg(bg));
frame.render_widget(flash_block, editor_area);
}
app.editor_ctx
.editor
.render(frame, editor_area, &highlighter);
if app.editor_ctx.editor.search_active() {
let hints = hint_line(&[("Enter", "confirm"), ("Esc", "cancel")]);
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
} else if app.editor_ctx.show_stack {
let stack_text = app
.editor_ctx
.stack_cache
.borrow()
.as_ref()
.map(|c| c.result.clone())
.unwrap_or_else(|| "Stack: []".to_string());
let hints = hint_line(&[("Esc", "save"), ("C-e", "eval"), ("C-s", "hide")]);
let [hint_left, stack_right] = Layout::horizontal([
Constraint::Length(hints.width() as u16),
Constraint::Fill(1),
])
.areas(hint_area);
frame.render_widget(Paragraph::new(hints), hint_left);
let dim = Style::default().fg(theme.hint.text);
frame.render_widget(
Paragraph::new(Span::styled(stack_text, dim)).alignment(Alignment::Right),
stack_right,
);
} else {
let hints = hint_line(&[
("Esc", "save"),
("C-e", "eval"),
("C-f", "find"),
("C-b", "samples"),
("C-s", "stack"),
("C-u", "/"),
("C-r", "undo/redo"),
]);
frame.render_widget(Paragraph::new(hints).alignment(Alignment::Right), hint_area);
}
inner
}
fn render_modal_keybindings(frame: &mut Frame, app: &App, scroll: usize, term: Rect) -> Rect {
let theme = theme::get();
let width = (term.width * 80 / 100).clamp(60, 100);
let height = (term.height * 80 / 100).max(15);
let title = format!("Keybindings — {}", app.page.name());
let inner = ModalFrame::new(&title)
.width(width)
.height(height)
.border_color(theme.modal.editor)
.render_centered(frame, term);
let bindings = super::keybindings::bindings_for(app.page);
let visible_rows = inner.height.saturating_sub(2) as usize;
let rows: Vec<Row> = bindings
.iter()
.enumerate()
.skip(scroll)
.take(visible_rows)
.map(|(i, (key, name, desc))| {
let bg = if i % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
};
Row::new(vec![
Cell::from(*key).style(Style::default().fg(theme.modal.confirm)),
Cell::from(*name).style(Style::default().fg(theme.modal.input)),
Cell::from(*desc).style(Style::default().fg(theme.ui.text_primary)),
])
.style(Style::default().bg(bg))
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(14),
Constraint::Length(12),
Constraint::Fill(1),
],
)
.column_spacing(2);
let table_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1),
};
frame.render_widget(table, table_area);
let hint_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
let hints = hint_line(&[("↑↓", "scroll"), ("PgUp/Dn", "page"), ("Esc/?", "close")]);
frame.render_widget(
Paragraph::new(hints).alignment(Alignment::Right),
hint_area,
);
inner
}
fn format_euclidean_preview(pulses: usize, steps: usize, rotation: usize) -> String {
if pulses == 0 || steps == 0 || pulses > steps {
return "[invalid]".to_string();