Write some amount of documentation
This commit is contained in:
55
src/app.rs
55
src/app.rs
@@ -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();
|
||||
|
||||
@@ -138,8 +138,9 @@ pub enum AppCommand {
|
||||
PageDown,
|
||||
|
||||
// Help navigation
|
||||
HelpNextTopic,
|
||||
HelpPrevTopic,
|
||||
HelpToggleFocus,
|
||||
HelpNextTopic(usize),
|
||||
HelpPrevTopic(usize),
|
||||
HelpScrollDown(usize),
|
||||
HelpScrollUp(usize),
|
||||
HelpActivateSearch,
|
||||
|
||||
47
src/input.rs
47
src/input.rs
@@ -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') => {
|
||||
|
||||
12
src/page.rs
12
src/page.rs
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user