245 lines
8.0 KiB
Rust
245 lines
8.0 KiB
Rust
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, Padding, Paragraph, Wrap};
|
|
use ratatui::Frame;
|
|
|
|
use crate::app::App;
|
|
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")),
|
|
("Keybindings", include_str!("../../docs/keybindings.md")),
|
|
("Sequencer", include_str!("../../docs/sequencer.md")),
|
|
];
|
|
|
|
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);
|
|
render_content(frame, app, content_area);
|
|
}
|
|
|
|
fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
|
|
let items: Vec<ListItem> = DOCS
|
|
.iter()
|
|
.enumerate()
|
|
.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 selected { "> " } else { " " };
|
|
ListItem::new(format!("{prefix}{name}")).style(style)
|
|
})
|
|
.collect();
|
|
|
|
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Topics"));
|
|
frame.render_widget(list, area);
|
|
}
|
|
|
|
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 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 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));
|
|
}
|
|
_ => {
|
|
lines.push(RLine::from(""));
|
|
}
|
|
}
|
|
}
|
|
|
|
lines
|
|
}
|
|
|
|
fn composite_to_line(composite: Composite) -> RLine<'static> {
|
|
let base_style = match composite.style {
|
|
CompositeStyle::Header(1) => Style::new()
|
|
.fg(Color::Cyan)
|
|
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
|
CompositeStyle::Header(2) => Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
|
CompositeStyle::Header(_) => Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
|
|
CompositeStyle::ListItem(_) => Style::new().fg(Color::White),
|
|
CompositeStyle::Quote => Style::new().fg(Color::Rgb(150, 150, 150)),
|
|
CompositeStyle::Code => Style::new().fg(Color::Green),
|
|
CompositeStyle::Paragraph => Style::new().fg(Color::White),
|
|
};
|
|
|
|
let prefix = match composite.style {
|
|
CompositeStyle::ListItem(_) => " • ",
|
|
CompositeStyle::Quote => " │ ",
|
|
_ => "",
|
|
};
|
|
|
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
|
if !prefix.is_empty() {
|
|
spans.push(Span::styled(prefix.to_string(), base_style));
|
|
}
|
|
|
|
for compound in composite.compounds {
|
|
spans.push(compound_to_span(compound, base_style));
|
|
}
|
|
|
|
RLine::from(spans)
|
|
}
|
|
|
|
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
|
|
let mut style = base;
|
|
|
|
if compound.bold {
|
|
style = style.add_modifier(Modifier::BOLD);
|
|
}
|
|
if compound.italic {
|
|
style = style.add_modifier(Modifier::ITALIC);
|
|
}
|
|
if compound.code {
|
|
style = Style::new().fg(Color::Green);
|
|
}
|
|
if compound.strikeout {
|
|
style = style.add_modifier(Modifier::CROSSED_OUT);
|
|
}
|
|
|
|
Span::styled(compound.src.to_string(), style)
|
|
}
|