diff --git a/README.md b/README.md index fc06a40..c395dee 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features -- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Its shared task UI keeps queued work off blocking workers, presents active local and remote work plus recent history in bDS2-style grouped progress cards, and supports cooperative cancellation without oversubscribing background work. +- Native Iced desktop workspace with a localized native macOS menu and in-window Linux/Windows menu bar, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. The in-window menu supports Alt mnemonics and arrow-key navigation. Its shared task UI keeps queued work off blocking workers, presents active local and remote work plus recent history in bDS2-style grouped progress cards, and supports cooperative cancellation without oversubscribing background work. - Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, cursor-preserving link and media insertion, live link/backlink graphs, media, and batch gallery-image import. - Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), 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/app.rs b/crates/bds-ui/src/app.rs index 6830258..16a834e 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -95,6 +95,7 @@ enum AutoTranslationTaskEvent { pub enum Message { // Menu MenuEvent(muda::MenuId), + WindowMenu(menu::WindowMenuEvent), // Navigation SetActiveView(SidebarView), @@ -1079,6 +1080,7 @@ pub struct BdsApp { // Platform _menu_bar: Option, menu_registry: MenuRegistry, + window_menu: menu::WindowMenuState, native_edit_commands: native_edit::EditCommandQueue, #[cfg(target_os = "macos")] _lifecycle_handler: Option>, @@ -1291,6 +1293,7 @@ impl BdsApp { image_drop_import_running: false, _menu_bar: Some(menu_bar), menu_registry: registry, + window_menu: menu::WindowMenuState::default(), native_edit_commands: native_edit::command_queue(), #[cfg(target_os = "macos")] _lifecycle_handler: lifecycle_handler, @@ -1397,6 +1400,7 @@ impl BdsApp { image_drop_import_running: false, _menu_bar: None, menu_registry: MenuRegistry::empty(), + window_menu: menu::WindowMenuState::default(), native_edit_commands: native_edit::command_queue(), #[cfg(target_os = "macos")] _lifecycle_handler: None, @@ -1465,6 +1469,25 @@ impl BdsApp { } Task::none() } + Message::WindowMenu(event) => { + let has_project = self.active_project.is_some(); + let active_tab = self.active_tab_type(); + let offline = self.offline_mode; + let interactions_enabled = !self.search_index_rebuild_running; + let connected = self.remote_client.is_some(); + let enabled = |action| { + menu::action_enabled( + action, + has_project, + active_tab.as_ref(), + offline, + interactions_enabled, + ) && (action != MenuAction::DisconnectServer || connected) + }; + self.window_menu + .handle_event(event, enabled) + .map_or_else(Task::none, |action| self.dispatch_menu_action(action)) + } // ── Navigation ── Message::SetActiveView(view) => { @@ -3778,74 +3801,84 @@ impl BdsApp { None }; - native_edit::native_edit( - workspace::view( - self.sidebar_view, - self.sidebar_visible, - self.sidebar_width, - &self.tabs, - self.active_tab.as_deref(), - self.panel_visible, - self.panel_tab, - &self.task_snapshots, - &self.collapsed_task_groups, - &self.output_entries, - &self.sidebar_posts, - &self.sidebar_media, - &self.sidebar_scripts, - &self.sidebar_templates, - &self.sidebar_imports, - &self.chat_conversations, - active_post_filter, - &self.media_filter, - &self.sidebar_media_thumbs, - self.sidebar_posts_has_more, - self.sidebar_media_has_more, - active_name, - &self.projects, - self.active_project.as_ref().map(|p| p.id.as_str()), - self.post_count, - self.media_count, - self.offline_mode, - self.locale_dropdown_open, - self.project_dropdown_open, - &self.theme_badge, - self.ui_locale, - &self.toasts, - self.active_modal.clone(), - self.data_dir.as_deref(), - post_preview_widget, - style_preview_widget, - &self.post_editors, - &self.media_editors, - &self.template_editors, - &self.script_editors, - &self.import_editors, - &self.chat_editors, - self.tags_view_state.as_ref(), - self.settings_state.as_ref(), - self.style_view_state.as_ref(), - self.dashboard_state.as_ref(), - &self.site_validation_state, - &self.duplicates_state, - &self.guide_documentation, - &self.api_documentation, - &self.cli_documentation, - &self.mcp_documentation, - &self.metadata_diff_state, - &self.menu_editor_state, - &self.translation_validation_state, - &self.git_state, - &self.git_diffs, - &self.git_file_history, - ), - Arc::clone(&self.native_edit_commands), - ) - .into() + let content = workspace::view( + self.sidebar_view, + self.sidebar_visible, + self.sidebar_width, + &self.tabs, + self.active_tab.as_deref(), + self.panel_visible, + self.panel_tab, + &self.task_snapshots, + &self.collapsed_task_groups, + &self.output_entries, + &self.sidebar_posts, + &self.sidebar_media, + &self.sidebar_scripts, + &self.sidebar_templates, + &self.sidebar_imports, + &self.chat_conversations, + active_post_filter, + &self.media_filter, + &self.sidebar_media_thumbs, + self.sidebar_posts_has_more, + self.sidebar_media_has_more, + active_name, + &self.projects, + self.active_project.as_ref().map(|p| p.id.as_str()), + self.post_count, + self.media_count, + self.offline_mode, + self.locale_dropdown_open, + self.project_dropdown_open, + &self.theme_badge, + self.ui_locale, + &self.toasts, + self.active_modal.clone(), + self.data_dir.as_deref(), + post_preview_widget, + style_preview_widget, + &self.post_editors, + &self.media_editors, + &self.template_editors, + &self.script_editors, + &self.import_editors, + &self.chat_editors, + self.tags_view_state.as_ref(), + self.settings_state.as_ref(), + self.style_view_state.as_ref(), + self.dashboard_state.as_ref(), + &self.site_validation_state, + &self.duplicates_state, + &self.guide_documentation, + &self.api_documentation, + &self.cli_documentation, + &self.mcp_documentation, + &self.metadata_diff_state, + &self.menu_editor_state, + &self.translation_validation_state, + &self.git_state, + &self.git_diffs, + &self.git_file_history, + ); + #[cfg(not(target_os = "macos"))] + let content = + menu::window_menu_view(content, &self.window_menu, self.ui_locale, |action| { + menu::action_enabled( + action, + self.active_project.is_some(), + self.active_tab_type(), + self.offline_mode, + !self.search_index_rebuild_running, + ) && (action != MenuAction::DisconnectServer || self.remote_client.is_some()) + }); + + native_edit::native_edit(content, Arc::clone(&self.native_edit_commands)).into() } pub fn subscription(&self) -> Subscription { let menu_sub = menu::menu_subscription(); + let window_menu_sub = menu::window_menu_subscription(); let task_tick = iced::time::every(std::time::Duration::from_millis(500)).map(|_| Message::TaskTick); @@ -3915,6 +3948,7 @@ impl BdsApp { Subscription::batch([ menu_sub, + window_menu_sub, task_tick, domain_event_tick, toast_tick, diff --git a/crates/bds-ui/src/components/popover.rs b/crates/bds-ui/src/components/popover.rs index eb50a8c..ca9977e 100644 --- a/crates/bds-ui/src/components/popover.rs +++ b/crates/bds-ui/src/components/popover.rs @@ -15,6 +15,7 @@ pub struct Popover<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> is_open: bool, on_dismiss: Message, gap: f32, + align_right: bool, } impl<'a, Message, Theme, Renderer> Popover<'a, Message, Theme, Renderer> { @@ -30,13 +31,31 @@ impl<'a, Message, Theme, Renderer> Popover<'a, Message, Theme, Renderer> { is_open, on_dismiss, gap: 8.0, + align_right: true, } } + + /// Anchor the popup to the trigger's left edge. + pub fn align_left(mut self) -> Self { + self.align_right = false; + self + } } -fn popup_position(anchor: Rectangle, popup: Size, viewport: Size, gap: f32) -> Point { +fn popup_position( + anchor: Rectangle, + popup: Size, + viewport: Size, + gap: f32, + align_right: bool, +) -> 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 x = if align_right { + anchor.x + anchor.width - popup.width + } else { + anchor.x + } + .clamp(0.0, max_x); let below = anchor.y + anchor.height + gap; let y = if below + popup.height <= viewport.height { below @@ -169,6 +188,7 @@ where state: &mut popup_tree[0], on_dismiss: self.on_dismiss.clone(), gap: self.gap, + align_right: self.align_right, })) }); @@ -203,6 +223,7 @@ struct PopupOverlay<'a, 'b, Message, Theme, Renderer> { state: &'b mut Tree, on_dismiss: Message, gap: f32, + align_right: bool, } impl overlay::Overlay @@ -217,7 +238,13 @@ where renderer, &layout::Limits::new(Size::ZERO, bounds), ); - let position = popup_position(self.anchor, popup.size(), bounds, self.gap); + let position = popup_position( + self.anchor, + popup.size(), + bounds, + self.gap, + self.align_right, + ); layout::Node::with_children(popup.size(), vec![popup]) .translate(Vector::new(position.x, position.y)) } @@ -345,6 +372,7 @@ mod tests { Size::new(220.0, 180.0), Size::new(800.0, 600.0), 8.0, + true, ), Point::new(280.0, 58.0) ); @@ -358,8 +386,23 @@ mod tests { Size::new(220.0, 180.0), Size::new(800.0, 600.0), 8.0, + true, ), Point::new(0.0, 382.0) ); } + + #[test] + fn popup_can_be_left_aligned_to_its_trigger() { + assert_eq!( + popup_position( + Rectangle::new(Point::new(120.0, 20.0), Size::new(80.0, 30.0)), + Size::new(220.0, 180.0), + Size::new(800.0, 600.0), + 8.0, + false, + ), + Point::new(120.0, 58.0) + ); + } } diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index 1758c6c..5a08c1c 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -1,10 +1,16 @@ use std::collections::HashMap; -use iced::Subscription; +use iced::widget::{ + Column, button, column, container, horizontal_space, mouse_area, row, scrollable, text, +}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Shadow, Subscription, Theme, Vector, +}; use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers}; use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu}; use crate::app::Message; +use crate::components::{inputs, popover}; use crate::state::tabs::TabType; use bds_core::i18n::{UiLocale, translate}; @@ -213,6 +219,548 @@ impl MenuAction { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowMenuGroup { + File, + Edit, + View, + Blog, + Help, +} + +impl WindowMenuGroup { + const ALL: [Self; 5] = [Self::File, Self::Edit, Self::View, Self::Blog, Self::Help]; + + fn i18n_key(self) -> &'static str { + match self { + Self::File => "menu.group.file", + Self::Edit => "menu.group.edit", + Self::View => "menu.group.view", + Self::Blog => "menu.group.blog", + Self::Help => "menu.group.help", + } + } + + fn mnemonic(self) -> char { + match self { + Self::File => 'f', + Self::Edit => 'e', + Self::View => 'v', + Self::Blog => 'b', + Self::Help => 'h', + } + } + + fn shifted(self, offset: isize) -> Self { + let current = Self::ALL + .iter() + .position(|group| *group == self) + .expect("window menu group is exhaustive") as isize; + Self::ALL[(current + offset).rem_euclid(Self::ALL.len() as isize) as usize] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowMenuEntry { + Action(MenuAction), + Separator, +} + +#[derive(Debug)] +struct WindowMenuDefinition { + group: WindowMenuGroup, + entries: &'static [WindowMenuEntry], +} + +use WindowMenuEntry::{Action, Separator}; + +const FILE_MENU: &[WindowMenuEntry] = &[ + Action(MenuAction::NewPost), + Action(MenuAction::ImportMedia), + Action(MenuAction::Save), + Separator, + Action(MenuAction::OpenInBrowser), + Action(MenuAction::OpenDataFolder), + Separator, + Action(MenuAction::ConnectServer), + Action(MenuAction::DisconnectServer), +]; +const EDIT_MENU: &[WindowMenuEntry] = &[ + Action(MenuAction::Undo), + Action(MenuAction::Redo), + Separator, + Action(MenuAction::Cut), + Action(MenuAction::Copy), + Action(MenuAction::Paste), + Action(MenuAction::SelectAll), + Separator, + Action(MenuAction::Find), + Action(MenuAction::Replace), + Separator, + Action(MenuAction::EditPreferences), +]; +const VIEW_MENU: &[WindowMenuEntry] = &[ + Action(MenuAction::ViewPosts), + Action(MenuAction::ViewMedia), + Separator, + Action(MenuAction::ToggleSidebar), + Action(MenuAction::TogglePanel), +]; +const BLOG_MENU: &[WindowMenuEntry] = &[ + Action(MenuAction::PublishSelected), + Action(MenuAction::PreviewPost), + Separator, + Action(MenuAction::EditMenu), + Separator, + Action(MenuAction::RebuildDatabase), + Action(MenuAction::ReindexText), + Action(MenuAction::RebuildEmbeddingIndex), + Action(MenuAction::FindDuplicates), + Action(MenuAction::MetadataDiff), + Separator, + Action(MenuAction::RegenerateCalendar), + Action(MenuAction::ValidateTranslations), + Action(MenuAction::FillMissingTranslations), + Separator, + Action(MenuAction::GenerateSitemap), + Action(MenuAction::ForceRenderSite), + Action(MenuAction::ValidateSite), + Action(MenuAction::UploadSite), +]; +const HELP_MENU: &[WindowMenuEntry] = &[ + Action(MenuAction::About), + Separator, + Action(MenuAction::OpenDocumentation), + Action(MenuAction::OpenApiDocumentation), + Action(MenuAction::OpenCliDocumentation), + Action(MenuAction::OpenMcpDocumentation), + Action(MenuAction::ViewOnGitHub), + Action(MenuAction::ReportIssue), +]; + +const WINDOW_MENU_GROUPS: &[WindowMenuDefinition] = &[ + WindowMenuDefinition { + group: WindowMenuGroup::File, + entries: FILE_MENU, + }, + WindowMenuDefinition { + group: WindowMenuGroup::Edit, + entries: EDIT_MENU, + }, + WindowMenuDefinition { + group: WindowMenuGroup::View, + entries: VIEW_MENU, + }, + WindowMenuDefinition { + group: WindowMenuGroup::Blog, + entries: BLOG_MENU, + }, + WindowMenuDefinition { + group: WindowMenuGroup::Help, + entries: HELP_MENU, + }, +]; + +fn window_menu(group: WindowMenuGroup) -> &'static WindowMenuDefinition { + WINDOW_MENU_GROUPS + .iter() + .find(|definition| definition.group == group) + .expect("window menu group is exhaustive") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowMenuInput { + AltChanged(bool), + Mnemonic(char), + Left, + Right, + Down, + Up, + Home, + End, + Activate, + Escape, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowMenuEvent { + Toggle(WindowMenuGroup), + Hover(WindowMenuGroup), + Dismiss, + Input(WindowMenuInput), + Action(MenuAction), +} + +#[derive(Debug, Default)] +pub struct WindowMenuState { + open_group: Option, + selected_index: Option, + show_mnemonics: bool, +} + +impl WindowMenuState { + fn actions(&self) -> impl Iterator { + self.open_group + .into_iter() + .flat_map(|group| window_menu(group).entries) + .filter_map(|entry| match entry { + Action(action) => Some(*action), + Separator => None, + }) + } + + fn selected_action(&self) -> Option { + self.selected_index + .and_then(|index| self.actions().nth(index)) + } + + fn close(&mut self) { + self.open_group = None; + self.selected_index = None; + } + + fn select_edge(&mut self, from_end: bool, enabled: impl Fn(MenuAction) -> bool) { + let actions = self.actions().collect::>(); + self.selected_index = if from_end { + actions.iter().rposition(|action| enabled(*action)) + } else { + actions.iter().position(|action| enabled(*action)) + }; + } + + fn advance(&mut self, offset: isize, enabled: impl Fn(MenuAction) -> bool) { + let actions = self.actions().collect::>(); + if actions.is_empty() { + self.selected_index = None; + return; + } + let start = self + .selected_index + .map_or(if offset > 0 { -1 } else { 0 }, |index| index as isize); + self.selected_index = (1..=actions.len()).find_map(|step| { + let index = + (start + offset * step as isize).rem_euclid(actions.len() as isize) as usize; + enabled(actions[index]).then_some(index) + }); + } + + pub fn handle( + &mut self, + input: WindowMenuInput, + enabled: impl Fn(MenuAction) -> bool + Copy, + ) -> Option { + match input { + WindowMenuInput::AltChanged(pressed) => self.show_mnemonics = pressed, + WindowMenuInput::Mnemonic(key) => { + if let Some(group) = WindowMenuGroup::ALL + .into_iter() + .find(|group| group.mnemonic() == key.to_ascii_lowercase()) + { + self.open_group = Some(group); + self.selected_index = None; + } + } + WindowMenuInput::Left if self.open_group.is_some() => { + self.open_group = self.open_group.map(|group| group.shifted(-1)); + self.selected_index = None; + } + WindowMenuInput::Right if self.open_group.is_some() => { + self.open_group = self.open_group.map(|group| group.shifted(1)); + self.selected_index = None; + } + WindowMenuInput::Down if self.open_group.is_some() => self.advance(1, enabled), + WindowMenuInput::Up if self.open_group.is_some() => self.advance(-1, enabled), + WindowMenuInput::Home if self.open_group.is_some() => self.select_edge(false, enabled), + WindowMenuInput::End if self.open_group.is_some() => self.select_edge(true, enabled), + WindowMenuInput::Activate if self.open_group.is_some() => { + let action = self.selected_action().filter(|action| enabled(*action)); + if action.is_some() { + self.close(); + } + return action; + } + WindowMenuInput::Escape if self.open_group.is_some() => self.close(), + _ => {} + } + None + } + + pub fn handle_event( + &mut self, + event: WindowMenuEvent, + enabled: impl Fn(MenuAction) -> bool + Copy, + ) -> Option { + match event { + WindowMenuEvent::Toggle(group) => { + if self.open_group == Some(group) { + self.close(); + } else { + self.open_group = Some(group); + self.selected_index = None; + } + None + } + WindowMenuEvent::Hover(group) => { + if self.open_group.is_some() && self.open_group != Some(group) { + self.open_group = Some(group); + self.selected_index = None; + } + None + } + WindowMenuEvent::Dismiss => { + self.close(); + None + } + WindowMenuEvent::Input(input) => self.handle(input, enabled), + WindowMenuEvent::Action(action) if enabled(action) => { + self.close(); + Some(action) + } + WindowMenuEvent::Action(_) => None, + } + } +} + +fn shortcut_hint(action: MenuAction) -> Option<&'static str> { + Some(match action { + MenuAction::NewPost => "Ctrl+N", + MenuAction::ImportMedia => "Ctrl+I", + MenuAction::Save => "Ctrl+S", + MenuAction::Undo => "Ctrl+Z", + MenuAction::Redo => "Ctrl+Shift+Z", + MenuAction::Cut => "Ctrl+X", + MenuAction::Copy => "Ctrl+C", + MenuAction::Paste => "Ctrl+V", + MenuAction::SelectAll => "Ctrl+A", + MenuAction::Find => "Ctrl+F", + MenuAction::Replace => "Ctrl+H", + MenuAction::EditPreferences => "Ctrl+,", + MenuAction::ViewPosts => "Ctrl+1", + MenuAction::ViewMedia => "Ctrl+2", + MenuAction::ToggleSidebar => "Ctrl+B", + MenuAction::TogglePanel => "Ctrl+J", + MenuAction::PublishSelected => "Ctrl+Shift+P", + MenuAction::PreviewPost => "Ctrl+Shift+V", + MenuAction::GenerateSitemap => "Ctrl+R", + MenuAction::ForceRenderSite => "Ctrl+Shift+R", + MenuAction::ValidateSite => "Ctrl+Shift+L", + MenuAction::UploadSite => "Ctrl+Shift+U", + _ => return None, + }) +} + +fn menu_bar_style(_theme: &Theme) -> iced::widget::container::Style { + iced::widget::container::Style { + background: Some(Background::Color(Color::from_rgb8(0x25, 0x25, 0x26))), + border: Border { + color: Color::from_rgb8(0x3C, 0x3C, 0x3C), + width: 0.0, + radius: 0.0.into(), + }, + ..iced::widget::container::Style::default() + } +} + +fn menu_popup_style(_theme: &Theme) -> iced::widget::container::Style { + iced::widget::container::Style { + background: Some(Background::Color(Color::from_rgb8(0x25, 0x25, 0x26))), + border: Border { + color: Color::from_rgb8(0x4A, 0x4A, 0x4D), + width: 1.0, + radius: 6.0.into(), + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.45), + offset: Vector::new(0.0, 8.0), + blur_radius: 24.0, + }, + ..iced::widget::container::Style::default() + } +} + +fn menu_button_style( + active: bool, + _theme: &Theme, + status: iced::widget::button::Status, +) -> iced::widget::button::Style { + let highlighted = active || matches!(status, iced::widget::button::Status::Hovered); + iced::widget::button::Style { + background: highlighted.then_some(Background::Color(Color::from_rgb8(0x3A, 0x3D, 0x41))), + text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4), + border: Border { + radius: 4.0.into(), + ..Border::default() + }, + ..iced::widget::button::Style::default() + } +} + +fn menu_item_style( + selected: bool, + _theme: &Theme, + status: iced::widget::button::Status, +) -> iced::widget::button::Style { + let highlighted = selected || matches!(status, iced::widget::button::Status::Hovered); + let disabled = matches!(status, iced::widget::button::Status::Disabled); + iced::widget::button::Style { + background: highlighted.then_some(Background::Color(Color::from_rgb8(0x3A, 0x3D, 0x41))), + text_color: if disabled { + Color::from_rgb8(0x7D, 0x7D, 0x7D) + } else { + Color::from_rgb8(0xE4, 0xE4, 0xE4) + }, + border: Border { + radius: 4.0.into(), + ..Border::default() + }, + ..iced::widget::button::Style::default() + } +} + +fn window_menu_popup<'a>( + definition: &'static WindowMenuDefinition, + state: &WindowMenuState, + locale: UiLocale, + enabled: impl Fn(MenuAction) -> bool + Copy + 'a, +) -> Element<'a, Message> { + let selected = state.selected_action(); + let mut entries = Column::new().spacing(2); + for entry in definition.entries { + match entry { + Action(action) => { + let action = *action; + let is_enabled = enabled(action); + let shortcut = shortcut_hint(action).unwrap_or_default(); + let content = row![ + text(translate(locale, action.i18n_key())).size(12), + horizontal_space(), + text(shortcut) + .size(11) + .color(Color::from_rgb8(0x9D, 0xA5, 0xB4)), + ] + .align_y(Alignment::Center) + .spacing(16); + let mut item = button(content).padding([6, 8]).width(Length::Fill).style( + move |theme, status| menu_item_style(selected == Some(action), theme, status), + ); + if is_enabled { + item = item.on_press(Message::WindowMenu(WindowMenuEvent::Action(action))); + } + entries = entries.push(item); + } + Separator => { + entries = entries.push( + container(horizontal_space()) + .height(Length::Fixed(1.0)) + .width(Length::Fill) + .style(|_| iced::widget::container::Style { + background: Some(Background::Color(Color::from_rgb8(0x3C, 0x3C, 0x3C))), + ..iced::widget::container::Style::default() + }), + ); + } + } + } + let entries = scrollable(entries) + .direction(scrollable::Direction::Vertical(inputs::compact_scrollbar())) + .style(inputs::scrollable_style); + container(entries) + .padding(6) + .width(Length::Fixed(290.0)) + .max_height(520.0) + .style(menu_popup_style) + .into() +} + +/// Wrap the workspace in the five-group in-window menu used on Linux and Windows. +pub fn window_menu_view<'a>( + content: Element<'a, Message>, + state: &WindowMenuState, + locale: UiLocale, + enabled: impl Fn(MenuAction) -> bool + Copy + 'a, +) -> Element<'a, Message> { + let mut groups = row![].spacing(2).align_y(Alignment::Center); + for definition in WINDOW_MENU_GROUPS { + let active = state.open_group == Some(definition.group); + let label = translate(locale, definition.group.i18n_key()); + let label = if state.show_mnemonics { + format!( + "{label} {}", + definition.group.mnemonic().to_ascii_uppercase() + ) + } else { + label + }; + let trigger = mouse_area( + button(text(label).size(12)) + .padding([5, 8]) + .style(move |theme, status| menu_button_style(active, theme, status)) + .on_press(Message::WindowMenu(WindowMenuEvent::Toggle( + definition.group, + ))), + ) + .on_enter(Message::WindowMenu(WindowMenuEvent::Hover( + definition.group, + ))); + groups = groups.push( + popover::popover( + trigger, + window_menu_popup(definition, state, locale, enabled), + active, + Message::WindowMenu(WindowMenuEvent::Dismiss), + ) + .align_left(), + ); + } + + let bar = container(groups) + .padding([5, 6]) + .height(Length::Fixed(34.0)) + .width(Length::Fill) + .style(menu_bar_style); + column![bar, content].height(Length::Fill).into() +} + +fn window_menu_input(event: iced::keyboard::Event) -> Option { + use iced::keyboard::{Key, key}; + + match event { + iced::keyboard::Event::ModifiersChanged(modifiers) => { + Some(WindowMenuInput::AltChanged(modifiers.alt())) + } + iced::keyboard::Event::KeyPressed { + key: Key::Character(character), + modifiers, + .. + } if modifiers.alt() => character.chars().next().map(WindowMenuInput::Mnemonic), + iced::keyboard::Event::KeyPressed { key, .. } => match key { + Key::Named(key::Named::ArrowLeft) => Some(WindowMenuInput::Left), + Key::Named(key::Named::ArrowRight) => Some(WindowMenuInput::Right), + Key::Named(key::Named::ArrowDown) => Some(WindowMenuInput::Down), + Key::Named(key::Named::ArrowUp) => Some(WindowMenuInput::Up), + Key::Named(key::Named::Home) => Some(WindowMenuInput::Home), + Key::Named(key::Named::End) => Some(WindowMenuInput::End), + Key::Named(key::Named::Enter | key::Named::Space) => Some(WindowMenuInput::Activate), + Key::Named(key::Named::Escape) => Some(WindowMenuInput::Escape), + _ => None, + }, + _ => None, + } +} + +/// Keyboard subscription for the non-macOS in-window menu. +pub fn window_menu_subscription() -> Subscription { + if cfg!(target_os = "macos") { + return Subscription::none(); + } + iced::event::listen_with(|event, _status, _window| match event { + iced::Event::Keyboard(event) => { + window_menu_input(event).map(|input| Message::WindowMenu(WindowMenuEvent::Input(input))) + } + _ => None, + }) +} + pub(crate) fn action_enabled( action: MenuAction, has_project: bool, @@ -638,6 +1186,79 @@ mod tests { use super::*; use bds_core::i18n::UiLocale; + #[test] + fn window_menu_exposes_the_five_specified_groups() { + assert_eq!( + WINDOW_MENU_GROUPS + .iter() + .map(|group| group.group) + .collect::>(), + vec![ + WindowMenuGroup::File, + WindowMenuGroup::Edit, + WindowMenuGroup::View, + WindowMenuGroup::Blog, + WindowMenuGroup::Help, + ] + ); + assert!(WINDOW_MENU_GROUPS.iter().all(|group| { + group + .entries + .iter() + .any(|entry| matches!(entry, WindowMenuEntry::Action(_))) + })); + let actions = WINDOW_MENU_GROUPS + .iter() + .flat_map(|group| group.entries) + .filter_map(|entry| match entry { + WindowMenuEntry::Action(action) => Some(*action), + WindowMenuEntry::Separator => None, + }) + .collect::>(); + assert_eq!(actions.len(), MenuAction::ALL.len()); + assert!( + MenuAction::ALL + .iter() + .all(|action| actions.contains(action)) + ); + } + + #[test] + fn window_menu_keyboard_navigation_matches_the_layout_spec() { + let mut state = WindowMenuState::default(); + + assert_eq!(state.handle(WindowMenuInput::Mnemonic('f'), |_| true), None); + assert_eq!(state.open_group, Some(WindowMenuGroup::File)); + assert_eq!( + state.handle(WindowMenuInput::Down, |action| { + action != MenuAction::NewPost + }), + None + ); + assert_eq!(state.selected_action(), Some(MenuAction::ImportMedia)); + assert_eq!( + state.handle(WindowMenuInput::Activate, |_| true), + Some(MenuAction::ImportMedia) + ); + assert_eq!(state.open_group, None); + + state.handle(WindowMenuInput::Mnemonic('b'), |_| true); + state.handle(WindowMenuInput::Right, |_| true); + assert_eq!(state.open_group, Some(WindowMenuGroup::Help)); + state.handle(WindowMenuInput::Escape, |_| true); + assert_eq!(state.open_group, None); + } + + #[test] + fn window_menu_alt_state_controls_mnemonic_hints() { + let mut state = WindowMenuState::default(); + + state.handle(WindowMenuInput::AltChanged(true), |_| true); + assert!(state.show_mnemonics); + state.handle(WindowMenuInput::AltChanged(false), |_| true); + assert!(!state.show_mnemonics); + } + #[test] fn i18n_keys_resolve_for_english() { for &action in MenuAction::ALL {