diff --git a/README.md b/README.md index 5599a39..efbc7c0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features -- Native Iced desktop workspace with localized menus, tabs, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor. +- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor. - Post and translation authoring with draft/published lifecycle, metadata, tags, categories, links, media, and batch gallery-image import. - Media import, thumbnails, metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. diff --git a/crates/bds-ui/src/components/mod.rs b/crates/bds-ui/src/components/mod.rs index 28ebdc2..60f3e5d 100644 --- a/crates/bds-ui/src/components/mod.rs +++ b/crates/bds-ui/src/components/mod.rs @@ -1,3 +1,4 @@ pub mod inputs; pub mod native_edit; +pub mod popover; pub mod webview; diff --git a/crates/bds-ui/src/components/popover.rs b/crates/bds-ui/src/components/popover.rs new file mode 100644 index 0000000..1a257b8 --- /dev/null +++ b/crates/bds-ui/src/components/popover.rs @@ -0,0 +1,373 @@ +use iced::advanced::layout; +use iced::advanced::overlay; +use iced::advanced::renderer; +use iced::advanced::widget::{Operation, Tree}; +use iced::advanced::{Clipboard, Layout, Shell, Widget}; +use iced::event; +use iced::keyboard::{self, Key, key}; +use iced::mouse; +use iced::{Element, Event, Length, Point, Rectangle, Size, Vector}; + +/// An interactive popup anchored below the right edge of its trigger. +pub struct Popover<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + trigger: Element<'a, Message, Theme, Renderer>, + popup: Element<'a, Message, Theme, Renderer>, + is_open: bool, + on_dismiss: Message, + gap: f32, +} + +impl<'a, Message, Theme, Renderer> Popover<'a, Message, Theme, Renderer> { + pub fn new( + trigger: impl Into>, + popup: impl Into>, + is_open: bool, + on_dismiss: Message, + ) -> Self { + Self { + trigger: trigger.into(), + popup: popup.into(), + is_open, + on_dismiss, + gap: 8.0, + } + } +} + +fn popup_position(anchor: Rectangle, popup: Size, viewport: Size, gap: f32) -> Point { + let max_x = (viewport.width - popup.width).max(0.0); + let x = (anchor.x + anchor.width - popup.width).clamp(0.0, max_x); + let below = anchor.y + anchor.height + gap; + let y = if below + popup.height <= viewport.height { + below + } else { + (anchor.y - gap - popup.height).max(0.0) + }; + Point::new(x, y) +} + +impl<'a, Message, Theme, Renderer> Widget + for Popover<'a, Message, Theme, Renderer> +where + Message: Clone, + Renderer: renderer::Renderer, +{ + fn children(&self) -> Vec { + vec![Tree::new(&self.trigger), Tree::new(&self.popup)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(&[self.trigger.as_widget(), self.popup.as_widget()]); + } + + fn size(&self) -> Size { + self.trigger.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.trigger + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.trigger + .as_widget() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + self.trigger.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.trigger.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.trigger.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + translation: Vector, + ) -> Option> { + let (trigger_tree, popup_tree) = tree.children.split_at_mut(1); + let trigger_overlay = self.trigger.as_widget_mut().overlay( + &mut trigger_tree[0], + layout, + renderer, + translation, + ); + let popup_overlay = self.is_open.then(|| { + overlay::Element::new(Box::new(PopupOverlay { + anchor: layout.bounds() + translation, + popup: &mut self.popup, + state: &mut popup_tree[0], + on_dismiss: self.on_dismiss.clone(), + gap: self.gap, + })) + }); + + if trigger_overlay.is_some() || popup_overlay.is_some() { + Some( + overlay::Group::with_children( + trigger_overlay.into_iter().chain(popup_overlay).collect(), + ) + .overlay(), + ) + } else { + None + } + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: Clone + 'a, + Theme: 'a, + Renderer: renderer::Renderer + 'a, +{ + fn from(popover: Popover<'a, Message, Theme, Renderer>) -> Self { + Element::new(popover) + } +} + +struct PopupOverlay<'a, 'b, Message, Theme, Renderer> { + anchor: Rectangle, + popup: &'b mut Element<'a, Message, Theme, Renderer>, + state: &'b mut Tree, + on_dismiss: Message, + gap: f32, +} + +impl overlay::Overlay + for PopupOverlay<'_, '_, Message, Theme, Renderer> +where + Message: Clone, + Renderer: renderer::Renderer, +{ + fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node { + let popup = self.popup.as_widget().layout( + self.state, + renderer, + &layout::Limits::new(Size::ZERO, bounds), + ); + let position = popup_position(self.anchor, popup.size(), bounds, self.gap); + layout::Node::with_children(popup.size(), vec![popup]) + .translate(Vector::new(position.x, position.y)) + } + + fn draw( + &self, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + ) { + self.popup.as_widget().draw( + self.state, + renderer, + theme, + style, + layout.children().next().expect("popover child layout"), + cursor, + &Rectangle::with_size(Size::INFINITY), + ); + } + + fn operate(&mut self, layout: Layout<'_>, renderer: &Renderer, operation: &mut dyn Operation) { + self.popup.as_widget().operate( + self.state, + layout.children().next().expect("popover child layout"), + renderer, + operation, + ); + } + + fn on_event( + &mut self, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + let popup_layout = layout.children().next().expect("popover child layout"); + let status = self.popup.as_widget_mut().on_event( + self.state, + event.clone(), + popup_layout, + cursor, + renderer, + clipboard, + shell, + &Rectangle::with_size(Size::INFINITY), + ); + if status == event::Status::Captured { + return status; + } + + let dismiss = matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(iced::touch::Event::FingerPressed { .. }) + | Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Escape), + .. + }) + ); + if dismiss && !cursor.is_over(layout.bounds()) { + shell.publish(self.on_dismiss.clone()); + event::Status::Captured + } else if dismiss + && matches!( + event, + Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Escape), + .. + }) + ) + { + shell.publish(self.on_dismiss.clone()); + event::Status::Captured + } else { + status + } + } + + fn mouse_interaction( + &self, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.popup.as_widget().mouse_interaction( + self.state, + layout.children().next().expect("popover child layout"), + cursor, + viewport, + renderer, + ) + } + + fn overlay<'b>( + &'b mut self, + layout: Layout<'_>, + renderer: &Renderer, + ) -> Option> { + self.popup.as_widget_mut().overlay( + self.state, + layout.children().next().expect("popover child layout"), + renderer, + Vector::ZERO, + ) + } +} + +pub fn popover<'a, Message: Clone + 'a>( + trigger: impl Into>, + popup: impl Into>, + is_open: bool, + on_dismiss: Message, +) -> Popover<'a, Message> { + Popover::new(trigger, popup, is_open, on_dismiss) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn popup_is_right_aligned_below_its_trigger() { + assert_eq!( + popup_position( + Rectangle::new(Point::new(400.0, 20.0), Size::new(100.0, 30.0)), + Size::new(220.0, 180.0), + Size::new(800.0, 600.0), + 8.0, + ), + Point::new(280.0, 58.0) + ); + } + + #[test] + fn popup_flips_above_and_stays_inside_the_viewport() { + assert_eq!( + popup_position( + Rectangle::new(Point::new(10.0, 570.0), Size::new(40.0, 24.0)), + Size::new(220.0, 180.0), + Size::new(800.0, 600.0), + 8.0, + ), + Point::new(0.0, 382.0) + ); + } +} diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index 906457b..ecf8469 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -9,7 +9,7 @@ use bds_core::i18n::{self, UiLocale}; use bds_core::model::{Media, MediaTranslation}; use crate::app::Message; -use crate::components::inputs; +use crate::components::{inputs, popover}; use crate::i18n::t; use crate::views::post_editor::TranslationFlag; use crate::views::status_bar; @@ -235,16 +235,48 @@ pub fn view<'a>( data_dir: Option<&Path>, ai_enabled: bool, ) -> Element<'a, Message> { + let mut quick_action_items: Vec> = Vec::new(); + if state.mime_type.starts_with("image/") { + quick_action_items.push(quick_action_item( + t(locale, "editor.aiAnalyze"), + MediaEditorMsg::AnalyzeWithAi, + ai_enabled, + )); + } + quick_action_items.push(quick_action_item( + t(locale, "editor.detectLanguage"), + MediaEditorMsg::DetectLanguage, + ai_enabled, + )); + quick_action_items.push(quick_action_item( + t(locale, "editor.translate"), + MediaEditorMsg::TranslateMetadata, + ai_enabled, + )); + let quick_actions_menu: Element<'a, Message> = container(column(quick_action_items).spacing(4)) + .padding(8) + .style(status_bar::dropdown_bg) + .into(); + let quick_actions_button: Element<'a, Message> = + button(text(t(locale, "editor.quickActions")).size(13)) + .on_press_maybe( + ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::ToggleQuickActions)), + ) + .style(inputs::secondary_button) + .padding([6, 16]) + .into(); + let quick_actions: Element<'a, Message> = popover::popover( + quick_actions_button, + quick_actions_menu, + state.quick_actions_open, + Message::MediaEditor(MediaEditorMsg::ToggleQuickActions), + ) + .into(); + let header_row = inputs::toolbar( vec![text(state.original_name.clone()).size(18).into()], vec![ - button(text(t(locale, "editor.quickActions")).size(13)) - .on_press_maybe( - ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::ToggleQuickActions)), - ) - .style(inputs::secondary_button) - .padding([6, 16]) - .into(), + quick_actions, button(text(t(locale, "editor.replaceFile")).size(13)) .on_press(Message::MediaEditor(MediaEditorMsg::ReplaceFile)) .style(inputs::secondary_button) @@ -263,38 +295,7 @@ pub fn view<'a>( ], ); - let header_menu: Element<'a, Message> = if state.quick_actions_open { - let mut items: Vec> = Vec::new(); - if state.mime_type.starts_with("image/") { - items.push(quick_action_item( - t(locale, "editor.aiAnalyze"), - MediaEditorMsg::AnalyzeWithAi, - ai_enabled, - )); - } - items.push(quick_action_item( - t(locale, "editor.detectLanguage"), - MediaEditorMsg::DetectLanguage, - ai_enabled, - )); - items.push(quick_action_item( - t(locale, "editor.translate"), - MediaEditorMsg::TranslateMetadata, - ai_enabled, - )); - row![ - Space::with_width(Length::Fill), - container(column(items).spacing(4)) - .padding(8) - .style(status_bar::dropdown_bg), - ] - .align_y(iced::Alignment::Start) - .into() - } else { - Space::new(0, 0).into() - }; - - let header = column![header_row, header_menu].spacing(6); + let header = header_row; // Translation flags bar let flags = state.translation_flags(); diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 4b2adb9..8651d9d 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -10,7 +10,7 @@ use bds_core::model::{Post, PostStatus, PostTranslation}; use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter}; use crate::app::Message; -use crate::components::inputs; +use crate::components::{inputs, popover}; use crate::i18n::t; use crate::views::status_bar; @@ -409,9 +409,55 @@ pub fn view<'a>( .style(inputs::secondary_button) .into(); + let quick_actions_menu: Element<'a, Message> = container( + column![ + quick_action_item( + locale, + t(locale, "editor.aiAnalyze"), + PostEditorMsg::AnalyzeWithAi, + ai_enabled + ), + quick_action_item( + locale, + t(locale, "editor.suggestTaxonomy"), + PostEditorMsg::AnalyzeTaxonomy, + ai_enabled + ), + quick_action_item( + locale, + t(locale, "editor.translate"), + PostEditorMsg::Translate, + ai_enabled + ), + quick_action_item( + locale, + t(locale, "editor.detectLanguage"), + PostEditorMsg::DetectLanguage, + ai_enabled + ), + quick_action_item( + locale, + t(locale, "editor.addGalleryImages"), + PostEditorMsg::AddGalleryImages, + true + ), + ] + .spacing(4), + ) + .padding(8) + .style(status_bar::dropdown_bg) + .into(); + let quick_actions: Element<'a, Message> = popover::popover( + quick_actions_button, + quick_actions_menu, + state.quick_actions_open, + Message::PostEditor(PostEditorMsg::ToggleQuickActions), + ) + .into(); + let mut header_action_items: Vec> = vec![ status_badge(&state.status), - quick_actions_button, + quick_actions, button( text(t(locale, "common.save")) .size(13) @@ -478,54 +524,7 @@ pub fn view<'a>( .align_y(iced::Alignment::Center) .width(Length::Fill); - let header_menu: Element<'a, Message> = if state.quick_actions_open { - row![ - Space::with_width(Length::Fill), - container( - column![ - quick_action_item( - locale, - t(locale, "editor.aiAnalyze"), - PostEditorMsg::AnalyzeWithAi, - ai_enabled - ), - quick_action_item( - locale, - t(locale, "editor.suggestTaxonomy"), - PostEditorMsg::AnalyzeTaxonomy, - ai_enabled - ), - quick_action_item( - locale, - t(locale, "editor.translate"), - PostEditorMsg::Translate, - ai_enabled - ), - quick_action_item( - locale, - t(locale, "editor.detectLanguage"), - PostEditorMsg::DetectLanguage, - ai_enabled - ), - quick_action_item( - locale, - t(locale, "editor.addGalleryImages"), - PostEditorMsg::AddGalleryImages, - true - ), - ] - .spacing(4) - ) - .padding(8) - .style(status_bar::dropdown_bg), - ] - .align_y(iced::Alignment::Start) - .into() - } else { - Space::new(0, 0).into() - }; - - let header = inputs::card(column![header_row, header_menu].spacing(6)).padding(10); + let header = inputs::card(header_row).padding(10); // ── Collapsible Metadata Section ── let meta_toggle_label = if state.metadata_expanded {