Open quick actions in editor popovers

This commit is contained in:
2026-07-20 21:57:14 +02:00
parent e6fa506b28
commit b713f7fa07
5 changed files with 465 additions and 91 deletions

View File

@@ -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.

View File

@@ -1,3 +1,4 @@
pub mod inputs;
pub mod native_edit;
pub mod popover;
pub mod webview;

View File

@@ -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<Element<'a, Message, Theme, Renderer>>,
popup: impl Into<Element<'a, Message, Theme, Renderer>>,
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<Message, Theme, Renderer>
for Popover<'a, Message, Theme, Renderer>
where
Message: Clone,
Renderer: renderer::Renderer,
{
fn children(&self) -> Vec<Tree> {
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<Length> {
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<overlay::Element<'b, Message, Theme, Renderer>> {
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<Popover<'a, Message, Theme, Renderer>>
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<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
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<overlay::Element<'b, Message, Theme, Renderer>> {
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<Element<'a, Message>>,
popup: impl Into<Element<'a, Message>>,
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)
);
}
}

View File

@@ -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<Element<'a, Message>> = 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<Element<'a, Message>> = 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();

View File

@@ -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<Element<'a, Message>> = 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 {