Add in-window menus for Linux and Windows.

This commit is contained in:
2026-08-07 15:23:12 +02:00
parent 3b1a65359c
commit 358648378e
4 changed files with 767 additions and 69 deletions

View File

@@ -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<muda::Menu>,
menu_registry: MenuRegistry,
window_menu: menu::WindowMenuState,
native_edit_commands: native_edit::EditCommandQueue,
#[cfg(target_os = "macos")]
_lifecycle_handler: Option<objc2::rc::Retained<crate::platform::macos::BdsAppleEventHandler>>,
@@ -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<Message> {
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,

View File

@@ -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<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
@@ -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)
);
}
}

View File

@@ -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<WindowMenuGroup>,
selected_index: Option<usize>,
show_mnemonics: bool,
}
impl WindowMenuState {
fn actions(&self) -> impl Iterator<Item = MenuAction> {
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<MenuAction> {
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::<Vec<_>>();
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::<Vec<_>>();
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<MenuAction> {
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<MenuAction> {
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<WindowMenuInput> {
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<Message> {
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<_>>(),
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::<std::collections::HashSet<_>>();
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 {