diff options
Diffstat (limited to 'examples')
25 files changed, 659 insertions, 281 deletions
diff --git a/examples/bezier_tool/src/main.rs b/examples/bezier_tool/src/main.rs index 4d438bd9..95ad299d 100644 --- a/examples/bezier_tool/src/main.rs +++ b/examples/bezier_tool/src/main.rs @@ -93,7 +93,7 @@ mod bezier { fn update( &self, state: &mut Self::State, - event: Event, + event: &Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option<canvas::Action<Curve>> { diff --git a/examples/changelog/src/main.rs b/examples/changelog/src/main.rs index f889e757..a1d0d799 100644 --- a/examples/changelog/src/main.rs +++ b/examples/changelog/src/main.rs @@ -267,25 +267,21 @@ impl Generator { } => { let details = { let title = rich_text![ - span(&pull_request.title).size(24).link( - Message::OpenPullRequest(pull_request.id) - ), + span(&pull_request.title) + .size(24) + .link(pull_request.id), span(format!(" by {}", pull_request.author)) .font(Font { style: font::Style::Italic, ..Font::default() }), ] + .on_link_click(Message::OpenPullRequest) .font(Font::MONOSPACE); - let description = markdown::view( - description, - markdown::Settings::default(), - markdown::Style::from_palette( - self.theme().palette(), - ), - ) - .map(Message::UrlClicked); + let description = + markdown(description, self.theme()) + .map(Message::UrlClicked); let labels = row(pull_request.labels.iter().map(|label| { @@ -348,11 +344,11 @@ impl Generator { } else { container( scrollable( - markdown::view( + markdown( preview, - markdown::Settings::with_text_size(12), - markdown::Style::from_palette( - self.theme().palette(), + markdown::Settings::with_text_size( + 12, + self.theme(), ), ) .map(Message::UrlClicked), diff --git a/examples/download_progress/src/download.rs b/examples/download_progress/src/download.rs index d63fb906..5b81f7a2 100644 --- a/examples/download_progress/src/download.rs +++ b/examples/download_progress/src/download.rs @@ -1,16 +1,14 @@ -use iced::futures::{SinkExt, Stream, StreamExt}; -use iced::stream::try_channel; +use iced::futures::StreamExt; +use iced::task::{sipper, Straw}; use std::sync::Arc; -pub fn download( - url: impl AsRef<str>, -) -> impl Stream<Item = Result<Progress, Error>> { - try_channel(1, move |mut output| async move { +pub fn download(url: impl AsRef<str>) -> impl Straw<(), Progress, Error> { + sipper(move |mut progress| async move { let response = reqwest::get(url.as_ref()).await?; let total = response.content_length().ok_or(Error::NoContentLength)?; - let _ = output.send(Progress::Downloading { percent: 0.0 }).await; + let _ = progress.send(Progress { percent: 0.0 }).await; let mut byte_stream = response.bytes_stream(); let mut downloaded = 0; @@ -19,23 +17,20 @@ pub fn download( let bytes = next_bytes?; downloaded += bytes.len(); - let _ = output - .send(Progress::Downloading { + let _ = progress + .send(Progress { percent: 100.0 * downloaded as f32 / total as f32, }) .await; } - let _ = output.send(Progress::Finished).await; - Ok(()) }) } #[derive(Debug, Clone)] -pub enum Progress { - Downloading { percent: f32 }, - Finished, +pub struct Progress { + pub percent: f32, } #[derive(Debug, Clone)] diff --git a/examples/download_progress/src/main.rs b/examples/download_progress/src/main.rs index f4b07203..8082eccd 100644 --- a/examples/download_progress/src/main.rs +++ b/examples/download_progress/src/main.rs @@ -4,7 +4,7 @@ use download::download; use iced::task; use iced::widget::{button, center, column, progress_bar, text, Column}; -use iced::{Center, Element, Right, Task}; +use iced::{Center, Element, Function, Right, Task}; pub fn main() -> iced::Result { iced::application( @@ -25,7 +25,7 @@ struct Example { pub enum Message { Add, Download(usize), - DownloadProgressed(usize, Result<download::Progress, download::Error>), + DownloadUpdated(usize, Update), } impl Example { @@ -52,15 +52,13 @@ impl Example { let task = download.start(); - task.map(move |progress| { - Message::DownloadProgressed(index, progress) - }) + task.map(Message::DownloadUpdated.with(index)) } - Message::DownloadProgressed(id, progress) => { + Message::DownloadUpdated(id, update) => { if let Some(download) = self.downloads.iter_mut().find(|download| download.id == id) { - download.progress(progress); + download.update(update); } Task::none() @@ -95,6 +93,12 @@ struct Download { state: State, } +#[derive(Debug, Clone)] +pub enum Update { + Downloading(download::Progress), + Finished(Result<(), download::Error>), +} + #[derive(Debug)] enum State { Idle, @@ -111,18 +115,20 @@ impl Download { } } - pub fn start( - &mut self, - ) -> Task<Result<download::Progress, download::Error>> { + pub fn start(&mut self) -> Task<Update> { match self.state { State::Idle { .. } | State::Finished { .. } | State::Errored { .. } => { - let (task, handle) = Task::stream(download( - "https://huggingface.co/\ + let (task, handle) = Task::sip( + download( + "https://huggingface.co/\ mattshumer/Reflection-Llama-3.1-70B/\ resolve/main/model-00001-of-00162.safetensors", - )) + ), + Update::Downloading, + Update::Finished, + ) .abortable(); self.state = State::Downloading { @@ -136,20 +142,18 @@ impl Download { } } - pub fn progress( - &mut self, - new_progress: Result<download::Progress, download::Error>, - ) { + pub fn update(&mut self, update: Update) { if let State::Downloading { progress, .. } = &mut self.state { - match new_progress { - Ok(download::Progress::Downloading { percent }) => { - *progress = percent; - } - Ok(download::Progress::Finished) => { - self.state = State::Finished; + match update { + Update::Downloading(new_progress) => { + *progress = new_progress.percent; } - Err(_error) => { - self.state = State::Errored; + Update::Finished(result) => { + self.state = if result.is_ok() { + State::Finished + } else { + State::Errored + }; } } } diff --git a/examples/gallery/Cargo.toml b/examples/gallery/Cargo.toml index 573389b1..6e8aba06 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -17,7 +17,10 @@ serde.features = ["derive"] bytes.workspace = true image.workspace = true +sipper.workspace = true tokio.workspace = true +blurhash = "0.2.3" + [lints] workspace = true diff --git a/examples/gallery/src/civitai.rs b/examples/gallery/src/civitai.rs index 986b6bf2..04589030 100644 --- a/examples/gallery/src/civitai.rs +++ b/examples/gallery/src/civitai.rs @@ -1,5 +1,6 @@ use bytes::Bytes; use serde::Deserialize; +use sipper::{sipper, Straw}; use tokio::task; use std::fmt; @@ -10,6 +11,7 @@ use std::sync::Arc; pub struct Image { pub id: Id, url: String, + hash: String, } impl Image { @@ -40,45 +42,76 @@ impl Image { Ok(response.items) } - pub async fn download(self, size: Size) -> Result<Rgba, Error> { - let client = reqwest::Client::new(); - - let bytes = client - .get(match size { - Size::Original => self.url, - Size::Thumbnail => self - .url - .split("/") - .map(|part| { - if part.starts_with("width=") { - "width=640" - } else { - part - } - }) - .collect::<Vec<_>>() - .join("/"), + pub async fn blurhash( + self, + width: u32, + height: u32, + ) -> Result<Blurhash, Error> { + task::spawn_blocking(move || { + let pixels = blurhash::decode(&self.hash, width, height, 1.0)?; + + Ok::<_, Error>(Blurhash { + rgba: Rgba { + width, + height, + pixels: Bytes::from(pixels), + }, }) - .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??; + .await? + } - Ok(Rgba { - width: image.width(), - height: image.height(), - pixels: Bytes::from(image.into_raw()), + pub fn download(self, size: Size) -> impl Straw<Rgba, Blurhash, Error> { + sipper(move |mut sender| async move { + let client = reqwest::Client::new(); + + if let Size::Thumbnail { width, height } = size { + let image = self.clone(); + + drop(task::spawn(async move { + if let Ok(blurhash) = image.blurhash(width, height).await { + sender.send(blurhash).await; + } + })); + } + + let bytes = client + .get(match size { + Size::Original => self.url, + Size::Thumbnail { width, .. } => self + .url + .split("/") + .map(|part| { + if part.starts_with("width=") { + format!("width={}", width * 2) // High DPI + } else { + part.to_owned() + } + }) + .collect::<Vec<_>>() + .join("/"), + }) + .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(Rgba { + width: image.width(), + height: image.height(), + pixels: Bytes::from(image.into_raw()), + }) }) } } @@ -88,6 +121,11 @@ impl Image { )] pub struct Id(u32); +#[derive(Debug, Clone)] +pub struct Blurhash { + pub rgba: Rgba, +} + #[derive(Clone)] pub struct Rgba { pub width: u32, @@ -107,7 +145,7 @@ impl fmt::Debug for Rgba { #[derive(Debug, Clone, Copy)] pub enum Size { Original, - Thumbnail, + Thumbnail { width: u32, height: u32 }, } #[derive(Debug, Clone)] @@ -117,6 +155,7 @@ pub enum Error { IOFailed(Arc<io::Error>), JoinFailed(Arc<task::JoinError>), ImageDecodingFailed(Arc<image::ImageError>), + BlurhashDecodingFailed(Arc<blurhash::Error>), } impl From<reqwest::Error> for Error { @@ -142,3 +181,9 @@ impl From<image::ImageError> for Error { Self::ImageDecodingFailed(Arc::new(error)) } } + +impl From<blurhash::Error> for Error { + fn from(error: blurhash::Error) -> Self { + Self::BlurhashDecodingFailed(Arc::new(error)) + } +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 290fa6a0..abafaf2d 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -7,14 +7,15 @@ mod civitai; use crate::civitai::{Error, Id, Image, Rgba, Size}; use iced::animation; -use iced::time::Instant; +use iced::time::{milliseconds, Instant}; use iced::widget::{ button, center_x, container, horizontal_space, image, mouse_area, opaque, pop, row, scrollable, stack, }; use iced::window; use iced::{ - color, Animation, ContentFit, Element, Fill, Subscription, Task, Theme, + color, Animation, ContentFit, Element, Fill, Function, Subscription, Task, + Theme, }; use std::collections::HashMap; @@ -28,7 +29,7 @@ fn main() -> iced::Result { struct Gallery { images: Vec<Image>, - thumbnails: HashMap<Id, Thumbnail>, + previews: HashMap<Id, Preview>, viewer: Viewer, now: Instant, } @@ -40,6 +41,7 @@ enum Message { ImageDownloaded(Result<Rgba, Error>), ThumbnailDownloaded(Id, Result<Rgba, Error>), ThumbnailHovered(Id, bool), + BlurhashDecoded(Id, civitai::Blurhash), Open(Id), Close, Animate(Instant), @@ -50,7 +52,7 @@ impl Gallery { ( Self { images: Vec::new(), - thumbnails: HashMap::new(), + previews: HashMap::new(), viewer: Viewer::new(), now: Instant::now(), }, @@ -64,9 +66,9 @@ impl Gallery { pub fn subscription(&self) -> Subscription<Message> { let is_animating = self - .thumbnails + .previews .values() - .any(|thumbnail| thumbnail.is_animating(self.now)) + .any(|preview| preview.is_animating(self.now)) || self.viewer.is_animating(self.now); if is_animating { @@ -93,9 +95,14 @@ impl Gallery { return Task::none(); }; - Task::perform(image.download(Size::Thumbnail), move |result| { - Message::ThumbnailDownloaded(id, result) - }) + Task::sip( + image.download(Size::Thumbnail { + width: Preview::WIDTH, + height: Preview::HEIGHT, + }), + Message::BlurhashDecoded.with(id), + Message::ThumbnailDownloaded.with(id), + ) } Message::ImageDownloaded(Ok(rgba)) => { self.viewer.show(rgba); @@ -103,14 +110,29 @@ impl Gallery { Task::none() } Message::ThumbnailDownloaded(id, Ok(rgba)) => { - let thumbnail = Thumbnail::new(rgba); - let _ = self.thumbnails.insert(id, thumbnail); + let thumbnail = if let Some(preview) = self.previews.remove(&id) + { + preview.load(rgba) + } else { + Preview::ready(rgba) + }; + + let _ = self.previews.insert(id, thumbnail); Task::none() } Message::ThumbnailHovered(id, is_hovered) => { - if let Some(thumbnail) = self.thumbnails.get_mut(&id) { - thumbnail.zoom.go_mut(is_hovered); + if let Some(preview) = self.previews.get_mut(&id) { + preview.toggle_zoom(is_hovered); + } + + Task::none() + } + Message::BlurhashDecoded(id, blurhash) => { + if !self.previews.contains_key(&id) { + let _ = self + .previews + .insert(id, Preview::loading(blurhash.rgba)); } Task::none() @@ -157,7 +179,7 @@ impl Gallery { row((0..=Image::LIMIT).map(|_| placeholder())) } else { row(self.images.iter().map(|image| { - card(image, self.thumbnails.get(&image.id), self.now) + card(image, self.previews.get(&image.id), self.now) })) } .spacing(10) @@ -174,33 +196,52 @@ impl Gallery { fn card<'a>( metadata: &'a Image, - thumbnail: Option<&'a Thumbnail>, + preview: Option<&'a Preview>, now: Instant, ) -> Element<'a, Message> { - let image: Element<'_, _> = if let Some(thumbnail) = thumbnail { - image(&thumbnail.handle) - .width(Fill) - .height(Fill) - .content_fit(ContentFit::Cover) - .opacity(thumbnail.fade_in.interpolate(0.0, 1.0, now)) - .scale(thumbnail.zoom.interpolate(1.0, 1.1, now)) - .into() + let image = if let Some(preview) = preview { + let thumbnail: Element<'_, _> = + if let Preview::Ready { thumbnail, .. } = &preview { + image(&thumbnail.handle) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(thumbnail.fade_in.interpolate(0.0, 1.0, now)) + .scale(thumbnail.zoom.interpolate(1.0, 1.1, now)) + .into() + } else { + horizontal_space().into() + }; + + if let Some(blurhash) = preview.blurhash(now) { + let blurhash = image(&blurhash.handle) + .width(Fill) + .height(Fill) + .content_fit(ContentFit::Cover) + .opacity(blurhash.fade_in.interpolate(0.0, 1.0, now)); + + stack![blurhash, thumbnail].into() + } else { + thumbnail + } } else { horizontal_space().into() }; let card = mouse_area( container(image) - .width(Thumbnail::WIDTH) - .height(Thumbnail::HEIGHT) + .width(Preview::WIDTH) + .height(Preview::HEIGHT) .style(container::dark), ) .on_enter(Message::ThumbnailHovered(metadata.id, true)) .on_exit(Message::ThumbnailHovered(metadata.id, false)); - if thumbnail.is_some() { + if let Some(preview) = preview { + let is_thumbnail = matches!(preview, Preview::Ready { .. }); + button(card) - .on_press(Message::Open(metadata.id)) + .on_press_maybe(is_thumbnail.then_some(Message::Open(metadata.id))) .padding(0) .style(button::text) .into() @@ -213,23 +254,102 @@ fn card<'a>( fn placeholder<'a>() -> Element<'a, Message> { container(horizontal_space()) - .width(Thumbnail::WIDTH) - .height(Thumbnail::HEIGHT) + .width(Preview::WIDTH) + .height(Preview::HEIGHT) .style(container::dark) .into() } +enum Preview { + Loading { + blurhash: Blurhash, + }, + Ready { + blurhash: Option<Blurhash>, + thumbnail: Thumbnail, + }, +} + +struct Blurhash { + handle: image::Handle, + fade_in: Animation<bool>, +} + struct Thumbnail { handle: image::Handle, fade_in: Animation<bool>, zoom: Animation<bool>, } -impl Thumbnail { - const WIDTH: u16 = 320; - const HEIGHT: u16 = 410; +impl Preview { + const WIDTH: u32 = 320; + const HEIGHT: u32 = 410; + + fn loading(rgba: Rgba) -> Self { + Self::Loading { + blurhash: Blurhash { + fade_in: Animation::new(false) + .duration(milliseconds(700)) + .easing(animation::Easing::EaseIn) + .go(true), + handle: image::Handle::from_rgba( + rgba.width, + rgba.height, + rgba.pixels, + ), + }, + } + } - fn new(rgba: Rgba) -> Self { + fn ready(rgba: Rgba) -> Self { + Self::Ready { + blurhash: None, + thumbnail: Thumbnail::new(rgba), + } + } + + fn load(self, rgba: Rgba) -> Self { + let Self::Loading { blurhash } = self else { + return self; + }; + + Self::Ready { + blurhash: Some(blurhash), + thumbnail: Thumbnail::new(rgba), + } + } + + fn toggle_zoom(&mut self, enabled: bool) { + if let Self::Ready { thumbnail, .. } = self { + thumbnail.zoom.go_mut(enabled); + } + } + + fn is_animating(&self, now: Instant) -> bool { + match &self { + Self::Loading { blurhash } => blurhash.fade_in.is_animating(now), + Self::Ready { thumbnail, .. } => { + thumbnail.fade_in.is_animating(now) + || thumbnail.zoom.is_animating(now) + } + } + } + + fn blurhash(&self, now: Instant) -> Option<&Blurhash> { + match self { + Self::Loading { blurhash, .. } => Some(blurhash), + Self::Ready { + blurhash: Some(blurhash), + thumbnail, + .. + } if thumbnail.fade_in.is_animating(now) => Some(blurhash), + Self::Ready { .. } => None, + } + } +} + +impl Thumbnail { + pub fn new(rgba: Rgba) -> Self { Self { handle: image::Handle::from_rgba( rgba.width, @@ -242,10 +362,6 @@ impl Thumbnail { .easing(animation::Easing::EaseInOut), } } - - fn is_animating(&self, now: Instant) -> bool { - self.fade_in.is_animating(now) || self.zoom.is_animating(now) - } } struct Viewer { diff --git a/examples/game_of_life/src/main.rs b/examples/game_of_life/src/main.rs index 1008e477..9516f832 100644 --- a/examples/game_of_life/src/main.rs +++ b/examples/game_of_life/src/main.rs @@ -9,7 +9,7 @@ use iced::time::{self, milliseconds}; use iced::widget::{ button, checkbox, column, container, pick_list, row, slider, text, }; -use iced::{Center, Element, Fill, Subscription, Task, Theme}; +use iced::{Center, Element, Fill, Function, Subscription, Task, Theme}; pub fn main() -> iced::Result { tracing_subscriber::fmt::init(); @@ -37,7 +37,7 @@ struct GameOfLife { #[derive(Debug, Clone)] enum Message { - Grid(grid::Message, usize), + Grid(usize, grid::Message), Tick, TogglePlayback, ToggleGrid(bool), @@ -61,7 +61,7 @@ impl GameOfLife { fn update(&mut self, message: Message) -> Task<Message> { match message { - Message::Grid(message, version) => { + Message::Grid(version, message) => { if version == self.version { self.grid.update(message); } @@ -78,9 +78,7 @@ impl GameOfLife { let version = self.version; - return Task::perform(task, move |message| { - Message::Grid(message, version) - }); + return Task::perform(task, Message::Grid.with(version)); } } Message::TogglePlayback => { @@ -129,9 +127,7 @@ impl GameOfLife { ); let content = column![ - self.grid - .view() - .map(move |message| Message::Grid(message, version)), + self.grid.view().map(Message::Grid.with(version)), controls, ] .height(Fill); @@ -380,7 +376,7 @@ mod grid { fn update( &self, interaction: &mut Interaction, - event: Event, + event: &Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option<canvas::Action<Message>> { @@ -471,7 +467,7 @@ mod grid { _ => action.and_capture(), }) } - mouse::Event::WheelScrolled { delta } => match delta { + mouse::Event::WheelScrolled { delta } => match *delta { mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => { if y < 0.0 && self.scaling > Self::MIN_SCALING diff --git a/examples/loading_spinners/src/circular.rs b/examples/loading_spinners/src/circular.rs index 33232fac..24293138 100644 --- a/examples/loading_spinners/src/circular.rs +++ b/examples/loading_spinners/src/circular.rs @@ -264,7 +264,7 @@ where fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, _layout: Layout<'_>, _cursor: mouse::Cursor, _renderer: &Renderer, @@ -278,7 +278,7 @@ where state.animation = state.animation.timed_transition( self.cycle_duration, self.rotation_duration, - now, + *now, ); state.cache.clear(); diff --git a/examples/loading_spinners/src/linear.rs b/examples/loading_spinners/src/linear.rs index a10b64f0..a6713c7a 100644 --- a/examples/loading_spinners/src/linear.rs +++ b/examples/loading_spinners/src/linear.rs @@ -178,7 +178,7 @@ where fn update( &mut self, tree: &mut Tree, - event: Event, + event: &Event, _layout: Layout<'_>, _cursor: mouse::Cursor, _renderer: &Renderer, @@ -189,7 +189,7 @@ where let state = tree.state.downcast_mut::<State>(); if let Event::Window(window::Event::RedrawRequested(now)) = event { - *state = state.timed_transition(self.cycle_duration, now); + *state = state.timed_transition(self.cycle_duration, *now); shell.request_redraw(); } diff --git a/examples/markdown/Cargo.toml b/examples/markdown/Cargo.toml index fa6ced74..7af1741b 100644 --- a/examples/markdown/Cargo.toml +++ b/examples/markdown/Cargo.toml @@ -7,6 +7,17 @@ publish = false [dependencies] iced.workspace = true -iced.features = ["markdown", "highlighter", "tokio", "debug"] +iced.features = ["markdown", "highlighter", "image", "tokio", "debug"] + +reqwest.version = "0.12" +reqwest.features = ["json"] + +image.workspace = true +tokio.workspace = true open = "5.3" + +# Disabled to keep amount of build dependencies low +# This can be re-enabled on demand +# [build-dependencies] +# iced_fontello = "0.13" diff --git a/examples/markdown/build.rs b/examples/markdown/build.rs new file mode 100644 index 00000000..ecbb7666 --- /dev/null +++ b/examples/markdown/build.rs @@ -0,0 +1,5 @@ +pub fn main() { + // println!("cargo::rerun-if-changed=fonts/markdown-icons.toml"); + // iced_fontello::build("fonts/markdown-icons.toml") + // .expect("Build icons font"); +} diff --git a/examples/markdown/fonts/markdown-icons.toml b/examples/markdown/fonts/markdown-icons.toml new file mode 100644 index 00000000..60c91d17 --- /dev/null +++ b/examples/markdown/fonts/markdown-icons.toml @@ -0,0 +1,4 @@ +module = "icon" + +[glyphs] +copy = "fontawesome-docs" diff --git a/examples/markdown/fonts/markdown-icons.ttf b/examples/markdown/fonts/markdown-icons.ttf Binary files differnew file mode 100644 index 00000000..013f03a5 --- /dev/null +++ b/examples/markdown/fonts/markdown-icons.ttf diff --git a/examples/markdown/src/icon.rs b/examples/markdown/src/icon.rs new file mode 100644 index 00000000..cfe32541 --- /dev/null +++ b/examples/markdown/src/icon.rs @@ -0,0 +1,15 @@ +// Generated automatically by iced_fontello at build time. +// Do not edit manually. Source: ../fonts/markdown-icons.toml +// dcd2f0c969d603e2ee9237a4b70fa86b1a6e84d86f4689046d8fdd10440b06b9 +use iced::widget::{text, Text}; +use iced::Font; + +pub const FONT: &[u8] = include_bytes!("../fonts/markdown-icons.ttf"); + +pub fn copy<'a>() -> Text<'a> { + icon("\u{F0C5}") +} + +fn icon(codepoint: &str) -> Text<'_> { + text(codepoint).font(Font::with_name("markdown-icons")) +} diff --git a/examples/markdown/src/main.rs b/examples/markdown/src/main.rs index ba93ee18..512d4b44 100644 --- a/examples/markdown/src/main.rs +++ b/examples/markdown/src/main.rs @@ -1,50 +1,79 @@ +mod icon; + +use iced::animation; +use iced::clipboard; use iced::highlighter; -use iced::time::{self, milliseconds}; +use iced::task; +use iced::time::{self, milliseconds, Instant}; use iced::widget::{ - self, hover, markdown, right, row, scrollable, text_editor, toggler, + self, button, center_x, container, horizontal_space, hover, image, + markdown, pop, right, row, scrollable, text_editor, toggler, }; -use iced::{Element, Fill, Font, Subscription, Task, Theme}; +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) + .font(icon::FONT) .subscription(Markdown::subscription) .theme(Markdown::theme) .run_with(Markdown::new) } struct Markdown { - content: text_editor::Content, + content: markdown::Content, + raw: text_editor::Content, + images: HashMap<markdown::Url, Image>, mode: Mode, theme: Theme, + now: Instant, } enum Mode { - Preview(Vec<markdown::Item>), - Stream { - pending: String, - parsed: markdown::Content, + 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), + Copy(String), 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"); - let theme = Theme::TokyoNight; - ( Self { - content: text_editor::Content::with_text(INITIAL_CONTENT), - mode: Mode::Preview(markdown::parse(INITIAL_CONTENT).collect()), - theme, + 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(), ) @@ -55,26 +84,73 @@ impl Markdown { Message::Edit(action) => { let is_edit = action.is_edit(); - self.content.perform(action); + self.raw.perform(action); if is_edit { - self.mode = Mode::Preview( - markdown::parse(&self.content.text()).collect(), - ); + 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::Copy(content) => clipboard::write(content), 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.content = markdown::Content::new(); + self.mode = Mode::Stream { - pending: self.content.text(), - parsed: markdown::Content::new(), + pending: self.raw.text(), }; scrollable::snap_to( @@ -82,24 +158,22 @@ impl Markdown { scrollable::RelativeOffset::END, ) } else { - self.mode = Mode::Preview( - markdown::parse(&self.content.text()).collect(), - ); + self.mode = Mode::Preview; Task::none() } } Message::NextToken => { match &mut self.mode { - Mode::Preview(_) => {} - Mode::Stream { pending, parsed } => { + Mode::Preview => {} + Mode::Stream { pending } => { if pending.is_empty() { - self.mode = Mode::Preview(parsed.items().to_vec()); + self.mode = Mode::Preview; } else { let mut tokens = pending.split(' '); if let Some(token) = tokens.next() { - parsed.push_str(&format!("{token} ")); + self.content.push_str(&format!("{token} ")); } *pending = tokens.collect::<Vec<_>>().join(" "); @@ -109,11 +183,16 @@ impl Markdown { Task::none() } + Message::Animate(now) => { + self.now = now; + + Task::none() + } } } fn view(&self) -> Element<Message> { - let editor = text_editor(&self.content) + let editor = text_editor(&self.raw) .placeholder("Type your Markdown here...") .on_action(Message::Edit) .height(Fill) @@ -121,17 +200,14 @@ impl Markdown { .font(Font::MONOSPACE) .highlight("markdown", highlighter::Theme::Base16Ocean); - let items = match &self.mode { - Mode::Preview(items) => items.as_slice(), - Mode::Stream { parsed, .. } => parsed.items(), - }; - - let preview = markdown( - items, - markdown::Settings::default(), - markdown::Style::from_palette(self.theme.palette()), - ) - .map(Message::LinkClicked); + let preview = markdown::view_with( + self.content.items(), + &self.theme, + &CustomViewer { + images: &self.images, + now: self.now, + }, + ); row![ editor, @@ -159,11 +235,146 @@ impl Markdown { } fn subscription(&self) -> Subscription<Message> { - match self.mode { - Mode::Preview(_) => Subscription::none(), + 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 CustomViewer<'a> { + images: &'a HashMap<markdown::Url, Image>, + now: Instant, +} + +impl<'a> markdown::Viewer<'a, Message> for CustomViewer<'a> { + fn on_link_click(url: markdown::Url) -> Message { + Message::LinkClicked(url) + } + + fn image( + &self, + _settings: markdown::Settings, + url: &'a markdown::Url, + _title: &'a str, + _alt: &markdown::Text, + ) -> 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() } } + + fn code_block( + &self, + settings: markdown::Settings, + _language: Option<&'a str>, + code: &'a str, + lines: &'a [markdown::Text], + ) -> Element<'a, Message> { + let code_block = + markdown::code_block(settings, lines, Message::LinkClicked); + + let copy = button(icon::copy().size(12)) + .padding(2) + .on_press_with(|| Message::Copy(code.to_owned())) + .style(button::text); + + hover( + code_block, + right(container(copy).style(container::dark)) + .padding(settings.spacing / 2), + ) + } +} + +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)) + } } diff --git a/examples/multi_window/src/main.rs b/examples/multi_window/src/main.rs index f9021c8d..8cec9d4c 100644 --- a/examples/multi_window/src/main.rs +++ b/examples/multi_window/src/main.rs @@ -3,7 +3,9 @@ use iced::widget::{ text_input, }; use iced::window; -use iced::{Center, Element, Fill, Subscription, Task, Theme, Vector}; +use iced::{ + Center, Element, Fill, Function, Subscription, Task, Theme, Vector, +}; use std::collections::BTreeMap; @@ -169,7 +171,7 @@ impl Window { let scale_input = column![ text("Window scale factor:"), text_input("Window Scale", &self.scale_input) - .on_input(move |msg| { Message::ScaleInputChanged(id, msg) }) + .on_input(Message::ScaleInputChanged.with(id)) .on_submit(Message::ScaleChanged( id, self.scale_input.to_string() @@ -179,7 +181,7 @@ impl Window { let title_input = column![ text("Window title:"), text_input("Window Title", &self.title) - .on_input(move |msg| { Message::TitleChanged(id, msg) }) + .on_input(Message::TitleChanged.with(id)) .id(format!("input-{id}")) ]; diff --git a/examples/multitouch/src/main.rs b/examples/multitouch/src/main.rs index 5f4a5c90..bda3b8f7 100644 --- a/examples/multitouch/src/main.rs +++ b/examples/multitouch/src/main.rs @@ -55,11 +55,11 @@ impl canvas::Program<Message> for Multitouch { fn update( &self, _state: &mut Self::State, - event: Event, + event: &Event, _bounds: Rectangle, _cursor: mouse::Cursor, ) -> Option<canvas::Action<Message>> { - let message = match event { + let message = match event.clone() { Event::Touch( touch::Event::FingerPressed { id, position } | touch::Event::FingerMoved { id, position }, diff --git a/examples/scrollable/src/main.rs b/examples/scrollable/src/main.rs index 6359fb5a..fec4e1b4 100644 --- a/examples/scrollable/src/main.rs +++ b/examples/scrollable/src/main.rs @@ -21,9 +21,9 @@ pub fn main() -> iced::Result { struct ScrollableDemo { scrollable_direction: Direction, - scrollbar_width: u16, - scrollbar_margin: u16, - scroller_width: u16, + scrollbar_width: u32, + scrollbar_margin: u32, + scroller_width: u32, current_scroll_offset: scrollable::RelativeOffset, anchor: scrollable::Anchor, } @@ -39,9 +39,9 @@ enum Direction { enum Message { SwitchDirection(Direction), AlignmentChanged(scrollable::Anchor), - ScrollbarWidthChanged(u16), - ScrollbarMarginChanged(u16), - ScrollerWidthChanged(u16), + ScrollbarWidthChanged(u32), + ScrollbarMarginChanged(u32), + ScrollerWidthChanged(u32), ScrollToBeginning, ScrollToEnd, Scrolled(scrollable::Viewport), diff --git a/examples/sierpinski_triangle/src/main.rs b/examples/sierpinski_triangle/src/main.rs index d4d483f5..a4a89455 100644 --- a/examples/sierpinski_triangle/src/main.rs +++ b/examples/sierpinski_triangle/src/main.rs @@ -76,7 +76,7 @@ impl canvas::Program<Message> for SierpinskiGraph { fn update( &self, _state: &mut Self::State, - event: Event, + event: &Event, bounds: Rectangle, cursor: mouse::Cursor, ) -> Option<canvas::Action<Message>> { diff --git a/examples/system_information/src/main.rs b/examples/system_information/src/main.rs index afa657d8..56f934b7 100644 --- a/examples/system_information/src/main.rs +++ b/examples/system_information/src/main.rs @@ -102,22 +102,22 @@ impl Example { ); let memory_readable = - ByteSize::b(information.memory_total).to_string(); + ByteSize::b(information.memory_total).to_string_as(true); let memory_total = text!( "Memory (total): {} bytes ({memory_readable})", information.memory_total, ); - let memory_text = if let Some(memory_used) = - information.memory_used - { - let memory_readable = ByteSize::b(memory_used).to_string(); + let memory_text = + if let Some(memory_used) = information.memory_used { + let memory_readable = + ByteSize::b(memory_used).to_string_as(true); - format!("{memory_used} bytes ({memory_readable})") - } else { - String::from("None") - }; + format!("{memory_used} bytes ({memory_readable})") + } else { + String::from("None") + }; let memory_used = text!("Memory (used): {memory_text}"); diff --git a/examples/toast/src/main.rs b/examples/toast/src/main.rs index 2ae9bfe2..dc314df8 100644 --- a/examples/toast/src/main.rs +++ b/examples/toast/src/main.rs @@ -361,7 +361,7 @@ mod toast { fn update( &mut self, state: &mut Tree, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, @@ -491,7 +491,7 @@ mod toast { fn update( &mut self, - event: Event, + event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, renderer: &Renderer, @@ -530,7 +530,7 @@ mod toast { child.as_widget_mut().update( state, - event.clone(), + event, layout, cursor, renderer, diff --git a/examples/todos/src/main.rs b/examples/todos/src/main.rs index 74c4c464..033cb122 100644 --- a/examples/todos/src/main.rs +++ b/examples/todos/src/main.rs @@ -4,7 +4,9 @@ use iced::widget::{ scrollable, text, text_input, Text, }; use iced::window; -use iced::{Center, Element, Fill, Font, Subscription, Task as Command}; +use iced::{ + Center, Element, Fill, Font, Function, Subscription, Task as Command, +}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -215,9 +217,8 @@ impl Todos { .map(|(i, task)| { ( task.id, - task.view(i).map(move |message| { - Message::TaskMessage(i, message) - }), + task.view(i) + .map(Message::TaskMessage.with(i)), ) }), ) diff --git a/examples/tour/src/main.rs b/examples/tour/src/main.rs index 32720c47..2ca1df44 100644 --- a/examples/tour/src/main.rs +++ b/examples/tour/src/main.rs @@ -24,12 +24,12 @@ pub struct Tour { screen: Screen, slider: u8, layout: Layout, - spacing: u16, - text_size: u16, + spacing: u32, + text_size: u32, text_color: Color, language: Option<Language>, toggler: bool, - image_width: u16, + image_width: u32, image_filter_method: image::FilterMethod, input_value: String, input_is_secure: bool, @@ -43,11 +43,11 @@ pub enum Message { NextPressed, SliderChanged(u8), LayoutChanged(Layout), - SpacingChanged(u16), - TextSizeChanged(u16), + SpacingChanged(u32), + TextSizeChanged(u32), TextColorChanged(Color), LanguageSelected(Language), - ImageWidthChanged(u16), + ImageWidthChanged(u32), ImageUseNearestToggled(bool), InputChanged(String), ToggleSecureInput(bool), @@ -537,7 +537,7 @@ impl Screen { } fn ferris<'a>( - width: u16, + width: u32, filter_method: image::FilterMethod, ) -> Container<'a, Message> { center_x( diff --git a/examples/websocket/src/echo.rs b/examples/websocket/src/echo.rs index 14652936..149a260c 100644 --- a/examples/websocket/src/echo.rs +++ b/examples/websocket/src/echo.rs @@ -1,73 +1,59 @@ pub mod server; use iced::futures; -use iced::stream; +use iced::task::{sipper, Never, Sipper}; use iced::widget::text; use futures::channel::mpsc; use futures::sink::SinkExt; -use futures::stream::{Stream, StreamExt}; +use futures::stream::StreamExt; use async_tungstenite::tungstenite; use std::fmt; -pub fn connect() -> impl Stream<Item = Event> { - stream::channel(100, |mut output| async move { - let mut state = State::Disconnected; - +pub fn connect() -> impl Sipper<Never, Event> { + sipper(|mut output| async move { loop { - match &mut state { - State::Disconnected => { - const ECHO_SERVER: &str = "ws://127.0.0.1:3030"; + const ECHO_SERVER: &str = "ws://127.0.0.1:3030"; - match async_tungstenite::tokio::connect_async(ECHO_SERVER) - .await - { - Ok((websocket, _)) => { - let (sender, receiver) = mpsc::channel(100); + let (mut websocket, mut input) = + match async_tungstenite::tokio::connect_async(ECHO_SERVER).await + { + Ok((websocket, _)) => { + let (sender, receiver) = mpsc::channel(100); - let _ = output - .send(Event::Connected(Connection(sender))) - .await; + output.send(Event::Connected(Connection(sender))).await; - state = State::Connected(websocket, receiver); - } - Err(_) => { - tokio::time::sleep( - tokio::time::Duration::from_secs(1), - ) + (websocket.fuse(), receiver) + } + Err(_) => { + tokio::time::sleep(tokio::time::Duration::from_secs(1)) .await; - let _ = output.send(Event::Disconnected).await; - } + output.send(Event::Disconnected).await; + continue; } - } - State::Connected(websocket, input) => { - let mut fused_websocket = websocket.by_ref().fuse(); - - futures::select! { - received = fused_websocket.select_next_some() => { - match received { - Ok(tungstenite::Message::Text(message)) => { - let _ = output.send(Event::MessageReceived(Message::User(message))).await; - } - Err(_) => { - let _ = output.send(Event::Disconnected).await; - - state = State::Disconnected; - } - Ok(_) => continue, + }; + + loop { + futures::select! { + received = websocket.select_next_some() => { + match received { + Ok(tungstenite::Message::Text(message)) => { + output.send(Event::MessageReceived(Message::User(message))).await; + } + Err(_) => { + output.send(Event::Disconnected).await; + break; } + Ok(_) => {}, } + } + message = input.select_next_some() => { + let result = websocket.send(tungstenite::Message::Text(message.to_string())).await; - message = input.select_next_some() => { - let result = websocket.send(tungstenite::Message::Text(message.to_string())).await; - - if result.is_err() { - let _ = output.send(Event::Disconnected).await; - - state = State::Disconnected; - } + if result.is_err() { + output.send(Event::Disconnected).await; } } } @@ -76,18 +62,6 @@ pub fn connect() -> impl Stream<Item = Event> { }) } -#[derive(Debug)] -#[allow(clippy::large_enum_variant)] -enum State { - Disconnected, - Connected( - async_tungstenite::WebSocketStream< - async_tungstenite::tokio::ConnectStream, - >, - mpsc::Receiver<Message>, - ), -} - #[derive(Debug, Clone)] pub enum Event { Connected(Connection), |