Draft incremental markdown parsing

Specially useful when dealing with long Markdown
streams, like LLMs.
This commit is contained in:
Héctor Ramón Jiménez 2025-01-31 17:35:38 +01:00
parent 6aab76e3a0
commit 128058ea94
No known key found for this signature in database
GPG key ID: 7CC46565708259A7
3 changed files with 167 additions and 28 deletions

View file

@ -7,6 +7,6 @@ publish = false
[dependencies]
iced.workspace = true
iced.features = ["markdown", "highlighter", "debug"]
iced.features = ["markdown", "highlighter", "tokio", "debug"]
open = "5.3"

View file

@ -1,23 +1,37 @@
use iced::highlighter;
use iced::widget::{self, markdown, row, scrollable, text_editor};
use iced::{Element, Fill, Font, Task, Theme};
use iced::time::{self, milliseconds};
use iced::widget::{
self, hover, markdown, right, row, scrollable, text_editor, toggler,
};
use iced::{Element, Fill, Font, Subscription, Task, Theme};
pub fn main() -> iced::Result {
iced::application("Markdown - Iced", Markdown::update, Markdown::view)
.subscription(Markdown::subscription)
.theme(Markdown::theme)
.run_with(Markdown::new)
}
struct Markdown {
content: text_editor::Content,
items: Vec<markdown::Item>,
mode: Mode,
theme: Theme,
}
enum Mode {
Oneshot(Vec<markdown::Item>),
Stream {
pending: String,
parsed: markdown::Content,
},
}
#[derive(Debug, Clone)]
enum Message {
Edit(text_editor::Action),
LinkClicked(markdown::Url),
ToggleStream(bool),
NextToken,
}
impl Markdown {
@ -29,7 +43,7 @@ impl Markdown {
(
Self {
content: text_editor::Content::with_text(INITIAL_CONTENT),
items: markdown::parse(INITIAL_CONTENT).collect(),
mode: Mode::Oneshot(markdown::parse(INITIAL_CONTENT).collect()),
theme,
},
widget::focus_next(),
@ -44,13 +58,48 @@ impl Markdown {
self.content.perform(action);
if is_edit {
self.items =
markdown::parse(&self.content.text()).collect();
self.mode = match self.mode {
Mode::Oneshot(_) => Mode::Oneshot(
markdown::parse(&self.content.text()).collect(),
),
Mode::Stream { .. } => Mode::Stream {
pending: self.content.text(),
parsed: markdown::Content::parse(""),
},
}
}
}
Message::LinkClicked(link) => {
let _ = open::that_in_background(link.to_string());
}
Message::ToggleStream(enable_stream) => {
self.mode = if enable_stream {
Mode::Stream {
pending: self.content.text(),
parsed: markdown::Content::parse(""),
}
} else {
Mode::Oneshot(
markdown::parse(&self.content.text()).collect(),
)
};
}
Message::NextToken => match &mut self.mode {
Mode::Oneshot(_) => {}
Mode::Stream { pending, parsed } => {
if pending.is_empty() {
self.mode = Mode::Oneshot(parsed.items().to_vec());
} else {
let mut tokens = pending.split(' ');
if let Some(token) = tokens.next() {
parsed.push_str(&format!("{token} "));
}
*pending = tokens.collect::<Vec<_>>().join(" ");
}
}
},
}
}
@ -63,20 +112,45 @@ impl Markdown {
.font(Font::MONOSPACE)
.highlight("markdown", highlighter::Theme::Base16Ocean);
let items = match &self.mode {
Mode::Oneshot(items) => items.as_slice(),
Mode::Stream { parsed, .. } => parsed.items(),
};
let preview = markdown(
&self.items,
items,
markdown::Settings::default(),
markdown::Style::from_palette(self.theme.palette()),
)
.map(Message::LinkClicked);
row![editor, scrollable(preview).spacing(10).height(Fill)]
.spacing(10)
.padding(10)
.into()
row![
editor,
hover(
scrollable(preview).spacing(10).width(Fill).height(Fill),
right(
toggler(matches!(self.mode, Mode::Stream { .. }))
.label("Stream")
.on_toggle(Message::ToggleStream)
)
.padding([0, 20])
)
]
.spacing(10)
.padding(10)
.into()
}
fn theme(&self) -> Theme {
self.theme.clone()
}
fn subscription(&self) -> Subscription<Message> {
match self.mode {
Mode::Oneshot(_) => Subscription::none(),
Mode::Stream { .. } => {
time::every(milliseconds(20)).map(|_| Message::NextToken)
}
}
}
}