diff --git a/Cargo.toml b/Cargo.toml index 47d408f..d3f2745 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,10 @@ iced = { git = "https://github.com/iced-rs/iced.git", features = [ "image", "sipper", "tokio", + "unconditional-rendering" ] } +iced_renderer = {version = "0.13.0",features = ["wgpu","tiny-skia"]} +iced_aw = {git = "https://github.com/iced-rs/iced_aw.git",features = ["menu"]} tokio = { version = "1.45.1", features = ["full"] } reqwest = "0.12.20" tracing-subscriber = { version = "0.3.19", features = [ @@ -49,7 +52,9 @@ num_enum = "0.7.4" trace = "0.1.7" tracing = "0.1.41" log = "0.4.27" +dirs = "6.0.0" [build-dependencies] embed-resource = "3.0.3" + diff --git a/README.md b/README.md index 20158da..be79e97 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ ## 待办 - +* 下载成功的日志以Toast消息形式呈现 +* 软件更新以消息弹窗的形式呈现 +* 关注iced_aw项目,该项目与现有iced版本并不兼容,期待更新 ## 周期性待办 @@ -23,3 +25,8 @@ ## 忐忑 * 本软件的3D封装下载调用了jlc的api,不知道哪天就收到了某函,所以暂时只在本站开源了,在未想办法解决掉该可能引起纠纷的事项之前不想广泛传播,所以也请各位道友手下留情,不要随意传播本软件 + +## 笔记 +* 如需窗口透明可以参考gradient例程 +* 加载动画参见loading_spinners例程 + diff --git a/src/main.rs b/src/main.rs index e41572e..18679d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,11 @@ +#![windows_subsystem = "windows"] + + mod ui; mod utils; mod widgets; fn main() { tracing_subscriber::fmt::init(); - unsafe { - use utils::winsparkle::*; - win_sparkle_set_appcast_url( - "https://dl.wuembed.com/hardware_tk/appcast.xml\0".as_ptr() as *const i8 - ); - // win_sparkle_set_eddsa_public_key( - // "pXr0FyLTCvtX2BP7d/i3Ot8T9hL+ODBQforwfBp2oLo=\0".as_ptr() as *const i8 - // ); - win_sparkle_init(); - } ui::main_window::main_window(); } diff --git a/src/ui/home_page.rs b/src/ui/home_page.rs index 549b70b..86f2727 100644 --- a/src/ui/home_page.rs +++ b/src/ui/home_page.rs @@ -1,18 +1,27 @@ -use iced::{alignment::Horizontal, widget::Column, Length, Task}; +use iced::{Length, Task, alignment::Horizontal, widget::Column}; use tracing::info; -use crate::utils::winsparkle; - +use crate::widgets::toast; #[allow(unused_imports)] use crate::ui::main_window::{MainWindowMsg, TabContent}; pub struct HomePage { - step_dir: String, + pub step_dir: String, pub theme: iced::Theme, } impl Default for HomePage { fn default() -> Self { + let mut step_dir = String::new(); + if let Ok(dir) = crate::utils::app_settings::get_step_dir(){ + step_dir = dir; + }else{ + if let Some(path) = dirs::download_dir(){ + step_dir = path.to_str().unwrap().to_string(); + }else{ + step_dir = ".\\".to_string(); + } + }; Self { - step_dir: crate::utils::app_settings::get_step_dir().unwrap(), + step_dir, theme: Default::default(), } } @@ -27,6 +36,38 @@ pub enum HomePageMsg { } impl TabContent for HomePage { + type TabMessage = HomePageMsg; + + fn update(&mut self, msg: Self::TabMessage) -> Task { + match msg { + HomePageMsg::Nothing => { + info!("This way ok."); + } + HomePageMsg::OpenStepDir => { + info!("To open the dir."); + let _ = std::process::Command::new(r"explorer.exe") + .arg(self.step_dir.as_str()) + .output() + .unwrap() + .stdout; + } + HomePageMsg::ChooseStepDir => { + info!("To choose the step dir."); + if let Some(path) = rfd::FileDialog::new().pick_folder() { + let path = path.to_str(); + if let Some(path) = path { + self.step_dir = path.to_string(); + let _ = crate::utils::app_settings::set_step_dir(path.to_string().as_str()); + } + } + } + HomePageMsg::CheckUpdate => { + info!("To check update."); + } + } + Task::none() + } + fn content(&self) -> iced::Element<'_, MainWindowMsg> { let info = iced::widget::row![ iced::widget::text("版本:"), @@ -64,41 +105,6 @@ impl TabContent for HomePage { .spacing(5.0) .into() } - - type TabMessage = HomePageMsg; - - fn update(&mut self, msg: Self::TabMessage) -> Task { - match msg { - HomePageMsg::Nothing => { - info!("This way ok."); - } - HomePageMsg::OpenStepDir => { - info!("To open the dir."); - let _ = std::process::Command::new(r"explorer.exe") - .arg(self.step_dir.as_str()) - .output() - .unwrap() - .stdout; - } - HomePageMsg::ChooseStepDir => { - info!("To choose the step dir."); - if let Some(path) = rfd::FileDialog::new().pick_folder() { - let path = path.to_str(); - if let Some(path) = path { - self.step_dir = path.to_string(); - let _ = crate::utils::app_settings::set_step_dir(path.to_string().as_str()); - } - } - } - HomePageMsg::CheckUpdate => { - info!("To check update."); - unsafe{ - winsparkle::win_sparkle_check_update_with_ui_and_install(); - } - } - } - Task::none() - } } impl HomePage { pub fn set_theme(&mut self, theme: iced::Theme) { diff --git a/src/ui/jlc_downloader.rs b/src/ui/jlc_downloader.rs index c827560..d7c0c17 100644 --- a/src/ui/jlc_downloader.rs +++ b/src/ui/jlc_downloader.rs @@ -1,9 +1,11 @@ use crate::ui::main_window::{MainWindowMsg, TabContent}; use crate::utils::step_downloader::{self as downloader, FetchResultItem, SearchResultItem}; +use crate::widgets::toast; #[allow(unused_imports)] use anyhow::Result; use iced::widget::{Row, button, keyed_column}; use iced::{Element, Length, Task, alignment::Horizontal, widget::Column}; +use tokio::io::AsyncWriteExt; use tracing::info; pub struct JlcDownloader { @@ -16,10 +18,10 @@ pub struct JlcDownloader { msg_disp: String, step_data: String, theme: iced::Theme, - item_clickable:bool, - current_step_content:String, - current_step_name:String, - download_log:Vec, + item_clickable: bool, + current_step_content: String, + current_step_name: String, + pub step_save_dir: String, } impl JlcDownloader { pub fn set_theme(&mut self, theme: iced::Theme) { @@ -50,7 +52,7 @@ impl JlcDownloader { let theme = self.theme.clone(); let palette = theme.extended_palette(); let info = iced::widget::column![ - iced::widget::text(format!("元件:{}",item.name)), + iced::widget::text(format!("元件:{}", item.name)), iced::widget::text(format!("编号:{}", item.code)), iced::widget::text(format!("模型:{}", item.model)), ] @@ -61,15 +63,15 @@ impl JlcDownloader { .push(iced::widget::Image::new(img.clone()).width(Length::FillPortion(1))); } btn_content = btn_content.push(info); - if self.item_clickable{ - let attr = DownloadAttr{ - name:item.name.clone(), - id:item.model_id.clone(), + if self.item_clickable { + let attr = DownloadAttr { + name: item.name.clone(), + id: item.model_id.clone(), }; iced::widget::Button::new(btn_content).on_press(MainWindowMsg::JlcDownloader( JlcDownloaderMsg::PartClicked(attr), )) - }else{ + } else { iced::widget::Button::new(btn_content) } } @@ -89,7 +91,7 @@ impl Default for JlcDownloader { item_clickable: true, current_step_content: "".to_string(), current_step_name: "".to_string(), - download_log: vec![], + step_save_dir: String::new(), } } } @@ -108,10 +110,10 @@ pub enum JlcDownloaderMsg { OpenDatasheet(String), SearchPart, } -#[derive(Debug,Clone,PartialEq,Eq)] -struct DownloadAttr{ - name:String, - id:String, +#[derive(Debug, Clone, PartialEq, Eq)] +struct DownloadAttr { + name: String, + id: String, } impl TabContent for JlcDownloader { type TabMessage = JlcDownloaderMsg; @@ -179,12 +181,60 @@ impl TabContent for JlcDownloader { }); } JlcDownloaderMsg::StepFetched(s) => { - self.download_log.push(format!("3D for {} 下载成功",self.current_step_name)); + // MainWindowMsg::Toast(toast::Toast{title:"下载成功".into(),body:format!("{}的模型下载成功",s),status:toast::Status::Success}); self.item_clickable = true; + let model_name = self.current_step_name.clone(); + let mut step_dir = String::new(); + if let Ok(dir) = crate::utils::app_settings::get_step_dir() { + step_dir = dir; + } else { + step_dir = self.step_save_dir.clone(); + } + return Task::perform( + async move { + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + let file_path = format!("{step_dir}\\{model_name}.step"); + if let Ok(mut f) = tokio::fs::File::create(file_path.clone()).await{ + if f.write_all(s.as_str().as_bytes()).await.is_ok(){ + return (true,file_path); + } + } + return (false,file_path) + }, + |(rst,path)| { + let title = match rst{ + true=>"下载成功".to_string(), + false=>"下载失败".to_string(), + }; + let body = match rst{ + true=>format!("Step模型存储到{path}"), + false=> "Step模型下载成功但无法保存,请检查是否有写入权限".to_string(), + }; + let status = match rst{ + true=>toast::Status::Success, + false=>toast::Status::Danger, + }; + MainWindowMsg::Toast(toast::Toast { + title, + body, + status + }) + }, + ); } JlcDownloaderMsg::StepFetchErr(e) => { self.item_clickable = true; - info!("JlcDownloaderMsg::StepFetchError({:?})", e); + let model_name = self.current_step_name.clone(); + return Task::perform( + async { tokio::time::sleep(tokio::time::Duration::from_millis(10)) }, + move |_| { + MainWindowMsg::Toast(toast::Toast { + title: "下载失败".into(), + body: format!("{}的模型下载失败", model_name), + status: toast::Status::Danger, + }) + }, + ); } JlcDownloaderMsg::OpenDatasheet(url) => { todo!("To open the url!"); @@ -212,10 +262,7 @@ impl TabContent for JlcDownloader { results = results.push(self.create_fetched_item_button(&item, 1000)); } - let mut logs = iced::widget::column!["预览暂不可用,期待后期iced更新","日志:"]; - for item in self.download_log.iter(){ - logs = logs.push(iced::widget::text(item.clone())); - } + let mut logs = iced::widget::column!["预览暂不可用,期待后期iced更新",]; let body = iced::widget::row![ iced::widget::column![ iced::widget::text(self.msg_disp.clone()).height(Length::Shrink), diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 0ccb381..d653733 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -3,11 +3,12 @@ use crate::ui::home_page::HomePage; use crate::ui::home_page::HomePageMsg; use crate::ui::jlc_downloader::JlcDownloaderMsg; use crate::ui::part_viewer::PartViewerMsg; -use iced::color; use iced::Subscription; use iced::Task; +use iced::color; use tracing::info; use tracing::warn; +use crate::widgets::toast; #[allow(unused_imports)] use super::db_browser; @@ -17,16 +18,16 @@ use super::home_page; use super::jlc_downloader; #[allow(unused_imports)] use super::part_viewer; +use iced::Theme; #[allow(unused_imports)] use iced::widget as w; use iced::widget::button; use iced::widget::row; -use iced::Theme; #[allow(unused_imports)] use iced::{ - alignment::{Horizontal, Vertical}, widget::{column, Column, Container, Text}, - Element, - Length, + Element, Length, + alignment::{Horizontal, Vertical}, + widget::{Column, Container, Text, column}, }; use std::fmt::Display; @@ -40,6 +41,7 @@ struct MainWindow { db_browser: crate::ui::db_browser::DbBrowser, part_viewer: crate::ui::part_viewer::PartViewer, explain: bool, + toasts:Vec, } impl Default for MainWindow { fn default() -> Self { @@ -51,6 +53,7 @@ impl Default for MainWindow { } let mut jlc_downloader = crate::ui::jlc_downloader::JlcDownloader::default(); jlc_downloader.set_theme(theme.clone()); + jlc_downloader.step_save_dir = home_page.step_dir.clone(); Self { title: "HardwareToolkit".into(), theme, @@ -60,6 +63,7 @@ impl Default for MainWindow { db_browser: Default::default(), part_viewer: Default::default(), explain: false, + toasts:Default::default(), } } } @@ -90,6 +94,8 @@ pub enum MainWindowMsg { DbBrowser(DbBrowserMsg), PartViewer(PartViewerMsg), Explain(bool), + Toast(toast::Toast), + CloseToast(usize), Nothing, } @@ -220,6 +226,14 @@ impl MainWindow { self.explain = explain; Task::none() } + MainWindowMsg::Toast(t)=>{ + self.toasts.push(t.clone()); + Task::none() + } + MainWindowMsg::CloseToast(i)=>{ + self.toasts.remove(i); + Task::none() + } } } fn view(&self) -> Element<'_, MainWindowMsg> { @@ -262,7 +276,9 @@ impl MainWindow { } }; // let content = iced::widget::Button::new("Click").on_press(MainWindowMsg::Nothing); - column![h, v].into() + + //let content = column![h, v].into(); + toast::Manager::new(column![h,v], &self.toasts, MainWindowMsg::CloseToast).timeout(8).into() } } diff --git a/src/utils/gitea.rs b/src/utils/gitea.rs new file mode 100644 index 0000000..eaa0538 --- /dev/null +++ b/src/utils/gitea.rs @@ -0,0 +1,77 @@ +use reqwest; +const GET_URL:&str = "https://git.wuembed.com/api/v1/repos/z/hardware_toolkit/releases/latest"; + +/// example json content: +/// +/// { +// "id": 3, +// "tag_name": "0.0.3", +// "target_commitish": "master", +// "name": "测试发布标题", +// "body": "测试版本描述", +// "url": "https://git.wuembed.com/api/v1/repos/z/hardware_toolkit/releases/3", +// "html_url": "https://git.wuembed.com/z/hardware_toolkit/releases/tag/0.0.3", +// "tarball_url": "https://git.wuembed.com/z/hardware_toolkit/archive/0.0.3.tar.gz", +// "zipball_url": "https://git.wuembed.com/z/hardware_toolkit/archive/0.0.3.zip", +// "upload_url": "https://git.wuembed.com/api/v1/repos/z/hardware_toolkit/releases/3/assets", +// "draft": false, +// "prerelease": false, +// "created_at": "2025-07-04T01:23:07+08:00", +// "published_at": "2025-07-04T01:23:07+08:00", +// "author": { +// "id": 1, +// "login": "z", +// "login_name": "", +// "source_id": 0, +// "full_name": "", +// "email": "z@noreply.localhost", +// "avatar_url": "https://git.wuembed.com/avatars/8b2edd24e3e5e88d3e78b5d40989440439931b3bcf7f3702ce87e5b345080c72", +// "html_url": "https://git.wuembed.com/z", +// "language": "", +// "is_admin": false, +// "last_login": "0001-01-01T00:00:00Z", +// "created": "2025-06-18T16:21:24+08:00", +// "restricted": false, +// "active": false, +// "prohibit_login": false, +// "location": "", +// "website": "", +// "description": "", +// "visibility": "public", +// "followers_count": 0, +// "following_count": 0, +// "starred_repos_count": 0, +// "username": "z" +// }, +// "assets": [ +// { +// "id": 2, +// "name": "hardware_toolkit.exe", +// "size": 41979904, +// "download_count": 0, +// "created_at": "2025-07-04T01:23:05+08:00", +// "uuid": "a1bc2d0d-bf43-4eb5-b28b-26bdd413c4ce", +// "browser_download_url": "https://git.wuembed.com/z/hardware_toolkit/releases/download/0.0.3/hardware_toolkit.exe" +// } +// ] +// } + +#[derive(serde::Serialize,serde::Deserialize)] +struct Assets{ + id:u32, + name:String, + size:usize, + download_count:u32, + created_at:String, + browser_download_url:String, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct ReleaseTag{ + // 对标于版本号 + tag_name:String, + // 对标于发布标题 + name:String, + // 对标于版本描述 + body:String, +} + diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1dc88ba..6a492ea 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ pub mod app_settings; pub mod lazy; pub mod step_downloader; -pub mod winsparkle; +pub mod gitea; diff --git a/src/utils/step_downloader.rs b/src/utils/step_downloader.rs index 0062746..929091f 100644 --- a/src/utils/step_downloader.rs +++ b/src/utils/step_downloader.rs @@ -14,10 +14,10 @@ pub struct SearchResultItem { pub struct FetchResultItem { pub imgs: Vec, pub name: String, - pub model:String, - pub code :String, + pub model: String, + pub code: String, pub model_id: String, - pub datasheet:String, + pub datasheet: String, } /// 访问一次api,得到需要的部分数据 pub async fn search_keyword( @@ -49,7 +49,7 @@ pub async fn search_keyword( } let ii = SearchResultItem { name: i.model.clone(), - code:i.code.clone(), + code: i.code.clone(), has_device: has, img_urls: imgs, }; @@ -78,13 +78,14 @@ pub async fn fetch_item(item: SearchResultItem) -> Result { let mut footprint = String::new(); let mut datasheet = String::new(); - if let serde_json::Value::String(t) = v["result"][0]["title"].clone(){ + if let serde_json::Value::String(t) = v["result"][0]["title"].clone() { title = t; } - if let serde_json::Value::String(t) = v["result"][0]["attributes"]["Supplier Footprint"].clone(){ + if let serde_json::Value::String(t) = v["result"][0]["attributes"]["Supplier Footprint"].clone() + { footprint = t; } - if let serde_json::Value::String(t) = v["result"][0]["attributes"]["Datasheet"].clone(){ + if let serde_json::Value::String(t) = v["result"][0]["attributes"]["Datasheet"].clone() { datasheet = t; } @@ -99,7 +100,7 @@ pub async fn fetch_item(item: SearchResultItem) -> Result { let data_str: DataStr = serde_json::from_str(data_str.as_str())?; if let Some(m) = data_str.model { step_id = m; - }else{ + } else { return Err(anyhow::Error::msg("Failed to get model")); } } @@ -115,8 +116,8 @@ pub async fn fetch_item(item: SearchResultItem) -> Result { let rst = FetchResultItem { name: item.name.clone(), model_id: step_id, - model:footprint, - code:item.code, + model: footprint, + code: item.code, imgs, datasheet, }; @@ -147,8 +148,7 @@ async fn search_model_id(uuid: &str) -> Result { .send() .await?; let text = resp.text().await?; - info!("In search_model_id: The v is : {}",text.clone()); - return Ok(text) + return Ok(text); } #[derive(Clone, Default, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)] struct KeywordSearchRoot { diff --git a/src/utils/winsparkle.rs b/src/utils/winsparkle.rs deleted file mode 100644 index 7f69164..0000000 --- a/src/utils/winsparkle.rs +++ /dev/null @@ -1,68 +0,0 @@ -// lib.rs -// winsparkle-sys - -#![cfg(target_os = "windows")] - -// Link to the WinSparkle.dll -#[cfg_attr(target_os = "windows", link(name = "WinSparkle", kind = "dylib"))] -#[allow(dead_code)] -unsafe extern "C" { - /// Initialize WinSparkle. - /// - /// This initializes WinSparkle and should be called at application startup. - pub fn win_sparkle_init(); - - /// Clean up after WinSparkle. - /// - /// Should be called at application shutdown. - /// Cancels any pending update checks and shuts down its helper threads. - pub fn win_sparkle_cleanup(); - - /// Set the URL for the appcast file. - /// - /// This specifies the URL where WinSparkle will look for updates. - pub fn win_sparkle_set_appcast_url(url: *const i8); - - /// Set DSA public key. - /// - /// Only PEM format is supported. - /// Public key will be used to verify DSA signatures of the update file. - /// PEM data will be set only if it contains valid DSA public key. - /// - /// If this function isn't called by the app, public key is obtained from - /// Windows resource named "DSAPub" of type "DSAPEM". - /// - /// returns 1 if valid DSA public key provided, 0 otherwise. - pub fn win_sparkle_set_dsa_pub_pem(dsa_pub_pem: *const i8) -> i32; - - /// Set EDDSA public key. - /// The function above should not be used. - pub unsafe fn win_sparkle_set_eddsa_public_key(eddsa_pub_pem: *const i8) -> i32; - - /// Set the path in the registry where WinSparkle will store its settings. - /// - /// This sets the path where WinSparkle will store its settings in the registry. - pub fn win_sparkle_set_registry_path(path: *const i8); - - /// Set the callback function for handling shutdown requests. - /// - /// This sets the callback function that WinSparkle will call when it receives a - /// request to shut down the application during an update. - pub fn win_sparkle_set_shutdown_request_callback(callback: Option ()>); - - /// Check for updates with the WinSparkle UI. - /// - /// This checks for updates and displays the WinSparkle UI if an update is available. - pub fn win_sparkle_check_update_with_ui(); - - /// Check for updates with the WinSparkle UI - /// and immediately install the update if one is available. - pub fn win_sparkle_check_update_with_ui_and_install(); - - /// Check for updates. - /// - /// No progress UI is shown to the user when checking. - /// If an update is available, the usual "update available" UI is shown. - /// This function is *not* completely UI-less. - pub fn win_sparkle_check_update_without_ui(); -} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index cb723e7..6bc63a2 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1 +1,2 @@ pub mod tab_header; +pub(crate) mod toast; diff --git a/src/widgets/toast.rs b/src/widgets/toast.rs new file mode 100644 index 0000000..b626375 --- /dev/null +++ b/src/widgets/toast.rs @@ -0,0 +1,479 @@ + +use std::fmt; + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::overlay; +use iced::advanced::renderer; +use iced::advanced::widget::{self, Operation, Tree}; +use iced::advanced::{Clipboard, Shell, Widget}; +use iced::mouse; +use iced::theme; +use iced::time::{self, Duration, Instant}; +use iced::widget::{button, column, container, horizontal_rule, horizontal_space, row, text}; +use iced::window; +use iced::{ + Alignment, Center, Element, Event, Fill, Length, Point, Rectangle, Renderer, Size, Theme, + Vector, +}; + +pub const DEFAULT_TIMEOUT: u64 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Status { + #[default] + Primary, + Secondary, + Success, + Danger, +} + +impl Status { + pub const ALL: &'static [Self] = &[Self::Primary, Self::Secondary, Self::Success, Self::Danger]; +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Status::Primary => "Primary", + Status::Secondary => "Secondary", + Status::Success => "Success", + Status::Danger => "Danger", + } + .fmt(f) + } +} + +#[derive(Debug, Clone, Default)] +pub struct Toast { + pub title: String, + pub body: String, + pub status: Status, +} + +pub struct Manager<'a, Message> { + content: Element<'a, Message>, + toasts: Vec>, + timeout_secs: u64, + on_close: Box Message + 'a>, +} + +impl<'a, Message> Manager<'a, Message> +where + Message: 'a + Clone, +{ + pub fn new( + content: impl Into>, + toasts: &'a [Toast], + on_close: impl Fn(usize) -> Message + 'a, + ) -> Self { + let toasts = toasts + .iter() + .enumerate() + .map(|(index, toast)| { + container(column![ + container( + row![ + text(toast.title.as_str()), + horizontal_space(), + button("X").on_press((on_close)(index)).padding(3), + ] + .align_y(Center) + ) + .width(Fill) + .padding(5) + .style(match toast.status { + Status::Primary => primary, + Status::Secondary => secondary, + Status::Success => success, + Status::Danger => danger, + }), + horizontal_rule(1), + container(text(toast.body.as_str())) + .width(Fill) + .padding(5) + .style(container::rounded_box), + ]) + .max_width(600) + .into() + }) + .collect(); + + Self { + content: content.into(), + toasts, + timeout_secs: DEFAULT_TIMEOUT, + on_close: Box::new(on_close), + } + } + + pub fn timeout(self, seconds: u64) -> Self { + Self { + timeout_secs: seconds, + ..self + } + } +} + +impl Widget for Manager<'_, Message> { + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn tag(&self) -> widget::tree::Tag { + struct Marker; + widget::tree::Tag::of::() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Vec::>::new()) + } + + fn children(&self) -> Vec { + std::iter::once(Tree::new(&self.content)) + .chain(self.toasts.iter().map(Tree::new)) + .collect() + } + + fn diff(&self, tree: &mut Tree) { + let instants = tree.state.downcast_mut::>>(); + + // Invalidating removed instants to None allows us to remove + // them here so that diffing for removed / new toast instants + // is accurate + instants.retain(Option::is_some); + + match (instants.len(), self.toasts.len()) { + (old, new) if old > new => { + instants.truncate(new); + } + (old, new) if old < new => { + instants.extend(std::iter::repeat_n(Some(Instant::now()), new - old)); + } + _ => {} + } + + tree.diff_children( + &std::iter::once(&self.content) + .chain(self.toasts.iter()) + .collect::>(), + ); + } + + fn operate( + &self, + state: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.content + .as_widget() + .operate(&mut state.children[0], layout, renderer, operation); + }); + } + + fn update( + &mut self, + state: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut state.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + fn draw( + &self, + state: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &state.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &state.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let instants = state.state.downcast_mut::>>(); + + let (content_state, toasts_state) = state.children.split_at_mut(1); + + let content = self.content.as_widget_mut().overlay( + &mut content_state[0], + layout, + renderer, + viewport, + translation, + ); + + let toasts = (!self.toasts.is_empty()).then(|| { + overlay::Element::new(Box::new(Overlay { + position: layout.bounds().position() + translation, + viewport: *viewport, + toasts: &mut self.toasts, + state: toasts_state, + instants, + on_close: &self.on_close, + timeout_secs: self.timeout_secs, + })) + }); + let overlays = content.into_iter().chain(toasts).collect::>(); + + (!overlays.is_empty()).then(|| overlay::Group::with_children(overlays).overlay()) + } +} + +struct Overlay<'a, 'b, Message> { + position: Point, + viewport: Rectangle, + toasts: &'b mut [Element<'a, Message>], + state: &'b mut [Tree], + instants: &'b mut [Option], + on_close: &'b dyn Fn(usize) -> Message, + timeout_secs: u64, +} + +impl overlay::Overlay for Overlay<'_, '_, Message> { + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let limits = layout::Limits::new(Size::ZERO, bounds); + + layout::flex::resolve( + layout::flex::Axis::Vertical, + renderer, + &limits, + Fill, + Fill, + 10.into(), + 10.0, + Alignment::End, + self.toasts, + self.state, + ) + .translate(Vector::new(self.position.x, self.position.y)) + } + + fn update( + &mut self, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) { + if let Event::Window(window::Event::RedrawRequested(now)) = &event { + self.instants + .iter_mut() + .enumerate() + .for_each(|(index, maybe_instant)| { + if let Some(instant) = maybe_instant.as_mut() { + let remaining = + time::seconds(self.timeout_secs).saturating_sub(instant.elapsed()); + + if remaining == Duration::ZERO { + maybe_instant.take(); + shell.publish((self.on_close)(index)); + } else { + shell.request_redraw_at(*now + remaining); + } + } + }); + } + + let viewport = layout.bounds(); + + for (((child, state), layout), instant) in self + .toasts + .iter_mut() + .zip(self.state.iter_mut()) + .zip(layout.children()) + .zip(self.instants.iter_mut()) + { + let mut local_messages = vec![]; + let mut local_shell = Shell::new(&mut local_messages); + + child.as_widget_mut().update( + state, + event, + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + &viewport, + ); + + if !local_shell.is_empty() { + instant.take(); + } + + shell.merge(local_shell, std::convert::identity); + } + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + let viewport = layout.bounds(); + + for ((child, state), layout) in self + .toasts + .iter() + .zip(self.state.iter()) + .zip(layout.children()) + { + child + .as_widget() + .draw(state, renderer, theme, style, layout, cursor, &viewport); + } + } + + fn operate( + &mut self, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.toasts + .iter() + .zip(self.state.iter_mut()) + .zip(layout.children()) + .for_each(|((child, state), layout)| { + child + .as_widget() + .operate(state, layout, renderer, operation); + }); + }); + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + ) -> mouse::Interaction { + self.toasts + .iter() + .zip(self.state.iter()) + .zip(layout.children()) + .map(|((child, state), layout)| { + child + .as_widget() + .mouse_interaction(state, layout, cursor, &self.viewport, renderer) + .max( + cursor + .is_over(layout.bounds()) + .then_some(mouse::Interaction::Idle) + .unwrap_or_default(), + ) + }) + .max() + .unwrap_or_default() + } +} + +impl<'a, Message> From> for Element<'a, Message> +where + Message: 'a, +{ + fn from(manager: Manager<'a, Message>) -> Self { + Element::new(manager) + } +} + +fn styled(pair: theme::palette::Pair) -> container::Style { + container::Style { + background: Some(pair.color.into()), + text_color: pair.text.into(), + ..Default::default() + } +} + +fn primary(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); + + styled(palette.primary.weak) +} + +fn secondary(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); + + styled(palette.secondary.weak) +} + +fn success(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); + + styled(palette.success.weak) +} + +fn danger(theme: &Theme) -> container::Style { + let palette = theme.extended_palette(); + + styled(palette.danger.weak) +}