This commit is contained in:
2026-01-27 15:23:04 +01:00
parent a3a39ea28e
commit 935df84920
7 changed files with 378 additions and 110 deletions

View File

@@ -2,39 +2,43 @@ use minimad::{Composite, CompositeStyle, Compound, Line};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line as RLine, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap};
use ratatui::Frame;
use crate::app::App;
use crate::views::highlight;
const STATIC_DOCS: &[(&str, &str)] = &[
// 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")),
("Keybindings", include_str!("../../docs/keybindings.md")),
("Sequencer", include_str!("../../docs/sequencer.md")),
];
const TOPICS: &[&str] = &["Keybindings", "Sequencer"];
pub fn topic_count() -> usize {
DOCS.len()
}
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);
render_topics(frame, app, topics_area);
let topic = TOPICS[app.ui.help_topic];
render_markdown_content(frame, app, content_area, topic);
render_content(frame, app, content_area);
}
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = TOPICS
let items: Vec<ListItem> = DOCS
.iter()
.enumerate()
.map(|(i, name)| {
let style = if i == app.ui.help_topic {
.map(|(i, (name, _))| {
let selected = i == app.ui.help_topic;
let style = if selected {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::White)
};
let prefix = if i == app.ui.help_topic { "> " } else { " " };
let prefix = if selected { "> " } else { " " };
ListItem::new(format!("{prefix}{name}")).style(style)
})
.collect();
@@ -43,39 +47,144 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(list, area);
}
fn render_markdown_content(frame: &mut Frame, app: &App, area: Rect, topic: &str) {
let md = STATIC_DOCS
.iter()
.find(|(name, _)| *name == topic)
.map(|(_, content)| *content)
.unwrap_or("");
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let (name, md) = DOCS[app.ui.help_topic];
let query = &app.ui.help_search_query;
let has_query = !query.is_empty();
let query_lower = query.to_lowercase();
let lines = parse_markdown(md);
let visible_height = area.height.saturating_sub(2) as usize;
let total_lines = lines.len();
let max_scroll = total_lines.saturating_sub(visible_height);
let scroll = app.ui.help_scroll.min(max_scroll);
let has_search_bar = app.ui.help_search_active || has_query;
let search_bar_height: u16 = u16::from(has_search_bar);
let visible_height = 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 para = Paragraph::new(visible).block(Block::default().borders(Borders::ALL).title(topic));
frame.render_widget(para, area);
let content_area = if has_search_bar {
let [content, search] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
render_search_bar(frame, app, search);
content
} else {
area
};
let para = Paragraph::new(visible)
.block(
Block::default()
.borders(Borders::ALL)
.title(name)
.padding(Padding::new(2, 2, 2, 2)),
)
.wrap(Wrap { trim: false });
frame.render_widget(para, content_area);
}
fn render_search_bar(frame: &mut Frame, app: &App, area: Rect) {
let style = if app.ui.help_search_active {
Style::new().fg(Color::Yellow)
} else {
Style::new().fg(Color::DarkGray)
};
let cursor = if app.ui.help_search_active { "" } else { "" };
let text = format!(" /{}{cursor}", app.ui.help_search_query);
frame.render_widget(Paragraph::new(text).style(style), area);
}
fn highlight_line<'a>(line: RLine<'a>, query: &str) -> RLine<'a> {
let mut result: Vec<Span<'a>> = Vec::new();
for span in line.spans {
let lower = span.content.to_lowercase();
if !lower.contains(query) {
result.push(span);
continue;
}
let content = span.content.to_string();
let base_style = span.style;
let hl_style = base_style.bg(Color::Yellow).fg(Color::Black);
let mut start = 0;
let lower_bytes = lower.as_bytes();
let query_bytes = query.as_bytes();
while let Some(pos) = find_bytes(&lower_bytes[start..], query_bytes) {
let abs = start + pos;
if abs > start {
result.push(Span::styled(content[start..abs].to_string(), base_style));
}
result.push(Span::styled(
content[abs..abs + query.len()].to_string(),
hl_style,
));
start = abs + query.len();
}
if start < content.len() {
result.push(Span::styled(content[start..].to_string(), base_style));
}
}
RLine::from(result)
}
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
/// 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 (line_idx, line) in content.lines().enumerate() {
if line.to_lowercase().contains(&query) {
return Some((topic_idx, line_idx));
}
}
}
None
}
fn code_border_style() -> Style {
Style::new().fg(Color::Rgb(60, 60, 70))
}
fn parse_markdown(md: &str) -> Vec<RLine<'static>> {
let text = minimad::Text::from(md);
let mut lines = Vec::new();
let mut code_line_nr: usize = 0;
for line in text.lines {
match line {
Line::Normal(composite) if composite.style == CompositeStyle::Code => {
code_line_nr += 1;
let raw: String = composite.compounds.iter().map(|c| &*c.src).collect();
let mut spans = vec![
Span::styled(format!(" {code_line_nr:>2} "), code_border_style()),
Span::styled("", code_border_style()),
];
spans.extend(
highlight::highlight_line(&raw)
.into_iter()
.map(|(style, text)| Span::styled(text, style)),
);
lines.push(RLine::from(spans));
}
Line::Normal(composite) => {
code_line_nr = 0;
lines.push(composite_to_line(composite));
}
Line::TableRow(_) | Line::HorizontalRule | Line::CodeFence(_) | Line::TableRule(_) => {
_ => {
lines.push(RLine::from(""));
}
}
@@ -133,7 +242,3 @@ fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
Span::styled(compound.src.to_string(), style)
}
pub fn topic_count() -> usize {
TOPICS.len()
}

View File

@@ -12,20 +12,31 @@ use crate::model::SourceSpan;
use crate::page::Page;
use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel};
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
use crate::widgets::{ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal};
use crate::widgets::{
ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal,
};
use super::{dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view};
use super::{
dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view,
};
fn adjust_spans_for_line(spans: &[SourceSpan], line_start: usize, line_len: usize) -> Vec<SourceSpan> {
spans.iter().filter_map(|s| {
if s.end <= line_start || s.start >= line_start + line_len {
return None;
}
Some(SourceSpan {
start: s.start.max(line_start) - line_start,
end: (s.end.min(line_start + line_len)) - line_start,
fn adjust_spans_for_line(
spans: &[SourceSpan],
line_start: usize,
line_len: usize,
) -> Vec<SourceSpan> {
spans
.iter()
.filter_map(|s| {
if s.end <= line_start || s.start >= line_start + line_len {
return None;
}
Some(SourceSpan {
start: s.start.max(line_start) - line_start,
end: (s.end.min(line_start + line_len)) - line_start,
})
})
}).collect()
.collect()
}
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &SequencerSnapshot) {
@@ -60,19 +71,15 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
let (page_area, panel_area) = if app.panel.visible && app.panel.side.is_some() {
if body_area.width >= 120 {
let panel_width = body_area.width * 35 / 100;
let [main, side] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Length(panel_width),
])
.areas(body_area);
let [main, side] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(panel_width)])
.areas(body_area);
(main, Some(side))
} else {
let panel_height = body_area.height * 40 / 100;
let [main, side] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(panel_height),
])
.areas(body_area);
let [main, side] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(panel_height)])
.areas(body_area);
(main, Some(side))
}
} else {
@@ -106,7 +113,11 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, snapshot: &Sequenc
.iter()
.map(|p| {
let (col, row) = p.grid_pos();
NavTile { col, row, name: p.name() }
NavTile {
col,
row,
name: p.name(),
}
})
.collect();
let selected = app.page.grid_pos();
@@ -170,9 +181,13 @@ fn render_header(
// Fill indicator
let fill = app.live_keys.fill();
let fill_style = if fill {
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(100, 220, 100))
Style::new()
.bg(Color::Rgb(30, 30, 35))
.fg(Color::Rgb(100, 220, 100))
} else {
Style::new().bg(Color::Rgb(30, 30, 35)).fg(Color::Rgb(60, 60, 70))
Style::new()
.bg(Color::Rgb(30, 30, 35))
.fg(Color::Rgb(60, 60, 70))
};
frame.render_widget(
Paragraph::new(if fill { "F" } else { "·" })
@@ -303,21 +318,14 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("Enter", "Select"),
("A", "Add path"),
],
Page::Options => vec![
("Tab", "Next"),
("←→", "Toggle"),
("Space", "Play"),
],
Page::Options => vec![("Tab", "Next"), ("←→", "Toggle"), ("Space", "Play")],
Page::Help => vec![
("↑↓", "Scroll"),
("Tab", "Topic"),
("PgUp/Dn", "Page"),
],
Page::Dict => vec![
("Tab", "Focus"),
("↑↓", "Navigate"),
("/", "Search"),
],
Page::Dict => vec![("Tab", "Focus"), ("↑↓", "Navigate"), ("/", "Search")],
};
let page_width = page_indicator.chars().count();
@@ -505,8 +513,16 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
.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 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(),
);
highlight_line_with_runtime(line_str, &exec, &sel)
} else {
highlight_line(line_str)
@@ -544,7 +560,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
.render_centered(frame, term);
let trace = if app.ui.runtime_highlight && app.playback.playing {
let source = app.current_edit_pattern().resolve_source(app.editor_ctx.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
@@ -575,11 +593,22 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
let (search_area, editor_area, hint_area) = if show_search {
let search_area = Rect::new(inner.x, inner.y, inner.width, 1);
let editor_area = Rect::new(inner.x, inner.y + 1, inner.width, inner.height.saturating_sub(2));
let hint_area = Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
let editor_area = Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(2),
);
let hint_area =
Rect::new(inner.x, inner.y + 1 + editor_area.height, inner.width, 1);
(Some(search_area), editor_area, hint_area)
} else {
let editor_area = Rect::new(inner.x, inner.y, inner.width, inner.height.saturating_sub(1));
let editor_area = Rect::new(
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(1),
);
let hint_area = Rect::new(inner.x, inner.y + editor_area.height, inner.width, 1);
(None, editor_area, hint_area)
};
@@ -590,7 +619,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
} else {
Style::default().fg(Color::DarkGray)
};
let cursor = if app.editor_ctx.editor.search_active() { "_" } else { "" };
let cursor = if app.editor_ctx.editor.search_active() {
"_"
} else {
""
};
let text = format!("/{}{}", app.editor_ctx.editor.search_query(), cursor);
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), sa);
}
@@ -604,24 +637,35 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
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);
app.editor_ctx
.editor
.render(frame, editor_area, &highlighter);
let dim = Style::default().fg(Color::DarkGray);
let key = Style::default().fg(Color::Yellow);
let hint = if app.editor_ctx.editor.search_active() {
Line::from(vec![
Span::styled("Enter", key), Span::styled(" confirm ", dim),
Span::styled("Esc", key), Span::styled(" cancel", dim),
Span::styled("Enter", key),
Span::styled(" confirm ", dim),
Span::styled("Esc", key),
Span::styled(" cancel", dim),
])
} else {
Line::from(vec![
Span::styled("Esc", key), Span::styled(" save ", dim),
Span::styled("C-e", key), Span::styled(" eval ", dim),
Span::styled("C-f", key), Span::styled(" find ", dim),
Span::styled("C-n", key), Span::styled("/", dim),
Span::styled("C-p", key), Span::styled(" next/prev ", dim),
Span::styled("C-u", key), Span::styled("/", dim),
Span::styled("C-r", key), Span::styled(" undo/redo", dim),
Span::styled("Esc", key),
Span::styled(" save ", dim),
Span::styled("C-e", key),
Span::styled(" eval ", dim),
Span::styled("C-f", key),
Span::styled(" find ", dim),
Span::styled("C-n", key),
Span::styled("/", dim),
Span::styled("C-p", key),
Span::styled(" next/prev ", dim),
Span::styled("C-u", key),
Span::styled("/", dim),
Span::styled("C-r", key),
Span::styled(" undo/redo", dim),
])
};
frame.render_widget(Paragraph::new(hint).alignment(Alignment::Right), hint_area);
@@ -654,7 +698,11 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
let fields = [
("Name", name.as_str(), *field == PatternPropsField::Name),
("Length", length.as_str(), *field == PatternPropsField::Length),
(
"Length",
length.as_str(),
*field == PatternPropsField::Length,
),
("Speed", speed.label(), *field == PatternPropsField::Speed),
(
"Quantization",
@@ -676,7 +724,9 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
let (label_style, value_style) = if *selected {
(
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
Style::default().fg(Color::White).bg(Color::DarkGray),
)
} else {
@@ -693,10 +743,7 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
Paragraph::new(format!("{label}:")).style(label_style),
label_area,
);
frame.render_widget(
Paragraph::new(*value).style(value_style),
value_area,
);
frame.render_widget(Paragraph::new(*value).style(value_style), value_area);
}
let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1);