Add in-window menus for Linux and Windows.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user