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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/moon-ui-components/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ windows = { workspace = true, features = [
] }

[dev-dependencies]
image = { version = "0.25.1", default-features = false }
gpui = { workspace = true, features = ["test-support"] }
indoc = "2"

Expand Down
2 changes: 1 addition & 1 deletion crates/moon-ui-components/component-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@
"theme_source": "MoonTheme tokens through theme bridge / Moon wrapper",
"public_path": "moon_ui::MoonSelect",
"upstream_ref": "Longbridge::select@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675",
"fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/style hook lives in base select; Longbridge select state remains source-owned",
"fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/style and configurable deferred menu layer live in base select; MoonSelect explicitly opts popover-hosted menus above their parent, while ordinary selects retain layer 1 and Longbridge selection behavior",
"donor_drift_budget": 1,
"contracts": ["select.open_select_lifecycle", "gallery.visual_coverage"]
},
Expand Down
3 changes: 2 additions & 1 deletion crates/moon-ui-components/src/moon/popover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use super::{
tokens::{MoonPalette, MoonRect, rgba_from},
};

const MOON_POPOVER_PRIORITY: usize = 30_000;
/// Shared layer boundary for popovers and controls opening menus above them.
pub(super) const MOON_POPOVER_PRIORITY: usize = 30_000;
const POPOVER_PADDING: f32 = 6.0;
const POPOVER_BORDER: f32 = 1.0;

Expand Down
22 changes: 22 additions & 0 deletions crates/moon-ui-components/src/moon/select.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! Moon selection controls adapting core behavior and semantic overlay hosting.

use crate::searchable_list::{SearchableListItem, SearchableVec};
use crate::select::{
Select as CoreSelect, SelectEvent as CoreSelectEvent, SelectState as CoreSelectState,
Expand Down Expand Up @@ -243,6 +245,7 @@ where
}
}

/// Single-selection control with an explicit opt-in for popover-hosted menus.
#[derive(IntoElement)]
pub struct MoonSelect<T>
where
Expand All @@ -263,12 +266,14 @@ where
menu_width: f32,
menu_max_height: Option<f32>,
menu_size: MoonMenuSize,
in_popover: bool,
}

impl<T> MoonSelect<T>
where
T: Clone + PartialEq + 'static,
{
/// Create a select using the ordinary menu layer; popover hosts must opt in explicitly.
pub fn new(state: &Entity<MoonSelectState<T>>) -> Self {
Self {
id: SharedString::from(format!("moon-select:{}", state.entity_id())),
Expand All @@ -286,6 +291,7 @@ where
menu_width: 180.0,
menu_max_height: None,
menu_size: MoonMenuSize::Normal,
in_popover: false,
}
}

Expand Down Expand Up @@ -358,12 +364,21 @@ where
self.menu_size = size;
self
}

/// Paint this select's menu above its hosting [`super::MoonPopover`].
///
/// Use only for controls inside a MoonPopover; ordinary selects keep their default layer.
pub fn in_popover(mut self) -> Self {
self.in_popover = true;
self
}
}

impl<T> RenderOnce for MoonSelect<T>
where
T: Clone + PartialEq + 'static,
{
/// Forward the host's layering opt-in while preserving core selection behavior.
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
self.state.update(cx, |state, cx| {
state.sync_core(self.searchable, window, cx);
Expand All @@ -379,6 +394,10 @@ where
.menu_width(px(self.menu_width))
.with_size(size_for(self.trigger_size, self.menu_size));

if self.in_popover {
select = select.menu_priority(super::popover::MOON_POPOVER_PRIORITY + 1);
}

if let Some(trigger_variant) = self.trigger_variant {
select = select.trigger_variant(trigger_variant.into());
}
Expand Down Expand Up @@ -424,3 +443,6 @@ fn size_for(trigger: MoonButtonSize, _menu: MoonMenuSize) -> Size {
MoonButtonSize::Custom { height, .. } => Size::Size(px(height)),
}
}

#[cfg(test)]
mod tests;
195 changes: 195 additions & 0 deletions crates/moon-ui-components/src/moon/select/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//! Scene-order regression for select menus overlapping their hosting popover.

use super::{MoonSelect, MoonSelectItem, MoonSelectState};
use crate::moon::{IndexPath, MoonPopover, MoonTheme, MoonThemeConfig};
use gpui::{
AppContext as _, AtlasKey, AtlasTile, Context, DevicePixels, Entity, HeadlessAppContext,
IntoElement, NoopTextSystem, ParentElement as _, PlatformAtlas, PlatformHeadlessRenderer, Quad,
Render, Scene, Size, Styled as _, Window, div, point, px, size,
};
use std::{borrow::Cow, cell::RefCell, rc::Rc, sync::Arc};

/// Records submitted quads without pretending to rasterize text or pixels.
struct SceneRecorder(Rc<RefCell<Vec<Quad>>>);

impl PlatformHeadlessRenderer for SceneRecorder {
/// Record the final scene; image output is intentionally unsupported.
fn render_scene_to_image(
&mut self,
scene: &Scene,
size: Size<DevicePixels>,
) -> anyhow::Result<image::RgbaImage> {
self.render_scene(scene, size)?;
anyhow::bail!("scene recorder does not rasterize images")
}

/// Replace the preceding frame so assertions inspect only the final submission.
fn render_scene(&mut self, scene: &Scene, _size: Size<DevicePixels>) -> anyhow::Result<()> {
self.0.replace(scene.quads.clone());
Ok(())
}

/// Quad ordering does not depend on glyph or icon textures.
fn sprite_atlas(&self) -> Arc<dyn PlatformAtlas> {
Arc::new(QuadOnlyAtlas)
}
}

/// Omits sprites from this quad-only scene probe.
struct QuadOnlyAtlas;

impl PlatformAtlas for QuadOnlyAtlas {
/// No texture is allocated because the recorder only consumes solid quads.
fn get_or_insert_with<'a>(
&self,
_key: &AtlasKey,
_build: &mut dyn FnMut() -> anyhow::Result<Option<(Size<DevicePixels>, Cow<'a, [u8]>)>>,
) -> anyhow::Result<Option<AtlasTile>> {
Ok(None)
}

/// There are no allocated textures to remove.
fn remove(&self, _key: &AtlasKey) {}
}

/// A tall popover whose surface overlaps the child select menu.
struct NestedSelectHarness {
state: Entity<MoonSelectState<usize>>,
opt_in: bool,
}

impl Render for NestedSelectHarness {
/// Distinct fixed widths identify the actual parent and menu surface quads.
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let mut select = MoonSelect::new(&self.state).menu_width(180.0);
if self.opt_in {
select = select.in_popover();
}
MoonPopover::new("nested-select")
.open(true)
.width(220.0)
.trigger(div().w(px(20.0)).h(px(20.0)))
.content(
div()
.w(px(200.0))
.h(px(220.0))
.child(div().w(px(180.0)).h(px(32.0)).child(select)),
)
}
}

/// Removing the Moon in_popover forwarding paints the overlapping menu below its parent.
/// Raising every select instead breaks the false case, which preserves ordinary paint order.
#[test]
fn popover_menu_opt_in_controls_overlapping_quad_order() {
for (theme, config) in [
("dark", MoonThemeConfig::moon_terminal()),
("light", MoonThemeConfig::moon_light()),
] {
for opt_in in [false, true] {
let quads = Rc::new(RefCell::new(Vec::new()));
let recorded = quads.clone();
let mut cx = HeadlessAppContext::with_platform(
Arc::new(NoopTextSystem::new()),
Arc::new(()),
move || Some(Box::new(SceneRecorder(recorded.clone()))),
);
cx.update(crate::init);
cx.update(|cx| MoonTheme::install_config(config.clone(), cx));
let window = cx
.open_window(size(px(800.0), px(600.0)), |window, cx| {
let state = cx.new(|cx| {
MoonSelectState::new(
[
MoonSelectItem::new(0, "First"),
MoonSelectItem::new(1, "Second"),
],
Some(IndexPath::new(1)),
window,
cx,
)
});
state.update(cx, |state, _| state.set_open(true));
cx.new(|_| NestedSelectHarness { state, opt_in })
})
.expect("headless window must open");
for _ in 0..8 {
cx.update_window(window.into(), |_, window, cx| {
window.refresh();
window.draw(cx).clear();
})
.expect("headless frame must draw");
cx.run_until_parked();
}
let scale = cx
.update_window(window.into(), |_, window, _| window.scale_factor())
.expect("headless window must retain its scale");
// The recorder captures submission and explicitly refuses pixel output.
let error = cx
.capture_screenshot(window.into())
.expect_err("quad recorder has no pixels");
assert_eq!(
error.to_string(),
"scene recorder does not rasterize images"
);
let quads = quads.borrow();
let parents: Vec<_> = quads
.iter()
.filter(|quad| {
!quad.background.is_transparent()
&& quad.bounds.size.width.0 == 220.0 * scale
&& quad.bounds.size.height.0 > 220.0 * scale
})
.collect();
let menus: Vec<_> = quads
.iter()
.filter(|quad| {
!quad.background.is_transparent()
&& quad.bounds.size.width.0 == 180.0 * scale
&& quad.bounds.size.height.0 > 40.0 * scale
})
.collect();
assert_eq!(
parents.len(),
1,
"{theme}/{opt_in}: unique parent surface: {quads:?}"
);
assert_eq!(
menus.len(),
1,
"{theme}/{opt_in}: unique menu surface: {quads:?}"
);
let parent = parents[0];
let menu = menus[0];
let target = point(
menu.bounds.center().x,
menu.bounds.top() + gpui::ScaledPixels(20.0 * scale),
);
assert!(parent.bounds.contains(&target), "probe must overlap parent");
assert!(menu.bounds.contains(&target), "probe must overlap menu");
assert!(
parent.content_mask.bounds.contains(&target),
"parent must not be clipped at probe"
);
assert!(
menu.content_mask.bounds.contains(&target),
"menu must not be clipped at probe"
);
assert_ne!(
menu.order, parent.order,
"overlapping surfaces must have distinct draw orders"
);
assert_eq!(
menu.order > parent.order,
opt_in,
"{theme}/{opt_in}: overlapping menu must paint above parent exactly when opted in; parent={}, menu={}",
parent.order,
menu.order
);
eprintln!(
"{theme}/{opt_in}: parent={}, menu={}",
parent.order, menu.order
);
}
}
}
Loading
Loading