658 lines
22 KiB
Rust
658 lines
22 KiB
Rust
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};
|
|
use ratatui::widgets::{Block, Borders, List, ListItem, Padding, Paragraph, Wrap};
|
|
use ratatui::Frame;
|
|
use tui_big_text::{BigText, PixelSize};
|
|
|
|
use crate::app::App;
|
|
use crate::state::HelpFocus;
|
|
use crate::theme;
|
|
use crate::views::highlight;
|
|
|
|
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.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(24), 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 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()
|
|
.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(border_color))
|
|
.title("Topics"),
|
|
);
|
|
frame.render_widget(list, area);
|
|
}
|
|
|
|
const WELCOME_TOPIC: usize = 0;
|
|
const BIG_TITLE_HEIGHT: u16 = 6;
|
|
|
|
fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
|
let theme = theme::get();
|
|
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 {
|
|
let [title_area, rest] =
|
|
Layout::vertical([Constraint::Length(BIG_TITLE_HEIGHT), Constraint::Fill(1)])
|
|
.areas(area);
|
|
let big_title = BigText::builder()
|
|
.pixel_size(PixelSize::Quadrant)
|
|
.style(Style::new().fg(theme.markdown.h1).bold())
|
|
.lines(vec!["CAGIRE".into()])
|
|
.centered()
|
|
.build();
|
|
let subtitle = Paragraph::new(RLine::from(Span::styled(
|
|
"A Forth Sequencer",
|
|
Style::new().fg(theme.ui.text_primary),
|
|
)))
|
|
.alignment(ratatui::layout::Alignment::Center);
|
|
let [big_area, subtitle_area] =
|
|
Layout::vertical([Constraint::Length(4), Constraint::Length(2)]).areas(title_area);
|
|
frame.render_widget(big_title, big_area);
|
|
frame.render_widget(subtitle, subtitle_area);
|
|
rest
|
|
} else {
|
|
area
|
|
};
|
|
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 content_area = if has_search_bar {
|
|
let [content, search] =
|
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area);
|
|
render_search_bar(frame, app, search);
|
|
content
|
|
} else {
|
|
md_area
|
|
};
|
|
|
|
// 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(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 {
|
|
Style::new().fg(theme.search.active)
|
|
} else {
|
|
Style::new().fg(theme.search.inactive)
|
|
};
|
|
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 theme = theme::get();
|
|
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(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();
|
|
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()
|
|
.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));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn code_border_style() -> Style {
|
|
let theme = theme::get();
|
|
Style::new().fg(theme.markdown.code_border)
|
|
}
|
|
|
|
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();
|
|
while let Some((i, c)) = chars.next() {
|
|
if c == '`' {
|
|
result.push(c);
|
|
for (_, ch) in chars.by_ref() {
|
|
result.push(ch);
|
|
if ch == '`' {
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if c == '_' {
|
|
let before_is_space = i == 0 || bytes[i - 1] == b' ';
|
|
if before_is_space {
|
|
if let Some(end) = line[i + 1..].find('_') {
|
|
let inner = &line[i + 1..i + 1 + end];
|
|
if !inner.is_empty() {
|
|
result.push('*');
|
|
result.push_str(inner);
|
|
result.push('*');
|
|
for _ in 0..end {
|
|
chars.next();
|
|
}
|
|
chars.next(); // skip closing _
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result.push(c);
|
|
}
|
|
out.push_str(&result);
|
|
out.push('\n');
|
|
}
|
|
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_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 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) => {
|
|
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::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: 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, base_style));
|
|
}
|
|
|
|
for compound in composite.compounds {
|
|
compound_to_spans(compound, base_style, &mut spans);
|
|
}
|
|
|
|
RLine::from(spans)
|
|
}
|
|
|
|
fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec<Span<'static>>) {
|
|
let theme = theme::get();
|
|
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(theme.markdown.code);
|
|
}
|
|
if compound.strikeout {
|
|
style = style.add_modifier(Modifier::CROSSED_OUT);
|
|
}
|
|
|
|
let src = compound.src.to_string();
|
|
let link_style = Style::new()
|
|
.fg(theme.markdown.link)
|
|
.add_modifier(Modifier::UNDERLINED);
|
|
|
|
let mut rest = src.as_str();
|
|
while let Some(start) = rest.find('[') {
|
|
let after_bracket = &rest[start + 1..];
|
|
if let Some(text_end) = after_bracket.find("](") {
|
|
let url_start = start + 1 + text_end + 2;
|
|
if let Some(url_end) = rest[url_start..].find(')') {
|
|
if start > 0 {
|
|
out.push(Span::styled(rest[..start].to_string(), style));
|
|
}
|
|
let text = &rest[start + 1..start + 1 + text_end];
|
|
let url = &rest[url_start..url_start + url_end];
|
|
if text == url {
|
|
out.push(Span::styled(url.to_string(), link_style));
|
|
} else {
|
|
out.push(Span::styled(text.to_string(), link_style));
|
|
out.push(Span::styled(
|
|
format!(" ({url})"),
|
|
Style::new().fg(theme.markdown.link_url),
|
|
));
|
|
}
|
|
rest = &rest[url_start + url_end + 1..];
|
|
continue;
|
|
}
|
|
}
|
|
out.push(Span::styled(rest[..start + 1].to_string(), style));
|
|
rest = &rest[start + 1..];
|
|
}
|
|
if !rest.is_empty() {
|
|
out.push(Span::styled(rest.to_string(), style));
|
|
}
|
|
}
|