diff --git a/README.md b/README.md index d9c885e..f493174 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ RuDS is a native Rust blogging desktop application and the successor to bDS2. It manages local projects from authoring through preview, static-site generation, integrity checks, and publishing while preserving the existing bDS filesystem and SQLite formats. -The desktop is a single-window application: closing its window persists UI state and exits RuDS. +The desktop is a single-window application: closing its window persists UI state and exits RuDS. Background notifications remain unobtrusive and preserve keyboard focus while editing. The project is under active development. Core blogging workflows are broadly available; remaining core work and optional extensions are tracked separately. diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index d4ec4da..e585d6f 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -387,16 +387,17 @@ pub fn view<'a>( overlays.push(modal::view(modal_state, locale, data_dir)); } - 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() - } + workspace_with_overlays(base_layout, overlays) +} + +fn workspace_with_overlays<'a>( + base_layout: Element<'a, Message>, + overlays: Vec>, +) -> Element<'a, Message> { + stack(std::iter::once(base_layout).chain(overlays)) + .width(Length::Fill) + .height(Length::Fill) + .into() } /// Route the content area based on the active tab type. @@ -711,6 +712,11 @@ fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> { mod tests { use super::*; use crate::state::tabs::{Tab, TabType}; + use iced::advanced::widget::Tree; + use iced::widget::text_input; + + type TextInputState = + text_input::State<::Paragraph>; fn tab(id: &str, tab_type: TabType, title: &str) -> Tab { Tab { @@ -722,6 +728,38 @@ mod tests { } } + #[test] + fn overlays_preserve_the_focused_workspace_input() { + let base = || -> Element<'static, Message> { + text_input("Commit message", "draft") + .on_input(|_| Message::Noop) + .into() + }; + let mut tree = Tree::new(workspace_with_overlays(base(), Vec::new()).as_widget()); + tree.children[0] + .state + .downcast_mut::() + .focus(); + + let with_toast = workspace_with_overlays(base(), vec![text("Done").into()]); + tree.diff(with_toast.as_widget()); + assert!( + tree.children[0] + .state + .downcast_ref::() + .is_focused() + ); + + let without_toast = workspace_with_overlays(base(), Vec::new()); + tree.diff(without_toast.as_widget()); + assert!( + tree.children[0] + .state + .downcast_ref::() + .is_focused() + ); + } + #[test] fn style_tab_routes_to_real_editor_only_with_project_state() { let empty_posts = HashMap::new(); diff --git a/specs/layout.allium b/specs/layout.allium index 6a2a448..3db890a 100644 --- a/specs/layout.allium +++ b/specs/layout.allium @@ -210,6 +210,11 @@ invariant ActivityActiveHighlight { btn.active = (active_view = btn.id and sidebar_visible) } +invariant PassiveOverlayFocusStability { + -- Adding or removing passive overlays such as toasts and background-task + -- notifications never changes the keyboard-focused control. +} + -- ─── Git badge ──────────────────────────────────────────────── -- Only the git activity button carries a badge.