feat: finalizations for M0-M2
This commit is contained in:
@@ -15,6 +15,7 @@ use crate::state::navigation::{
|
||||
handle_activity_click, OutputEntry, PanelTab, SidebarView, TaskSnapshot,
|
||||
};
|
||||
use crate::state::tabs::{self, Tab, TabType};
|
||||
use crate::state::toast::{Toast, ToastLevel};
|
||||
use crate::views::workspace;
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
@@ -67,6 +68,11 @@ pub enum Message {
|
||||
ToggleLocaleDropdown,
|
||||
ToggleProjectDropdown,
|
||||
|
||||
// Toast
|
||||
ShowToast(ToastLevel, String),
|
||||
DismissToast(u64),
|
||||
ExpireToasts,
|
||||
|
||||
// Blog actions (dispatched to engine)
|
||||
RebuildDatabase,
|
||||
ReindexText,
|
||||
@@ -131,9 +137,14 @@ pub struct BdsApp {
|
||||
locale_dropdown_open: bool,
|
||||
project_dropdown_open: bool,
|
||||
|
||||
// macOS lifecycle receiver
|
||||
// Toasts
|
||||
toasts: Vec<Toast>,
|
||||
|
||||
// macOS lifecycle receiver and retained delegate
|
||||
#[cfg(target_os = "macos")]
|
||||
_lifecycle_rx: std::sync::mpsc::Receiver<crate::platform::macos::LifecycleEvent>,
|
||||
#[cfg(target_os = "macos")]
|
||||
_lifecycle_delegate: Option<objc2::rc::Retained<crate::platform::macos::BdsAppDelegate>>,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
@@ -156,8 +167,8 @@ impl BdsApp {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
let db = Database::open(&db_path).ok();
|
||||
if let Some(ref db) = db {
|
||||
let mut db = Database::open(&db_path).ok();
|
||||
if let Some(ref mut db) = db {
|
||||
let _ = db.migrate();
|
||||
}
|
||||
|
||||
@@ -215,6 +226,8 @@ impl BdsApp {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let (_lifecycle_tx, _lifecycle_rx) = crate::platform::macos::lifecycle_channel();
|
||||
#[cfg(target_os = "macos")]
|
||||
let _lifecycle_delegate = crate::platform::macos::install_delegate(_lifecycle_tx);
|
||||
|
||||
(
|
||||
Self {
|
||||
@@ -242,8 +255,11 @@ impl BdsApp {
|
||||
offline_mode: false,
|
||||
locale_dropdown_open: false,
|
||||
project_dropdown_open: false,
|
||||
toasts: Vec::new(),
|
||||
#[cfg(target_os = "macos")]
|
||||
_lifecycle_rx,
|
||||
#[cfg(target_os = "macos")]
|
||||
_lifecycle_delegate,
|
||||
},
|
||||
init_task,
|
||||
)
|
||||
@@ -282,6 +298,7 @@ impl BdsApp {
|
||||
if let Some(t) = self.tabs.get(idx) {
|
||||
self.active_tab = Some(t.id.clone());
|
||||
}
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
}
|
||||
Message::CloseTab(id) => {
|
||||
@@ -290,6 +307,7 @@ impl BdsApp {
|
||||
} else {
|
||||
self.active_tab = None;
|
||||
}
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
}
|
||||
Message::SelectTab(id) => {
|
||||
@@ -306,7 +324,6 @@ impl BdsApp {
|
||||
// ── Project management ──
|
||||
Message::ProjectsLoaded(projects) => {
|
||||
self.projects = projects;
|
||||
// Re-resolve active
|
||||
if let Some(ref db) = self.db {
|
||||
self.active_project = engine::project::get_active_project(db.conn())
|
||||
.ok()
|
||||
@@ -318,6 +335,7 @@ impl BdsApp {
|
||||
.map(PathBuf::from);
|
||||
}
|
||||
self.refresh_counts();
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
}
|
||||
Message::SwitchProject(project_id) => {
|
||||
@@ -332,24 +350,25 @@ impl BdsApp {
|
||||
.and_then(|p| p.data_path.as_ref())
|
||||
.map(PathBuf::from);
|
||||
let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default();
|
||||
self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
|
||||
self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
|
||||
}
|
||||
Err(_) => {
|
||||
self.add_output(&t(self.ui_locale, "projectSelector.toast.switchFailed"));
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.switchFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
}
|
||||
Message::ProjectSwitched(result) => {
|
||||
match result {
|
||||
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
|
||||
Err(msg) => self.add_output(&msg),
|
||||
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)])),
|
||||
Err(msg) => self.notify(ToastLevel::Error, &msg),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::RequestCreateProject => {
|
||||
crate::platform::dialog::pick_folder()
|
||||
crate::platform::dialog::pick_folder(t(self.ui_locale, "dialog.selectFolder"))
|
||||
}
|
||||
Message::CreateProject { name, data_path } => {
|
||||
if let Some(ref db) = self.db {
|
||||
@@ -365,10 +384,10 @@ impl BdsApp {
|
||||
self.active_project = Some(project.clone());
|
||||
self.data_dir = project.data_path.as_ref().map(PathBuf::from);
|
||||
let msg = tw(self.ui_locale, "projectSelector.toast.created", &[("name", &project.name)]);
|
||||
self.add_output(&msg);
|
||||
self.notify(ToastLevel::Success, &msg);
|
||||
}
|
||||
Err(_) => {
|
||||
self.add_output(&t(self.ui_locale, "projectSelector.toast.createFailed"));
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.createFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,8 +395,8 @@ impl BdsApp {
|
||||
}
|
||||
Message::ProjectCreated(result) => {
|
||||
match result {
|
||||
Ok(name) => self.add_output(&tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
|
||||
Err(msg) => self.add_output(&msg),
|
||||
Ok(name) => self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.created", &[("name", &name)])),
|
||||
Err(msg) => self.notify(ToastLevel::Error, &msg),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -392,7 +411,7 @@ impl BdsApp {
|
||||
self.projects.retain(|p| p.id != project_id);
|
||||
}
|
||||
Err(_) => {
|
||||
self.add_output(&t(self.ui_locale, "projectSelector.toast.deleteFailed"));
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "projectSelector.toast.deleteFailed"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +419,7 @@ impl BdsApp {
|
||||
}
|
||||
Message::ProjectDeleted(result) => {
|
||||
if let Err(msg) = result {
|
||||
self.add_output(&msg);
|
||||
self.notify(ToastLevel::Error, &msg);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -449,6 +468,7 @@ impl BdsApp {
|
||||
// ── Settings ──
|
||||
Message::SetOfflineMode(mode) => {
|
||||
self.offline_mode = mode;
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
}
|
||||
Message::SetUiLocale(locale) => {
|
||||
@@ -564,11 +584,11 @@ impl BdsApp {
|
||||
match &result {
|
||||
Ok(detail) => {
|
||||
self.task_manager.complete(task_id);
|
||||
self.add_output(&format!("{label}: {detail}"));
|
||||
self.notify(ToastLevel::Success, &format!("{label}: {detail}"));
|
||||
}
|
||||
Err(err) => {
|
||||
self.task_manager.fail(task_id, err.clone());
|
||||
self.add_output(&format!("{label} failed: {err}"));
|
||||
self.notify(ToastLevel::Error, &format!("{label} failed: {err}"));
|
||||
}
|
||||
}
|
||||
self.refresh_counts();
|
||||
@@ -576,6 +596,20 @@ impl BdsApp {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Toast ──
|
||||
Message::ShowToast(level, msg) => {
|
||||
self.toasts.push(Toast::new(level, msg));
|
||||
Task::none()
|
||||
}
|
||||
Message::DismissToast(id) => {
|
||||
self.toasts.retain(|t| t.id != id);
|
||||
Task::none()
|
||||
}
|
||||
Message::ExpireToasts => {
|
||||
self.toasts.retain(|t| !t.is_expired());
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::Noop => Task::none(),
|
||||
Message::InitMenuBar => {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -608,6 +642,7 @@ impl BdsApp {
|
||||
self.locale_dropdown_open,
|
||||
self.project_dropdown_open,
|
||||
self.ui_locale,
|
||||
&self.toasts,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -617,7 +652,14 @@ impl BdsApp {
|
||||
let task_tick = iced::time::every(std::time::Duration::from_millis(500))
|
||||
.map(|_| Message::TaskTick);
|
||||
|
||||
Subscription::batch([menu_sub, task_tick])
|
||||
let toast_tick = if !self.toasts.is_empty() {
|
||||
iced::time::every(std::time::Duration::from_millis(250))
|
||||
.map(|_| Message::ExpireToasts)
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
|
||||
Subscription::batch([menu_sub, task_tick, toast_tick])
|
||||
}
|
||||
|
||||
// ── Private helpers ──
|
||||
@@ -661,7 +703,10 @@ impl BdsApp {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(),
|
||||
MenuAction::ImportMedia => crate::platform::dialog::pick_media_files(
|
||||
t(self.ui_locale, "dialog.importMedia"),
|
||||
t(self.ui_locale, "dialog.imageFilter"),
|
||||
),
|
||||
MenuAction::Save => Task::none(), // Disabled in M2
|
||||
MenuAction::OpenInBrowser => Task::none(),
|
||||
MenuAction::OpenDataFolder => {
|
||||
@@ -704,10 +749,9 @@ impl BdsApp {
|
||||
MenuAction::ValidateTranslations => Task::done(Message::ValidateTranslations),
|
||||
MenuAction::FillMissingTranslations => {
|
||||
if self.offline_mode {
|
||||
self.add_output(&t(self.ui_locale, "engine.fillMissingTranslationsOffline"));
|
||||
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsOffline"));
|
||||
} else {
|
||||
// AI translation not yet available — inform user
|
||||
self.add_output(&t(self.ui_locale, "engine.fillMissingTranslationsNoAi"));
|
||||
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.fillMissingTranslationsNoAi"));
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -718,9 +762,8 @@ impl BdsApp {
|
||||
}
|
||||
MenuAction::UploadSite => {
|
||||
if self.offline_mode {
|
||||
self.add_output(&t(self.ui_locale, "engine.uploadOffline"));
|
||||
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadOffline"));
|
||||
} else if let Some(data_dir) = &self.data_dir {
|
||||
// Check publishing credentials
|
||||
let pub_prefs = engine::meta::read_publishing_json(data_dir).ok();
|
||||
let has_creds = pub_prefs
|
||||
.as_ref()
|
||||
@@ -730,10 +773,9 @@ impl BdsApp {
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !has_creds {
|
||||
self.add_output(&t(self.ui_locale, "engine.uploadMissingCredentials"));
|
||||
self.notify(ToastLevel::Warning, &t(self.ui_locale, "engine.uploadMissingCredentials"));
|
||||
} else {
|
||||
self.add_output(&t(self.ui_locale, "engine.uploadStarted"));
|
||||
// SSH upload requires async SSH library (deferred to publishing milestone)
|
||||
self.notify(ToastLevel::Info, &t(self.ui_locale, "engine.uploadStarted"));
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
@@ -802,6 +844,12 @@ impl BdsApp {
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a toast notification AND log to output panel.
|
||||
fn notify(&mut self, level: ToastLevel, text: &str) {
|
||||
self.toasts.push(Toast::new(level, text.to_string()));
|
||||
self.add_output(text);
|
||||
}
|
||||
|
||||
/// Spawn a blocking engine operation on a background thread via TaskManager.
|
||||
///
|
||||
/// Returns `Task::none()` if no active project/db/data_dir.
|
||||
@@ -879,4 +927,38 @@ impl BdsApp {
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronise menu enabled/disabled state with current app state.
|
||||
///
|
||||
/// Called after state-changing operations (project switch, tab open/close,
|
||||
/// offline toggle) so that menu items reflect what's actually possible.
|
||||
fn sync_menu_state(&self) {
|
||||
let has_project = self.active_project.is_some();
|
||||
let has_tab = self.active_tab.is_some();
|
||||
|
||||
// File group: need active project for most, need open tab for Save
|
||||
self.menu_registry.set_enabled(MenuAction::NewPost, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::ImportMedia, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::Save, has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::OpenInBrowser, has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::OpenDataFolder, has_project);
|
||||
|
||||
// Edit: Find/Replace need an open tab
|
||||
self.menu_registry.set_enabled(MenuAction::Find, has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::Replace, has_tab);
|
||||
|
||||
// Blog group: need active project
|
||||
self.menu_registry.set_enabled(MenuAction::PublishSelected, has_project && has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::PreviewPost, has_project && has_tab);
|
||||
self.menu_registry.set_enabled(MenuAction::EditMenu, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::RebuildDatabase, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::ReindexText, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::MetadataDiff, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::RegenerateCalendar, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::ValidateTranslations, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::FillMissingTranslations, has_project && !self.offline_mode);
|
||||
self.menu_registry.set_enabled(MenuAction::GenerateSitemap, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::ValidateSite, has_project);
|
||||
self.menu_registry.set_enabled(MenuAction::UploadSite, has_project && !self.offline_mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ use iced::Task;
|
||||
use crate::app::Message;
|
||||
|
||||
/// Pick a folder using the native file dialog.
|
||||
pub fn pick_folder() -> Task<Message> {
|
||||
pub fn pick_folder(title: String) -> Task<Message> {
|
||||
Task::perform(
|
||||
async {
|
||||
async move {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Select Folder")
|
||||
.set_title(&title)
|
||||
.pick_folder()
|
||||
.await
|
||||
.map(|h| h.path().to_path_buf())
|
||||
@@ -17,13 +17,13 @@ pub fn pick_folder() -> Task<Message> {
|
||||
}
|
||||
|
||||
/// Pick one or more image/media files using the native file dialog.
|
||||
pub fn pick_media_files() -> Task<Message> {
|
||||
pub fn pick_media_files(title: String, filter_label: String) -> Task<Message> {
|
||||
Task::perform(
|
||||
async {
|
||||
async move {
|
||||
rfd::AsyncFileDialog::new()
|
||||
.set_title("Import Media")
|
||||
.set_title(&title)
|
||||
.add_filter(
|
||||
"Images",
|
||||
&filter_label,
|
||||
&["jpg", "jpeg", "png", "gif", "webp", "svg", "tiff", "bmp"],
|
||||
)
|
||||
.pick_files()
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::ProtocolObject;
|
||||
use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly};
|
||||
use objc2_app_kit::{NSApplication, NSApplicationDelegate};
|
||||
use objc2_foundation::{NSArray, NSNotification, NSObject, NSObjectProtocol, NSString, NSURL};
|
||||
|
||||
use crate::app::Message;
|
||||
|
||||
/// Events that arrive from macOS lifecycle hooks (openFile, openURLs).
|
||||
@@ -10,11 +16,75 @@ pub enum LifecycleEvent {
|
||||
UrlOpen(String),
|
||||
}
|
||||
|
||||
/// Create the macOS lifecycle channel (sender goes to objc hooks, receiver polled by subscription).
|
||||
/// Ivars for the delegate — holds the sender side of the lifecycle channel.
|
||||
#[derive(Debug)]
|
||||
pub struct DelegateIvars {
|
||||
tx: mpsc::Sender<LifecycleEvent>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
// SAFETY: NSObject has no subclassing requirements. BdsAppDelegate does not impl Drop.
|
||||
#[unsafe(super(NSObject))]
|
||||
#[thread_kind = MainThreadOnly]
|
||||
#[name = "BdsAppDelegate"]
|
||||
#[ivars = DelegateIvars]
|
||||
pub struct BdsAppDelegate;
|
||||
|
||||
unsafe impl NSObjectProtocol for BdsAppDelegate {}
|
||||
|
||||
unsafe impl NSApplicationDelegate for BdsAppDelegate {
|
||||
#[unsafe(method(applicationDidFinishLaunching:))]
|
||||
fn did_finish_launching(&self, _notification: &NSNotification) {
|
||||
// App is already running via Iced; nothing to do here.
|
||||
}
|
||||
|
||||
#[unsafe(method(application:openFile:))]
|
||||
fn application_open_file(&self, _sender: &NSApplication, filename: &NSString) -> bool {
|
||||
let path = PathBuf::from(filename.to_string());
|
||||
let _ = self.ivars().tx.send(LifecycleEvent::FileOpen(path));
|
||||
true
|
||||
}
|
||||
|
||||
#[unsafe(method(application:openURLs:))]
|
||||
fn application_open_urls(&self, _sender: &NSApplication, urls: &NSArray<NSURL>) {
|
||||
for i in 0..urls.len() {
|
||||
if let Some(url) = urls.objectAtIndex(i).absoluteString() {
|
||||
let _ = self.ivars().tx.send(LifecycleEvent::UrlOpen(url.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl BdsAppDelegate {
|
||||
fn new(mtm: MainThreadMarker, tx: mpsc::Sender<LifecycleEvent>) -> Retained<Self> {
|
||||
let this = Self::alloc(mtm);
|
||||
let this = this.set_ivars(DelegateIvars { tx });
|
||||
unsafe { msg_send![super(this), init] }
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the macOS lifecycle channel and wire the native delegate.
|
||||
///
|
||||
/// Returns `(Retained<BdsAppDelegate>, Receiver)`. The delegate must be kept alive
|
||||
/// for the lifetime of the application (drop it and the callbacks stop).
|
||||
pub fn lifecycle_channel() -> (mpsc::Sender<LifecycleEvent>, mpsc::Receiver<LifecycleEvent>) {
|
||||
mpsc::channel()
|
||||
}
|
||||
|
||||
/// Install the native Objective-C delegate on NSApplication, wiring it to the given sender.
|
||||
///
|
||||
/// Must be called from the main thread after the Iced application is running.
|
||||
/// Returns the retained delegate (caller must keep it alive).
|
||||
pub fn install_delegate(tx: mpsc::Sender<LifecycleEvent>) -> Option<Retained<BdsAppDelegate>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let delegate = BdsAppDelegate::new(mtm, tx);
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
let object = ProtocolObject::from_ref(&*delegate);
|
||||
app.setDelegate(Some(object));
|
||||
Some(delegate)
|
||||
}
|
||||
|
||||
/// Poll the macOS lifecycle receiver for the next event and map to a Message.
|
||||
pub fn poll_lifecycle(
|
||||
receiver: &mpsc::Receiver<LifecycleEvent>,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod navigation;
|
||||
pub mod tabs;
|
||||
pub mod toast;
|
||||
|
||||
pub use navigation::{SidebarView, PanelTab, TaskSnapshot, OutputEntry};
|
||||
pub use tabs::{Tab, TabType};
|
||||
pub use toast::{Toast, ToastLevel};
|
||||
|
||||
80
crates/bds-ui/src/state/toast.rs
Normal file
80
crates/bds-ui/src/state/toast.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
/// Toast notification state.
|
||||
///
|
||||
/// Toasts are ephemeral, auto-dismissing messages shown at the top of
|
||||
/// the workspace. Each toast has a severity level, a message, and a
|
||||
/// monotonically increasing id used for targeted dismissal.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Severity determines the visual style.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToastLevel {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// A single toast notification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Toast {
|
||||
pub id: u64,
|
||||
pub level: ToastLevel,
|
||||
pub message: String,
|
||||
/// Unix-millis when this toast was created.
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Default display duration in milliseconds.
|
||||
pub const DEFAULT_DURATION_MS: u64 = 4000;
|
||||
|
||||
pub fn new(level: ToastLevel, message: String) -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
Self {
|
||||
id: NEXT_TOAST_ID.fetch_add(1, Ordering::Relaxed),
|
||||
level,
|
||||
message,
|
||||
created_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this toast has exceeded its display duration.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
now.saturating_sub(self.created_at) >= Self::DEFAULT_DURATION_MS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toast_ids_are_unique() {
|
||||
let a = Toast::new(ToastLevel::Info, "a".into());
|
||||
let b = Toast::new(ToastLevel::Info, "b".into());
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_levels() {
|
||||
let t = Toast::new(ToastLevel::Error, "oops".into());
|
||||
assert_eq!(t.level, ToastLevel::Error);
|
||||
assert!(!t.message.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_toast_not_expired() {
|
||||
let t = Toast::new(ToastLevel::Info, "test".into());
|
||||
assert!(!t.is_expired());
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,5 @@ pub mod tab_bar;
|
||||
pub mod status_bar;
|
||||
pub mod panel;
|
||||
pub mod project_selector;
|
||||
pub mod toast;
|
||||
pub mod welcome;
|
||||
|
||||
104
crates/bds-ui/src/views/toast.rs
Normal file
104
crates/bds-ui/src/views/toast.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use iced::widget::{button, container, row, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::toast::{Toast, ToastLevel};
|
||||
|
||||
/// Background color per toast severity.
|
||||
fn toast_bg(level: ToastLevel) -> Color {
|
||||
match level {
|
||||
ToastLevel::Info => Color::from_rgb(0.16, 0.22, 0.34),
|
||||
ToastLevel::Success => Color::from_rgb(0.12, 0.30, 0.16),
|
||||
ToastLevel::Warning => Color::from_rgb(0.38, 0.30, 0.10),
|
||||
ToastLevel::Error => Color::from_rgb(0.38, 0.14, 0.14),
|
||||
}
|
||||
}
|
||||
|
||||
/// Border color per toast severity.
|
||||
fn toast_border(level: ToastLevel) -> Color {
|
||||
match level {
|
||||
ToastLevel::Info => Color::from_rgb(0.25, 0.40, 0.65),
|
||||
ToastLevel::Success => Color::from_rgb(0.20, 0.55, 0.25),
|
||||
ToastLevel::Warning => Color::from_rgb(0.65, 0.50, 0.15),
|
||||
ToastLevel::Error => Color::from_rgb(0.65, 0.20, 0.20),
|
||||
}
|
||||
}
|
||||
|
||||
fn toast_style(level: ToastLevel) -> impl Fn(&Theme) -> container::Style {
|
||||
move |_theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(toast_bg(level))),
|
||||
border: Border {
|
||||
color: toast_border(level),
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn dismiss_btn(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let color = match status {
|
||||
button::Status::Hovered => Color::WHITE,
|
||||
_ => Color::from_rgb(0.65, 0.65, 0.70),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(Color::TRANSPARENT)),
|
||||
text_color: color,
|
||||
border: Border::default(),
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the toast stack as an overlay element.
|
||||
///
|
||||
/// Returns `None` when no toasts are visible.
|
||||
pub fn view(toasts: &[Toast]) -> Option<Element<'static, Message>> {
|
||||
if toasts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let items: Vec<Element<'static, Message>> = toasts
|
||||
.iter()
|
||||
.map(|toast| {
|
||||
let level = toast.level;
|
||||
let dismiss = button(text("\u{2715}").size(11).shaping(Shaping::Advanced))
|
||||
.on_press(Message::DismissToast(toast.id))
|
||||
.padding([2, 4])
|
||||
.style(dismiss_btn);
|
||||
|
||||
container(
|
||||
row![
|
||||
text(toast.message.clone())
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE),
|
||||
Space::with_width(Length::Fill),
|
||||
dismiss,
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.padding([6, 12]),
|
||||
)
|
||||
.width(Length::Fixed(420.0))
|
||||
.style(toast_style(level))
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Some(
|
||||
container(
|
||||
container(
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.padding(Padding { top: 8.0, right: 0.0, bottom: 0.0, left: 0.0 }),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ use bds_core::model::{Media, Post, Project};
|
||||
use crate::app::Message;
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
||||
use crate::state::tabs::Tab;
|
||||
use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, welcome};
|
||||
use crate::state::toast::Toast;
|
||||
use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
|
||||
|
||||
/// Main content area background.
|
||||
fn content_bg(_theme: &Theme) -> container::Style {
|
||||
@@ -67,6 +68,8 @@ pub fn view(
|
||||
project_dropdown_open: bool,
|
||||
// i18n
|
||||
locale: UiLocale,
|
||||
// Toasts
|
||||
toasts: &[Toast],
|
||||
) -> Element<'static, Message> {
|
||||
// Activity bar (leftmost column)
|
||||
let activity = activity_bar::view(sidebar_view, locale);
|
||||
@@ -181,12 +184,25 @@ pub fn view(
|
||||
None
|
||||
};
|
||||
|
||||
// Collect overlays: dropdowns and toasts
|
||||
let mut overlays: Vec<Element<'static, Message>> = Vec::new();
|
||||
|
||||
if let Some(toast_overlay) = toast::view(toasts) {
|
||||
overlays.push(toast_overlay);
|
||||
}
|
||||
|
||||
if let Some(overlay) = overlay {
|
||||
stack![base_layout, overlay]
|
||||
overlays.push(overlay);
|
||||
}
|
||||
|
||||
if overlays.is_empty() {
|
||||
base_layout
|
||||
} else {
|
||||
let mut layers = vec![base_layout];
|
||||
layers.extend(overlays);
|
||||
stack(layers)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
base_layout
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user