Compare commits
No commits in common. "dev" and "master" have entirely different histories.
@ -11,10 +11,7 @@ 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 = [
|
||||
@ -52,9 +49,7 @@ 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"
|
||||
|
||||
|
||||
@ -14,9 +14,7 @@
|
||||
|
||||
|
||||
## 待办
|
||||
* 下载成功的日志以Toast消息形式呈现
|
||||
* 软件更新以消息弹窗的形式呈现
|
||||
* 关注iced_aw项目,该项目与现有iced版本并不兼容,期待更新
|
||||
|
||||
|
||||
|
||||
## 周期性待办
|
||||
@ -25,8 +23,3 @@
|
||||
|
||||
## 忐忑
|
||||
* 本软件的3D封装下载调用了jlc的api,不知道哪天就收到了某函,所以暂时只在本站开源了,在未想办法解决掉该可能引起纠纷的事项之前不想广泛传播,所以也请各位道友手下留情,不要随意传播本软件
|
||||
|
||||
## 笔记
|
||||
* 如需窗口透明可以参考gradient例程
|
||||
* 加载动画参见loading_spinners例程
|
||||
|
||||
|
||||
13
src/main.rs
13
src/main.rs
@ -1,11 +1,18 @@
|
||||
#![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();
|
||||
}
|
||||
|
||||
@ -1,27 +1,18 @@
|
||||
use iced::{Length, Task, alignment::Horizontal, widget::Column};
|
||||
use iced::{alignment::Horizontal, widget::Column, Length, Task};
|
||||
use tracing::info;
|
||||
use crate::widgets::toast;
|
||||
use crate::utils::winsparkle;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
use crate::ui::main_window::{MainWindowMsg, TabContent};
|
||||
|
||||
pub struct HomePage {
|
||||
pub step_dir: String,
|
||||
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,
|
||||
step_dir: crate::utils::app_settings::get_step_dir().unwrap(),
|
||||
theme: Default::default(),
|
||||
}
|
||||
}
|
||||
@ -36,38 +27,6 @@ pub enum HomePageMsg {
|
||||
}
|
||||
|
||||
impl TabContent for HomePage {
|
||||
type TabMessage = HomePageMsg;
|
||||
|
||||
fn update(&mut self, msg: Self::TabMessage) -> Task<MainWindowMsg> {
|
||||
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("版本:"),
|
||||
@ -105,6 +64,41 @@ impl TabContent for HomePage {
|
||||
.spacing(5.0)
|
||||
.into()
|
||||
}
|
||||
|
||||
type TabMessage = HomePageMsg;
|
||||
|
||||
fn update(&mut self, msg: Self::TabMessage) -> Task<MainWindowMsg> {
|
||||
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) {
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
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 {
|
||||
@ -21,7 +19,7 @@ pub struct JlcDownloader {
|
||||
item_clickable:bool,
|
||||
current_step_content:String,
|
||||
current_step_name:String,
|
||||
pub step_save_dir: String,
|
||||
download_log:Vec<String>,
|
||||
}
|
||||
impl JlcDownloader {
|
||||
pub fn set_theme(&mut self, theme: iced::Theme) {
|
||||
@ -91,7 +89,7 @@ impl Default for JlcDownloader {
|
||||
item_clickable: true,
|
||||
current_step_content: "".to_string(),
|
||||
current_step_name: "".to_string(),
|
||||
step_save_dir: String::new(),
|
||||
download_log: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -181,60 +179,12 @@ impl TabContent for JlcDownloader {
|
||||
});
|
||||
}
|
||||
JlcDownloaderMsg::StepFetched(s) => {
|
||||
// MainWindowMsg::Toast(toast::Toast{title:"下载成功".into(),body:format!("{}的模型下载成功",s),status:toast::Status::Success});
|
||||
self.download_log.push(format!("3D for {} 下载成功",self.current_step_name));
|
||||
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;
|
||||
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,
|
||||
})
|
||||
},
|
||||
);
|
||||
info!("JlcDownloaderMsg::StepFetchError({:?})", e);
|
||||
}
|
||||
JlcDownloaderMsg::OpenDatasheet(url) => {
|
||||
todo!("To open the url!");
|
||||
@ -262,7 +212,10 @@ impl TabContent for JlcDownloader {
|
||||
results = results.push(self.create_fetched_item_button(&item, 1000));
|
||||
}
|
||||
|
||||
let mut logs = iced::widget::column!["预览暂不可用,期待后期iced更新",];
|
||||
let mut logs = iced::widget::column!["预览暂不可用,期待后期iced更新","日志:"];
|
||||
for item in self.download_log.iter(){
|
||||
logs = logs.push(iced::widget::text(item.clone()));
|
||||
}
|
||||
let body = iced::widget::row![
|
||||
iced::widget::column![
|
||||
iced::widget::text(self.msg_disp.clone()).height(Length::Shrink),
|
||||
|
||||
@ -3,12 +3,11 @@ 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;
|
||||
@ -18,16 +17,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::{
|
||||
Element, Length,
|
||||
alignment::{Horizontal, Vertical},
|
||||
widget::{Column, Container, Text, column},
|
||||
alignment::{Horizontal, Vertical}, widget::{column, Column, Container, Text},
|
||||
Element,
|
||||
Length,
|
||||
};
|
||||
use std::fmt::Display;
|
||||
|
||||
@ -41,7 +40,6 @@ struct MainWindow {
|
||||
db_browser: crate::ui::db_browser::DbBrowser,
|
||||
part_viewer: crate::ui::part_viewer::PartViewer,
|
||||
explain: bool,
|
||||
toasts:Vec<toast::Toast>,
|
||||
}
|
||||
impl Default for MainWindow {
|
||||
fn default() -> Self {
|
||||
@ -53,7 +51,6 @@ 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,
|
||||
@ -63,7 +60,6 @@ impl Default for MainWindow {
|
||||
db_browser: Default::default(),
|
||||
part_viewer: Default::default(),
|
||||
explain: false,
|
||||
toasts:Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,8 +90,6 @@ pub enum MainWindowMsg {
|
||||
DbBrowser(DbBrowserMsg),
|
||||
PartViewer(PartViewerMsg),
|
||||
Explain(bool),
|
||||
Toast(toast::Toast),
|
||||
CloseToast(usize),
|
||||
Nothing,
|
||||
}
|
||||
|
||||
@ -226,14 +220,6 @@ 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> {
|
||||
@ -276,9 +262,7 @@ impl MainWindow {
|
||||
}
|
||||
};
|
||||
// let content = iced::widget::Button::new("Click").on_press(MainWindowMsg::Nothing);
|
||||
|
||||
//let content = column![h, v].into();
|
||||
toast::Manager::new(column![h,v], &self.toasts, MainWindowMsg::CloseToast).timeout(8).into()
|
||||
column![h, v].into()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
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,
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
pub mod app_settings;
|
||||
pub mod lazy;
|
||||
pub mod step_downloader;
|
||||
pub mod gitea;
|
||||
pub mod winsparkle;
|
||||
|
||||
@ -81,8 +81,7 @@ pub async fn fetch_item(item: SearchResultItem) -> Result<FetchResultItem> {
|
||||
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(){
|
||||
@ -148,7 +147,8 @@ async fn search_model_id(uuid: &str) -> Result<String> {
|
||||
.send()
|
||||
.await?;
|
||||
let text = resp.text().await?;
|
||||
return Ok(text);
|
||||
info!("In search_model_id: The v is : {}",text.clone());
|
||||
return Ok(text)
|
||||
}
|
||||
#[derive(Clone, Default, Debug, serde::Serialize, serde::Deserialize, Eq, PartialEq)]
|
||||
struct KeywordSearchRoot {
|
||||
|
||||
68
src/utils/winsparkle.rs
Normal file
68
src/utils/winsparkle.rs
Normal file
@ -0,0 +1,68 @@
|
||||
// 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<extern "C" fn() -> ()>);
|
||||
|
||||
/// 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();
|
||||
}
|
||||
@ -1,2 +1 @@
|
||||
pub mod tab_header;
|
||||
pub(crate) mod toast;
|
||||
|
||||
@ -1,479 +0,0 @@
|
||||
|
||||
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<Element<'a, Message>>,
|
||||
timeout_secs: u64,
|
||||
on_close: Box<dyn Fn(usize) -> Message + 'a>,
|
||||
}
|
||||
|
||||
impl<'a, Message> Manager<'a, Message>
|
||||
where
|
||||
Message: 'a + Clone,
|
||||
{
|
||||
pub fn new(
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
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<Message> Widget<Message, Theme, Renderer> for Manager<'_, Message> {
|
||||
fn size(&self) -> Size<Length> {
|
||||
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::<Marker>()
|
||||
}
|
||||
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(Vec::<Option<Instant>>::new())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
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::<Vec<Option<Instant>>>();
|
||||
|
||||
// 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::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
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<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
let instants = state.state.downcast_mut::<Vec<Option<Instant>>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
(!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<Instant>],
|
||||
on_close: &'b dyn Fn(usize) -> Message,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl<Message> overlay::Overlay<Message, Theme, Renderer> 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<Manager<'a, Message>> 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user