Write some amount of documentation

This commit is contained in:
2026-01-31 01:46:18 +01:00
parent e1c4987db5
commit 8cd0ec92c0
57 changed files with 2096 additions and 198 deletions

View File

@@ -281,6 +281,45 @@ impl App {
self.project_state.mark_dirty(change.bank, change.pattern);
}
pub fn execute_script_oneshot(
&self,
script: &str,
link: &LinkState,
audio_tx: &arc_swap::ArcSwap<Sender<crate::engine::AudioCommand>>,
) -> Result<(), String> {
let (bank, pattern) = self.current_bank_pattern();
let step_idx = self.editor_ctx.step;
let speed = self
.project_state
.project
.pattern_at(bank, pattern)
.speed
.multiplier();
let ctx = StepContext {
step: step_idx,
beat: link.beat(),
bank,
pattern,
tempo: link.tempo(),
phase: link.phase(),
slot: 0,
runs: 0,
iter: 0,
speed,
fill: false,
nudge_secs: 0.0,
};
let cmds = self.script_engine.evaluate(script, &ctx)?;
for cmd in cmds {
let _ = audio_tx
.load()
.send(crate::engine::AudioCommand::Evaluate { cmd, time: None });
}
Ok(())
}
pub fn compile_current_step(&mut self, link: &LinkState) {
let step_idx = self.editor_ctx.step;
let (bank, pattern) = self.current_bank_pattern();
@@ -1117,12 +1156,20 @@ impl App {
AppCommand::PageDown => self.page.down(),
// Help navigation
AppCommand::HelpNextTopic => {
self.ui.help_topic = (self.ui.help_topic + 1) % help_view::topic_count();
AppCommand::HelpToggleFocus => {
use crate::state::HelpFocus;
self.ui.help_focus = match self.ui.help_focus {
HelpFocus::Topics => HelpFocus::Content,
HelpFocus::Content => HelpFocus::Topics,
};
}
AppCommand::HelpPrevTopic => {
AppCommand::HelpNextTopic(n) => {
let count = help_view::topic_count();
self.ui.help_topic = (self.ui.help_topic + count - 1) % count;
self.ui.help_topic = (self.ui.help_topic + n) % count;
}
AppCommand::HelpPrevTopic(n) => {
let count = help_view::topic_count();
self.ui.help_topic = (self.ui.help_topic + count - (n % count)) % count;
}
AppCommand::HelpScrollDown(n) => {
let s = self.ui.help_scroll_mut();

View File

@@ -138,8 +138,9 @@ pub enum AppCommand {
PageDown,
// Help navigation
HelpNextTopic,
HelpPrevTopic,
HelpToggleFocus,
HelpNextTopic(usize),
HelpPrevTopic(usize),
HelpScrollDown(usize),
HelpScrollUp(usize),
HelpActivateSearch,

View File

@@ -508,6 +508,13 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
KeyCode::Char('s') if ctrl => {
ctx.app.editor_ctx.show_stack = !ctx.app.editor_ctx.show_stack;
}
KeyCode::Char('r') if ctrl => {
let script = ctx.app.editor_ctx.editor.lines().join("\n");
match ctx.app.execute_script_oneshot(&script, ctx.link, ctx.audio_tx) {
Ok(()) => ctx.app.ui.flash("Executed", 100, crate::state::FlashKind::Info),
Err(e) => ctx.app.ui.flash(&format!("Error: {e}"), 200, crate::state::FlashKind::Error),
}
}
KeyCode::Char('a') if ctrl => {
editor.select_all();
}
@@ -899,6 +906,17 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
}));
}
}
KeyCode::Char('r') if ctrl => {
let pattern = ctx.app.current_edit_pattern();
if let Some(script) = pattern.resolve_script(ctx.app.editor_ctx.step) {
if !script.trim().is_empty() {
match ctx.app.execute_script_oneshot(script, ctx.link, ctx.audio_tx) {
Ok(()) => ctx.app.ui.flash("Executed", 100, crate::state::FlashKind::Info),
Err(e) => ctx.app.ui.flash(&format!("Error: {e}"), 200, crate::state::FlashKind::Error),
}
}
}
}
KeyCode::Char('r') => {
let (bank, pattern, step) = (
ctx.app.editor_ctx.bank,
@@ -1269,26 +1287,43 @@ fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
}
fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
use crate::state::HelpFocus;
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctx.app.ui.help_search_active {
match key.code {
KeyCode::Esc => ctx.dispatch(AppCommand::HelpClearSearch),
KeyCode::Enter => ctx.dispatch(AppCommand::HelpSearchConfirm),
KeyCode::Backspace => ctx.dispatch(AppCommand::HelpSearchBackspace),
KeyCode::Char(c) => ctx.dispatch(AppCommand::HelpSearchInput(c)),
KeyCode::Char(c) if !ctrl => ctx.dispatch(AppCommand::HelpSearchInput(c)),
_ => {}
}
return InputResult::Continue;
}
match key.code {
KeyCode::Char('/') => ctx.dispatch(AppCommand::HelpActivateSearch),
KeyCode::Char('/') | KeyCode::Char('f') if key.code == KeyCode::Char('/') || ctrl => {
ctx.dispatch(AppCommand::HelpActivateSearch);
}
KeyCode::Esc if !ctx.app.ui.help_search_query.is_empty() => {
ctx.dispatch(AppCommand::HelpClearSearch);
}
KeyCode::Char('j') | KeyCode::Down => ctx.dispatch(AppCommand::HelpScrollDown(1)),
KeyCode::Char('k') | KeyCode::Up => ctx.dispatch(AppCommand::HelpScrollUp(1)),
KeyCode::Tab => ctx.dispatch(AppCommand::HelpNextTopic),
KeyCode::BackTab => ctx.dispatch(AppCommand::HelpPrevTopic),
KeyCode::Tab => ctx.dispatch(AppCommand::HelpToggleFocus),
KeyCode::Char('j') | KeyCode::Down if ctrl => {
ctx.dispatch(AppCommand::HelpNextTopic(5));
}
KeyCode::Char('k') | KeyCode::Up if ctrl => {
ctx.dispatch(AppCommand::HelpPrevTopic(5));
}
KeyCode::Char('j') | KeyCode::Down => match ctx.app.ui.help_focus {
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpNextTopic(1)),
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollDown(1)),
},
KeyCode::Char('k') | KeyCode::Up => match ctx.app.ui.help_focus {
HelpFocus::Topics => ctx.dispatch(AppCommand::HelpPrevTopic(1)),
HelpFocus::Content => ctx.dispatch(AppCommand::HelpScrollUp(1)),
},
KeyCode::PageDown => ctx.dispatch(AppCommand::HelpScrollDown(10)),
KeyCode::PageUp => ctx.dispatch(AppCommand::HelpScrollUp(10)),
KeyCode::Char('q') => {

View File

@@ -26,16 +26,16 @@ impl Page {
/// Grid position (col, row) for each page
/// Layout:
/// col 0 col 1 col 2
/// row 0 Options Patterns Help
/// row 1 Dict Sequencer Engine
/// row 0 Dict Patterns Options
/// row 1 Help Sequencer Engine
pub const fn grid_pos(self) -> (i8, i8) {
match self {
Page::Options => (0, 0),
Page::Dict => (0, 1),
Page::Main => (1, 1),
Page::Dict => (0, 0),
Page::Help => (0, 1),
Page::Patterns => (1, 0),
Page::Main => (1, 1),
Page::Options => (2, 0),
Page::Engine => (2, 1),
Page::Help => (2, 0),
}
}

View File

@@ -23,4 +23,4 @@ pub use patterns_nav::{PatternsColumn, PatternsNav};
pub use playback::{PlaybackState, StagedChange};
pub use project::ProjectState;
pub use sample_browser::SampleBrowserState;
pub use ui::{DictFocus, FlashKind, UiState};
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};

View File

@@ -19,12 +19,20 @@ pub enum DictFocus {
Words,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum HelpFocus {
#[default]
Topics,
Content,
}
pub struct UiState {
pub sparkles: Sparkles,
pub status_message: Option<String>,
pub flash_until: Option<Instant>,
pub flash_kind: FlashKind,
pub modal: Modal,
pub help_focus: HelpFocus,
pub help_topic: usize,
pub help_scrolls: Vec<usize>,
pub help_search_active: bool,
@@ -52,6 +60,7 @@ impl Default for UiState {
flash_until: None,
flash_kind: FlashKind::Success,
modal: Modal::None,
help_focus: HelpFocus::default(),
help_topic: 0,
help_scrolls: vec![0; crate::views::help_view::topic_count()],
help_search_active: false,

View File

@@ -1,4 +1,4 @@
use minimad::{Composite, CompositeStyle, Compound, Line};
use minimad::{Composite, CompositeStyle, Compound, Line, TableRow};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line as RLine, Span};
@@ -7,25 +7,121 @@ use ratatui::Frame;
use tui_big_text::{BigText, PixelSize};
use crate::app::App;
use crate::state::HelpFocus;
use crate::theme;
use crate::views::highlight;
// To add a new help topic: drop a .md file in docs/ and add one line here.
const DOCS: &[(&str, &str)] = &[
("Welcome", include_str!("../../docs/welcome.md")),
("Audio Engine", include_str!("../../docs/audio_engine.md")),
("Keybindings", include_str!("../../docs/keybindings.md")),
("Sequencer", include_str!("../../docs/sequencer.md")),
("About", include_str!("../../docs/about.md")),
enum DocEntry {
Section(&'static str),
Topic(&'static str, &'static str),
}
use DocEntry::{Section, Topic};
const DOCS: &[DocEntry] = &[
// Getting Started
Section("Getting Started"),
Topic("Welcome", include_str!("../../docs/welcome.md")),
Topic("Moving Around", include_str!("../../docs/navigation.md")),
Topic(
"How Does It Work?",
include_str!("../../docs/how_it_works.md"),
),
Topic(
"Banks & Patterns",
include_str!("../../docs/banks_patterns.md"),
),
Topic("Stage / Commit", include_str!("../../docs/staging.md")),
Topic("Using the Sequencer", include_str!("../../docs/grid.md")),
// Forth fundamentals
Section("Forth"),
Topic("The Dictionary", include_str!("../../docs/dictionary.md")),
Topic("The Stack", include_str!("../../docs/stack.md")),
Topic("Arithmetic", include_str!("../../docs/arithmetic.md")),
Topic("Comparison", include_str!("../../docs/comparison.md")),
Topic("Logic", include_str!("../../docs/logic.md")),
// Sound generation
Section("Sounds"),
Topic("Emitting", include_str!("../../docs/emitting.md")),
Topic("Samples", include_str!("../../docs/samples.md")),
Topic("Oscillators", include_str!("../../docs/oscillators.md")),
Topic("Wavetables", include_str!("../../docs/wavetables.md")),
// Sound shaping
Section("Shaping"),
Topic("Envelopes", include_str!("../../docs/envelopes.md")),
Topic(
"Pitch Envelope",
include_str!("../../docs/pitch_envelope.md"),
),
Topic("Filters", include_str!("../../docs/filters.md")),
Topic(
"Ladder Filters",
include_str!("../../docs/ladder_filters.md"),
),
// Movement and modulation
Section("Movement"),
Topic("LFO & Ramps", include_str!("../../docs/lfo.md")),
Topic("Modulation", include_str!("../../docs/modulation.md")),
Topic("Vibrato", include_str!("../../docs/vibrato.md")),
// Effects
Section("Effects"),
Topic("Delay & Reverb", include_str!("../../docs/delay_reverb.md")),
Topic("Mod FX", include_str!("../../docs/mod_fx.md")),
Topic("EQ & Stereo", include_str!("../../docs/eq_stereo.md")),
Topic("Lo-fi", include_str!("../../docs/lofi.md")),
// Variation and randomness
Section("Variation"),
Topic("Randomness", include_str!("../../docs/randomness.md")),
Topic("Probability", include_str!("../../docs/probability.md")),
Topic("Selection", include_str!("../../docs/selection.md")),
// Timing
Section("Timing"),
Topic("Context", include_str!("../../docs/context.md")),
Topic("Cycles", include_str!("../../docs/cycles.md")),
Topic("Timing", include_str!("../../docs/timing.md")),
Topic("Patterns", include_str!("../../docs/patterns.md")),
Topic("Chaining", include_str!("../../docs/chaining.md")),
// Music theory
Section("Music"),
Topic("Notes", include_str!("../../docs/notes.md")),
Topic("Scales", include_str!("../../docs/scales.md")),
Topic("Chords", include_str!("../../docs/chords.md")),
Topic("Generators", include_str!("../../docs/generators.md")),
// Advanced
Section("Advanced"),
Topic("Variables", include_str!("../../docs/variables.md")),
Topic("Conditionals", include_str!("../../docs/conditionals.md")),
Topic("Custom Words", include_str!("../../docs/definitions.md")),
Topic("Ableton Link", include_str!("../../docs/link.md")),
// Reference
Section("Reference"),
Topic("Audio Engine", include_str!("../../docs/audio_engine.md")),
Topic("Keybindings", include_str!("../../docs/keybindings.md")),
Topic("Sequencer", include_str!("../../docs/sequencer.md")),
// Archive - old files to sort
Section("Archive"),
Topic("Sound Basics", include_str!("../../docs/sound_basics.md")),
Topic("Parameters", include_str!("../../docs/parameters.md")),
Topic("Tempo & Speed", include_str!("../../docs/tempo.md")),
Topic("Effects (old)", include_str!("../../docs/effects.md")),
];
pub fn topic_count() -> usize {
DOCS.len()
DOCS.iter().filter(|e| matches!(e, Topic(_, _))).count()
}
fn get_topic(index: usize) -> Option<(&'static str, &'static str)> {
DOCS.iter()
.filter_map(|e| match e {
Topic(name, content) => Some((*name, *content)),
Section(_) => None,
})
.nth(index)
}
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let [topics_area, content_area] =
Layout::horizontal([Constraint::Length(18), Constraint::Fill(1)]).areas(area);
Layout::horizontal([Constraint::Length(24), Constraint::Fill(1)]).areas(area);
render_topics(frame, app, topics_area);
render_content(frame, app, content_area);
@@ -33,25 +129,77 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let visible_height = area.height.saturating_sub(2) as usize;
let total_items = DOCS.len();
// Find the visual index of the selected topic (including sections)
let selected_visual_idx = {
let mut visual = 0;
let mut topic_count = 0;
for entry in DOCS.iter() {
if let Topic(_, _) = entry {
if topic_count == app.ui.help_topic {
break;
}
topic_count += 1;
}
visual += 1;
}
visual
};
// Calculate scroll to keep selection visible (centered when possible)
let scroll = if selected_visual_idx < visible_height / 2 {
0
} else if selected_visual_idx > total_items.saturating_sub(visible_height / 2) {
total_items.saturating_sub(visible_height)
} else {
selected_visual_idx.saturating_sub(visible_height / 2)
};
// Count topics before the scroll offset to track topic_idx correctly
let mut topic_idx = DOCS
.iter()
.take(scroll)
.filter(|e| matches!(e, Topic(_, _)))
.count();
let items: Vec<ListItem> = DOCS
.iter()
.enumerate()
.map(|(i, (name, _))| {
let selected = i == app.ui.help_topic;
let style = if selected {
Style::new().fg(theme.dict.category_selected).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(theme.ui.text_primary)
};
let prefix = if selected { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
.skip(scroll)
.take(visible_height)
.map(|entry| match entry {
Section(name) => {
let style = Style::new().fg(theme.ui.text_dim);
ListItem::new(format!("{name}")).style(style)
}
Topic(name, _) => {
let selected = topic_idx == app.ui.help_topic;
let style = if selected {
Style::new()
.fg(theme.dict.category_selected)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(theme.ui.text_primary)
};
let prefix = if selected { "> " } else { " " };
topic_idx += 1;
ListItem::new(format!("{prefix}{name}")).style(style)
}
})
.collect();
let focused = app.ui.help_focus == HelpFocus::Topics;
let border_color = if focused {
theme.dict.border_focused
} else {
theme.dict.border_normal
};
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.dict.border_focused))
.border_style(Style::new().fg(border_color))
.title("Topics"),
);
frame.render_widget(list, area);
@@ -62,7 +210,9 @@ const BIG_TITLE_HEIGHT: u16 = 6;
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let (_, md) = DOCS[app.ui.help_topic];
let Some((_, md)) = get_topic(app.ui.help_topic) else {
return;
};
let is_welcome = app.ui.help_topic == WELCOME_TOPIC;
let md_area = if is_welcome {
@@ -95,24 +245,6 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let lines = parse_markdown(md);
let has_search_bar = app.ui.help_search_active || has_query;
let search_bar_height: u16 = u16::from(has_search_bar);
let visible_height = md_area.height.saturating_sub(6 + search_bar_height) as usize;
let max_scroll = lines.len().saturating_sub(visible_height);
let scroll = app.ui.help_scroll().min(max_scroll);
let visible: Vec<RLine> = lines
.into_iter()
.skip(scroll)
.take(visible_height)
.map(|line| {
if has_query {
highlight_line(line, &query_lower)
} else {
line
}
})
.collect();
let content_area = if has_search_bar {
let [content, search] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area);
@@ -122,17 +254,55 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
md_area
};
let para = Paragraph::new(visible)
// Calculate dimensions: 2 borders + 4 padding (2 left + 2 right)
let content_width = content_area.width.saturating_sub(6) as usize;
// 2 borders + 4 padding (2 top + 2 bottom)
let visible_height = content_area.height.saturating_sub(6) as usize;
// Calculate total wrapped line count for accurate max_scroll
let total_wrapped: usize = lines
.iter()
.map(|l| wrapped_line_count(l, content_width))
.sum();
let max_scroll = total_wrapped.saturating_sub(visible_height);
let scroll = app.ui.help_scroll().min(max_scroll);
let lines: Vec<RLine> = if has_query {
lines
.into_iter()
.map(|line| highlight_line(line, &query_lower))
.collect()
} else {
lines
};
let focused = app.ui.help_focus == HelpFocus::Content;
let border_color = if focused {
theme.dict.border_focused
} else {
theme.dict.border_normal
};
let para = Paragraph::new(lines)
.scroll((scroll as u16, 0))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::new().fg(theme.ui.border))
.border_style(Style::new().fg(border_color))
.padding(Padding::new(2, 2, 2, 2)),
)
.wrap(Wrap { trim: false });
frame.render_widget(para, content_area);
}
fn wrapped_line_count(line: &RLine, width: usize) -> usize {
let char_count: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
if char_count == 0 || width == 0 {
1
} else {
(char_count + width - 1) / width
}
}
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get();
let style = if app.ui.help_search_active {
@@ -156,7 +326,9 @@ fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> {
}
let content = span.content.to_string();
let base_style = span.style;
let hl_style = base_style.bg(theme.search.match_bg).fg(theme.search.match_fg);
let hl_style = base_style
.bg(theme.search.match_bg)
.fg(theme.search.match_fg);
let mut start = 0;
let lower_bytes = lower.as_bytes();
let query_bytes = query.as_bytes();
@@ -185,7 +357,14 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
/// Find first line matching query across all topics. Returns (topic_index, line_index).
pub fn find_match(query: &str) -> Option<(usize, usize)> {
let query = query.to_lowercase();
for (topic_idx, (_, content)) in DOCS.iter().enumerate() {
for (topic_idx, (_, content)) in DOCS
.iter()
.filter_map(|e| match e {
Topic(name, content) => Some((*name, *content)),
Section(_) => None,
})
.enumerate()
{
for (line_idx, line) in content.lines().enumerate() {
if line.to_lowercase().contains(&query) {
return Some((topic_idx, line_idx));
@@ -200,9 +379,11 @@ fn code_border_style() -> Style {
Style::new().fg(theme.markdown.code_border)
}
fn preprocess_underscores(md: &str) -> String {
fn preprocess_markdown(md: &str) -> String {
let mut out = String::with_capacity(md.len());
for line in md.lines() {
// Convert dash list markers to asterisks (minimad only recognizes *)
let line = convert_dash_lists(line);
let mut result = String::with_capacity(line.len());
let mut chars = line.char_indices().peekable();
let bytes = line.as_bytes();
@@ -243,18 +424,44 @@ fn preprocess_underscores(md: &str) -> String {
out
}
fn convert_dash_lists(line: &str) -> String {
let trimmed = line.trim_start();
if trimmed.starts_with("- ") {
let indent = line.len() - trimmed.len();
format!("{}* {}", " ".repeat(indent), &trimmed[2..])
} else {
line.to_string()
}
}
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
let processed = preprocess_underscores(md);
let processed = preprocess_markdown(md);
let text = minimad::Text::from(processed.as_str());
let mut lines = Vec::new();
let mut code_line_nr: usize = 0;
let mut table_buffer: Vec<TableRow> = Vec::new();
let flush_table = |buf: &mut Vec<TableRow>, out: &mut Vec<RLine<'static>>| {
if buf.is_empty() {
return;
}
let col_widths = compute_column_widths(buf);
for (row_idx, row) in buf.drain(..).enumerate() {
out.push(render_table_row(row, row_idx, &col_widths));
}
};
for line in text.lines {
match line {
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
flush_table(&mut table_buffer, &mut lines);
code_line_nr += 1;
let raw: String = composite.compounds.iter().map(|c: &minimad::Compound| c.src).collect();
let raw: String = composite
.compounds
.iter()
.map(|c: &minimad::Compound| c.src)
.collect();
let mut spans = vec![
Span::styled(format!(" {code_line_nr:>2} "), code_border_style()),
Span::styled("", code_border_style()),
@@ -267,41 +474,125 @@ fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
lines.push(RLine::from(spans));
}
Line::Normal(composite) => {
flush_table(&mut table_buffer, &mut lines);
code_line_nr = 0;
lines.push(composite_to_line(composite));
}
Line::TableRow(row) => {
code_line_nr = 0;
table_buffer.push(row);
}
Line::TableRule(_) => {
// Skip the separator line (---|---|---)
}
_ => {
flush_table(&mut table_buffer, &mut lines);
code_line_nr = 0;
lines.push(RLine::from(""));
}
}
}
flush_table(&mut table_buffer, &mut lines);
lines
}
fn cell_text_width(cell: &Composite) -> usize {
cell.compounds.iter().map(|c| c.src.chars().count()).sum()
}
fn compute_column_widths(rows: &[TableRow]) -> Vec<usize> {
let mut widths: Vec<usize> = Vec::new();
for row in rows {
for (i, cell) in row.cells.iter().enumerate() {
let w = cell_text_width(cell);
if i >= widths.len() {
widths.push(w);
} else if w > widths[i] {
widths[i] = w;
}
}
}
widths
}
fn render_table_row(row: TableRow, row_idx: usize, col_widths: &[usize]) -> RLine<'static> {
let theme = theme::get();
let is_header = row_idx == 0;
let bg = if is_header {
theme.ui.surface
} else if row_idx % 2 == 0 {
theme.table.row_even
} else {
theme.table.row_odd
};
let base_style = if is_header {
Style::new()
.fg(theme.markdown.text)
.bg(bg)
.add_modifier(Modifier::BOLD)
} else {
Style::new().fg(theme.markdown.text).bg(bg)
};
let sep_style = Style::new().fg(theme.markdown.code_border).bg(bg);
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, cell) in row.cells.into_iter().enumerate() {
if i > 0 {
spans.push(Span::styled("", sep_style));
}
let target_width = col_widths.get(i).copied().unwrap_or(0);
let cell_width = cell
.compounds
.iter()
.map(|c| c.src.chars().count())
.sum::<usize>();
for compound in cell.compounds {
compound_to_spans(compound, base_style, &mut spans);
}
let padding = target_width.saturating_sub(cell_width);
if padding > 0 {
spans.push(Span::styled(" ".repeat(padding), base_style));
}
}
RLine::from(spans)
}
fn composite_to_line(composite: Composite) -> RLine<'static> {
let theme = theme::get();
let base_style = match composite.style {
CompositeStyle::Header(1) => Style::new()
.fg(theme.markdown.h1)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
CompositeStyle::Header(2) => Style::new().fg(theme.markdown.h2).add_modifier(Modifier::BOLD),
CompositeStyle::Header(_) => Style::new().fg(theme.markdown.h3).add_modifier(Modifier::BOLD),
CompositeStyle::Header(2) => Style::new()
.fg(theme.markdown.h2)
.add_modifier(Modifier::BOLD),
CompositeStyle::Header(_) => Style::new()
.fg(theme.markdown.h3)
.add_modifier(Modifier::BOLD),
CompositeStyle::ListItem(_) => Style::new().fg(theme.markdown.list),
CompositeStyle::Quote => Style::new().fg(theme.markdown.quote),
CompositeStyle::Code => Style::new().fg(theme.markdown.code),
CompositeStyle::Paragraph => Style::new().fg(theme.markdown.text),
};
let prefix = match composite.style {
CompositeStyle::ListItem(_) => "",
CompositeStyle::Quote => " ",
_ => "",
let prefix: String = match composite.style {
CompositeStyle::ListItem(depth) => {
let indent = " ".repeat(depth as usize);
format!("{indent}")
}
CompositeStyle::Quote => "".to_string(),
_ => String::new(),
};
let mut spans: Vec<Span<'static>> = Vec::new();
if !prefix.is_empty() {
spans.push(Span::styled(prefix.to_string(), base_style));
spans.push(Span::styled(prefix, base_style));
}
for compound in composite.compounds {

View File

@@ -34,6 +34,8 @@ pub fn bindings_for(page: Page) -> Vec<(&'static str, &'static str, &'static str
bindings.push(("s", "Save", "Save project"));
bindings.push(("l", "Load", "Load project"));
bindings.push(("f", "Fill", "Toggle fill mode (hold)"));
bindings.push(("r", "Rename", "Rename current step"));
bindings.push(("Ctrl+R", "Run", "Run step script immediately"));
}
Page::Patterns => {
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));