Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 79 additions & 26 deletions crates/moon-ui-components/src/moon/dropdown/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

use super::*;

mod submenu;
use submenu::SubmenuPlacement;

/// Shared immutable inputs used to render every row in one menu level.
struct MenuLevelRenderContext {
menu_id: SharedString,
Expand All @@ -14,6 +17,7 @@ struct MenuLevelRenderContext {
palette: MoonPalette,
tokens: MoonThemeTokens,
dropdown_selection: Option<std::rc::Rc<MoonDropdownSelectionContext>>,
submenu_state: Option<Entity<Option<Option<usize>>>>,
}

/// Retained variable-height list state for one large popup-menu level.
Expand Down Expand Up @@ -119,6 +123,7 @@ pub struct MoonPopupMenu {
max_height: Option<MoonMenuMaxHeight>,
mono: bool,
dropdown_selection: Option<std::rc::Rc<MoonDropdownSelectionContext>>,
submenu_state: Option<Entity<Option<Option<usize>>>>,
}

#[derive(IntoElement)]
Expand Down Expand Up @@ -149,6 +154,7 @@ impl MoonPopupMenu {
max_height: None,
mono: true,
dropdown_selection: None,
submenu_state: None,
}
}

Expand Down Expand Up @@ -399,9 +405,6 @@ impl MoonPopupMenu {
matches!(self.width, MoonMenuWidth::Rendered(_)),
"scaled or fitted menu widths require RenderOnce with an App context"
);
if !menu_level_is_virtualized(self.items.len()) {
return self.render_with_theme(p, MoonThemeTokens::default(), None, None);
}
MoonPopupMenuResolvedTheme {
menu: self,
palette: p,
Expand Down Expand Up @@ -512,8 +515,7 @@ impl MoonPopupMenu {
}
}
};
if self.width.is_measured()
&& let Some(max_width) = self.rendered_max_width
if let Some(max_width) = self.rendered_max_width
&& width > max_width
{
width = max_width.max(1.0);
Expand Down Expand Up @@ -579,6 +581,7 @@ impl MoonPopupMenu {
palette: p,
tokens,
dropdown_selection: self.dropdown_selection,
submenu_state: self.submenu_state,
});
if let Some(list_state) = virtual_list_state {
let list_height =
Expand Down Expand Up @@ -673,7 +676,16 @@ impl MoonPopupMenu {
);
}

match item.kind {
let hovered_branch = context
.submenu_state
.as_ref()
.and_then(|state| cx.and_then(|cx| *state.read(cx)));
let open = !item.disabled
&& !item.submenu.items.is_empty()
&& hovered_branch.map_or(item.selected, |branch| branch == Some(ix));
let hover_state = context.submenu_state.clone();
let hover_branch = (!item.disabled && !item.submenu.items.is_empty()).then_some(ix);
let row = match item.kind {
MoonMenuItemKind::Separator => div()
.id(ElementId::from(row_id.clone()))
.debug_selector(move || row_id.to_string())
Expand Down Expand Up @@ -746,7 +758,7 @@ impl MoonPopupMenu {
}
MoonMenuItemKind::Item => {
let disabled = item.disabled;
let selected = item.selected;
let selected = item.selected || open;
let checked = item.checked;
let on_click = menu_item_click_handler(&item, context.dropdown_selection.as_ref());
let submenu = item.submenu;
Expand Down Expand Up @@ -842,30 +854,61 @@ impl MoonPopupMenu {
}
}

if selected && has_submenu {
if open {
let row_bounds = std::rc::Rc::new(std::cell::Cell::new(Bounds::default()));
let capture = row_bounds.clone();
row = row.child(
canvas(move |bounds, _, _| capture.set(bounds), |_, _, _, _| {})
.absolute()
.inset_0()
.size_full(),
);
row = row.child(
deferred(
div()
.absolute()
.left_full()
.ml(px(tokens.ui(SUBMENU_OFFSET_X)))
.top(px(-tokens.ui(MENU_PADDING)))
.child(MoonPopupMenuResolvedTheme {
menu: MoonPopupMenu::new(format!("{menu_id}:submenu:{ix}"))
.shared_level(submenu)
.width_policy(menu_width_policy)
.size(menu_size),
palette: p,
tokens: tokens.clone(),
}),
)
deferred(SubmenuPlacement {
row_bounds,
gap: tokens.ui(SUBMENU_OFFSET_X),
top_overlap: tokens.ui(MENU_PADDING),
child: MoonPopupMenuResolvedTheme {
menu: MoonPopupMenu::new(format!("{menu_id}:submenu:{ix}"))
.shared_level(submenu)
.width_policy(menu_width_policy)
.size(menu_size),
palette: p,
tokens: tokens.clone(),
}
.into_any_element(),
})
.with_priority(1),
);
}

row.into_any_element()
}
}
};
div()
.id(SharedString::from(format!("{menu_id}:hover:{ix}")))
.on_hover(move |hovered, window, cx| {
if *hovered && let Some(state) = &hover_state {
state.update(cx, |state, cx| {
if *state != Some(hover_branch) {
*state = Some(hover_branch);
cx.notify();
window.refresh();
}
});
}
})
.child(row)
.into_any_element()
}

/// Retain one hovered branch per menu level; leaving a row keeps its child reachable.
fn retain_submenu_state(&mut self, window: &mut Window, cx: &mut App) {
self.submenu_state = Some(window.use_keyed_state(
ElementId::from(SharedString::from(format!("{}:hovered-branch", self.id))),
cx,
|_, _| None,
));
}

/// Return unscaled row metrics for the configured menu size.
Expand All @@ -886,7 +929,8 @@ impl RenderOnce for MoonPopupMenu {
///
/// Returns:
/// The rendered menu.
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
self.retain_submenu_state(window, cx);
let tokens = MoonTheme::active_tokens(cx);
let virtual_list_state = self.retained_virtual_list_state(&tokens, window, cx);
self.render_with_theme(
Expand All @@ -907,11 +951,20 @@ impl RenderOnce for MoonPopupMenuResolvedTheme {
///
/// Returns:
/// The popup rendered with the inherited palette and theme tokens.
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
self.menu.retain_submenu_state(window, cx);
let viewport = window.viewport_size();
self.menu.rendered_max_width = Some((f32::from(viewport.width) - 12.0).max(1.0));
let viewport_height = (f32::from(viewport.height) - 12.0).max(1.0);
let cap = resolve_menu_outer_max(self.menu.max_height, &self.tokens, false);
self.menu.max_height = Some(MoonMenuMaxHeight::Rendered(cap.min(viewport_height)));
let virtual_list_state = self
.menu
.retained_virtual_list_state(&self.tokens, window, cx);
self.menu
.render_with_theme(self.palette, self.tokens, Some(cx), virtual_list_state)
}
}

#[cfg(test)]
mod tests;
100 changes: 100 additions & 0 deletions crates/moon-ui-components/src/moon/dropdown/popup/submenu.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Place deferred submenu content beside its measured parent row within the viewport.

use gpui::*;
use std::{cell::Cell, rc::Rc};

/// A deferred portal whose parent-row bounds are captured before its prepaint pass.
pub(super) struct SubmenuPlacement {
pub row_bounds: Rc<Cell<Bounds<Pixels>>>,
pub gap: f32,
pub top_overlap: f32,
pub child: AnyElement,
}

impl IntoElement for SubmenuPlacement {
type Element = Self;

/// Preserve the portal as a layout element.
fn into_element(self) -> Self {
self
}
}

impl Element for SubmenuPlacement {
type RequestLayoutState = LayoutId;
type PrepaintState = ();

/// The surrounding menu owns retained identity.
fn id(&self) -> Option<ElementId> {
None
}

/// Internal geometry has no separate inspector source.
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}

/// Measure the menu outside normal row flow before choosing its opening side.
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, LayoutId) {
let child = self.child.request_layout(window, cx);
let layout = window.request_layout(
Style {
position: Position::Absolute,
..Style::default()
},
[child],
cx,
);
(layout, child)
}

/// Prefer the row's right side, flip to its left, then clamp both axes to the viewport.
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
child: &mut LayoutId,
window: &mut Window,
cx: &mut App,
) {
let row = self.row_bounds.get();
let size = window.layout_bounds(*child).size;
let viewport = window.viewport_size();
let margin = px(6.0);
let right = row.right() + px(self.gap);
let left = row.left() - px(self.gap) - size.width;
let x = if right + size.width <= viewport.width - margin {
right
} else {
left
};
let x = x.min(viewport.width - size.width - margin).max(margin);
let y = (row.top() - px(self.top_overlap))
.min(viewport.height - size.height - margin)
.max(px(0.0));
window.with_element_offset(point(x, y) - bounds.origin, |window| {
self.child.prepaint(window, cx)
});
}

/// Paint at the position retained by the child's prepaint pass.
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut LayoutId,
_: &mut (),
window: &mut Window,
cx: &mut App,
) {
self.child.paint(window, cx);
}
}
Loading
Loading