git.lucas.co / cce-data-editor
structured data editor
git clone https://git.lucas.co/cce-data-editor.git

commitcee79e545a12efe821a504c714a21a99682595f5
parent302c6757c7
authorLucas Galante <[email protected]>
date2026-08-06 19:44
feat: File/choice menus overflow the window edge instead of clipping

Opts into the runtime overflow margin: tick sizes the rim from the open
dropdowns FULL popover geometry (stable per open; the animated box
would resize the surface every frame), quantized to 64px steps.
display_list lays out against the frame (surface minus rim) and shifts
all paint through one push_translate bracket; input arrives
pre-translated from the engine, so handlers and widget rects are
untouched.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 57 insertions(+), 3 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index ef34bb8..fb1c898 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -383,6 +383,11 @@ struct DataEditorApp {
     // App state
     current_file_path: Option<std::path::PathBuf>,
     status_message: Option<(String, bool)>,
+    /// Overflow rim (engine `overflow_margin`): the popover overhang past the
+    /// window frame, quantized to 64px steps, 0 while no menu overhangs.
+    /// Recomputed in tick from the dropdowns' FULL popover geometry (the
+    /// animated box would resize the surface per frame).
+    overflow_now: u32,
     // Disk-sync watch: the open file's (mtime, len) and a hash of its content
     // as last loaded/saved. tick() polls once a second; a mismatch means
     // another writer touched the file — auto-reload when the in-memory
@@ -678,6 +683,10 @@ impl DataEditorApp {
 impl Application for DataEditorApp {
     type Message = AppMessage;
 
+    fn overflow_margin(&self) -> u32 {
+        self.overflow_now
+    }
+
     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
         Some(&self.ui_context)
     }
@@ -829,6 +838,7 @@ impl Application for DataEditorApp {
                 raw_json_editor,
                 current_file_path,
                 status_message: None,
+                overflow_now: 0,
                 disk_state,
                 disk_hash,
                 file_outdated: false,
@@ -1171,6 +1181,38 @@ impl Application for DataEditorApp {
             *needs_rebuild = true;
             self.needs_rebuild = true;
         }
+        // Overflow rim for overhanging menus: sized from the FULL popover
+        // geometry (stable per open, unlike the animated box) whenever a
+        // dropdown is open, so the menu extends past the window edge instead
+        // of clipping at the buffer. Quantized so it can't flap.
+        {
+            let mut need = 0.0f32;
+            let (fw, fh) = (self.width as f32, self.height as f32);
+            for (open, geom) in [
+                (self.btn_open.popover_rect().is_some(), self.btn_open.get_popover_geom()),
+                (self.selected_choice_editor.popover_rect().is_some(), self.selected_choice_editor.get_popover_geom()),
+            ] {
+                if !open {
+                    continue;
+                }
+                let (px, py, pw, ph) = geom;
+                need = need
+                    .max(px + pw - fw)
+                    .max(py + ph - fh)
+                    .max(-px)
+                    .max(-py);
+            }
+            let quantized = if need > 0.0 {
+                (((need / 64.0).ceil() * 64.0) as u32).min(512)
+            } else {
+                0
+            };
+            if quantized != self.overflow_now {
+                self.overflow_now = quantized;
+                *needs_rebuild = true;
+                self.needs_rebuild = true;
+            }
+        }
         // One-shot `--select` deep link. Deferred to here (not `new`) because
         // scroll_to_selected_key needs the tree's solved viewport height, which
         // exists only after the first frame has laid out.
@@ -1459,10 +1501,18 @@ impl Application for DataEditorApp {
             self.needs_rebuild = true;
         }
         
-        let size_changed = self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale;
+        // Overflow-margin mode: `size` is the SURFACE (frame + rim on every
+        // side, sized to an overhanging popover). The app stays entirely in
+        // window-frame coordinates — layout against the frame, input arrives
+        // pre-translated — and only the paint output shifts, through the
+        // push_translate bracket below.
+        let ov = self.overflow_now as f32;
+        let frame_w = (size.width as f32 - 2.0 * ov).max(1.0);
+        let frame_h = (size.height as f32 - 2.0 * ov).max(1.0);
+        let size_changed = self.width != frame_w as u32 || self.height != frame_h as u32 || self.scale_factor != scale;
         if self.needs_rebuild || size_changed {
-            self.width = size.width as u32;
-            self.height = size.height as u32;
+            self.width = frame_w as u32;
+            self.height = frame_h as u32;
             self.scale_factor = scale;
             cce_ui::scale::set_scale_factor(scale as f32);
             
@@ -1716,6 +1766,9 @@ impl Application for DataEditorApp {
         }
 
         let mut pc = cce_ui::scene::paint::PaintCtx::new();
+        // The overflow shift: everything below is authored in frame coords;
+        // the rim exists only in the emitted prims. Popped before finish.
+        pc.push_translate(ov, ov);
 
         // The dissolved root Backplate's plate — its exact legacy paint: page-low background
         // at the active backplate opacity, config corner radius (Backplate::color /
@@ -1848,6 +1901,7 @@ impl Application for DataEditorApp {
             }
         }
 
+        pc.pop_translate();
         Some(pc.finish())
     }