A ton of bug fixes

This commit is contained in:
2026-01-28 01:09:23 +01:00
parent a9ce70d292
commit 322885b908
13 changed files with 400 additions and 130 deletions

View File

@@ -309,6 +309,7 @@ impl App {
iter: 0,
speed,
fill: false,
nudge_secs: 0.0,
};
match self.script_engine.evaluate(&script, &ctx) {
@@ -385,6 +386,7 @@ impl App {
iter: 0,
speed,
fill: false,
nudge_secs: 0.0,
};
if let Ok(cmds) = self.script_engine.evaluate(&script, &ctx) {

View File

@@ -1,14 +1,15 @@
use arc_swap::ArcSwap;
use crossbeam_channel::{bounded, Receiver, Sender, TrySendError};
use std::collections::HashMap;
use std::sync::atomic::AtomicI64;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Duration;
use thread_priority::{set_current_thread_priority, ThreadPriority};
use super::LinkState;
use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
use crate::model::{Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables};
use crate::model::{LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
use crate::state::LiveKeyState;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -203,6 +204,7 @@ pub fn spawn_sequencer(
rng: Rng,
quantum: f64,
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
) -> (SequencerHandle, Receiver<AudioCommand>) {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = bounded::<AudioCommand>(256);
@@ -226,6 +228,7 @@ pub fn spawn_sequencer(
quantum,
shared_state_clone,
live_keys,
nudge_us,
);
})
.expect("Failed to spawn sequencer thread");
@@ -356,6 +359,7 @@ fn sequencer_loop(
quantum: f64,
shared_state: Arc<ArcSwap<SharedSequencerState>>,
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
) {
use std::sync::atomic::Ordering;
@@ -432,12 +436,8 @@ fn sequencer_loop(
// Process pending starts with per-pattern quantization
let mut started_ids: Vec<PatternId> = Vec::new();
for pending in &audio_state.pending_starts {
let should_start = check_quantization_boundary(
pending.quantization,
beat,
prev_beat,
quantum,
);
let should_start =
check_quantization_boundary(pending.quantization, beat, prev_beat, quantum);
if should_start {
let start_step = match pending.sync_mode {
SyncMode::Reset => 0,
@@ -462,17 +462,15 @@ fn sequencer_loop(
started_ids.push(pending.id);
}
}
audio_state.pending_starts.retain(|p| !started_ids.contains(&p.id));
audio_state
.pending_starts
.retain(|p| !started_ids.contains(&p.id));
// Process pending stops with per-pattern quantization
let mut stopped_ids: Vec<PatternId> = Vec::new();
for pending in &audio_state.pending_stops {
let should_stop = check_quantization_boundary(
pending.quantization,
beat,
prev_beat,
quantum,
);
let should_stop =
check_quantization_boundary(pending.quantization, beat, prev_beat, quantum);
if should_stop {
audio_state.active_patterns.remove(&pending.id);
step_traces.retain(|&(bank, pattern, _), _| {
@@ -485,7 +483,9 @@ fn sequencer_loop(
stopped_ids.push(pending.id);
}
}
audio_state.pending_stops.retain(|p| !stopped_ids.contains(&p.id));
audio_state
.pending_stops
.retain(|p| !stopped_ids.contains(&p.id));
let mut chain_transitions: Vec<(PatternId, PatternId)> = Vec::new();
let mut chain_keys_to_remove: Vec<String> = Vec::new();
@@ -513,6 +513,7 @@ fn sequencer_loop(
let source_idx = pattern.resolve_source(step_idx);
let runs =
runs_counter.get_and_increment(active.bank, active.pattern, source_idx);
let nudge_secs = nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0;
let ctx = StepContext {
step: step_idx,
beat,
@@ -525,6 +526,7 @@ fn sequencer_loop(
iter: active.iter,
speed: speed_mult,
fill: live_keys.fill(),
nudge_secs,
};
if let Some(script) = resolved_script {
let mut trace = ExecutionTrace::default();
@@ -582,15 +584,25 @@ fn sequencer_loop(
if let Some(Value::Str(s, _)) = vars.get(key) {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() == 2 {
if let (Ok(b), Ok(p)) = (parts[0].parse::<usize>(), parts[1].parse::<usize>()) {
let target = PatternId { bank: b, pattern: p };
if let (Ok(b), Ok(p)) =
(parts[0].parse::<usize>(), parts[1].parse::<usize>())
{
let target = PatternId {
bank: b,
pattern: p,
};
// Extract bank/pattern from key: "__chain_{bank}_{pattern}__"
if let Some(rest) = key.strip_prefix("__chain_") {
if let Some(rest) = rest.strip_suffix("__") {
let kparts: Vec<&str> = rest.split('_').collect();
if kparts.len() == 2 {
if let (Ok(sb), Ok(sp)) = (kparts[0].parse::<usize>(), kparts[1].parse::<usize>()) {
let source = PatternId { bank: sb, pattern: sp };
if let (Ok(sb), Ok(sp)) =
(kparts[0].parse::<usize>(), kparts[1].parse::<usize>())
{
let source = PatternId {
bank: sb,
pattern: sp,
};
chain_transitions.push((source, target));
}
}

View File

@@ -1,7 +1,7 @@
use arc_swap::ArcSwap;
use crossbeam_channel::Sender;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -26,6 +26,7 @@ pub struct InputContext<'a> {
pub snapshot: &'a SequencerSnapshot,
pub playing: &'a Arc<AtomicBool>,
pub audio_tx: &'a ArcSwap<Sender<AudioCommand>>,
pub nudge_us: &'a Arc<AtomicI64>,
}
impl<'a> InputContext<'a> {
@@ -937,6 +938,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
SettingKind::Channels => ctx.app.audio.adjust_channels(-1),
SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(-64),
SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(-1),
SettingKind::Nudge => {
let prev = ctx.nudge_us.load(Ordering::Relaxed);
ctx.nudge_us
.store((prev - 1000).max(-100_000), Ordering::Relaxed);
}
}
ctx.app.save_settings(ctx.link);
}
@@ -951,6 +957,11 @@ fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
SettingKind::Channels => ctx.app.audio.adjust_channels(1),
SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(64),
SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(1),
SettingKind::Nudge => {
let prev = ctx.nudge_us.load(Ordering::Relaxed);
ctx.nudge_us
.store((prev + 1000).min(100_000), Ordering::Relaxed);
}
}
ctx.app.save_settings(ctx.link);
}

View File

@@ -12,12 +12,12 @@ mod widgets;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use crossterm::event::{self, Event, EnableBracketedPaste, DisableBracketedPaste};
use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
@@ -27,7 +27,9 @@ use ratatui::prelude::CrosstermBackend;
use ratatui::Terminal;
use app::App;
use engine::{build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer};
use engine::{
build_stream, spawn_sequencer, AudioStreamConfig, LinkState, ScopeBuffer, SpectrumBuffer,
};
use input::{handle_key, InputContext, InputResult};
use settings::Settings;
use state::audio::RefreshRate;
@@ -66,6 +68,7 @@ fn main() -> io::Result<()> {
}
let playing = Arc::new(AtomicBool::new(true));
let nudge_us = Arc::new(AtomicI64::new(0));
let mut app = App::new();
@@ -111,6 +114,7 @@ fn main() -> io::Result<()> {
Arc::clone(&app.rng),
settings.link.quantum,
Arc::clone(&app.live_keys),
Arc::clone(&nudge_us),
);
let stream_config = AudioStreamConfig {
@@ -201,6 +205,7 @@ fn main() -> io::Result<()> {
app.metrics.scope = scope_buffer.read();
(app.metrics.peak_left, app.metrics.peak_right) = scope_buffer.peaks();
app.metrics.spectrum = spectrum_buffer.read();
app.metrics.nudge_ms = nudge_us.load(Ordering::Relaxed) as f64 / 1000.0;
}
let seq_snapshot = sequencer.snapshot();
@@ -215,7 +220,9 @@ fn main() -> io::Result<()> {
}
terminal.draw(|frame| views::render(frame, &app, &link, &seq_snapshot))?;
if event::poll(Duration::from_millis(app.audio.config.refresh_rate.millis()))? {
if event::poll(Duration::from_millis(
app.audio.config.refresh_rate.millis(),
))? {
match event::read()? {
Event::Key(key) => {
let mut ctx = InputContext {
@@ -224,6 +231,7 @@ fn main() -> io::Result<()> {
snapshot: &seq_snapshot,
playing: &playing,
audio_tx: &sequencer.audio_tx,
nudge_us: &nudge_us,
};
if let InputResult::Quit = handle_key(&mut ctx, key) {
@@ -238,7 +246,6 @@ fn main() -> io::Result<()> {
_ => {}
}
}
}
disable_raw_mode()?;

View File

@@ -139,6 +139,7 @@ pub enum SettingKind {
Channels,
BufferSize,
Polyphony,
Nudge,
}
impl SettingKind {
@@ -146,15 +147,17 @@ impl SettingKind {
match self {
Self::Channels => Self::BufferSize,
Self::BufferSize => Self::Polyphony,
Self::Polyphony => Self::Channels,
Self::Polyphony => Self::Nudge,
Self::Nudge => Self::Channels,
}
}
pub fn prev(self) -> Self {
match self {
Self::Channels => Self::Polyphony,
Self::Channels => Self::Nudge,
Self::BufferSize => Self::Channels,
Self::Polyphony => Self::BufferSize,
Self::Nudge => Self::Polyphony,
}
}
}
@@ -170,6 +173,7 @@ pub struct Metrics {
pub peak_left: f32,
pub peak_right: f32,
pub spectrum: [f32; 32],
pub nudge_ms: f64,
}
impl Default for Metrics {
@@ -185,6 +189,7 @@ impl Default for Metrics {
peak_left: 0.0,
peak_right: 0.0,
spectrum: [0.0; 32],
nudge_ms: 0.0,
}
}
}
@@ -211,8 +216,14 @@ impl Default for AudioSettings {
setting_kind: SettingKind::default(),
output_devices: doux::audio::list_output_devices(),
input_devices: doux::audio::list_input_devices(),
output_list: ListSelectState { cursor: 0, scroll_offset: 0 },
input_list: ListSelectState { cursor: 0, scroll_offset: 0 },
output_list: ListSelectState {
cursor: 0,
scroll_offset: 0,
},
input_list: ListSelectState {
cursor: 0,
scroll_offset: 0,
},
restart_pending: false,
error: None,
}
@@ -290,7 +301,6 @@ impl AudioSettings {
self.config.refresh_rate = self.config.refresh_rate.toggle();
}
pub fn add_sample_path(&mut self, path: PathBuf) {
if !self.config.sample_paths.contains(&path) {
self.config.sample_paths.push(path);

View File

@@ -45,7 +45,7 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
Constraint::Length(devices_height),
Constraint::Length(1),
Constraint::Length(7),
Constraint::Length(8),
Constraint::Length(1),
Constraint::Min(6),
])
@@ -106,7 +106,11 @@ fn truncate_name(name: &str, max_len: usize) -> String {
fn list_height(item_count: usize) -> u16 {
let visible = item_count.min(5) as u16;
if item_count > 5 { visible + 1 } else { visible }
if item_count > 5 {
visible + 1
} else {
visible
}
}
fn devices_section_height(app: &App) -> u16 {
@@ -116,10 +120,8 @@ fn devices_section_height(app: &App) -> u16 {
}
fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) {
let [header_area, divider_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
]).areas(area);
let [header_area, divider_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
let header_style = if focused {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
@@ -139,10 +141,8 @@ fn render_section_header(frame: &mut Frame, title: &str, focused: bool, area: Re
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
let section_focused = app.audio.section == EngineSection::Devices;
let [header_area, content_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Min(1),
]).areas(area);
let [header_area, content_area] =
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
render_section_header(frame, "DEVICES", section_focused, header_area);
@@ -150,14 +150,17 @@ fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
Constraint::Percentage(48),
Constraint::Length(3),
Constraint::Percentage(48),
]).areas(content_area);
])
.areas(content_area);
let output_focused = section_focused && app.audio.device_kind == DeviceKind::Output;
let input_focused = section_focused && app.audio.device_kind == DeviceKind::Input;
render_device_column(
frame, output_col,
"Output", &app.audio.output_devices,
frame,
output_col,
"Output",
&app.audio.output_devices,
app.audio.current_output_device_index(),
app.audio.output_list.cursor,
app.audio.output_list.scroll_offset,
@@ -172,8 +175,10 @@ fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Paragraph::new(sep_lines), separator);
render_device_column(
frame, input_col,
"Input", &app.audio.input_devices,
frame,
input_col,
"Input",
&app.audio.input_devices,
app.audio.current_input_device_index(),
app.audio.input_list.cursor,
app.audio.input_list.scroll_offset,
@@ -193,10 +198,8 @@ fn render_device_column(
focused: bool,
section_focused: bool,
) {
let [label_area, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
]).areas(area);
let [label_area, list_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area);
let label_style = if focused {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
@@ -212,9 +215,7 @@ fn render_device_column(
label_area,
);
let items: Vec<String> = devices.iter()
.map(|d| truncate_name(&d.name, 25))
.collect();
let items: Vec<String> = devices.iter().map(|d| truncate_name(&d.name, 25)).collect();
ListSelect::new(&items, selected_idx, cursor)
.focused(focused)
@@ -238,10 +239,25 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
let channels_focused = section_focused && app.audio.setting_kind == SettingKind::Channels;
let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize;
let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge;
let nudge_ms = app.metrics.nudge_ms;
let nudge_label = if nudge_ms == 0.0 {
"0 ms".to_string()
} else {
format!("{nudge_ms:+.1} ms")
};
let rows = vec![
Row::new(vec![
Span::styled(if channels_focused { "> Channels" } else { " Channels" }, label_style),
Span::styled(
if channels_focused {
"> Channels"
} else {
" Channels"
},
label_style,
),
render_selector(
&format!("{}", app.audio.config.channels),
channels_focused,
@@ -250,7 +266,14 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
),
]),
Row::new(vec![
Span::styled(if buffer_focused { "> Buffer" } else { " Buffer" }, label_style),
Span::styled(
if buffer_focused {
"> Buffer"
} else {
" Buffer"
},
label_style,
),
render_selector(
&format!("{}", app.audio.config.buffer_size),
buffer_focused,
@@ -259,7 +282,14 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
),
]),
Row::new(vec![
Span::styled(if polyphony_focused { "> Voices" } else { " Voices" }, label_style),
Span::styled(
if polyphony_focused {
"> Voices"
} else {
" Voices"
},
label_style,
),
render_selector(
&format!("{}", app.audio.config.max_voices),
polyphony_focused,
@@ -267,6 +297,13 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
normal,
),
]),
Row::new(vec![
Span::styled(
if nudge_focused { "> Nudge" } else { " Nudge" },
label_style,
),
render_selector(&nudge_label, nudge_focused, highlight, normal),
]),
Row::new(vec![
Span::styled(" Sample rate", label_style),
Span::styled(

View File

@@ -1,9 +1,10 @@
use minimad::{Composite, CompositeStyle, Compound, Line};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::{Color, Modifier, Style, Stylize};
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::views::highlight;
@@ -11,8 +12,10 @@ 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")),
];
pub fn topic_count() -> usize {
@@ -47,8 +50,36 @@ fn render_topics(frame: &mut Frame, app: &App, area: Rect) {
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 (name, md) = DOCS[app.ui.help_topic];
let (_, md) = DOCS[app.ui.help_topic];
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().cyan().bold())
.lines(vec!["CAGIRE".into()])
.centered()
.build();
let subtitle = Paragraph::new(RLine::from(Span::styled(
"A Forth Sequencer",
Style::new().fg(Color::White),
)))
.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();
@@ -57,7 +88,7 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
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 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);
@@ -76,18 +107,17 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
let content_area = if has_search_bar {
let [content, search] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(md_area);
render_search_bar(frame, app, search);
content
} else {
area
md_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 });
@@ -158,8 +188,52 @@ fn code_border_style() -> Style {
Style::new().fg(Color::Rgb(60, 60, 70))
}
fn preprocess_underscores(md: &str) -> String {
let mut out = String::with_capacity(md.len());
for line in md.lines() {
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 parse_markdown(md: &str) -> Vec<RLine<'static>> {
let text = minimad::Text::from(md);
let processed = preprocess_underscores(md);
let text = minimad::Text::from(processed.as_str());
let mut lines = Vec::new();
let mut code_line_nr: usize = 0;
@@ -218,13 +292,13 @@ fn composite_to_line(composite: Composite) -> RLine<'static> {
}
for compound in composite.compounds {
spans.push(compound_to_span(compound, base_style));
compound_to_spans(compound, base_style, &mut spans);
}
RLine::from(spans)
}
fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
fn compound_to_spans(compound: Compound, base: Style, out: &mut Vec<Span<'static>>) {
let mut style = base;
if compound.bold {
@@ -240,5 +314,39 @@ fn compound_to_span(compound: Compound, base: Style) -> Span<'static> {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
Span::styled(compound.src.to_string(), style)
let src = compound.src.to_string();
let link_style = Style::new()
.fg(Color::Rgb(120, 200, 180))
.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(Color::Rgb(100, 100, 100)),
));
}
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));
}
}