2735 lines
99 KiB
Rust
2735 lines
99 KiB
Rust
use std::cell::OnceCell;
|
|
use std::sync::Arc;
|
|
use std::{fmt, vec};
|
|
|
|
use crate::safe_triangle::SafeTriangle;
|
|
use crate::themes::theme::Fill;
|
|
use crate::util::time_format::format_approx_duration_from_now_sentence_case;
|
|
use crate::{appearance::Appearance, ui_components::icons};
|
|
use chrono::{DateTime, Local};
|
|
use galaxy_core::ui::color::blend::Blend;
|
|
use galaxyui::elements::{
|
|
ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, DropShadow, OffsetPositioning,
|
|
ParentAnchor, ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds,
|
|
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Stack,
|
|
};
|
|
use galaxyui::WindowId;
|
|
use galaxyui::{
|
|
accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole},
|
|
elements::{
|
|
Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss,
|
|
DispatchEventResult, Element, EventHandler, Flex, Hoverable, Icon, MainAxisAlignment,
|
|
MainAxisSize, MouseInBehavior, MouseStateHandle, ParentElement, Radius, Rect, SavePosition,
|
|
Shrinkable, Text,
|
|
},
|
|
fonts::{FamilyId, Properties},
|
|
keymap::FixedBinding,
|
|
platform::Cursor,
|
|
ui_components::components::UiComponent,
|
|
Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
|
};
|
|
use pathfinder_color::ColorU;
|
|
use pathfinder_geometry::rect::RectF;
|
|
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
|
|
|
pub const CHEVRON_RIGHT_ALIGN_SVG_PATH: &str = "bundled/svg/chevron-right-align.svg";
|
|
|
|
const SUBMENU_OVERLAP: f32 = 8.;
|
|
const MENU_VERTICAL_PADDING: f32 = 9.;
|
|
pub const MENU_ITEM_VERTICAL_PADDING: f32 = 5.;
|
|
pub const MENU_ITEM_HORIZONTAL_PADDING: f32 = 14.;
|
|
pub const SEPARATOR_VERTICAL_MARGIN: f32 = 4.;
|
|
const MINIMUM_MENU_ITEM_FONT_SIZE: f32 = 5.;
|
|
const PADDING_TO_ICON_SIZE_MULTIPLIER: f32 = 3.;
|
|
const MENU_ITEM_LEFT_PADDING_MULTIPLIER: f32 = 1.5;
|
|
const DROP_SHADOW_COLOR: ColorU = ColorU {
|
|
r: 0,
|
|
g: 0,
|
|
b: 0,
|
|
a: 48,
|
|
};
|
|
const SECONDARY_TEXT_RATIO: f32 = 0.9;
|
|
|
|
#[derive(Clone, Debug)]
|
|
/// At the current time, its not recommended to have more than 1 nested submenu due to
|
|
/// layout constraints.
|
|
pub struct SubMenu<A: Action + Clone = ()> {
|
|
depth: usize,
|
|
items: Vec<MenuItem<A>>,
|
|
height: f32,
|
|
/// The menu can have different items, including single item (standard menu) or a row of items,
|
|
/// for example, for tab colors that are rendered in a single row. selected_row_index points
|
|
/// to the specific item (or, in UI, specific row).
|
|
selected_row_index: Option<usize>,
|
|
/// Selected Item Field points to the actual idx within the row. For a Single item, it'll
|
|
/// always be 0, or None for separator.
|
|
selected_item_index: Option<usize>,
|
|
/// The index of the item or row of items that is currently hovered.
|
|
hovered_row_index: Option<usize>,
|
|
/// Tracks whether the most recent selection movement came from pointer
|
|
/// hover or from a keyboard/programmatic path.
|
|
last_selection_source: Option<MenuSelectionSource>,
|
|
/// Contains variant specific state.
|
|
menu_variant: MenuVariant,
|
|
}
|
|
|
|
/// Menu contains the menu items and defines the logic for managing the actions and rendering of the
|
|
/// items. It has two variants (scrollable and fixed) which slightly change the way it's laid out.
|
|
/// TODO: In the future, if we keep bumping into more rendering changes here, it may make sense to
|
|
/// abstract the Menu functionality into a trait and have separte implementations for dropdown and
|
|
/// context menu.
|
|
pub struct Menu<A: Action + Clone = ()> {
|
|
/// Whether or not the element should make the rest of the window unresponsive. All mouse events
|
|
/// are handled by the [`Menu`] rather than being propagated further down in the element
|
|
/// hierarchy.
|
|
prevent_interaction_with_other_elements: bool,
|
|
|
|
with_drop_shadow: bool,
|
|
|
|
border: Option<Border>,
|
|
|
|
/// All submenus must be the same width.
|
|
submenu_width: f32,
|
|
|
|
/// If present, this will be used to determine which way submenus should expand.
|
|
origin: Option<Vector2F>,
|
|
|
|
window_id: OnceCell<WindowId>,
|
|
|
|
menu: SubMenu<A>,
|
|
|
|
/// When set, the item at this index gets a subtle background highlight
|
|
/// to indicate that a submenu/sidecar panel is being shown for it.
|
|
submenu_being_shown_for_item_index: Option<usize>,
|
|
|
|
/// If true, menu items won't fire hover events when covered by another element.
|
|
ignore_hover_when_covered: bool,
|
|
|
|
/// Optional safe triangle for suppressing intermediate hovers when moving toward a sidecar.
|
|
safe_triangle: Option<SafeTriangle>,
|
|
|
|
/// When true, the menu's container uses flat bottom corners (radius 0).
|
|
/// Used when an external footer is rendered below the menu in the same visual container.
|
|
flatten_bottom_corners: bool,
|
|
|
|
/// Optional pinned element rendered below the scrollable items but inside the
|
|
/// `Dismiss`, so clicks on it do not trigger the dismiss handler.
|
|
pinned_footer_builder: Option<Box<PinnedFooterBuilder>>,
|
|
|
|
/// Optional pinned element rendered above the scrollable items but inside the
|
|
/// `Dismiss`, so clicks on it do not trigger the dismiss handler.
|
|
pinned_header_builder: Option<Box<PinnedHeaderBuilder>>,
|
|
|
|
/// Optional overrides for the depth-0 menu content padding.
|
|
content_top_padding_override: Option<f32>,
|
|
content_bottom_padding_override: Option<f32>,
|
|
/// If false, selecting a menu item updates selection and emits menu events
|
|
/// without dispatching the item's typed action directly from the menu.
|
|
dispatch_item_actions: bool,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub enum MenuVariant {
|
|
#[default]
|
|
Fixed,
|
|
Scrollable(ClippedScrollStateHandle),
|
|
}
|
|
|
|
impl std::fmt::Debug for MenuVariant {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Fixed => write!(f, "Fixed"),
|
|
Self::Scrollable(_) => write!(f, "Scrollable"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl MenuVariant {
|
|
pub fn scrollable() -> Self {
|
|
Self::Scrollable(Default::default())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default)]
|
|
pub(crate) enum MenuTooltipPosition {
|
|
#[default]
|
|
Right,
|
|
Above,
|
|
}
|
|
|
|
pub type CustomMenuItemLabelFn =
|
|
Arc<dyn Fn(bool, bool, &Appearance, &AppContext) -> Box<dyn Element>>;
|
|
|
|
pub type PinnedFooterBuilder = dyn Fn(&AppContext) -> Box<dyn Element>;
|
|
pub type PinnedHeaderBuilder = dyn Fn(&AppContext) -> Box<dyn Element>;
|
|
|
|
#[derive(Clone)]
|
|
pub enum MenuItemLabel {
|
|
Text(String),
|
|
// A label that can take up multiple lines. Doesn't support submenus yet.
|
|
MultilineText {
|
|
label: String,
|
|
max_lines: usize,
|
|
},
|
|
Icon {
|
|
path: &'static str,
|
|
color: Fill,
|
|
alt_text: String,
|
|
},
|
|
// Label with primary and secondary text, spaced apart (e.g., for keybinding hints).
|
|
LabeledText {
|
|
primary_text: String,
|
|
secondary_text: String,
|
|
},
|
|
// Label with primary and secondary text stacked vertically (follows dropdown pattern).
|
|
StackedText {
|
|
primary_text: String,
|
|
secondary_text: String,
|
|
},
|
|
Custom {
|
|
builder: CustomMenuItemLabelFn,
|
|
label: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl std::fmt::Debug for MenuItemLabel {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Text(arg0) => f.debug_tuple("Text").field(arg0).finish(),
|
|
Self::MultilineText { label, max_lines } => f
|
|
.debug_struct("MultilineText")
|
|
.field("label", label)
|
|
.field("max_lines", max_lines)
|
|
.finish(),
|
|
Self::Icon {
|
|
path,
|
|
color,
|
|
alt_text,
|
|
} => f
|
|
.debug_struct("Icon")
|
|
.field("path", path)
|
|
.field("color", color)
|
|
.field("alt_text", alt_text)
|
|
.finish(),
|
|
Self::LabeledText {
|
|
primary_text,
|
|
secondary_text,
|
|
} => f
|
|
.debug_struct("LabeledText")
|
|
.field("primary_text", primary_text)
|
|
.field("secondary_text", secondary_text)
|
|
.finish(),
|
|
Self::StackedText {
|
|
primary_text,
|
|
secondary_text,
|
|
} => f
|
|
.debug_struct("StackedText")
|
|
.field("primary_text", primary_text)
|
|
.field("secondary_text", secondary_text)
|
|
.finish(),
|
|
Self::Custom { .. } => f.debug_tuple("Custom").finish(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for MenuItemLabel {
|
|
fn default() -> Self {
|
|
Self::Text("".to_string())
|
|
}
|
|
}
|
|
|
|
impl MenuItemLabel {
|
|
fn label(&self) -> Option<&str> {
|
|
match self {
|
|
Self::Text(label) => Some(label),
|
|
Self::MultilineText { label, .. } => Some(label),
|
|
Self::StackedText { primary_text, .. } => Some(primary_text),
|
|
Self::Custom { label, .. } => label.as_deref(),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render(
|
|
&self,
|
|
primary_color: Fill,
|
|
secondary_color: Fill,
|
|
font_family: FamilyId,
|
|
font_size: f32,
|
|
_vertical_padding: f32, // for the future use cases
|
|
horizontal_padding: f32,
|
|
menu_width: f32,
|
|
is_selected: bool,
|
|
is_hovered: bool,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
match self {
|
|
Self::Text(label) => Shrinkable::new(
|
|
4.,
|
|
Text::new_inline(label.clone(), font_family, font_size)
|
|
.with_color(primary_color.into())
|
|
.autosize_text(MINIMUM_MENU_ITEM_FONT_SIZE)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
Self::MultilineText { label, max_lines } => {
|
|
let max_height_for_n_lines =
|
|
*max_lines as f32 * font_size * appearance.line_height_ratio();
|
|
ConstrainedBox::new(
|
|
Text::new(label.clone(), font_family, font_size)
|
|
.with_color(primary_color.into())
|
|
.autosize_text(MINIMUM_MENU_ITEM_FONT_SIZE)
|
|
.soft_wrap(true)
|
|
.finish(),
|
|
)
|
|
.with_max_height(max_height_for_n_lines)
|
|
.finish()
|
|
}
|
|
Self::Icon { path, color, .. } => {
|
|
// TODO figure out the sizes
|
|
let container = Container::new(Icon::new(path, *color).finish());
|
|
ConstrainedBox::new(container.finish())
|
|
.with_height(horizontal_padding * PADDING_TO_ICON_SIZE_MULTIPLIER)
|
|
.with_width(horizontal_padding * PADDING_TO_ICON_SIZE_MULTIPLIER)
|
|
.finish()
|
|
}
|
|
Self::LabeledText {
|
|
primary_text,
|
|
secondary_text,
|
|
} => {
|
|
let mut row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
|
.with_main_axis_size(MainAxisSize::Max);
|
|
row.add_child(
|
|
Shrinkable::new(
|
|
1.,
|
|
Text::new_inline(primary_text.clone(), font_family, font_size)
|
|
.with_color(primary_color.into())
|
|
.autosize_text(MINIMUM_MENU_ITEM_FONT_SIZE)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
);
|
|
row.add_child(
|
|
Container::new(
|
|
Text::new_inline(
|
|
secondary_text.clone(),
|
|
font_family,
|
|
font_size * SECONDARY_TEXT_RATIO,
|
|
)
|
|
.with_color(secondary_color.into())
|
|
.autosize_text(MINIMUM_MENU_ITEM_FONT_SIZE)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
);
|
|
let max_width = menu_width - (horizontal_padding * 2.0);
|
|
ConstrainedBox::new(row.finish())
|
|
.with_max_width(max_width)
|
|
.finish()
|
|
}
|
|
Self::StackedText {
|
|
primary_text,
|
|
secondary_text,
|
|
} => {
|
|
// Create column layout for stacked text
|
|
let mut column = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_main_axis_alignment(MainAxisAlignment::Start);
|
|
|
|
// Add primary text
|
|
column.add_child(
|
|
Text::new_inline(primary_text.clone(), font_family, font_size)
|
|
.with_color(primary_color.into())
|
|
.autosize_text(MINIMUM_MENU_ITEM_FONT_SIZE)
|
|
.finish(),
|
|
);
|
|
|
|
// Add secondary text with spacing
|
|
column.add_child(
|
|
Container::new(
|
|
Text::new_inline(
|
|
secondary_text.clone(),
|
|
font_family,
|
|
font_size * SECONDARY_TEXT_RATIO,
|
|
)
|
|
.with_color(secondary_color.into())
|
|
.autosize_text(MINIMUM_MENU_ITEM_FONT_SIZE)
|
|
.finish(),
|
|
)
|
|
.with_margin_top(2.)
|
|
.finish(),
|
|
);
|
|
|
|
let max_width = menu_width - (horizontal_padding * 2.0);
|
|
Box::new(
|
|
ConstrainedBox::new(column.finish())
|
|
.with_max_width(max_width)
|
|
.with_min_width(max_width),
|
|
)
|
|
}
|
|
Self::Custom { builder, .. } => {
|
|
let width = menu_width - (horizontal_padding * 2.0);
|
|
ConstrainedBox::new(builder(is_selected, is_hovered, appearance, app))
|
|
.with_width(width)
|
|
.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct RightSideLabel {
|
|
text: String,
|
|
font_properties: Properties,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct MenuItemFields<A: Action + Clone> {
|
|
element: MenuItemLabel,
|
|
timestamp: Option<DateTime<Local>>,
|
|
on_select_action: Option<A>,
|
|
key_shortcut_label: Option<String>,
|
|
has_submenu: bool,
|
|
disabled: bool,
|
|
mouse_state: MouseStateHandle,
|
|
icon: Option<icons::Icon>,
|
|
override_icon_color: Option<Fill>,
|
|
override_text_color: Option<ColorU>,
|
|
override_font_family: Option<FamilyId>,
|
|
override_font_size: Option<f32>,
|
|
highlight_on_hover: bool,
|
|
no_interaction_on_hover: bool,
|
|
indent: bool,
|
|
vertical_padding_override: Option<f32>,
|
|
horizontal_padding_override: Option<f32>,
|
|
tooltip: Option<String>,
|
|
tooltip_position: MenuTooltipPosition,
|
|
right_side_label: Option<RightSideLabel>,
|
|
/// Optional override for the background color rendered when this item is
|
|
/// hovered or selected. When `None`, the default hover/selected background
|
|
/// from the theme is used (accent or dark overlay, depending on
|
|
/// `highlight_on_hover`).
|
|
override_hover_background_color: Option<Fill>,
|
|
/// Optional override for the leading icon size in logical pixels. When
|
|
/// `None`, the icon is sized to `appearance.ui_font_size()`.
|
|
icon_size_override: Option<f32>,
|
|
}
|
|
|
|
impl<A: Action + Clone> std::fmt::Debug for MenuItemFields<A> {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.debug_struct("MenuItemFields")
|
|
.field("element", &self.element)
|
|
.field("on_select_action", &self.on_select_action)
|
|
.field("key_shortcut_label", &self.key_shortcut_label)
|
|
.field("disabled", &self.disabled)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> MenuItemFields<A> {
|
|
pub fn new<T: Into<String>>(label: T) -> Self {
|
|
MenuItemFields {
|
|
element: MenuItemLabel::Text(label.into()),
|
|
timestamp: None,
|
|
on_select_action: None,
|
|
key_shortcut_label: None,
|
|
disabled: false,
|
|
mouse_state: Default::default(),
|
|
icon: None,
|
|
override_icon_color: None,
|
|
override_font_family: None,
|
|
override_font_size: None,
|
|
override_text_color: None,
|
|
highlight_on_hover: true,
|
|
indent: false,
|
|
no_interaction_on_hover: false,
|
|
vertical_padding_override: None,
|
|
horizontal_padding_override: None,
|
|
has_submenu: false,
|
|
tooltip: None,
|
|
tooltip_position: MenuTooltipPosition::default(),
|
|
right_side_label: None,
|
|
override_hover_background_color: None,
|
|
icon_size_override: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_submenu<T: Into<String>>(label: T) -> Self {
|
|
MenuItemFields {
|
|
element: MenuItemLabel::Text(label.into()),
|
|
timestamp: None,
|
|
on_select_action: None,
|
|
key_shortcut_label: None,
|
|
disabled: false,
|
|
mouse_state: Default::default(),
|
|
icon: None,
|
|
override_icon_color: None,
|
|
override_font_family: None,
|
|
override_font_size: None,
|
|
override_text_color: None,
|
|
highlight_on_hover: true,
|
|
indent: false,
|
|
no_interaction_on_hover: false,
|
|
vertical_padding_override: None,
|
|
horizontal_padding_override: None,
|
|
has_submenu: true,
|
|
tooltip: None,
|
|
tooltip_position: MenuTooltipPosition::default(),
|
|
right_side_label: None,
|
|
override_hover_background_color: None,
|
|
icon_size_override: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_label<T: Into<String>>(text: T, label: T) -> Self {
|
|
MenuItemFields {
|
|
element: MenuItemLabel::LabeledText {
|
|
primary_text: text.into(),
|
|
secondary_text: label.into(),
|
|
},
|
|
timestamp: None,
|
|
on_select_action: None,
|
|
key_shortcut_label: None,
|
|
disabled: false,
|
|
mouse_state: Default::default(),
|
|
icon: None,
|
|
override_icon_color: None,
|
|
override_font_family: None,
|
|
override_font_size: None,
|
|
override_text_color: None,
|
|
highlight_on_hover: true,
|
|
indent: false,
|
|
no_interaction_on_hover: false,
|
|
vertical_padding_override: None,
|
|
horizontal_padding_override: None,
|
|
has_submenu: false,
|
|
tooltip: None,
|
|
tooltip_position: MenuTooltipPosition::default(),
|
|
right_side_label: None,
|
|
override_hover_background_color: None,
|
|
icon_size_override: None,
|
|
}
|
|
}
|
|
|
|
/// Creates a new menu item with vertically stacked primary and secondary text.
|
|
/// This is useful for items that need both a title and description/subtitle,
|
|
/// such as slash commands with their descriptions.
|
|
pub fn new_with_stacked_label<T: Into<String>>(title: T, subtitle: T) -> Self {
|
|
MenuItemFields {
|
|
element: MenuItemLabel::StackedText {
|
|
primary_text: title.into(),
|
|
secondary_text: subtitle.into(),
|
|
},
|
|
timestamp: None,
|
|
on_select_action: None,
|
|
key_shortcut_label: None,
|
|
disabled: false,
|
|
mouse_state: Default::default(),
|
|
icon: None,
|
|
override_icon_color: None,
|
|
override_font_family: None,
|
|
override_font_size: None,
|
|
override_text_color: None,
|
|
highlight_on_hover: true,
|
|
indent: false,
|
|
no_interaction_on_hover: false,
|
|
vertical_padding_override: None,
|
|
horizontal_padding_override: None,
|
|
has_submenu: false,
|
|
tooltip: None,
|
|
tooltip_position: MenuTooltipPosition::default(),
|
|
right_side_label: None,
|
|
override_hover_background_color: None,
|
|
icon_size_override: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_icon(icon_path: &'static str, icon_color: Fill, icon_label: String) -> Self {
|
|
MenuItemFields {
|
|
element: MenuItemLabel::Icon {
|
|
path: icon_path,
|
|
color: icon_color,
|
|
alt_text: icon_label,
|
|
},
|
|
timestamp: None,
|
|
on_select_action: None,
|
|
key_shortcut_label: None,
|
|
disabled: false,
|
|
mouse_state: Default::default(),
|
|
icon: None,
|
|
override_icon_color: None,
|
|
override_font_family: None,
|
|
override_font_size: None,
|
|
override_text_color: None,
|
|
highlight_on_hover: true,
|
|
indent: false,
|
|
no_interaction_on_hover: false,
|
|
vertical_padding_override: None,
|
|
horizontal_padding_override: None,
|
|
has_submenu: false,
|
|
tooltip: None,
|
|
tooltip_position: MenuTooltipPosition::default(),
|
|
right_side_label: None,
|
|
override_hover_background_color: None,
|
|
icon_size_override: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_multiline<T: Into<String>>(label: T, max_lines: usize) -> Self {
|
|
MenuItemFields {
|
|
element: MenuItemLabel::MultilineText {
|
|
label: label.into(),
|
|
max_lines,
|
|
},
|
|
timestamp: None,
|
|
on_select_action: None,
|
|
key_shortcut_label: None,
|
|
disabled: false,
|
|
mouse_state: Default::default(),
|
|
icon: None,
|
|
override_icon_color: None,
|
|
override_font_family: None,
|
|
override_font_size: None,
|
|
override_text_color: None,
|
|
highlight_on_hover: true,
|
|
indent: false,
|
|
no_interaction_on_hover: false,
|
|
vertical_padding_override: None,
|
|
horizontal_padding_override: None,
|
|
has_submenu: false,
|
|
tooltip: None,
|
|
tooltip_position: MenuTooltipPosition::default(),
|
|
right_side_label: None,
|
|
override_hover_background_color: None,
|
|
icon_size_override: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_with_custom_label(builder: CustomMenuItemLabelFn, label: Option<String>) -> Self {
|
|
MenuItemFields {
|
|
element: MenuItemLabel::Custom { builder, label },
|
|
timestamp: None,
|
|
on_select_action: None,
|
|
key_shortcut_label: None,
|
|
disabled: false,
|
|
mouse_state: Default::default(),
|
|
icon: None,
|
|
override_icon_color: None,
|
|
override_font_family: None,
|
|
override_font_size: None,
|
|
override_text_color: None,
|
|
highlight_on_hover: true,
|
|
indent: false,
|
|
no_interaction_on_hover: false,
|
|
vertical_padding_override: None,
|
|
horizontal_padding_override: None,
|
|
has_submenu: false,
|
|
tooltip: None,
|
|
tooltip_position: MenuTooltipPosition::default(),
|
|
right_side_label: None,
|
|
override_hover_background_color: None,
|
|
icon_size_override: None,
|
|
}
|
|
}
|
|
|
|
pub fn toggle_pane_action(is_maximized: bool) -> Self {
|
|
Self::new(if is_maximized {
|
|
"Minimize pane"
|
|
} else {
|
|
"Maximize pane"
|
|
})
|
|
}
|
|
|
|
/// Creates a [`MenuItemFields`] where the `on_select_action` is of a
|
|
/// specified type B.
|
|
/// Ideally, we can do this with a `From` implementation but lack of
|
|
/// impl speciailization makes it impossible today:
|
|
/// https://github.com/rust-lang/rfcs/blob/master/text/1210-impl-specialization.md.
|
|
pub fn with_different_on_select_action_type<B: Action + Clone>(
|
|
self,
|
|
on_select_action: Option<B>,
|
|
) -> MenuItemFields<B> {
|
|
MenuItemFields {
|
|
timestamp: self.timestamp,
|
|
on_select_action,
|
|
element: self.element,
|
|
key_shortcut_label: self.key_shortcut_label,
|
|
disabled: self.disabled,
|
|
mouse_state: self.mouse_state,
|
|
icon: self.icon,
|
|
override_icon_color: self.override_icon_color,
|
|
override_font_family: self.override_font_family,
|
|
override_font_size: self.override_font_size,
|
|
override_text_color: self.override_text_color,
|
|
highlight_on_hover: self.highlight_on_hover,
|
|
indent: self.indent,
|
|
no_interaction_on_hover: self.no_interaction_on_hover,
|
|
vertical_padding_override: self.vertical_padding_override,
|
|
horizontal_padding_override: self.horizontal_padding_override,
|
|
has_submenu: self.has_submenu,
|
|
tooltip: self.tooltip,
|
|
tooltip_position: self.tooltip_position,
|
|
right_side_label: self.right_side_label,
|
|
override_hover_background_color: self.override_hover_background_color,
|
|
icon_size_override: self.icon_size_override,
|
|
}
|
|
}
|
|
|
|
pub fn with_timestamp(mut self, timestamp: DateTime<Local>) -> Self {
|
|
self.timestamp = Some(timestamp);
|
|
self
|
|
}
|
|
|
|
pub fn on_select_action(&self) -> Option<&A> {
|
|
self.on_select_action.as_ref()
|
|
}
|
|
|
|
pub fn no_highlight_on_hover(mut self) -> Self {
|
|
self.highlight_on_hover = false;
|
|
self
|
|
}
|
|
|
|
pub fn with_no_interaction_on_hover(mut self) -> Self {
|
|
self.no_interaction_on_hover = true;
|
|
self
|
|
}
|
|
|
|
pub fn with_padding_override(mut self, vertical_padding: f32, horizontal_padding: f32) -> Self {
|
|
self.vertical_padding_override = Some(vertical_padding);
|
|
self.horizontal_padding_override = Some(horizontal_padding);
|
|
self
|
|
}
|
|
|
|
pub fn with_font_override(mut self, override_font_family: FamilyId) -> Self {
|
|
self.override_font_family = Some(override_font_family);
|
|
self
|
|
}
|
|
|
|
pub fn with_font_size_override(mut self, override_font_size: f32) -> Self {
|
|
self.override_font_size = Some(override_font_size);
|
|
self
|
|
}
|
|
|
|
pub fn with_on_select_action(mut self, action: A) -> Self {
|
|
self.on_select_action = Some(action);
|
|
self
|
|
}
|
|
|
|
pub fn with_override_text_color(mut self, override_text_color: impl Into<ColorU>) -> Self {
|
|
self.override_text_color = Some(override_text_color.into());
|
|
self
|
|
}
|
|
|
|
/// Overrides the background color rendered when this item is hovered or
|
|
/// selected. Takes precedence over the default hover color (which depends
|
|
/// on `highlight_on_hover`). Has no effect when `no_interaction_on_hover`
|
|
/// is set, or when the item is disabled.
|
|
pub fn with_override_hover_background_color(mut self, color: impl Into<Fill>) -> Self {
|
|
self.override_hover_background_color = Some(color.into());
|
|
self
|
|
}
|
|
|
|
/// Overrides the leading icon size (in logical pixels) for this item.
|
|
/// The default is `appearance.ui_font_size()`.
|
|
pub fn with_icon_size_override(mut self, size: f32) -> Self {
|
|
self.icon_size_override = Some(size);
|
|
self
|
|
}
|
|
|
|
pub fn with_key_shortcut_label(mut self, label: Option<impl Into<String>>) -> Self {
|
|
self.key_shortcut_label = label.map(Into::into);
|
|
self
|
|
}
|
|
|
|
pub fn with_disabled(mut self, disabled: bool) -> Self {
|
|
self.disabled = disabled;
|
|
self
|
|
}
|
|
|
|
pub fn with_icon(mut self, icon: icons::Icon) -> Self {
|
|
self.icon = Some(icon);
|
|
self
|
|
}
|
|
|
|
pub fn with_indent(mut self) -> Self {
|
|
self.indent = true;
|
|
self
|
|
}
|
|
|
|
/// Set an override color for an icon added by [`Self::with_icon`]. The
|
|
/// default is to match the text color, which is based on the hover state.
|
|
pub fn with_override_icon_color(mut self, color: Fill) -> Self {
|
|
self.override_icon_color = Some(color);
|
|
self
|
|
}
|
|
|
|
/// Set a tooltip to display when hovering over this menu item.
|
|
pub fn with_tooltip(mut self, tooltip: impl Into<String>) -> Self {
|
|
self.tooltip = Some(tooltip.into());
|
|
self
|
|
}
|
|
|
|
pub(crate) fn with_tooltip_position(mut self, position: MenuTooltipPosition) -> Self {
|
|
self.tooltip_position = position;
|
|
self
|
|
}
|
|
|
|
/// Adds a right-aligned secondary label with custom font properties to this menu item.
|
|
pub fn with_right_side_label(
|
|
mut self,
|
|
label: impl Into<String>,
|
|
font_properties: Properties,
|
|
) -> Self {
|
|
self.right_side_label = Some(RightSideLabel {
|
|
text: label.into(),
|
|
font_properties,
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn into_item(self) -> MenuItem<A> {
|
|
MenuItem::Item(self)
|
|
}
|
|
|
|
pub fn label(&self) -> &str {
|
|
self.element.label().unwrap_or_default()
|
|
}
|
|
|
|
pub fn is_disabled(&self) -> bool {
|
|
self.disabled
|
|
}
|
|
|
|
pub fn get_a11y_text(&self) -> &str {
|
|
match &self.element {
|
|
MenuItemLabel::Text(label) | MenuItemLabel::MultilineText { label, .. } => label,
|
|
MenuItemLabel::Icon { alt_text, .. } => alt_text,
|
|
MenuItemLabel::LabeledText { primary_text, .. } => primary_text,
|
|
MenuItemLabel::StackedText { primary_text, .. } => primary_text,
|
|
MenuItemLabel::Custom { label, .. } => label.as_deref().unwrap_or(""),
|
|
}
|
|
}
|
|
|
|
pub fn override_font_family(&self) -> Option<FamilyId> {
|
|
self.override_font_family
|
|
}
|
|
|
|
pub fn icon(&self) -> Option<icons::Icon> {
|
|
self.icon
|
|
}
|
|
|
|
pub fn override_icon_color(&self) -> Option<Fill> {
|
|
self.override_icon_color
|
|
}
|
|
|
|
fn render_icon(&self, appearance: &Appearance, color: Fill) -> Option<Box<dyn Element>> {
|
|
if let Some(icon) = self.icon {
|
|
let icon_size = self
|
|
.icon_size_override
|
|
.unwrap_or_else(|| appearance.ui_font_size());
|
|
let icon_color = self.override_icon_color.unwrap_or(color);
|
|
Some(
|
|
Shrinkable::new(
|
|
1.,
|
|
Container::new(
|
|
ConstrainedBox::new(icon.to_galaxyui_icon(icon_color).finish())
|
|
.with_width(icon_size)
|
|
.with_height(icon_size)
|
|
.finish(),
|
|
)
|
|
.with_margin_right(icon_size / 2.)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn render_right_side_label(
|
|
&self,
|
|
appearance: &Appearance,
|
|
color: ColorU,
|
|
) -> Option<Box<dyn Element>> {
|
|
let label = self.right_side_label.as_ref()?;
|
|
Some(
|
|
Shrinkable::new(
|
|
1.,
|
|
Align::new(
|
|
Text::new_inline(
|
|
label.text.clone(),
|
|
appearance.ui_builder().ui_font_family(),
|
|
appearance.ui_builder().ui_font_size() * 0.75,
|
|
)
|
|
.with_color(color)
|
|
.with_style(label.font_properties)
|
|
.finish(),
|
|
)
|
|
.right()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
fn render_key_shortcut(
|
|
&self,
|
|
appearance: &Appearance,
|
|
color: ColorU,
|
|
) -> Option<Box<dyn Element>> {
|
|
if let Some(key_shortcut) = self.key_shortcut_label.clone() {
|
|
Some(
|
|
Shrinkable::new(
|
|
1.,
|
|
Align::new(
|
|
Text::new_inline(
|
|
key_shortcut,
|
|
appearance.ui_builder().ui_font_family(),
|
|
appearance.ui_builder().ui_font_size(),
|
|
)
|
|
.with_color(color)
|
|
.finish(),
|
|
)
|
|
.right()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn render_right_aligned_chevron(
|
|
&self,
|
|
appearance: &Appearance,
|
|
icon_color: Fill,
|
|
) -> Box<dyn Element> {
|
|
let icon_size = appearance.ui_font_size() * 1.2;
|
|
let icon =
|
|
ConstrainedBox::new(Icon::new(CHEVRON_RIGHT_ALIGN_SVG_PATH, icon_color).finish())
|
|
.with_height(icon_size)
|
|
.with_width(icon_size)
|
|
.finish();
|
|
Shrinkable::new(
|
|
2.,
|
|
Align::new(Container::new(icon).finish()).right().finish(),
|
|
)
|
|
.finish()
|
|
}
|
|
|
|
fn render_right_aligned_time_estimation(
|
|
&self,
|
|
timestamp: &DateTime<Local>,
|
|
font_family: FamilyId,
|
|
font_size: f32,
|
|
text_background_color: Fill,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
Shrinkable::new(
|
|
1.,
|
|
Align::new(self.render_time_estimation(
|
|
timestamp,
|
|
font_family,
|
|
font_size,
|
|
text_background_color,
|
|
appearance,
|
|
))
|
|
.right()
|
|
.finish(),
|
|
)
|
|
.finish()
|
|
}
|
|
|
|
pub fn render_time_estimation(
|
|
&self,
|
|
timestamp: &DateTime<Local>,
|
|
font_family: FamilyId,
|
|
font_size: f32,
|
|
text_background_color: Fill,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let est_time_ago = format_approx_duration_from_now_sentence_case(*timestamp);
|
|
Text::new_inline(est_time_ago, font_family, font_size)
|
|
.with_color(theme.sub_text_color(text_background_color).into())
|
|
.finish()
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render(
|
|
&self,
|
|
menu_background_color: Fill,
|
|
depth: usize,
|
|
row_index: usize,
|
|
item_index: usize,
|
|
dispatch_item_actions: bool,
|
|
is_selected: bool,
|
|
ignore_hover_when_covered: bool,
|
|
safe_zone_suppresses_hover: bool,
|
|
submenu_being_shown_for_item: bool,
|
|
appearance: &Appearance,
|
|
vertical_padding: f32,
|
|
horizontal_padding: f32,
|
|
menu_width: f32,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let vertical_padding = self.vertical_padding_override.unwrap_or(vertical_padding);
|
|
let horizontal_padding = self
|
|
.horizontal_padding_override
|
|
.unwrap_or(horizontal_padding);
|
|
let mut ret = Hoverable::new(self.mouse_state.clone(), |state| {
|
|
let is_hovered = state.is_hovered() && !safe_zone_suppresses_hover;
|
|
let is_hovered_or_selected = is_hovered || is_selected;
|
|
let default_hover_background = if self.highlight_on_hover {
|
|
theme.accent_button_color()
|
|
} else {
|
|
theme.dark_overlay()
|
|
};
|
|
let hover_background = self
|
|
.override_hover_background_color
|
|
.unwrap_or(default_hover_background);
|
|
let background_color =
|
|
if is_hovered_or_selected && !self.disabled && !self.no_interaction_on_hover {
|
|
Some(hover_background)
|
|
} else if submenu_being_shown_for_item {
|
|
// Preserve prior behavior: the submenu-open state always uses the
|
|
// accent color (or the explicit override), regardless of
|
|
// `highlight_on_hover`.
|
|
Some(
|
|
self.override_hover_background_color
|
|
.unwrap_or_else(|| theme.accent_button_color()),
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
let text_background_color = match background_color {
|
|
Some(overlay) => menu_background_color.blend(&overlay),
|
|
None => menu_background_color,
|
|
};
|
|
|
|
let primary_color = if self.disabled {
|
|
theme.disabled_text_color(text_background_color)
|
|
} else if let Some(color) = self.override_text_color {
|
|
color.into()
|
|
} else {
|
|
theme.main_text_color(text_background_color)
|
|
};
|
|
let secondary_color = appearance
|
|
.theme()
|
|
.disabled_text_color(text_background_color);
|
|
|
|
let mut label_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
|
|
|
|
if let Some(icon) = self.render_icon(appearance, primary_color) {
|
|
label_row.add_child(icon);
|
|
}
|
|
|
|
let font_family = self
|
|
.override_font_family
|
|
.unwrap_or_else(|| appearance.ui_builder().ui_font_family());
|
|
let font_size = self
|
|
.override_font_size
|
|
.unwrap_or_else(|| appearance.ui_builder().ui_font_size());
|
|
|
|
let label_element = self.element.render(
|
|
primary_color,
|
|
secondary_color,
|
|
font_family,
|
|
font_size,
|
|
vertical_padding,
|
|
horizontal_padding,
|
|
menu_width,
|
|
is_selected,
|
|
state.is_hovered(),
|
|
appearance,
|
|
app,
|
|
);
|
|
|
|
if matches!(self.element, MenuItemLabel::MultilineText { .. }) {
|
|
// In multiline labels, the timestamp is positioned underneath the label.
|
|
let mut content_column = Flex::column()
|
|
.with_main_axis_size(MainAxisSize::Min)
|
|
.with_child(label_element);
|
|
if let Some(timestamp) = &self.timestamp {
|
|
content_column.add_child(
|
|
Container::new(self.render_time_estimation(
|
|
timestamp,
|
|
font_family,
|
|
font_size - 1.,
|
|
text_background_color,
|
|
appearance,
|
|
))
|
|
.with_margin_top(2.)
|
|
.finish(),
|
|
);
|
|
}
|
|
label_row.add_child(Shrinkable::new(1., content_column.finish()).finish());
|
|
} else {
|
|
label_row.add_child(label_element);
|
|
|
|
if self.has_submenu {
|
|
label_row
|
|
.add_child(self.render_right_aligned_chevron(appearance, primary_color));
|
|
} else if let Some(right_label) =
|
|
self.render_right_side_label(appearance, secondary_color.into())
|
|
{
|
|
label_row.add_child(right_label);
|
|
} else if let Some(key_shortcut) =
|
|
self.render_key_shortcut(appearance, secondary_color.into())
|
|
{
|
|
label_row.add_child(key_shortcut);
|
|
} else if let Some(timestamp) = &self.timestamp {
|
|
label_row.add_child(self.render_right_aligned_time_estimation(
|
|
timestamp,
|
|
font_family,
|
|
font_size,
|
|
text_background_color,
|
|
appearance,
|
|
));
|
|
}
|
|
}
|
|
|
|
// If menu item doesn't have an icon but we want to indent it, add left padding so it aligns with menu items that do have icons
|
|
let left_padding = if self.indent {
|
|
horizontal_padding + (appearance.ui_font_size() * MENU_ITEM_LEFT_PADDING_MULTIPLIER)
|
|
} else {
|
|
horizontal_padding
|
|
};
|
|
|
|
let horizontal_alignment =
|
|
if matches!(self.element, MenuItemLabel::MultilineText { .. }) {
|
|
CrossAxisAlignment::Start
|
|
} else {
|
|
CrossAxisAlignment::Center
|
|
};
|
|
|
|
let container = Container::new(
|
|
label_row
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceEvenly)
|
|
.with_cross_axis_alignment(horizontal_alignment)
|
|
.finish(),
|
|
)
|
|
.with_padding_top(vertical_padding)
|
|
.with_padding_bottom(vertical_padding)
|
|
.with_padding_left(left_padding)
|
|
.with_padding_right(horizontal_padding);
|
|
|
|
let container_element = if let Some(background_color) = background_color {
|
|
container.with_background(background_color).finish()
|
|
} else {
|
|
container.finish()
|
|
};
|
|
|
|
// Render tooltip if present and hovered
|
|
if let Some(tooltip_text) = &self.tooltip {
|
|
if state.is_hovered() {
|
|
let tooltip_element = appearance
|
|
.ui_builder()
|
|
.tool_tip(tooltip_text.clone())
|
|
.build()
|
|
.finish();
|
|
let positioning = match self.tooltip_position {
|
|
MenuTooltipPosition::Right => OffsetPositioning::offset_from_parent(
|
|
vec2f(4., 0.),
|
|
ParentOffsetBounds::WindowByPosition,
|
|
ParentAnchor::MiddleRight,
|
|
ChildAnchor::MiddleLeft,
|
|
),
|
|
MenuTooltipPosition::Above => OffsetPositioning::offset_from_parent(
|
|
vec2f(0., -4.),
|
|
ParentOffsetBounds::WindowByPosition,
|
|
ParentAnchor::TopMiddle,
|
|
ChildAnchor::BottomMiddle,
|
|
),
|
|
};
|
|
let mut stack = Stack::new();
|
|
stack.add_child(container_element);
|
|
// Use add_positioned_child instead of add_positioned_overlay_child
|
|
// to prevent the tooltip from intercepting mouse events and causing
|
|
// hover state flickering on the parent menu item.
|
|
stack.add_positioned_child(tooltip_element, positioning);
|
|
return stack.finish();
|
|
}
|
|
}
|
|
|
|
container_element
|
|
});
|
|
|
|
let has_submenu = self.has_submenu;
|
|
let is_enabled = !self.disabled;
|
|
ret = ret.on_hover(move |is_hovered, ctx, _, position| {
|
|
if has_submenu && is_enabled {
|
|
ctx.dispatch_typed_action(if is_hovered {
|
|
MenuAction::HoverSubmenuWithChildren(
|
|
depth,
|
|
SelectAction::Index {
|
|
row: row_index,
|
|
item: item_index,
|
|
},
|
|
)
|
|
} else {
|
|
MenuAction::UnhoverSubmenuParent(depth)
|
|
});
|
|
} else if is_hovered {
|
|
// Only dispatch on hover-in, not hover-out. Dispatching on
|
|
// hover-out sends a stale row_index that can overwrite the
|
|
// correct hovered_row_index set by a subsequently-entered
|
|
// submenu parent. Continuous position tracking for the
|
|
// safe-triangle is handled by the on_mouse_in handler below.
|
|
ctx.dispatch_typed_action(MenuAction::HoverSubmenuLeafNode {
|
|
depth,
|
|
row_index,
|
|
position,
|
|
});
|
|
}
|
|
});
|
|
|
|
let on_select_action = self.on_select_action.clone();
|
|
|
|
if !self.disabled {
|
|
if !self.no_interaction_on_hover {
|
|
ret = ret.with_cursor(Cursor::PointingHand);
|
|
}
|
|
ret = ret.on_click(move |ctx, _, _| {
|
|
if let Some(action) = &on_select_action {
|
|
ctx.dispatch_typed_action(MenuAction::Select(SelectAction::Index {
|
|
row: row_index,
|
|
item: item_index,
|
|
}));
|
|
if dispatch_item_actions {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
}
|
|
ctx.dispatch_typed_action(MenuAction::Close(true));
|
|
}
|
|
});
|
|
}
|
|
|
|
let mut element = ret.finish();
|
|
|
|
// For leaf items (no submenu), also emit hover on every mouse move within bounds,
|
|
// not just on hover-state transitions. This ensures ItemHovered fires when moving over
|
|
// an already-selected item, and keeps the safe triangle's position tracking current
|
|
// even when the mouse passes over disabled items.
|
|
if !has_submenu {
|
|
let mouse_in_behavior = if ignore_hover_when_covered {
|
|
Some(MouseInBehavior {
|
|
fire_on_synthetic_events: true,
|
|
fire_when_covered: false,
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
element = EventHandler::new(element)
|
|
.on_mouse_in(
|
|
move |ctx, _app, pos| {
|
|
ctx.dispatch_typed_action(MenuAction::HoverSubmenuLeafNode {
|
|
depth,
|
|
row_index,
|
|
position: pos,
|
|
});
|
|
DispatchEventResult::PropagateToParent
|
|
},
|
|
mouse_in_behavior,
|
|
)
|
|
.finish();
|
|
}
|
|
|
|
SavePosition::new(element, self.label()).finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum MenuItem<A: Action + Clone = ()> {
|
|
Item(MenuItemFields<A>),
|
|
/// Separator item in the menu is used to separate two sections. Note that the way it's
|
|
/// rendered makes it only look nice if the MenuVariant used is Fixed (vs Scrollable).
|
|
Separator,
|
|
/// One row with multiple items. This is used for example for tab colors in the context menu.
|
|
ItemsRow {
|
|
items: Vec<MenuItemFields<A>>,
|
|
},
|
|
/// Nested menu item. Expands on hover.
|
|
Submenu {
|
|
fields: MenuItemFields<A>,
|
|
menu: SubMenu<A>,
|
|
},
|
|
/// Header item used to group items. May or may not be clickable.
|
|
Header {
|
|
fields: MenuItemFields<A>,
|
|
clickable: bool,
|
|
right_side_fields: Option<MenuItemFields<A>>,
|
|
},
|
|
}
|
|
|
|
impl<A: Action + Clone> MenuItem<A> {
|
|
#[deprecated(note = "Submenus are not ready for use yet.")]
|
|
pub fn submenu<T: Into<String>>(label: T, items: Vec<MenuItem<A>>) -> Self {
|
|
let menu = SubMenu::new(items);
|
|
MenuItem::Submenu {
|
|
fields: MenuItemFields::new_submenu(label),
|
|
menu,
|
|
}
|
|
}
|
|
|
|
fn items_len(&self) -> Option<usize> {
|
|
match self {
|
|
MenuItem::Item(_) => Some(1),
|
|
MenuItem::Separator => None,
|
|
MenuItem::ItemsRow { items } => Some(items.len()),
|
|
MenuItem::Submenu { menu, .. } => {
|
|
// This includes the label as well.
|
|
Some(menu.items_len() + 1)
|
|
}
|
|
MenuItem::Header { clickable, .. } => Some(if *clickable { 1 } else { 0 }),
|
|
}
|
|
}
|
|
|
|
pub fn item_on_select_action(&self) -> Option<&A> {
|
|
match self {
|
|
MenuItem::Item(fields) => fields.on_select_action.as_ref(),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn selectable(&self) -> bool {
|
|
match self {
|
|
// Whether it's selectable depends only on whether the item is disabled or not.
|
|
MenuItem::Item(fields) => !fields.disabled && !fields.no_interaction_on_hover,
|
|
// For items row, right now, we assume that it's either entire row that's selectable or
|
|
// not (so for simplicity, either all items are selectable or none of them).
|
|
// TODO be smarter about it
|
|
MenuItem::ItemsRow { items } => items
|
|
.iter()
|
|
.any(|i| !i.disabled && !i.no_interaction_on_hover),
|
|
// Separator is simply a non-selectable option in the menu list.
|
|
MenuItem::Separator => false,
|
|
MenuItem::Submenu { menu, .. } => !menu.items.is_empty(),
|
|
MenuItem::Header { clickable, .. } => *clickable,
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render(
|
|
&self,
|
|
menu_background_color: Fill,
|
|
depth: usize,
|
|
row_index: usize,
|
|
selected_item_in_row: Option<usize>,
|
|
dispatch_item_actions: bool,
|
|
is_row_selected: bool,
|
|
ignore_hover_when_covered: bool,
|
|
safe_zone_suppresses_hover: bool,
|
|
submenu_being_shown_for_item: bool,
|
|
appearance: &Appearance,
|
|
menu_width: f32,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
match self {
|
|
MenuItem::Item(fields) => fields.render(
|
|
menu_background_color,
|
|
depth,
|
|
row_index,
|
|
0, // there's only 1 item
|
|
dispatch_item_actions,
|
|
is_row_selected,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
MENU_ITEM_VERTICAL_PADDING,
|
|
MENU_ITEM_HORIZONTAL_PADDING,
|
|
menu_width,
|
|
app,
|
|
),
|
|
MenuItem::ItemsRow { items } => {
|
|
let horizontal_padding = ((menu_width - (MENU_ITEM_HORIZONTAL_PADDING * 2.))
|
|
/ (items.len() as f32)
|
|
/ 5.)
|
|
.round();
|
|
let items_row = Flex::row()
|
|
.with_children(items.iter().enumerate().map(|(item_idx, fields)| {
|
|
fields.render(
|
|
menu_background_color,
|
|
depth,
|
|
row_index,
|
|
item_idx,
|
|
dispatch_item_actions,
|
|
is_row_selected && selected_item_in_row.unwrap_or_default() == item_idx,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
MENU_ITEM_VERTICAL_PADDING,
|
|
horizontal_padding,
|
|
menu_width,
|
|
app,
|
|
)
|
|
}))
|
|
.with_main_axis_alignment(MainAxisAlignment::Center)
|
|
.with_main_axis_size(MainAxisSize::Max);
|
|
|
|
Container::new(items_row.finish())
|
|
.with_margin_top(SEPARATOR_VERTICAL_MARGIN)
|
|
.with_margin_bottom(SEPARATOR_VERTICAL_MARGIN)
|
|
.with_padding_left(MENU_ITEM_HORIZONTAL_PADDING)
|
|
.with_padding_right(MENU_ITEM_HORIZONTAL_PADDING)
|
|
.finish()
|
|
}
|
|
MenuItem::Separator => Container::new(
|
|
ConstrainedBox::new(
|
|
Rect::new()
|
|
.with_background(appearance.theme().disabled_ui_text_color())
|
|
.finish(),
|
|
)
|
|
.with_height(1.)
|
|
.finish(),
|
|
)
|
|
.with_margin_top(SEPARATOR_VERTICAL_MARGIN)
|
|
.with_margin_bottom(SEPARATOR_VERTICAL_MARGIN)
|
|
.with_padding_left(MENU_ITEM_HORIZONTAL_PADDING)
|
|
.with_padding_right(MENU_ITEM_HORIZONTAL_PADDING)
|
|
.finish(),
|
|
MenuItem::Submenu { fields, .. } => {
|
|
fields.render(
|
|
menu_background_color,
|
|
depth,
|
|
row_index,
|
|
0, // there's only 1 item
|
|
dispatch_item_actions,
|
|
is_row_selected,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
MENU_ITEM_VERTICAL_PADDING,
|
|
MENU_ITEM_HORIZONTAL_PADDING,
|
|
menu_width,
|
|
app,
|
|
)
|
|
}
|
|
MenuItem::Header {
|
|
fields,
|
|
clickable,
|
|
right_side_fields,
|
|
} => {
|
|
let mut fields = fields.clone();
|
|
let mut right_side_fields = right_side_fields.clone();
|
|
if !*clickable {
|
|
fields = fields.with_no_interaction_on_hover();
|
|
right_side_fields =
|
|
right_side_fields.map(|fields| fields.with_no_interaction_on_hover());
|
|
}
|
|
|
|
if let Some(right_side_fields) = right_side_fields {
|
|
Flex::row()
|
|
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_children([
|
|
ConstrainedBox::new(fields.render(
|
|
menu_background_color,
|
|
depth,
|
|
row_index,
|
|
0, // there's only 1 item
|
|
dispatch_item_actions,
|
|
is_row_selected,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
MENU_ITEM_VERTICAL_PADDING,
|
|
MENU_ITEM_HORIZONTAL_PADDING,
|
|
menu_width,
|
|
app,
|
|
))
|
|
.with_max_width(menu_width / 2.)
|
|
.finish(),
|
|
ConstrainedBox::new(right_side_fields.render(
|
|
menu_background_color,
|
|
depth,
|
|
row_index,
|
|
0, // there's only 1 item
|
|
dispatch_item_actions,
|
|
is_row_selected,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
MENU_ITEM_VERTICAL_PADDING,
|
|
MENU_ITEM_HORIZONTAL_PADDING,
|
|
menu_width,
|
|
app,
|
|
))
|
|
.with_max_width(menu_width / 2.)
|
|
.finish(),
|
|
])
|
|
.finish()
|
|
} else {
|
|
fields.render(
|
|
menu_background_color,
|
|
depth,
|
|
row_index,
|
|
0, // there's only 1 item
|
|
dispatch_item_actions,
|
|
is_row_selected,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
MENU_ITEM_VERTICAL_PADDING,
|
|
MENU_ITEM_HORIZONTAL_PADDING,
|
|
menu_width,
|
|
app,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub const DEFAULT_WIDTH: f32 = 186.;
|
|
const DEFAULT_HEIGHT: f32 = 140.;
|
|
|
|
#[derive(Copy, Clone, Debug)]
|
|
pub enum SelectAction {
|
|
Previous,
|
|
Next,
|
|
Index { row: usize, item: usize },
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum MenuSelectionSource {
|
|
KeyboardOrProgrammatic,
|
|
Pointer,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum MenuAction {
|
|
Select(SelectAction),
|
|
OpenSubmenu,
|
|
/// Fires when the mouse leaves the Menu item containing a submenu.
|
|
UnhoverSubmenuParent(usize),
|
|
HoverSubmenuWithChildren(usize, SelectAction),
|
|
/// Fires when the mouse enters a submenu item with no children
|
|
HoverSubmenuLeafNode {
|
|
depth: usize,
|
|
row_index: usize,
|
|
position: Vector2F,
|
|
},
|
|
CloseSubmenu(usize),
|
|
Close(bool),
|
|
Enter,
|
|
}
|
|
|
|
pub fn init(app: &mut AppContext) {
|
|
use galaxyui::keymap::macros::*;
|
|
|
|
app.register_fixed_bindings([
|
|
FixedBinding::new(
|
|
"up",
|
|
MenuAction::Select(SelectAction::Previous),
|
|
id!(Menu::<()>::ui_name()),
|
|
),
|
|
FixedBinding::new(
|
|
"down",
|
|
MenuAction::Select(SelectAction::Next),
|
|
id!(Menu::<()>::ui_name()),
|
|
),
|
|
FixedBinding::new("right", MenuAction::OpenSubmenu, id!(Menu::<()>::ui_name())),
|
|
FixedBinding::new(
|
|
"escape",
|
|
MenuAction::Close(false),
|
|
id!(Menu::<()>::ui_name()),
|
|
),
|
|
FixedBinding::new("enter", MenuAction::Enter, id!(Menu::<()>::ui_name())),
|
|
]);
|
|
}
|
|
|
|
#[derive(Debug, Copy, Clone)]
|
|
pub enum Event {
|
|
ItemSelected,
|
|
// via_select_item is true when you close a menu by selecting an item (by click or enter)
|
|
// TODO: improve this logic since this is not the ideal solution
|
|
Close { via_select_item: bool },
|
|
ItemHovered,
|
|
}
|
|
|
|
impl<A: Action + Clone> SubMenu<A> {
|
|
pub fn new(mut items: Vec<MenuItem<A>>) -> Self {
|
|
Self::increment_depth(&mut items);
|
|
Self {
|
|
depth: 0,
|
|
items,
|
|
selected_row_index: None,
|
|
selected_item_index: None,
|
|
hovered_row_index: None,
|
|
last_selection_source: None,
|
|
height: DEFAULT_HEIGHT,
|
|
menu_variant: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn increment_depth(items: &mut [MenuItem<A>]) {
|
|
for item in items.iter_mut() {
|
|
if let MenuItem::Submenu { menu, .. } = item {
|
|
menu.depth += 1;
|
|
Self::increment_depth(&mut menu.items);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn with_height(&mut self, height: f32) -> &mut Self {
|
|
self.height = height;
|
|
self
|
|
}
|
|
|
|
pub fn with_menu_variant(&mut self, menu_variant: MenuVariant) -> &mut Self {
|
|
self.menu_variant = menu_variant;
|
|
self
|
|
}
|
|
|
|
pub fn reset_selection(&mut self, ctx: &mut ViewContext<Menu<A>>) {
|
|
self.selected_row_index = None;
|
|
self.selected_item_index = None;
|
|
self.last_selection_source = None;
|
|
ctx.notify();
|
|
}
|
|
|
|
pub fn selected_item(&self) -> Option<MenuItem<A>> {
|
|
self.selected_row_index
|
|
.and_then(|index| self.items.get(index).cloned())
|
|
}
|
|
|
|
pub fn selected_index(&self) -> Option<usize> {
|
|
self.selected_row_index
|
|
}
|
|
|
|
pub fn hovered_index(&self) -> Option<usize> {
|
|
self.hovered_row_index
|
|
}
|
|
|
|
pub fn last_selection_source(&self) -> Option<MenuSelectionSource> {
|
|
self.last_selection_source
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.items.is_empty()
|
|
}
|
|
|
|
pub fn items(&self) -> &[MenuItem<A>] {
|
|
&self.items
|
|
}
|
|
|
|
pub fn items_len(&self) -> usize {
|
|
self.items.len()
|
|
}
|
|
|
|
/// Select the menu item at the given index. If the index is out of bounds, this clears the selection.
|
|
pub fn set_selected_by_index(&mut self, selected_index: usize, ctx: &mut ViewContext<Menu<A>>) {
|
|
if selected_index < self.items.len() {
|
|
self.select(
|
|
SelectAction::Index {
|
|
row: selected_index,
|
|
item: 0,
|
|
},
|
|
ctx,
|
|
);
|
|
} else {
|
|
self.reset_selection(ctx);
|
|
}
|
|
}
|
|
|
|
fn select_internal(&mut self, action: SelectAction) {
|
|
let (selected_row_index, selected_item_index) =
|
|
match (action, self.selected_row_index, self.selected_item_index) {
|
|
(SelectAction::Index { row, item }, _, _) => (row, item),
|
|
(SelectAction::Previous, Some(item_idx), Some(field_idx)) if field_idx > 0 => {
|
|
// Currently we're assuming that for a selectable item row all items in a row are
|
|
// also selectable, and so - we just need to reduce the index of fields.
|
|
// TODO be smarter about it
|
|
(item_idx, field_idx.saturating_sub(1))
|
|
}
|
|
(SelectAction::Previous, Some(item_idx), _) => {
|
|
// Getting the previous element that is selectable. We compute the indexes of
|
|
// elements first, and then reverse the iterator and look for a first selectable
|
|
// element after the currently selected one.
|
|
// If there's none, for some reason, we default to the currently selected one.
|
|
|
|
let items_to_skip = self.items.len().saturating_sub(item_idx);
|
|
let selected_row_index = self
|
|
.items
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.cycle()
|
|
.skip(items_to_skip)
|
|
.take(self.items.len())
|
|
.find(|(_, item)| item.selectable())
|
|
.map(|(index, _)| index)
|
|
.unwrap_or(item_idx);
|
|
(
|
|
selected_row_index,
|
|
self.items[selected_row_index]
|
|
.items_len()
|
|
.map_or(0, |l| l.saturating_sub(1)),
|
|
)
|
|
}
|
|
(SelectAction::Next, Some(item_idx), Some(field_idx))
|
|
if field_idx
|
|
< self.items[item_idx]
|
|
.items_len()
|
|
.map_or(0, |l| l.saturating_sub(1)) =>
|
|
{
|
|
(item_idx, field_idx.saturating_add(1))
|
|
}
|
|
(SelectAction::Next, Some(item_idx), _) => {
|
|
// Getting the next element that is selectable. We compute the indexes of
|
|
// elements first, and look for a first selectable element after the currently
|
|
// selected one.
|
|
// If there's none, for some reason, we default to the currently selected one.
|
|
|
|
let items_to_skip = item_idx.saturating_add(1);
|
|
(
|
|
self.items
|
|
.iter()
|
|
.enumerate()
|
|
.cycle()
|
|
.skip(items_to_skip)
|
|
.take(self.items.len())
|
|
.find(|(_, item)| item.selectable())
|
|
.map(|(index, _)| index)
|
|
.unwrap_or(item_idx),
|
|
0,
|
|
)
|
|
}
|
|
_ => (0, 0),
|
|
};
|
|
|
|
self.selected_row_index = Some(selected_row_index);
|
|
self.selected_item_index = Some(selected_item_index);
|
|
self.hovered_row_index = Some(selected_row_index);
|
|
}
|
|
|
|
fn select_with_source(
|
|
&mut self,
|
|
action: SelectAction,
|
|
selection_source: MenuSelectionSource,
|
|
ctx: &mut ViewContext<Menu<A>>,
|
|
) {
|
|
self.select_internal(action);
|
|
self.last_selection_source = Some(selection_source);
|
|
if matches!(
|
|
selection_source,
|
|
MenuSelectionSource::KeyboardOrProgrammatic
|
|
) {
|
|
if let MenuVariant::Scrollable(scroll_state) = &self.menu_variant {
|
|
scroll_state.scroll_to_position(ScrollTarget {
|
|
position_id: Self::save_position_id(self.depth),
|
|
mode: ScrollToPositionMode::FullyIntoView,
|
|
});
|
|
}
|
|
}
|
|
ctx.emit(Event::ItemSelected);
|
|
ctx.notify();
|
|
}
|
|
|
|
fn select(&mut self, action: SelectAction, ctx: &mut ViewContext<Menu<A>>) {
|
|
self.select_with_source(action, MenuSelectionSource::KeyboardOrProgrammatic, ctx);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn selected_submenu(&self) -> Option<&SubMenu<A>> {
|
|
let selected_row_index = self.selected_row_index?;
|
|
match self.items.get(selected_row_index)? {
|
|
MenuItem::Submenu { menu, .. } => Some(menu),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn selected_submenu_mut(&mut self) -> Option<&mut SubMenu<A>> {
|
|
let selected_row_index = self.selected_row_index?;
|
|
match self.items.get_mut(selected_row_index)? {
|
|
MenuItem::Submenu { menu, .. } => Some(menu),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn active_menu_mut(&mut self) -> &mut Self {
|
|
match self.selected_row_index {
|
|
Some(selected_row_index)
|
|
if matches!(
|
|
self.items.get(selected_row_index),
|
|
Some(MenuItem::Submenu { menu, .. }) if menu.selected_row_index.is_some()
|
|
) =>
|
|
{
|
|
let Some(MenuItem::Submenu { menu, .. }) = self.items.get_mut(selected_row_index)
|
|
else {
|
|
unreachable!("checked selected submenu above");
|
|
};
|
|
menu.active_menu_mut()
|
|
}
|
|
_ => self,
|
|
}
|
|
}
|
|
|
|
fn select_first_selectable(&mut self, ctx: &mut ViewContext<Menu<A>>) -> bool {
|
|
let Some((row, _)) = self
|
|
.items
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, item)| item.selectable())
|
|
else {
|
|
return false;
|
|
};
|
|
self.select(SelectAction::Index { row, item: 0 }, ctx);
|
|
true
|
|
}
|
|
|
|
fn open_selected_submenu(&mut self, ctx: &mut ViewContext<Menu<A>>) -> bool {
|
|
let Some(submenu) = self.active_menu_mut().selected_submenu_mut() else {
|
|
return false;
|
|
};
|
|
submenu.select_first_selectable(ctx)
|
|
}
|
|
|
|
fn selected_action_for_enter(&mut self, ctx: &mut ViewContext<Menu<A>>) -> Option<A> {
|
|
let active_menu = self.active_menu_mut();
|
|
let selected_row_index = active_menu.selected_row_index?;
|
|
let selected_item_index = active_menu.selected_item_index.unwrap_or_default();
|
|
match active_menu.items.get_mut(selected_row_index)? {
|
|
MenuItem::Item(fields) => fields.on_select_action.clone(),
|
|
MenuItem::Separator => None,
|
|
MenuItem::ItemsRow { items } => items
|
|
.get(selected_item_index)
|
|
.and_then(|fields| fields.on_select_action.clone()),
|
|
MenuItem::Submenu { menu, .. } => {
|
|
menu.select_first_selectable(ctx);
|
|
None
|
|
}
|
|
MenuItem::Header {
|
|
fields, clickable, ..
|
|
} => {
|
|
if *clickable {
|
|
fields.on_select_action.clone()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Select the menu item with the given name. If no such item exists, this clears the selection.
|
|
/// Returns true if the item was found and selected, false otherwise.
|
|
pub fn set_selected_by_name<S>(
|
|
&mut self,
|
|
selected_item: S,
|
|
ctx: &mut ViewContext<Menu<A>>,
|
|
) -> bool
|
|
where
|
|
S: AsRef<str>,
|
|
{
|
|
let selected_item = selected_item.as_ref();
|
|
if let Some(index) = self.items.iter().position(|item| match item {
|
|
MenuItem::Item(MenuItemFields {
|
|
element: MenuItemLabel::Text(label) | MenuItemLabel::MultilineText { label, .. },
|
|
..
|
|
}) => selected_item == label,
|
|
_ => false,
|
|
}) {
|
|
self.select(
|
|
SelectAction::Index {
|
|
row: index,
|
|
item: 0,
|
|
},
|
|
ctx,
|
|
);
|
|
true
|
|
} else {
|
|
self.reset_selection(ctx);
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Select the menu item whose on-select action equals the given action. If no such item exists,
|
|
/// this clears the selection.
|
|
///
|
|
/// This is primarily useful when items are dynamically generated and correspond to some backing data that's captured by the action.
|
|
pub fn set_selected_by_action(&mut self, action: &A, ctx: &mut ViewContext<Menu<A>>)
|
|
where
|
|
A: PartialEq,
|
|
{
|
|
let selected_index = self.items.iter().position(|item| match item {
|
|
MenuItem::Item(MenuItemFields {
|
|
on_select_action: Some(item_action),
|
|
..
|
|
}) => item_action == action,
|
|
_ => false,
|
|
});
|
|
match selected_index {
|
|
Some(index) => {
|
|
self.select(
|
|
SelectAction::Index {
|
|
row: index,
|
|
item: 0,
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
None => self.reset_selection(ctx),
|
|
}
|
|
}
|
|
|
|
/// Currently, this assumes only one Menu with any depth submenu can be open
|
|
/// at a time. We need a way to uniquely identify Menus.
|
|
/// TODO(asweet): Allow multiple submenus to be open at a time.
|
|
fn save_position_id(depth: usize) -> String {
|
|
format!("submenu_{depth}")
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render_submenus(
|
|
&self,
|
|
submenu_width: f32,
|
|
menu_background_color: Fill,
|
|
selected_row: Option<usize>,
|
|
selected_item: Option<usize>,
|
|
dispatch_item_actions: bool,
|
|
ignore_hover_when_covered: bool,
|
|
safe_zone_anchor_row: Option<usize>,
|
|
submenu_being_shown_for_item_index: Option<usize>,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Vec<Box<dyn Element>> {
|
|
let height = self.height;
|
|
let depth = self.depth;
|
|
match &self.menu_variant {
|
|
MenuVariant::Fixed => {
|
|
let mut menus = vec![Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_children(self.items.iter().enumerate().map(
|
|
|(index, item)| -> Box<dyn Element> {
|
|
let is_selected = selected_row == Some(index);
|
|
// When the safe zone is active, suppress hover highlighting on
|
|
// non-anchor rows so intermediate items don't flash as the
|
|
// mouse moves toward the sidecar.
|
|
let safe_zone_suppresses_hover =
|
|
safe_zone_anchor_row.is_some_and(|anchor| anchor != index);
|
|
let submenu_being_shown_for_item =
|
|
submenu_being_shown_for_item_index == Some(index);
|
|
let item = item.render(
|
|
menu_background_color,
|
|
depth,
|
|
index,
|
|
selected_item,
|
|
dispatch_item_actions,
|
|
is_selected,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
submenu_width,
|
|
app,
|
|
);
|
|
let item = if is_selected {
|
|
let save_position = Self::save_position_id(depth);
|
|
SavePosition::new(item, &save_position).finish()
|
|
} else {
|
|
item
|
|
};
|
|
Container::new(item).finish()
|
|
},
|
|
))
|
|
.finish()];
|
|
let Some(selected_row) = self.selected_item() else {
|
|
return menus;
|
|
};
|
|
let MenuItem::Submenu { menu, .. } = selected_row else {
|
|
return menus;
|
|
};
|
|
|
|
menus.extend(menu.render_submenus(
|
|
submenu_width,
|
|
menu_background_color,
|
|
menu.selected_row_index,
|
|
menu.selected_item_index,
|
|
dispatch_item_actions,
|
|
ignore_hover_when_covered,
|
|
None,
|
|
None,
|
|
appearance,
|
|
app,
|
|
));
|
|
menus
|
|
}
|
|
MenuVariant::Scrollable(scroll_state) => {
|
|
let items = self.items.clone();
|
|
// TODO(asweet): handle scrollable, or make scrollable nested items invalid
|
|
let column_of_items = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_children(items.iter().enumerate().map(|(index, item)| {
|
|
let is_selected = selected_row == Some(index);
|
|
let safe_zone_suppresses_hover =
|
|
safe_zone_anchor_row.is_some_and(|anchor| anchor != index);
|
|
let submenu_being_shown_for_item =
|
|
submenu_being_shown_for_item_index == Some(index);
|
|
let item = item.render(
|
|
menu_background_color,
|
|
depth,
|
|
index,
|
|
selected_item,
|
|
dispatch_item_actions,
|
|
is_selected,
|
|
ignore_hover_when_covered,
|
|
safe_zone_suppresses_hover,
|
|
submenu_being_shown_for_item,
|
|
appearance,
|
|
submenu_width,
|
|
app,
|
|
);
|
|
let item = if is_selected {
|
|
let save_position = Self::save_position_id(depth);
|
|
SavePosition::new(item, &save_position).finish()
|
|
} else {
|
|
item
|
|
};
|
|
Container::new(item).finish()
|
|
}));
|
|
|
|
vec![ConstrainedBox::new(
|
|
ClippedScrollable::vertical(
|
|
scroll_state.clone(),
|
|
column_of_items.finish(),
|
|
ScrollbarWidth::Auto,
|
|
appearance.theme().nonactive_ui_detail().into(),
|
|
appearance.theme().active_ui_detail().into(),
|
|
galaxyui::elements::Fill::None,
|
|
)
|
|
.with_overlayed_scrollbar()
|
|
.finish(),
|
|
)
|
|
.with_max_height(height)
|
|
.finish()]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> Menu<A> {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
prevent_interaction_with_other_elements: false,
|
|
border: None,
|
|
with_drop_shadow: false,
|
|
origin: None,
|
|
window_id: Default::default(),
|
|
submenu_width: DEFAULT_WIDTH,
|
|
menu: SubMenu::new(vec![]),
|
|
submenu_being_shown_for_item_index: None,
|
|
ignore_hover_when_covered: false,
|
|
safe_triangle: None,
|
|
flatten_bottom_corners: false,
|
|
pinned_footer_builder: None,
|
|
pinned_header_builder: None,
|
|
content_top_padding_override: None,
|
|
content_bottom_padding_override: None,
|
|
dispatch_item_actions: true,
|
|
}
|
|
}
|
|
|
|
fn window_id(&self, app: &AppContext) -> Option<WindowId> {
|
|
if let Some(window_id) = self.window_id.get() {
|
|
return Some(*window_id);
|
|
}
|
|
if let Some(window_id) = app.windows().active_window() {
|
|
let _ = self.window_id.set(window_id);
|
|
}
|
|
self.window_id.get().copied()
|
|
}
|
|
|
|
pub fn with_border(mut self, border: Border) -> Self {
|
|
self.border = Some(border);
|
|
self
|
|
}
|
|
|
|
pub fn with_width(mut self, width: f32) -> Self {
|
|
self.submenu_width = width;
|
|
self
|
|
}
|
|
|
|
pub fn with_menu_variant(mut self, menu_variant: MenuVariant) -> Self {
|
|
self.menu.with_menu_variant(menu_variant);
|
|
self
|
|
}
|
|
|
|
/// Prevents interactions with any other elements outside of the [`Menu`]. All events are
|
|
/// handled by this element and are _not_ propagated further down the element hierarchy.
|
|
pub fn prevent_interaction_with_other_elements(mut self) -> Self {
|
|
self.prevent_interaction_with_other_elements = true;
|
|
self
|
|
}
|
|
|
|
pub fn with_drop_shadow(mut self) -> Self {
|
|
self.with_drop_shadow = true;
|
|
self
|
|
}
|
|
|
|
/// Prevent menu item clicks and Enter from dispatching their typed actions
|
|
/// directly. Selection and close events still occur normally.
|
|
pub fn without_item_action_dispatch(mut self) -> Self {
|
|
self.dispatch_item_actions = false;
|
|
self
|
|
}
|
|
|
|
/// If true, menu items won't fire hover events when covered by another element.
|
|
/// This is useful when a sidecar panel overlays the menu.
|
|
pub fn with_ignore_hover_when_covered(mut self) -> Self {
|
|
self.ignore_hover_when_covered = true;
|
|
self
|
|
}
|
|
|
|
/// Enable safe triangle tracking for suppressing intermediate hovers
|
|
/// when moving toward a sidecar/submenu.
|
|
pub fn with_safe_triangle(mut self) -> Self {
|
|
self.safe_triangle = Some(SafeTriangle::new());
|
|
self
|
|
}
|
|
|
|
/// Set or clear the target rect for the safe triangle. The rect should be the
|
|
/// bounding box of the sidecar/submenu panel that the user is moving toward.
|
|
pub fn set_safe_zone_target(&mut self, rect: Option<RectF>) {
|
|
if let Some(st) = &mut self.safe_triangle {
|
|
st.set_target_rect(rect);
|
|
}
|
|
}
|
|
|
|
/// Origin is only used to determine which direction submenus should expand in.
|
|
pub fn set_origin(&mut self, origin: Option<Vector2F>) {
|
|
self.origin = origin;
|
|
}
|
|
|
|
pub fn set_submenu_being_shown_for_item_index(&mut self, index: Option<usize>) {
|
|
self.submenu_being_shown_for_item_index = index;
|
|
}
|
|
|
|
pub fn set_width(&mut self, width: f32) {
|
|
self.submenu_width = width;
|
|
}
|
|
|
|
pub fn set_border(&mut self, border: Option<Border>) {
|
|
self.border = border;
|
|
}
|
|
|
|
pub fn set_flatten_bottom_corners(&mut self, flatten: bool) {
|
|
self.flatten_bottom_corners = flatten;
|
|
}
|
|
|
|
/// Set a pinned footer element
|
|
/// **inside** the `Dismiss`, so clicks on it never trigger the dismiss handler.
|
|
/// Use `on_click` (not `on_mouse_down`) on interactive elements in the footer.
|
|
pub fn set_pinned_footer_builder<F>(&mut self, builder: F)
|
|
where
|
|
F: Fn(&AppContext) -> Box<dyn Element> + 'static,
|
|
{
|
|
self.pinned_footer_builder = Some(Box::new(builder));
|
|
}
|
|
|
|
pub fn clear_pinned_footer_builder(&mut self) {
|
|
self.pinned_footer_builder = None;
|
|
}
|
|
|
|
/// Set a pinned header element rendered above the scrollable items
|
|
/// **inside** the `Dismiss`, so clicks on it never trigger the dismiss handler.
|
|
pub fn set_pinned_header_builder<F>(&mut self, builder: F)
|
|
where
|
|
F: Fn(&AppContext) -> Box<dyn Element> + 'static,
|
|
{
|
|
self.pinned_header_builder = Some(Box::new(builder));
|
|
}
|
|
|
|
pub fn clear_pinned_header_builder(&mut self) {
|
|
self.pinned_header_builder = None;
|
|
}
|
|
|
|
pub fn set_content_padding_overrides(
|
|
&mut self,
|
|
top_padding: Option<f32>,
|
|
bottom_padding: Option<f32>,
|
|
) {
|
|
self.content_top_padding_override = top_padding;
|
|
self.content_bottom_padding_override = bottom_padding;
|
|
}
|
|
|
|
pub fn set_height(&mut self, height: f32) {
|
|
self.menu.with_height(height);
|
|
}
|
|
|
|
pub fn set_items(
|
|
&mut self,
|
|
items: impl IntoIterator<Item = MenuItem<A>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.menu.items = items.into_iter().collect();
|
|
// No need to ctx.notify, since reset_selection will.
|
|
self.reset_selection(ctx);
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn add_item(&mut self, item: MenuItem<A>) {
|
|
self.menu.items.push(item);
|
|
}
|
|
|
|
pub fn add_items(&mut self, items: impl IntoIterator<Item = MenuItem<A>>) {
|
|
self.menu.items.extend(items);
|
|
}
|
|
|
|
pub fn reset_selection(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.menu.reset_selection(ctx)
|
|
}
|
|
|
|
pub fn selected_item(&self) -> Option<MenuItem<A>> {
|
|
self.menu.selected_item()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.menu.is_empty()
|
|
}
|
|
|
|
pub fn items(&self) -> &[MenuItem<A>] {
|
|
self.menu.items()
|
|
}
|
|
|
|
pub fn items_len(&self) -> usize {
|
|
self.menu.items_len()
|
|
}
|
|
|
|
pub fn selected_index(&self) -> Option<usize> {
|
|
self.menu.selected_index()
|
|
}
|
|
|
|
pub fn hovered_index(&self) -> Option<usize> {
|
|
self.menu.hovered_index()
|
|
}
|
|
|
|
pub fn last_selection_source(&self) -> Option<MenuSelectionSource> {
|
|
self.menu.last_selection_source()
|
|
}
|
|
|
|
/// Select the menu item at the given index. If the index is out of bounds, this clears the selection.
|
|
pub fn set_selected_by_index(&mut self, selected_index: usize, ctx: &mut ViewContext<Self>) {
|
|
self.menu.set_selected_by_index(selected_index, ctx);
|
|
}
|
|
|
|
/// Select the menu item with the given name. If no such item exists, this clears the selection.
|
|
/// Returns true if the item was found and selected, false otherwise.
|
|
pub fn set_selected_by_name<S>(&mut self, selected_item: S, ctx: &mut ViewContext<Self>) -> bool
|
|
where
|
|
S: AsRef<str>,
|
|
{
|
|
self.menu.set_selected_by_name(selected_item, ctx)
|
|
}
|
|
|
|
/// Select the menu item whose on-select action equals the given action. If no such item exists,
|
|
/// this clears the selection.
|
|
///
|
|
/// This is primarily useful when items are dynamically generated and correspond to some backing data that's captured by the action.
|
|
pub fn set_selected_by_action(&mut self, action: &A, ctx: &mut ViewContext<Self>)
|
|
where
|
|
A: PartialEq,
|
|
{
|
|
self.menu.set_selected_by_action(action, ctx)
|
|
}
|
|
|
|
fn select(&mut self, action: SelectAction, ctx: &mut ViewContext<Self>) {
|
|
self.menu.select(action, ctx);
|
|
}
|
|
|
|
pub fn select_previous(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.select(SelectAction::Previous, ctx);
|
|
}
|
|
|
|
pub fn select_next(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.select(SelectAction::Next, ctx);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn mimic_confirm(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if let Some(item) = self.selected_item() {
|
|
match item {
|
|
MenuItem::Item(MenuItemFields {
|
|
on_select_action: Some(on_select_action),
|
|
..
|
|
}) => {
|
|
ctx.dispatch_typed_action(&on_select_action);
|
|
}
|
|
MenuItem::ItemsRow { items } => {
|
|
if let Some(on_select_action) = self
|
|
.menu
|
|
.selected_item_index
|
|
.and_then(|i| items.get(i))
|
|
.and_then(|item| item.on_select_action().cloned())
|
|
{
|
|
ctx.dispatch_typed_action(&on_select_action);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> Default for SubMenu<A> {
|
|
fn default() -> Self {
|
|
Self::new(vec![])
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> Default for Menu<A> {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> Entity for SubMenu<A> {
|
|
type Event = Event;
|
|
}
|
|
|
|
impl<A: Action + Clone> Entity for Menu<A> {
|
|
type Event = Event;
|
|
}
|
|
|
|
impl<A: Action + Clone> SubMenu<A> {
|
|
fn action_accessibility_contents(
|
|
&mut self,
|
|
action: &MenuAction,
|
|
_: &mut ViewContext<Menu<A>>,
|
|
) -> ActionAccessibilityContent {
|
|
use ActionAccessibilityContent::*;
|
|
use MenuAction::*;
|
|
|
|
match action {
|
|
Select(_) => {
|
|
let menu_item = match self.selected_item() {
|
|
Some(item) => match item {
|
|
MenuItem::Item(fields) => format!("{} Selected", fields.get_a11y_text()),
|
|
MenuItem::ItemsRow { items } => {
|
|
let selected_item_text = items
|
|
.get(self.selected_item_index.unwrap_or_default())
|
|
.map_or_else(|| "", |item| item.get_a11y_text());
|
|
format!("{selected_item_text} Selected")
|
|
}
|
|
MenuItem::Separator => String::from(""),
|
|
MenuItem::Submenu { fields, .. } => {
|
|
format!("{} Expanded", fields.get_a11y_text())
|
|
}
|
|
MenuItem::Header { fields, .. } => {
|
|
format!("{} Selected", fields.get_a11y_text())
|
|
}
|
|
},
|
|
None => String::from(""),
|
|
};
|
|
|
|
let instructions = if matches!(self.selected_item(), Some(MenuItem::Submenu { .. }))
|
|
{
|
|
"Press the up key or the down key to select a menu item. Press the right key to open the submenu"
|
|
} else {
|
|
"Press the up key or the down key to select a menu item"
|
|
};
|
|
|
|
Custom(AccessibilityContent::new(
|
|
menu_item,
|
|
instructions,
|
|
WarpA11yRole::TextRole,
|
|
))
|
|
}
|
|
OpenSubmenu => Custom(AccessibilityContent::new(
|
|
String::from("Submenu Expanded"),
|
|
"Press the right key to open the selected submenu",
|
|
WarpA11yRole::TextRole,
|
|
)),
|
|
CloseSubmenu(_) => Custom(AccessibilityContent::new(
|
|
String::from("Submenu Closed"),
|
|
"Removing focus from a submenu will close the submenu",
|
|
WarpA11yRole::TextRole,
|
|
)),
|
|
Close(_) => Custom(AccessibilityContent::new(
|
|
String::from("Menu Closed"),
|
|
"Press the escape key to close the menu",
|
|
WarpA11yRole::TextRole,
|
|
)),
|
|
Enter => Custom(AccessibilityContent::new(
|
|
String::from("Action Selected"),
|
|
"Press the enter key to execute the selected menu item action",
|
|
WarpA11yRole::TextRole,
|
|
)),
|
|
HoverSubmenuLeafNode { .. }
|
|
| UnhoverSubmenuParent(_)
|
|
| HoverSubmenuWithChildren(_, _) => ActionAccessibilityContent::Empty,
|
|
}
|
|
}
|
|
|
|
fn handle_action(
|
|
&mut self,
|
|
action: &MenuAction,
|
|
dispatch_item_actions: bool,
|
|
ctx: &mut ViewContext<Menu<A>>,
|
|
) {
|
|
match action {
|
|
MenuAction::HoverSubmenuWithChildren(depth, selection) => {
|
|
if *depth != self.depth {
|
|
return;
|
|
}
|
|
let selection = *selection;
|
|
self.select_with_source(selection, MenuSelectionSource::Pointer, ctx);
|
|
}
|
|
MenuAction::UnhoverSubmenuParent(depth) => {
|
|
if *depth != self.depth {
|
|
return;
|
|
}
|
|
self.handle_action(
|
|
&MenuAction::CloseSubmenu(self.depth),
|
|
dispatch_item_actions,
|
|
ctx,
|
|
);
|
|
}
|
|
MenuAction::HoverSubmenuLeafNode {
|
|
depth, row_index, ..
|
|
} => {
|
|
if *depth != self.depth {
|
|
return;
|
|
}
|
|
self.hovered_row_index = Some(*row_index);
|
|
ctx.emit(Event::ItemHovered);
|
|
}
|
|
MenuAction::Select(selection) => self.active_menu_mut().select(*selection, ctx),
|
|
MenuAction::OpenSubmenu => {
|
|
self.open_selected_submenu(ctx);
|
|
}
|
|
MenuAction::CloseSubmenu(depth) => {
|
|
if *depth != self.depth {
|
|
return;
|
|
}
|
|
self.reset_selection(ctx);
|
|
}
|
|
MenuAction::Close(via_select_item) => ctx.emit(Event::Close {
|
|
via_select_item: *via_select_item,
|
|
}),
|
|
MenuAction::Enter => {
|
|
if let Some(action) = self.selected_action_for_enter(ctx) {
|
|
if dispatch_item_actions {
|
|
ctx.dispatch_typed_action(&action);
|
|
} else {
|
|
ctx.emit(Event::ItemSelected);
|
|
}
|
|
ctx.emit(Event::Close {
|
|
via_select_item: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> TypedActionView for Menu<A> {
|
|
type Action = MenuAction;
|
|
|
|
fn action_accessibility_contents(
|
|
&mut self,
|
|
action: &MenuAction,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> ActionAccessibilityContent {
|
|
self.menu.action_accessibility_contents(action, ctx)
|
|
}
|
|
|
|
fn handle_action(&mut self, action: &MenuAction, ctx: &mut ViewContext<Self>) {
|
|
if let MenuAction::HoverSubmenuLeafNode { position, .. } = action {
|
|
if let Some(st) = &mut self.safe_triangle {
|
|
if st.should_suppress_hover(*position) {
|
|
return;
|
|
}
|
|
st.update_position(*position);
|
|
}
|
|
}
|
|
|
|
self.menu
|
|
.handle_action(action, self.dispatch_item_actions, ctx)
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> SubMenu<A> {
|
|
fn should_reverse_layout(
|
|
&self,
|
|
window: Option<WindowId>,
|
|
origin: Option<Vector2F>,
|
|
submenu_width: f32,
|
|
// Including the main menu
|
|
num_submenus: usize,
|
|
app: &AppContext,
|
|
) -> bool {
|
|
if num_submenus <= 1 {
|
|
return false;
|
|
}
|
|
let extra_menus_width =
|
|
((num_submenus - 1) as f32 * (submenu_width - SUBMENU_OVERLAP)) + SUBMENU_OVERLAP;
|
|
let total_width = submenu_width + extra_menus_width;
|
|
let (Some(window), Some(origin)) = (window, origin) else {
|
|
return false;
|
|
};
|
|
let Some(window) = app.windows().platform_window(window) else {
|
|
return false;
|
|
};
|
|
let full_menu_end_x = origin.x() + total_width;
|
|
full_menu_end_x >= window.size().x()
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render(
|
|
&self,
|
|
border: Option<Border>,
|
|
submenu_width: f32,
|
|
with_drop_shadow: bool,
|
|
origin: Option<Vector2F>,
|
|
window_id: Option<WindowId>,
|
|
prevent_interaction_with_other_elements: bool,
|
|
dispatch_item_actions: bool,
|
|
ignore_hover_when_covered: bool,
|
|
safe_zone_anchor_row: Option<usize>,
|
|
submenu_being_shown_for_item_index: Option<usize>,
|
|
flatten_bottom_corners: bool,
|
|
pinned_footer_builder: Option<&PinnedFooterBuilder>,
|
|
pinned_header_builder: Option<&PinnedHeaderBuilder>,
|
|
content_top_padding_override: Option<f32>,
|
|
content_bottom_padding_override: Option<f32>,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
let selected_row_index = self.selected_row_index;
|
|
let selected_item_index = self.selected_item_index;
|
|
|
|
let background_color = appearance.theme().surface_2();
|
|
let submenus = self.render_submenus(
|
|
submenu_width,
|
|
background_color,
|
|
selected_row_index,
|
|
selected_item_index,
|
|
dispatch_item_actions,
|
|
ignore_hover_when_covered,
|
|
safe_zone_anchor_row,
|
|
submenu_being_shown_for_item_index,
|
|
appearance,
|
|
app,
|
|
);
|
|
// TODO(asweet): Currently, this is based on the _current number of submenus_
|
|
// instead of the maximum. We should likely change this.
|
|
let num_submenus = submenus.len();
|
|
|
|
let should_reverse_layout =
|
|
self.should_reverse_layout(window_id, origin, submenu_width, num_submenus, app);
|
|
|
|
let mut row = Flex::row();
|
|
let mut stack = Stack::new();
|
|
submenus
|
|
.into_iter()
|
|
.enumerate()
|
|
.for_each(|(depth, submenu)| {
|
|
let corner_radius = if flatten_bottom_corners && depth == 0 {
|
|
CornerRadius::with_top(Radius::Pixels(5.))
|
|
} else {
|
|
CornerRadius::with_all(Radius::Pixels(5.))
|
|
};
|
|
|
|
// At depth 0, place pinned header/footer inside the styled container
|
|
// so they inherit the menu box background, border, and corner radius.
|
|
let (content, top_padding, bottom_padding) = if depth == 0 {
|
|
let has_header = pinned_header_builder.is_some();
|
|
let has_footer = pinned_footer_builder.is_some();
|
|
if has_header || has_footer {
|
|
let mut col = Flex::column();
|
|
if let Some(header_builder) = pinned_header_builder {
|
|
col.add_child(header_builder(app));
|
|
}
|
|
col.add_child(submenu);
|
|
if let Some(footer_builder) = pinned_footer_builder {
|
|
col.add_child(footer_builder(app));
|
|
}
|
|
let top_padding = content_top_padding_override.unwrap_or(if has_header {
|
|
0.
|
|
} else {
|
|
MENU_VERTICAL_PADDING
|
|
});
|
|
let bottom_padding =
|
|
content_bottom_padding_override.unwrap_or(if has_footer {
|
|
0.
|
|
} else {
|
|
MENU_VERTICAL_PADDING
|
|
});
|
|
(
|
|
col.finish() as Box<dyn Element>,
|
|
top_padding,
|
|
bottom_padding,
|
|
)
|
|
} else {
|
|
(
|
|
submenu,
|
|
content_top_padding_override.unwrap_or(MENU_VERTICAL_PADDING),
|
|
content_bottom_padding_override.unwrap_or(MENU_VERTICAL_PADDING),
|
|
)
|
|
}
|
|
} else {
|
|
(submenu, MENU_VERTICAL_PADDING, MENU_VERTICAL_PADDING)
|
|
};
|
|
|
|
let mut container = Container::new(content)
|
|
.with_padding_top(top_padding)
|
|
.with_padding_bottom(bottom_padding)
|
|
.with_background(background_color)
|
|
.with_corner_radius(corner_radius);
|
|
|
|
if let Some(border) = border {
|
|
container = container.with_border(border);
|
|
}
|
|
|
|
let mut menu = Container::new(
|
|
ConstrainedBox::new(container.finish())
|
|
.with_width(submenu_width)
|
|
.finish(),
|
|
);
|
|
|
|
if with_drop_shadow {
|
|
menu = menu.with_drop_shadow(DropShadow::new_with_standard_offset_and_spread(
|
|
DROP_SHADOW_COLOR,
|
|
));
|
|
}
|
|
|
|
if depth == 0 {
|
|
row.add_child(menu.finish());
|
|
} else {
|
|
let saved_position_id = Self::save_position_id(depth - 1);
|
|
|
|
stack.add_positioned_overlay_child(
|
|
menu.finish(),
|
|
OffsetPositioning::offset_from_save_position_element(
|
|
saved_position_id,
|
|
vec2f(
|
|
if should_reverse_layout {
|
|
SUBMENU_OVERLAP
|
|
} else {
|
|
-SUBMENU_OVERLAP
|
|
},
|
|
-MENU_VERTICAL_PADDING,
|
|
),
|
|
PositionedElementOffsetBounds::WindowByPosition,
|
|
if should_reverse_layout {
|
|
PositionedElementAnchor::TopLeft
|
|
} else {
|
|
PositionedElementAnchor::TopRight
|
|
},
|
|
if should_reverse_layout {
|
|
ChildAnchor::TopRight
|
|
} else {
|
|
ChildAnchor::TopLeft
|
|
},
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
row.add_child(stack.finish());
|
|
|
|
// The footer (if any) was already placed inside the styled container above.
|
|
// Wrap the full row in an EventHandler before passing to Dismiss.
|
|
let dismiss_child = EventHandler::new(row.finish()).finish();
|
|
let mut dismiss = Dismiss::new(dismiss_child)
|
|
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(MenuAction::Close(false)));
|
|
|
|
if prevent_interaction_with_other_elements {
|
|
dismiss = dismiss.prevent_interaction_with_other_elements()
|
|
}
|
|
|
|
dismiss.finish()
|
|
}
|
|
}
|
|
|
|
impl<A: Action + Clone> View for Menu<A> {
|
|
fn ui_name() -> &'static str {
|
|
"Menu"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let safe_zone_anchor_row = self
|
|
.safe_triangle
|
|
.as_ref()
|
|
.filter(|st| st.is_suppressing())
|
|
.and(self.menu.hovered_row_index);
|
|
|
|
self.menu.render(
|
|
self.border,
|
|
self.submenu_width,
|
|
self.with_drop_shadow,
|
|
self.origin,
|
|
self.window_id(app),
|
|
self.prevent_interaction_with_other_elements,
|
|
self.dispatch_item_actions,
|
|
self.ignore_hover_when_covered,
|
|
safe_zone_anchor_row,
|
|
self.submenu_being_shown_for_item_index,
|
|
self.flatten_bottom_corners,
|
|
self.pinned_footer_builder.as_deref(),
|
|
self.pinned_header_builder.as_deref(),
|
|
self.content_top_padding_override,
|
|
self.content_bottom_padding_override,
|
|
app,
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Testing utilities.
|
|
#[cfg(test)]
|
|
impl<A: Action + Clone> MenuItem<A> {
|
|
pub fn fields(&self) -> Option<&MenuItemFields<A>> {
|
|
// This method is used purely for the unit tests purposes.
|
|
// It only returns the item fields for the single menu item.
|
|
match self {
|
|
MenuItem::Item(fields) => Some(fields),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn is_separator(&self) -> bool {
|
|
matches!(self, MenuItem::Separator)
|
|
}
|
|
|
|
/// Returns true iff a and b are "approximately" equal. By "approximately", we mean:
|
|
/// - they are either both separators
|
|
/// - or, they are both single items with the same label
|
|
/// - or, they are both item rows with items whose labels match pairwise
|
|
pub fn is_approximately_same_item_as(&self, other: &MenuItem<A>) -> bool {
|
|
match (self, other) {
|
|
(MenuItem::Separator, MenuItem::Separator) => true,
|
|
(MenuItem::Item(self_fields), MenuItem::Item(other_fields)) => {
|
|
self_fields.label() == other_fields.label()
|
|
}
|
|
(
|
|
MenuItem::ItemsRow { items: self_items },
|
|
MenuItem::ItemsRow { items: other_items },
|
|
) => {
|
|
self_items.len() == other_items.len()
|
|
&& self_items
|
|
.iter()
|
|
.zip(other_items)
|
|
.all(|(self_fields, other_fields)| {
|
|
self_fields.label() == other_fields.label()
|
|
})
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "menu_test.rs"]
|
|
mod tests;
|