349 lines
9.6 KiB
Rust
349 lines
9.6 KiB
Rust
use iced::animation;
|
|
use iced::highlighter;
|
|
use iced::task;
|
|
use iced::time::{self, milliseconds, Instant};
|
|
use iced::widget::{
|
|
self, center_x, horizontal_space, hover, image, markdown, pop, right, row,
|
|
scrollable, text_editor, toggler,
|
|
};
|
|
use iced::window;
|
|
use iced::{Animation, Element, Fill, Font, Subscription, Task, Theme};
|
|
|
|
use std::collections::HashMap;
|
|
use std::io;
|
|
use std::sync::Arc;
|
|
|
|
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: markdown::Content,
|
|
raw: text_editor::Content,
|
|
images: HashMap<markdown::Url, Image>,
|
|
mode: Mode,
|
|
theme: Theme,
|
|
now: Instant,
|
|
}
|
|
|
|
enum Mode {
|
|
Preview,
|
|
Stream { pending: String },
|
|
}
|
|
|
|
enum Image {
|
|
Loading {
|
|
_download: task::Handle,
|
|
},
|
|
Ready {
|
|
handle: image::Handle,
|
|
fade_in: Animation<bool>,
|
|
},
|
|
#[allow(dead_code)]
|
|
Errored(Error),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Message {
|
|
Edit(text_editor::Action),
|
|
LinkClicked(markdown::Url),
|
|
ImageShown(markdown::Url),
|
|
ImageDownloaded(markdown::Url, Result<image::Handle, Error>),
|
|
ToggleStream(bool),
|
|
NextToken,
|
|
Animate(Instant),
|
|
}
|
|
|
|
impl Markdown {
|
|
fn new() -> (Self, Task<Message>) {
|
|
const INITIAL_CONTENT: &str = include_str!("../overview.md");
|
|
|
|
(
|
|
Self {
|
|
content: markdown::Content::parse(INITIAL_CONTENT),
|
|
raw: text_editor::Content::with_text(INITIAL_CONTENT),
|
|
images: HashMap::new(),
|
|
mode: Mode::Preview,
|
|
theme: Theme::TokyoNight,
|
|
now: Instant::now(),
|
|
},
|
|
widget::focus_next(),
|
|
)
|
|
}
|
|
|
|
fn update(&mut self, message: Message) -> Task<Message> {
|
|
match message {
|
|
Message::Edit(action) => {
|
|
let is_edit = action.is_edit();
|
|
|
|
self.raw.perform(action);
|
|
|
|
if is_edit {
|
|
self.content = markdown::Content::parse(&self.raw.text());
|
|
self.mode = Mode::Preview;
|
|
|
|
let images = self.content.images();
|
|
self.images.retain(|url, _image| images.contains(url));
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
Message::LinkClicked(link) => {
|
|
let _ = open::that_in_background(link.to_string());
|
|
|
|
Task::none()
|
|
}
|
|
Message::ImageShown(url) => {
|
|
if self.images.contains_key(&url) {
|
|
return Task::none();
|
|
}
|
|
|
|
let (download_image, handle) = Task::future({
|
|
let url = url.clone();
|
|
|
|
async move {
|
|
// Wait half a second for further editions before attempting download
|
|
tokio::time::sleep(milliseconds(500)).await;
|
|
download_image(url).await
|
|
}
|
|
})
|
|
.abortable();
|
|
|
|
let _ = self.images.insert(
|
|
url.clone(),
|
|
Image::Loading {
|
|
_download: handle.abort_on_drop(),
|
|
},
|
|
);
|
|
|
|
download_image.map(move |result| {
|
|
Message::ImageDownloaded(url.clone(), result)
|
|
})
|
|
}
|
|
Message::ImageDownloaded(url, result) => {
|
|
let _ = self.images.insert(
|
|
url,
|
|
result
|
|
.map(|handle| Image::Ready {
|
|
handle,
|
|
fade_in: Animation::new(false)
|
|
.quick()
|
|
.easing(animation::Easing::EaseInOut)
|
|
.go(true),
|
|
})
|
|
.unwrap_or_else(Image::Errored),
|
|
);
|
|
|
|
Task::none()
|
|
}
|
|
Message::ToggleStream(enable_stream) => {
|
|
if enable_stream {
|
|
self.mode = Mode::Stream {
|
|
pending: self.raw.text(),
|
|
};
|
|
|
|
scrollable::snap_to(
|
|
"preview",
|
|
scrollable::RelativeOffset::END,
|
|
)
|
|
} else {
|
|
self.mode = Mode::Preview;
|
|
|
|
Task::none()
|
|
}
|
|
}
|
|
Message::NextToken => {
|
|
match &mut self.mode {
|
|
Mode::Preview => {}
|
|
Mode::Stream { pending } => {
|
|
if pending.is_empty() {
|
|
self.mode = Mode::Preview;
|
|
} else {
|
|
let mut tokens = pending.split(' ');
|
|
|
|
if let Some(token) = tokens.next() {
|
|
self.content.push_str(&format!("{token} "));
|
|
}
|
|
|
|
*pending = tokens.collect::<Vec<_>>().join(" ");
|
|
}
|
|
}
|
|
}
|
|
|
|
Task::none()
|
|
}
|
|
Message::Animate(now) => {
|
|
self.now = now;
|
|
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn view(&self) -> Element<Message> {
|
|
let editor = text_editor(&self.raw)
|
|
.placeholder("Type your Markdown here...")
|
|
.on_action(Message::Edit)
|
|
.height(Fill)
|
|
.padding(10)
|
|
.font(Font::MONOSPACE)
|
|
.highlight("markdown", highlighter::Theme::Base16Ocean);
|
|
|
|
let preview = markdown::view_with(
|
|
self.content.items(),
|
|
&self.theme,
|
|
&MarkdownViewer {
|
|
images: &self.images,
|
|
now: self.now,
|
|
},
|
|
);
|
|
|
|
row![
|
|
editor,
|
|
hover(
|
|
scrollable(preview)
|
|
.spacing(10)
|
|
.width(Fill)
|
|
.height(Fill)
|
|
.id("preview"),
|
|
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> {
|
|
let listen_stream = match self.mode {
|
|
Mode::Preview => Subscription::none(),
|
|
Mode::Stream { .. } => {
|
|
time::every(milliseconds(10)).map(|_| Message::NextToken)
|
|
}
|
|
};
|
|
|
|
let animate = {
|
|
let is_animating = self.images.values().any(|image| match image {
|
|
Image::Ready { fade_in, .. } => fade_in.is_animating(self.now),
|
|
_ => false,
|
|
});
|
|
|
|
if is_animating {
|
|
window::frames().map(Message::Animate)
|
|
} else {
|
|
Subscription::none()
|
|
}
|
|
};
|
|
|
|
Subscription::batch([listen_stream, animate])
|
|
}
|
|
}
|
|
|
|
struct MarkdownViewer<'a> {
|
|
images: &'a HashMap<markdown::Url, Image>,
|
|
now: Instant,
|
|
}
|
|
|
|
impl<'a> markdown::Viewer<'a, Message> for MarkdownViewer<'a> {
|
|
fn on_link_clicked(url: markdown::Url) -> Message {
|
|
Message::LinkClicked(url)
|
|
}
|
|
|
|
fn image(
|
|
&self,
|
|
_settings: markdown::Settings,
|
|
_title: &markdown::Text,
|
|
url: &'a markdown::Url,
|
|
) -> Element<'a, Message> {
|
|
if let Some(Image::Ready { handle, fade_in }) = self.images.get(url) {
|
|
center_x(
|
|
image(handle)
|
|
.opacity(fade_in.interpolate(0.0, 1.0, self.now))
|
|
.scale(fade_in.interpolate(1.2, 1.0, self.now)),
|
|
)
|
|
.into()
|
|
} else {
|
|
pop(horizontal_space())
|
|
.key(url.as_str())
|
|
.on_show(|_size| Message::ImageShown(url.clone()))
|
|
.into()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn download_image(url: markdown::Url) -> Result<image::Handle, Error> {
|
|
use std::io;
|
|
use tokio::task;
|
|
|
|
println!("Trying to download image: {url}");
|
|
|
|
let client = reqwest::Client::new();
|
|
|
|
let bytes = client
|
|
.get(url)
|
|
.send()
|
|
.await?
|
|
.error_for_status()?
|
|
.bytes()
|
|
.await?;
|
|
|
|
let image = task::spawn_blocking(move || {
|
|
Ok::<_, Error>(
|
|
::image::ImageReader::new(io::Cursor::new(bytes))
|
|
.with_guessed_format()?
|
|
.decode()?
|
|
.to_rgba8(),
|
|
)
|
|
})
|
|
.await??;
|
|
|
|
Ok(image::Handle::from_rgba(
|
|
image.width(),
|
|
image.height(),
|
|
image.into_raw(),
|
|
))
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Error {
|
|
RequestFailed(Arc<reqwest::Error>),
|
|
IOFailed(Arc<io::Error>),
|
|
JoinFailed(Arc<tokio::task::JoinError>),
|
|
ImageDecodingFailed(Arc<::image::ImageError>),
|
|
}
|
|
|
|
impl From<reqwest::Error> for Error {
|
|
fn from(error: reqwest::Error) -> Self {
|
|
Self::RequestFailed(Arc::new(error))
|
|
}
|
|
}
|
|
|
|
impl From<io::Error> for Error {
|
|
fn from(error: io::Error) -> Self {
|
|
Self::IOFailed(Arc::new(error))
|
|
}
|
|
}
|
|
|
|
impl From<tokio::task::JoinError> for Error {
|
|
fn from(error: tokio::task::JoinError) -> Self {
|
|
Self::JoinFailed(Arc::new(error))
|
|
}
|
|
}
|
|
|
|
impl From<::image::ImageError> for Error {
|
|
fn from(error: ::image::ImageError) -> Self {
|
|
Self::ImageDecodingFailed(Arc::new(error))
|
|
}
|
|
}
|