git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitcf9d7c8831cc7bb30e12e3f69a44b55389d86482
parent488970279e
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 16:54
fix(popups): fit submenus to the real screen

wlroots unconstrains a popup against a box in its ROOT window's surface
coordinates. The compositor measured that box from the popup's immediate
parent, which is right for a top-level menu but, for a submenu, is the
menu it opened from. That shifted the screen by the menu's offset in the
window, so Chrome's tall three-dot submenus slid past the top of the
screen and submenus near the right edge failed to flip.

Each popup now carries its root's tree (XdgPopup::root_tree), inherited
by every submenu under it, and the box is measured from there.

verify/popup-constrain-test drives a new test client, popup-nest (a
window with a menu and a nested submenu asking to flip and slide the way
Chrome does), and asserts both cases land fully on screen. Both fail on
the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                            |  15 ++
 src/server/layer_shell.rs            |   7 +-
 src/server/xdg_popup.rs              |  27 +++-
 src/server/xdg_toplevel.rs           |   1 +
 verify/clients/Cargo.toml            |   4 +
 verify/clients/src/bin/popup_nest.rs | 264 +++++++++++++++++++++++++++++++++++
 verify/popup-constrain-test          |  72 ++++++++++
 7 files changed, 386 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7915901d..cf5c0639 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -256,6 +256,21 @@ own `target/`, invisible to ccebuild), built on demand by the drivers:
   the status socket's `dismiss` topic, prints one line per push, and shrinks
   to a bar strip on the first one — reacting the way the real bar does.
 
+- **`popup-nest`** — a fixed-size window with a menu (`xdg_popup`) and a
+  submenu nested on it, both asking to flip sideways and slide vertically
+  the way Chrome's three-dot menu does, printing each popup's configured
+  position relative to its parent. `--menu-at`, `--menu`, `--sub-at` and
+  `--sub` move and size them.
+
+`./verify/popup-constrain-test` drives `popup-nest` to prove submenus are
+fitted to the real screen (`xdg_popup.rs::handle_reposition`): a tall
+submenu low in the window slides up to fit, and one near the right edge
+flips left. wlroots wants the unconstrain box in the ROOT window's surface
+coordinates, which each popup finds through `XdgPopup::root_tree`; until
+2026-09-25 the box was measured from a submenu's parent menu, which pushed
+Chrome's tall submenus past the top of the screen and stopped them flipping
+at the right edge.
+
 `./verify/escape-dismiss-test` composes the two to prove all three gates of
 the Escape-closes-status-menus arm (`handle_builtin_binding`): a chorded
 Escape stays out of the arm, a plain Escape while expanded pushes exactly one
diff --git a/src/server/layer_shell.rs b/src/server/layer_shell.rs
index c6e7954c..255a27c9 100644
--- a/src/server/layer_shell.rs
+++ b/src/server/layer_shell.rs
@@ -854,7 +854,12 @@ unsafe extern "C" fn handle_layer_surface_new_popup(listener: *mut ffi::wl_liste
     let layer_surface = crate::container_of!(listener, LayerSurface, new_popup);
     let wlr_xdg_popup = data as *mut ffi::wlr_xdg_popup;
 
-    if let Err(e) = XdgPopup::create(wlr_xdg_popup, (*layer_surface).popup_tree, std::ptr::null_mut()) {
+    if let Err(e) = XdgPopup::create(
+        wlr_xdg_popup,
+        (*layer_surface).popup_tree,
+        std::ptr::null_mut(),
+        (*layer_surface).popup_tree,
+    ) {
         log::error!("Failed to create layer surface popup: {}", e);
         ffi::wl_resource_post_no_memory((*wlr_xdg_popup).resource);
     }
diff --git a/src/server/xdg_popup.rs b/src/server/xdg_popup.rs
index 8153c96c..eb1bc8fd 100644
--- a/src/server/xdg_popup.rs
+++ b/src/server/xdg_popup.rs
@@ -8,6 +8,12 @@ pub struct XdgPopup {
     pub wlr_popup: *mut ffi::wlr_xdg_popup,
     pub tree: *mut ffi::wlr_scene_tree,
     pub capture_tree: *mut ffi::wlr_scene_tree,
+    /// The scene tree of the popup's ROOT — the window's or layer
+    /// surface's popup tree, which sits at that surface's origin — shared
+    /// by every submenu under it. `handle_reposition` measures the screen
+    /// from here, because wlroots wants the unconstrain box in the root
+    /// toplevel surface's coordinates, not the immediate parent's.
+    pub root_tree: *mut ffi::wlr_scene_tree,
 
     pub destroy: ffi::wl_listener,
     pub commit: ffi::wl_listener,
@@ -16,10 +22,14 @@ pub struct XdgPopup {
 }
 
 impl XdgPopup {
+    /// `root` is the tree the popup's root surface's popups live in: for a
+    /// top-level menu the same tree as `parent`, for a submenu the one its
+    /// parent popup carries.
     pub unsafe fn create(
         wlr_popup: *mut ffi::wlr_xdg_popup,
         parent: *mut ffi::wlr_scene_tree,
         capture_parent: *mut ffi::wlr_scene_tree,
+        root: *mut ffi::wlr_scene_tree,
     ) -> Result<*mut Self, &'static str> {
         let base_surface = ffi::river_wlr_xdg_popup_get_base(wlr_popup);
         let tree = ffi::wlr_scene_xdg_surface_create(parent, base_surface);
@@ -40,6 +50,7 @@ impl XdgPopup {
             wlr_popup,
             tree,
             capture_tree,
+            root_tree: root,
             destroy: std::mem::zeroed(),
             commit: std::mem::zeroed(),
             new_popup: std::mem::zeroed(),
@@ -142,7 +153,7 @@ unsafe extern "C" fn handle_new_popup(listener: *mut ffi::wl_listener, data: *mu
     let popup = crate::container_of!(listener, XdgPopup, new_popup);
     let wlr_xdg_popup = data as *mut ffi::wlr_xdg_popup;
 
-    if let Err(e) = XdgPopup::create(wlr_xdg_popup, (*popup).tree, (*popup).capture_tree) {
+    if let Err(e) = XdgPopup::create(wlr_xdg_popup, (*popup).tree, (*popup).capture_tree, (*popup).root_tree) {
         log::error!("Failed to create nested popup: {}", e);
         ffi::wl_resource_post_no_memory((*wlr_xdg_popup).resource);
     }
@@ -175,10 +186,20 @@ unsafe extern "C" fn handle_reposition(listener: *mut ffi::wl_listener, _data: *
         return;
     }
 
+    // The box goes to wlroots in the ROOT surface's coordinates. For a
+    // top-level menu the parent is the root; for a submenu it is the
+    // menu, and measuring from the menu's corner (as this did until
+    // 2026-09-25) shifted the screen up and left by the menu's offset in
+    // the window, so a tall submenu slid past the real top edge and the
+    // flip decisions were made against the wrong right edge.
+    let mut root_lx: i32 = 0;
+    let mut root_ly: i32 = 0;
+    ffi::wlr_scene_node_coords((*popup).root_tree as *mut ffi::wlr_scene_node, &mut root_lx, &mut root_ly);
+
     let mut constraint = std::mem::zeroed();
     ffi::wlr_output_layout_get_box((*server).om.output_layout, wlr_output, &mut constraint);
-    constraint.x -= parent_lx;
-    constraint.y -= parent_ly;
+    constraint.x -= root_lx;
+    constraint.y -= root_ly;
 
     ffi::wlr_xdg_popup_unconstrain_from_box((*popup).wlr_popup, &mut constraint);
     ffi::wlr_xdg_surface_schedule_configure(ffi::river_wlr_xdg_popup_get_base((*popup).wlr_popup));
diff --git a/src/server/xdg_toplevel.rs b/src/server/xdg_toplevel.rs
index 0d84279a..315190a2 100644
--- a/src/server/xdg_toplevel.rs
+++ b/src/server/xdg_toplevel.rs
@@ -518,6 +518,7 @@ unsafe extern "C" fn handle_new_popup(listener: *mut ffi::wl_listener, data: *mu
         wlr_xdg_popup,
         (*window).popup_tree,
         capture_node,
+        (*window).popup_tree,
     ) {
         log::error!("Failed to create popup: {}", e);
         ffi::wl_resource_post_no_memory((*wlr_xdg_popup).resource);
diff --git a/verify/clients/Cargo.toml b/verify/clients/Cargo.toml
index d23774a1..03069f23 100644
--- a/verify/clients/Cargo.toml
+++ b/verify/clients/Cargo.toml
@@ -16,6 +16,10 @@ path = "src/bin/status_stub.rs"
 name = "float-pair"
 path = "src/bin/float_pair.rs"
 
+[[bin]]
+name = "popup-nest"
+path = "src/bin/popup_nest.rs"
+
 [dependencies]
 wayland-client = "0.31"
 wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] }
diff --git a/verify/clients/src/bin/popup_nest.rs b/verify/clients/src/bin/popup_nest.rs
new file mode 100644
index 00000000..27df68dc
--- /dev/null
+++ b/verify/clients/src/bin/popup_nest.rs
@@ -0,0 +1,264 @@
+// popup-nest — a toplevel with a menu and a submenu, the way Chrome's
+// three-dot menu opens "Bookmarks and lists": a top-level xdg_popup
+// anchored in the window, then a nested xdg_popup anchored on the menu's
+// right edge, both asking to flip sideways and slide vertically to fit.
+// It proves the compositor unconstrains a SUBMENU against the real screen:
+// the box wlroots needs is in the root window's coordinates, and measuring
+// it from the parent menu instead pushed tall submenus off the top.
+//
+// Prints one line per milestone, each popup's position RELATIVE TO ITS
+// PARENT as the compositor configured it:
+//   toplevel mapped WxH
+//   menu X Y W H
+//   submenu X Y W H
+//
+// Args: --app-id ID  --size WxH
+//       --menu-at X,Y   anchor point in the window   (default 500,250)
+//       --menu WxH                                     (default 300x200)
+//       --sub-at X,Y    anchor point in the menu      (default 300,180)
+//       --sub WxH                                      (default 200x600)
+
+use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
+use wayland_client::{
+    delegate_noop,
+    protocol::{wl_buffer, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface},
+    Connection, Dispatch, QueueHandle,
+};
+use wayland_protocols::xdg::shell::client::{xdg_popup, xdg_positioner, xdg_surface, xdg_toplevel, xdg_wm_base};
+
+#[derive(Clone, Copy, PartialEq)]
+enum Role {
+    Toplevel,
+    Menu,
+    Submenu,
+}
+
+struct Surf {
+    role: Role,
+    surface: wl_surface::WlSurface,
+    xdg: xdg_surface::XdgSurface,
+    size: (i32, i32),
+    placed: Option<(i32, i32, i32, i32)>,
+    needs_buffer: bool,
+    mapped: bool,
+    color: u32,
+}
+
+#[derive(Default)]
+struct State {
+    compositor: Option<wl_compositor::WlCompositor>,
+    shm: Option<wl_shm::WlShm>,
+    wm_base: Option<xdg_wm_base::XdgWmBase>,
+    surfs: Vec<Surf>,
+    closed: bool,
+}
+
+impl Dispatch<wl_registry::WlRegistry, ()> for State {
+    fn event(
+        state: &mut Self,
+        registry: &wl_registry::WlRegistry,
+        event: wl_registry::Event,
+        _: &(),
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+    ) {
+        if let wl_registry::Event::Global { name, interface, version } = event {
+            match interface.as_str() {
+                "wl_compositor" => {
+                    state.compositor =
+                        Some(registry.bind::<wl_compositor::WlCompositor, _, _>(name, version.min(4), qh, ()))
+                }
+                "wl_shm" => state.shm = Some(registry.bind::<wl_shm::WlShm, _, _>(name, 1, qh, ())),
+                "xdg_wm_base" => {
+                    state.wm_base =
+                        Some(registry.bind::<xdg_wm_base::XdgWmBase, _, _>(name, version.min(3), qh, ()))
+                }
+                _ => {}
+            }
+        }
+    }
+}
+
+impl Dispatch<xdg_wm_base::XdgWmBase, ()> for State {
+    fn event(_: &mut Self, wm: &xdg_wm_base::XdgWmBase, event: xdg_wm_base::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
+        if let xdg_wm_base::Event::Ping { serial } = event {
+            wm.pong(serial);
+        }
+    }
+}
+
+impl Dispatch<xdg_surface::XdgSurface, ()> for State {
+    fn event(state: &mut Self, xdg: &xdg_surface::XdgSurface, event: xdg_surface::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
+        if let xdg_surface::Event::Configure { serial } = event {
+            xdg.ack_configure(serial);
+            if let Some(s) = state.surfs.iter_mut().find(|s| &s.xdg == xdg) {
+                s.needs_buffer = true;
+            }
+        }
+    }
+}
+
+impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
+    fn event(state: &mut Self, _: &xdg_toplevel::XdgToplevel, event: xdg_toplevel::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {
+        if let xdg_toplevel::Event::Close = event {
+            state.closed = true;
+        }
+    }
+}
+
+impl Dispatch<xdg_popup::XdgPopup, Role> for State {
+    fn event(state: &mut Self, _: &xdg_popup::XdgPopup, event: xdg_popup::Event, role: &Role, _: &Connection, _: &QueueHandle<Self>) {
+        match event {
+            xdg_popup::Event::Configure { x, y, width, height } => {
+                if let Some(s) = state.surfs.iter_mut().find(|s| s.role == *role) {
+                    s.placed = Some((x, y, width, height));
+                }
+            }
+            xdg_popup::Event::PopupDone => println!("popup done"),
+            _ => {}
+        }
+    }
+}
+
+delegate_noop!(State: ignore wl_compositor::WlCompositor);
+delegate_noop!(State: ignore wl_shm::WlShm);
+delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
+delegate_noop!(State: ignore wl_buffer::WlBuffer);
+delegate_noop!(State: ignore wl_surface::WlSurface);
+delegate_noop!(State: ignore xdg_positioner::XdgPositioner);
+
+fn make_buffer(shm: &wl_shm::WlShm, w: i32, h: i32, color: u32, qh: &QueueHandle<State>) -> wl_buffer::WlBuffer {
+    let size = (w * 4 * h) as u64;
+    let fd = unsafe { libc::memfd_create(b"popup-nest\0".as_ptr() as *const _, 0) };
+    assert!(fd >= 0, "memfd_create failed");
+    let file = unsafe { std::fs::File::from_raw_fd(fd) };
+    file.set_len(size).unwrap();
+    let mapped = unsafe {
+        libc::mmap(std::ptr::null_mut(), size as usize, libc::PROT_WRITE, libc::MAP_SHARED, file.as_raw_fd(), 0)
+    };
+    assert!(mapped != libc::MAP_FAILED, "mmap failed");
+    unsafe {
+        let px = mapped as *mut u32;
+        for i in 0..(w * h) as usize {
+            *px.add(i) = color;
+        }
+        libc::munmap(mapped, size as usize);
+    }
+    let fd: OwnedFd = OwnedFd::from(file);
+    let pool = shm.create_pool(fd.as_fd(), w * 4 * h, qh, ());
+    pool.create_buffer(0, w, h, w * 4, wl_shm::Format::Argb8888, qh, ())
+}
+
+fn pair(s: &str, sep: char) -> (i32, i32) {
+    let (a, b) = s.split_once(sep).expect("pair");
+    (a.parse().unwrap(), b.parse().unwrap())
+}
+
+fn main() {
+    let mut app_id = "test.popupnest".to_string();
+    let mut size = (800, 500);
+    let mut menu_at = (500, 250);
+    let mut menu = (300, 200);
+    let mut sub_at = (300, 180);
+    let mut sub = (200, 600);
+    let mut args = std::env::args().skip(1);
+    while let Some(a) = args.next() {
+        match a.as_str() {
+            "--app-id" => app_id = args.next().unwrap(),
+            "--size" => size = pair(&args.next().unwrap(), 'x'),
+            "--menu-at" => menu_at = pair(&args.next().unwrap(), ','),
+            "--menu" => menu = pair(&args.next().unwrap(), 'x'),
+            "--sub-at" => sub_at = pair(&args.next().unwrap(), ','),
+            "--sub" => sub = pair(&args.next().unwrap(), 'x'),
+            other => panic!("unknown arg {other}"),
+        }
+    }
+
+    let conn = Connection::connect_to_env().expect("connect to wayland display");
+    let mut queue = conn.new_event_queue();
+    let qh = queue.handle();
+    let _registry = conn.display().get_registry(&qh, ());
+    let mut state = State::default();
+    queue.roundtrip(&mut state).unwrap();
+    let compositor = state.compositor.clone().expect("no wl_compositor");
+    let shm = state.shm.clone().expect("no wl_shm");
+    let wm = state.wm_base.clone().expect("no xdg_wm_base");
+
+    // The toplevel insists on its size, so the popup arithmetic is fixed.
+    let surface = compositor.create_surface(&qh, ());
+    let xdg = wm.get_xdg_surface(&surface, &qh, ());
+    let toplevel = xdg.get_toplevel(&qh, ());
+    toplevel.set_app_id(app_id);
+    toplevel.set_title("popup-nest".into());
+    toplevel.set_min_size(size.0, size.1);
+    toplevel.set_max_size(size.0, size.1);
+    surface.commit();
+    state.surfs.push(Surf { role: Role::Toplevel, surface, xdg, size, placed: None, needs_buffer: false, mapped: false, color: 0xff2a_6f97 });
+
+    // What Chrome asks for: open to the lower right of the anchor point,
+    // flip sideways and slide vertically when that does not fit.
+    let adjust = xdg_positioner::ConstraintAdjustment::FlipX | xdg_positioner::ConstraintAdjustment::SlideY;
+    let mut menu_open = false;
+    let mut sub_open = false;
+
+    while !state.closed {
+        conn.flush().unwrap();
+        if let Some(guard) = conn.prepare_read() {
+            let mut pfd = libc::pollfd { fd: guard.connection_fd().as_raw_fd(), events: libc::POLLIN, revents: 0 };
+            if unsafe { libc::poll(&mut pfd, 1, 50) } > 0 && (pfd.revents & libc::POLLIN) != 0 {
+                let _ = guard.read();
+            }
+        }
+        queue.dispatch_pending(&mut state).unwrap();
+
+        for s in state.surfs.iter_mut() {
+            if !s.needs_buffer {
+                continue;
+            }
+            s.needs_buffer = false;
+            let (w, h) = match (s.role, s.placed) {
+                (Role::Toplevel, _) => s.size,
+                (_, Some((_, _, w, h))) if w > 0 && h > 0 => (w, h),
+                _ => s.size,
+            };
+            let buf = make_buffer(&shm, w, h, s.color, &qh);
+            s.surface.attach(Some(&buf), 0, 0);
+            s.surface.damage_buffer(0, 0, w, h);
+            s.surface.commit();
+            if !s.mapped {
+                s.mapped = true;
+                match (s.role, s.placed) {
+                    (Role::Toplevel, _) => println!("toplevel mapped {}x{}", w, h),
+                    (Role::Menu, Some((x, y, w, h))) => println!("menu {x} {y} {w} {h}"),
+                    (Role::Submenu, Some((x, y, w, h))) => println!("submenu {x} {y} {w} {h}"),
+                    _ => {}
+                }
+            }
+        }
+
+        let mapped = |state: &State, role: Role| state.surfs.iter().any(|s| s.role == role && s.mapped);
+        let open = |state: &mut State, role: Role, parent: &xdg_surface::XdgSurface, at: (i32, i32), sz: (i32, i32), color: u32| {
+            let pos = wm.create_positioner(&qh, ());
+            pos.set_size(sz.0, sz.1);
+            pos.set_anchor_rect(at.0, at.1, 1, 1);
+            pos.set_anchor(xdg_positioner::Anchor::BottomRight);
+            pos.set_gravity(xdg_positioner::Gravity::BottomRight);
+            pos.set_constraint_adjustment(adjust);
+            let surface = compositor.create_surface(&qh, ());
+            let xdg = wm.get_xdg_surface(&surface, &qh, ());
+            let _popup = xdg.get_popup(Some(parent), &pos, &qh, role);
+            surface.commit();
+            state.surfs.push(Surf { role, surface, xdg, size: sz, placed: None, needs_buffer: false, mapped: false, color });
+        };
+        if !menu_open && mapped(&state, Role::Toplevel) {
+            menu_open = true;
+            let parent = state.surfs[0].xdg.clone();
+            open(&mut state, Role::Menu, &parent, menu_at, menu, 0xffe0_e0e0);
+        }
+        if !sub_open && mapped(&state, Role::Menu) {
+            sub_open = true;
+            let parent = state.surfs.iter().find(|s| s.role == Role::Menu).unwrap().xdg.clone();
+            open(&mut state, Role::Submenu, &parent, sub_at, sub, 0xffd9_8c2b);
+        }
+    }
+}
diff --git a/verify/popup-constrain-test b/verify/popup-constrain-test
new file mode 100755
index 00000000..6a8cbce2
--- /dev/null
+++ b/verify/popup-constrain-test
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# popup-constrain-test — behavioral test for submenu unconstraining in
+# xdg_popup.rs::handle_reposition. wlroots wants the unconstrain box in the
+# ROOT window's surface coordinates; measuring it from a submenu's parent
+# menu instead shifted the screen by the menu's offset, so Chrome's
+# three-dot submenus were pushed past the top edge or failed to flip.
+#
+# Setup: a private cce-shadow instance and verify/clients' popup-nest — a
+# fixed-size window at a known spot with a menu and a nested submenu, each
+# asking to flip sideways and slide vertically (what Chrome asks for).
+# Two cases, each asserting the submenu lands fully on the 1280x720 output:
+#
+#   1. a tall submenu off a menu low in the window: must slide up to fit
+#   2. a submenu off a menu near the right edge: must flip to the left
+#
+# Extra args pass to `cce-shadow start`, so `--bin ../target/release/cce-fx`
+# pins the tree's own build.
+
+set -euo pipefail
+
+here=$(cd "$(dirname "$0")" && pwd)
+repo=$(dirname "$here")
+shadow="$repo/scripts/cce-shadow"
+out=$(mktemp)
+inst=""
+fails=0
+
+cleanup() {
+    [ -n "$inst" ] && "$shadow" --instance "$inst" stop >/dev/null 2>&1 || true
+    rm -f "$out"
+}
+trap cleanup EXIT
+
+echo "==> building test clients"
+cargo build --quiet --manifest-path "$here/clients/Cargo.toml"
+client="$here/clients/target/debug/popup-nest"
+
+run_case() {
+    local label=$1; shift
+    : >"$out"
+    inst=$("$shadow" start --new "${start_args[@]}" 2>&1 | sed -n "s/.*claimed instance '\([^']*\)'.*/\1/p")
+    [ -n "$inst" ] || { echo "FAIL: could not claim a shadow instance"; exit 1; }
+    sc() { "$shadow" --instance "$inst" "$@"; }
+    sc ctl idle timeouts 0 0 >/dev/null
+    sc ctl place-next test.popupnest 100 100 >/dev/null
+    sc run "$client" "$@" >"$out" 2>&1 &
+    for _ in $(seq 1 50); do grep -q '^submenu' "$out" && break; sleep 0.2; done
+    sleep 0.5
+    local wx wy mx my sx sy sw sh _x
+    read -r wx wy < <(sc ctl windows --json | jq -r 'select(.app_id=="test.popupnest") | "\(.x) \(.y)"')
+    read -r _x mx my _ _ < <(grep '^menu' "$out") || true
+    read -r _x sx sy sw sh < <(grep '^submenu' "$out") || true
+    if [ -z "${sx:-}" ]; then
+        echo "FAIL: $label: no submenu configure"; cat "$out"; fails=$((fails + 1))
+    else
+        local lx=$((wx + mx + sx)) ly=$((wy + my + sy))
+        if [ $lx -ge 0 ] && [ $ly -ge 0 ] && [ $((lx + sw)) -le 1280 ] && [ $((ly + sh)) -le 720 ]; then
+            echo "PASS: $label: submenu at x $lx..$((lx + sw)) y $ly..$((ly + sh))"
+        else
+            echo "FAIL: $label: submenu at x $lx..$((lx + sw)) y $ly..$((ly + sh)), off the 1280x720 output"
+            fails=$((fails + 1))
+        fi
+    fi
+    sc stop >/dev/null 2>&1 || true
+    inst=""
+}
+
+start_args=("$@")
+run_case "tall submenu slides up" --menu-at 500,250 --sub 200x600
+run_case "submenu near the right edge flips left" --menu-at 700,100 --sub 200x300
+
+[ $fails -eq 0 ] && echo "all passed" || { echo "$fails failed"; exit 1; }