Fixes menu a11y, and adds landmarks with `F6`-navigation. Also fixes a GPUI bug, and adds debug actions for dumping a11y tree info. Since `F6` was already in use by the pause debugger keybind, also tightens up the debugger keybind context so they require an active debugger session. When there is one active, `F6` stays as pause debugger. `ctrl-F6` always works to go to the next landmark. Also adds an "accessible mode" setting. Currently, this only controls whether we show all menus all the time, but I suspect it will expand significantly in the future. Also adds `.aria_keyshortcuts()` API, but it's not wired up within accesskit adapters, so is not yet reported to screen readers. --- Release Notes: - N/A or Added/Fixed/Improved ...
346 lines
13 KiB
Rust
346 lines
13 KiB
Rust
use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, Subscription, actions};
|
|
use settings::{Settings, SettingsStore};
|
|
use workspace::AccessibleMode;
|
|
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
|
|
use smallvec::SmallVec;
|
|
use ui::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
|
|
|
|
use crate::title_bar_settings::TitleBarSettings;
|
|
|
|
actions!(
|
|
app_menu,
|
|
[
|
|
/// Activates the menu on the right in the client-side application menu.
|
|
///
|
|
/// Does not apply to platform menu bars (e.g. on macOS).
|
|
ActivateMenuRight,
|
|
/// Activates the menu on the left in the client-side application menu.
|
|
///
|
|
/// Does not apply to platform menu bars (e.g. on macOS).
|
|
ActivateMenuLeft
|
|
]
|
|
);
|
|
|
|
/// Opens the named menu in the client-side application menu.
|
|
///
|
|
/// Does not apply to platform menu bars (e.g. on macOS).
|
|
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Default, Action)]
|
|
#[action(namespace = app_menu)]
|
|
pub struct OpenApplicationMenu(String);
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub enum ActivateDirection {
|
|
Left,
|
|
Right,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct MenuEntry {
|
|
menu: OwnedMenu,
|
|
handle: PopoverMenuHandle<ContextMenu>,
|
|
}
|
|
|
|
pub struct ApplicationMenu {
|
|
entries: SmallVec<[MenuEntry; 8]>,
|
|
pending_menu_open: Option<String>,
|
|
_settings_subscription: Subscription,
|
|
}
|
|
|
|
impl ApplicationMenu {
|
|
pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
|
|
let menus = cx.get_menus().unwrap_or_default();
|
|
// Re-render when settings change so toggling "accessible mode" expands or
|
|
// collapses the menu bar (see `all_menus_shown`).
|
|
let settings_subscription = cx.observe_global::<SettingsStore>(|_, cx| cx.notify());
|
|
Self {
|
|
entries: menus
|
|
.into_iter()
|
|
.map(|menu| MenuEntry {
|
|
menu,
|
|
handle: PopoverMenuHandle::default(),
|
|
})
|
|
.collect(),
|
|
pending_menu_open: None,
|
|
_settings_subscription: settings_subscription,
|
|
}
|
|
}
|
|
|
|
fn sanitize_menu_items(items: Vec<OwnedMenuItem>) -> Vec<OwnedMenuItem> {
|
|
let mut cleaned = Vec::new();
|
|
let mut last_was_separator = false;
|
|
|
|
for item in items {
|
|
match item {
|
|
OwnedMenuItem::Separator => {
|
|
if !last_was_separator {
|
|
cleaned.push(item);
|
|
last_was_separator = true;
|
|
}
|
|
}
|
|
OwnedMenuItem::Submenu(submenu) => {
|
|
// Skip empty submenus
|
|
if !submenu.items.is_empty() {
|
|
cleaned.push(OwnedMenuItem::Submenu(submenu));
|
|
last_was_separator = false;
|
|
}
|
|
}
|
|
item => {
|
|
cleaned.push(item);
|
|
last_was_separator = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove trailing separator
|
|
if let Some(OwnedMenuItem::Separator) = cleaned.last() {
|
|
cleaned.pop();
|
|
}
|
|
|
|
cleaned
|
|
}
|
|
|
|
fn build_menu_from_items(
|
|
entry: MenuEntry,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Entity<ContextMenu> {
|
|
ContextMenu::build(window, cx, |menu, window, cx| {
|
|
// Grab current focus handle so menu can shown items in context with the focused element
|
|
let menu = menu.when_some(window.focused(cx), |menu, focused| menu.context(focused));
|
|
let sanitized_items = Self::sanitize_menu_items(entry.menu.items);
|
|
|
|
sanitized_items
|
|
.into_iter()
|
|
.fold(menu, |menu, item| match item {
|
|
OwnedMenuItem::Separator => menu.separator(),
|
|
OwnedMenuItem::Action {
|
|
name,
|
|
action,
|
|
checked,
|
|
disabled,
|
|
..
|
|
} => menu.action_checked_with_disabled(name, action, checked, disabled),
|
|
OwnedMenuItem::Submenu(submenu) => {
|
|
submenu
|
|
.items
|
|
.into_iter()
|
|
.fold(menu, |menu, item| match item {
|
|
OwnedMenuItem::Separator => menu.separator(),
|
|
OwnedMenuItem::Action {
|
|
name,
|
|
action,
|
|
checked,
|
|
disabled,
|
|
..
|
|
} => menu
|
|
.action_checked_with_disabled(name, action, checked, disabled),
|
|
OwnedMenuItem::Submenu(_) => menu,
|
|
OwnedMenuItem::SystemMenu(_) => {
|
|
// A system menu doesn't make sense in this context, so ignore it
|
|
menu
|
|
}
|
|
})
|
|
}
|
|
OwnedMenuItem::SystemMenu(_) => {
|
|
// A system menu doesn't make sense in this context, so ignore it
|
|
menu
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement {
|
|
let handle = entry.handle.clone();
|
|
|
|
let menu_name = entry.menu.name.clone();
|
|
let entry = entry.clone();
|
|
|
|
// Application menu must have same ids as first menu item in standard menu
|
|
div()
|
|
.id(format!("{}-menu-item", menu_name))
|
|
.occlude()
|
|
.child(
|
|
PopoverMenu::new(format!("{}-menu-popover", menu_name))
|
|
.menu(move |window, cx| {
|
|
Self::build_menu_from_items(entry.clone(), window, cx).into()
|
|
})
|
|
.trigger_with_tooltip(
|
|
IconButton::new(
|
|
SharedString::from(format!("{}-menu-trigger", menu_name)),
|
|
ui::IconName::Menu,
|
|
)
|
|
.style(ButtonStyle::Subtle)
|
|
.icon_size(IconSize::Small)
|
|
.tab_index(0isize)
|
|
.aria_label("Application menu"),
|
|
Tooltip::text("Open Application Menu"),
|
|
)
|
|
.with_handle(handle),
|
|
)
|
|
}
|
|
|
|
fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
|
|
let current_handle = entry.handle.clone();
|
|
|
|
let menu_name = entry.menu.name.clone();
|
|
let entry = entry.clone();
|
|
|
|
let all_handles: Vec<_> = self
|
|
.entries
|
|
.iter()
|
|
.map(|entry| entry.handle.clone())
|
|
.collect();
|
|
|
|
div()
|
|
.id(format!("{}-menu-item", menu_name))
|
|
.occlude()
|
|
.child(
|
|
PopoverMenu::new(format!("{}-menu-popover", menu_name))
|
|
.menu(move |window, cx| {
|
|
Self::build_menu_from_items(entry.clone(), window, cx).into()
|
|
})
|
|
.trigger(
|
|
Button::new(
|
|
SharedString::from(format!("{}-menu-trigger", menu_name)),
|
|
menu_name,
|
|
)
|
|
.style(ButtonStyle::Subtle)
|
|
.label_size(LabelSize::Small)
|
|
.tab_index(0isize),
|
|
)
|
|
.with_handle(current_handle.clone()),
|
|
)
|
|
.on_hover(move |hover_enter, window, cx| {
|
|
if *hover_enter && !current_handle.is_deployed() {
|
|
all_handles.iter().for_each(|h| h.hide(cx));
|
|
|
|
// We need to defer this so that this menu handle can take focus from the previous menu
|
|
let handle = current_handle.clone();
|
|
window.defer(cx, move |window, cx| handle.show(window, cx));
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn open_menu(
|
|
&mut self,
|
|
action: &OpenApplicationMenu,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Self>,
|
|
) {
|
|
self.pending_menu_open = Some(action.0.clone());
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn navigate_menus_in_direction(
|
|
&mut self,
|
|
direction: ActivateDirection,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let current_index = self
|
|
.entries
|
|
.iter()
|
|
.position(|entry| entry.handle.is_deployed());
|
|
let Some(current_index) = current_index else {
|
|
// No menu is open, so there is nothing to switch between. Let the
|
|
// arrow key continue to the title bar's toolbar navigation so it
|
|
// can move focus between title bar controls. Without this, the
|
|
// `left`/`right` bindings in the `ApplicationMenu` context would
|
|
// silently swallow the key as a no-op.
|
|
cx.propagate();
|
|
return;
|
|
};
|
|
|
|
let next_index = match direction {
|
|
ActivateDirection::Left => {
|
|
if current_index == 0 {
|
|
self.entries.len() - 1
|
|
} else {
|
|
current_index - 1
|
|
}
|
|
}
|
|
ActivateDirection::Right => {
|
|
if current_index == self.entries.len() - 1 {
|
|
0
|
|
} else {
|
|
current_index + 1
|
|
}
|
|
}
|
|
};
|
|
|
|
self.entries[current_index].handle.hide(cx);
|
|
|
|
// We need to defer this so that this menu handle can take focus from the previous menu
|
|
let next_handle = self.entries[next_index].handle.clone();
|
|
cx.defer_in(window, move |_, window, cx| next_handle.show(window, cx));
|
|
}
|
|
|
|
pub fn all_menus_shown(&self, cx: &mut Context<Self>) -> bool {
|
|
show_menus(cx)
|
|
|| self.entries.iter().any(|entry| entry.handle.is_deployed())
|
|
|| self.pending_menu_open.is_some()
|
|
// In accessible mode, keep the full menu bar expanded so every menu
|
|
// is individually reachable and labeled for assistive technology.
|
|
|| cx.accessible_mode()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn show_menus(cx: &mut App) -> bool {
|
|
TitleBarSettings::get_global(cx).show_menus
|
|
&& (cfg!(not(target_os = "macos")) || option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some())
|
|
}
|
|
|
|
impl Render for ApplicationMenu {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let all_menus_shown = self.all_menus_shown(cx);
|
|
|
|
if let Some(pending_menu_open) = self.pending_menu_open.take()
|
|
&& let Some(entry) = self
|
|
.entries
|
|
.iter()
|
|
.find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed())
|
|
{
|
|
let handle_to_show = entry.handle.clone();
|
|
let handles_to_hide: Vec<_> = self
|
|
.entries
|
|
.iter()
|
|
.filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed())
|
|
.map(|e| e.handle.clone())
|
|
.collect();
|
|
|
|
if handles_to_hide.is_empty() {
|
|
// We need to wait for the next frame to show all menus first,
|
|
// before we can handle show/hide operations
|
|
window.on_next_frame(move |window, cx| {
|
|
handles_to_hide.iter().for_each(|handle| handle.hide(cx));
|
|
window.defer(cx, move |window, cx| handle_to_show.show(window, cx));
|
|
});
|
|
} else {
|
|
// Since menus are already shown, we can directly handle show/hide operations
|
|
handles_to_hide.iter().for_each(|handle| handle.hide(cx));
|
|
cx.defer_in(window, move |_, window, cx| handle_to_show.show(window, cx));
|
|
}
|
|
}
|
|
|
|
div()
|
|
.key_context("ApplicationMenu")
|
|
.flex()
|
|
.flex_row()
|
|
.gap_x_1()
|
|
.when(!all_menus_shown && !self.entries.is_empty(), |this| {
|
|
this.child(self.render_application_menu(&self.entries[0]))
|
|
})
|
|
.when(all_menus_shown, |this| {
|
|
this.children(
|
|
self.entries
|
|
.iter()
|
|
.map(|entry| self.render_standard_menu(entry)),
|
|
)
|
|
})
|
|
}
|
|
}
|