git.lucas.co / cce-cloud
cloud storage client
git clone https://git.lucas.co/cce-cloud.git

commit820bf57c30cf2944c152d82e5daa7adf880705f5
parenta7b4889e50
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 22:11
feat(json): a menu's marks and page turns are cce-icons glyphs

A JSON popup's buttons now follow cce-ui's context-menu conventions, so
a menu built by a script reads like every other menu in the DE: a label
that begins with "✓ ", "● " or "○ " (context_menu::MARK_*) is drawn with
the check / circle / circle-outline glyph and the text without the mark;
a button whose target_page is lower than its own page is a back row,
chevron-left at its left; any other target_page leads to a page,
chevron-right at its right end. A page with a left glyph reserves the
column on every button, so the labels share an edge, and the popup's
width budgets the glyphs.

No new schema field: the marks are the toolkit's and the direction is
the target_page the protocol already carries. Layouts must stop spelling
their own (" >", "< Back", "[x] "), which cce-desktop-menu and
cce-app-menu do in the same change.

Test: json_menu_marks_and_page_turns_are_glyphs.

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

 CLAUDE.md          |  17 ++++
 src/json_layout.rs | 225 ++++++++++++++++++++++++++++++++++++++++++++---------
 src/main.rs        |  86 +++++++++++++++++++-
 3 files changed, 288 insertions(+), 40 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 1d1cdba..3789238 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -142,6 +142,23 @@ bound with plain `get_keyboard`, and a held Backspace deleted one character.
 - `Json` — a `JsonLayoutConfig` read from stdin builds a widget panel; clicking a
   button prints one JSON object with the button id and every control's state
   (`{"button", "checkboxes", "spinboxes", "colors", "sliders"}`) and closes.
+  A button with `target_page` turns the panel to that page instead.
+  **A button's marks and page turns are cce-icons glyphs**, by cce-ui's
+  context-menu conventions (`json_layout::button_glyphs`; since 2026-10-05):
+  a `text` that BEGINS with `"✓ "`, `"● "` or `"○ "` (`context_menu::MARK_CHECK`
+  / `MARK_ON` / `MARK_OFF`) is drawn with the check / circle / circle-outline
+  glyph and the text without the mark; a `target_page` LOWER than the
+  button's own page is a back row, chevron-left at its left; any other
+  `target_page` leads to a page, chevron-right at its right end. A page where
+  any button has a left glyph reserves the column on all of them, so the
+  labels share an edge, and the popup's width budgets the glyphs. Nothing new
+  in the schema — the marks are the toolkit's, the direction is the
+  `target_page` the protocol already had — so a layout must NOT spell its own
+  (`"Window Mode >"`, `"< Back"`, `"[x] "`), or it shows them twice; a back
+  row's text is a word ("Back"), which is also all that shows if the icon set
+  is missing. `cce-compositor/scripts/cce-desktop-menu` and `cce-app-menu`
+  are the consumers that page. Glyphs draw through `PaintCtx::icon`, whose
+  upload rides the shared queue this app's renderer drains each frame.
 
 Key flags: `-p/--prompt`, `-s/--select <item>`, `-x/-y` (position → forces layer-shell
 anchoring), `--align-right`, `--parent-app-id` (app_id becomes `cce-cloud:<parent>`),
diff --git a/src/json_layout.rs b/src/json_layout.rs
index c3b5af8..5226469 100644
--- a/src/json_layout.rs
+++ b/src/json_layout.rs
@@ -17,6 +17,70 @@ use serde::Deserialize;
 /// rung of the spacing ladder.
 const CLIP_SLACK: f32 = 4.0;
 
+/// A button's glyphs, by cce-ui's context-menu conventions, so a JSON menu
+/// shows its marks and page turns the way every other menu in the DE does:
+///
+/// - a label that BEGINS with `MARK_CHECK` ("✓ "), `MARK_ON` ("● ") or
+///   `MARK_OFF` ("○ ") wears the check, circle or circle-outline glyph at
+///   its left, and the text is drawn without the mark;
+/// - a button with a `target_page` LOWER than its own page turns back, and
+///   wears the chevron-left glyph at its left; one with any other target
+///   leads to a page, and wears the chevron-right glyph at its right end.
+///
+/// Returns `(left glyph, label without its mark, right glyph)`. Every
+/// argument is in the protocol already, so a layout written before the
+/// glyphs needs nothing new — though one that spelled its own marks ("< ",
+/// " >", "[x] ") would now show them twice, which is why the DE's scripts
+/// were rewritten with this.
+pub fn button_glyphs(
+    text: &str,
+    page_idx: usize,
+    target_page: Option<usize>,
+) -> (Option<&'static str>, &str, Option<&'static str>) {
+    let (mark, text) = cce_ui::widget::context_menu::split_mark(text);
+    match target_page {
+        Some(t) if t < page_idx => (Some("chevron-left"), text, None),
+        Some(_) => (mark, text, Some("chevron-right")),
+        None => (mark, text, None),
+    }
+}
+
+/// The side of a mark glyph, and the gap after it, at button font size
+/// `size` — cce-ui's context-menu proportions: a mark about as tall as a
+/// capital, a chevron smaller, since it points rather than labels.
+fn mark_side(size: f32) -> f32 {
+    (size * 0.95).round()
+}
+fn chevron_side(size: f32) -> f32 {
+    (size * 0.8).round()
+}
+const GLYPH_GAP: f32 = 6.0;
+/// Where a capital's middle stands in a line box, as a share of the font
+/// size from its top: what a glyph beside the text is centred on.
+const CAP_MIDDLE: f32 = 0.66;
+
+/// The room a button's glyphs take beside its text: the left column (on
+/// every button of a page where any button has a left glyph, so the labels
+/// share one edge) and the right chevron.
+pub fn glyph_room(lead_column: bool, trail: bool, size: f32) -> f32 {
+    let lead = if lead_column { mark_side(size) + GLYPH_GAP } else { 0.0 };
+    let trail = if trail { GLYPH_GAP + chevron_side(size) } else { 0.0 };
+    lead + trail
+}
+
+/// The button font's size, which a glyph is sized from.
+pub fn button_font_size() -> f32 {
+    cce_ui::layout::parse_font_string(&cce_ui::layout::button_font()).1.unwrap_or(12.0)
+}
+
+/// Whether a page's buttons reserve the left glyph column: any one of them
+/// has a left glyph.
+pub fn page_has_lead(widgets: &[JsonWidgetConfig], page_idx: usize) -> bool {
+    widgets
+        .iter()
+        .any(|w| w.widget_type == "button" && button_glyphs(&w.text, page_idx, w.target_page).0.is_some())
+}
+
 #[derive(Deserialize, Debug, Clone)]
 pub struct JsonWidgetConfig {
     #[serde(rename = "type")]
@@ -110,6 +174,13 @@ pub struct JsonWidget {
     pub label_text: Option<TextLabel>,
     pub page_idx: usize,
     pub target_page: Option<usize>,
+    /// A button's glyphs (see [`button_glyphs`]); `text` is the label
+    /// without its mark.
+    pub lead: Option<&'static str>,
+    pub trail: Option<&'static str>,
+    /// Whether this button reserves the left glyph column ([`page_has_lead`]).
+    pub lead_column: bool,
+    pub justify: Justification,
 }
 
 pub struct JsonLayoutWidget {
@@ -135,10 +206,16 @@ impl JsonLayoutWidget {
             for (page_idx, page) in pages_conf.iter().enumerate() {
                 page_titles.push(page.title.clone());
                 let page_justify = page.justify.unwrap_or(Justification::Center);
+                let lead_column = page_has_lead(&page.widgets, page_idx);
                 for (idx, w_conf) in page.widgets.iter().enumerate() {
                     let id = w_conf.id.clone().unwrap_or_else(|| format!("widget_{}_{}", page_idx, idx));
                     let widget_type = w_conf.widget_type.clone();
-                    let text = w_conf.text.clone();
+                    let (lead, text, trail) = if widget_type == "button" {
+                        button_glyphs(&w_conf.text, page_idx, w_conf.target_page)
+                    } else {
+                        (None, w_conf.text.as_str(), None)
+                    };
+                    let text = text.to_string();
 
                     let widget: JsonControl = match widget_type.as_str() {
                         "checkbox" => {
@@ -202,15 +279,25 @@ impl JsonLayoutWidget {
                         label_text: None,
                         page_idx,
                         target_page: w_conf.target_page,
+                        lead,
+                        trail,
+                        lead_column: lead_column && w_conf.widget_type == "button",
+                        justify: page_justify,
                     });
                 }
             }
         } else if let Some(ref widgets_conf) = config.widgets {
             let global_justify = config.justify.unwrap_or(Justification::Center);
+            let lead_column = page_has_lead(widgets_conf, 0);
             for (idx, w_conf) in widgets_conf.iter().enumerate() {
                 let id = w_conf.id.clone().unwrap_or_else(|| format!("widget_{}", idx));
                 let widget_type = w_conf.widget_type.clone();
-                let text = w_conf.text.clone();
+                let (lead, text, trail) = if widget_type == "button" {
+                    button_glyphs(&w_conf.text, 0, w_conf.target_page)
+                } else {
+                    (None, w_conf.text.as_str(), None)
+                };
+                let text = text.to_string();
 
                 let widget: JsonControl = match widget_type.as_str() {
                     "checkbox" => {
@@ -274,6 +361,10 @@ impl JsonLayoutWidget {
                     label_text: None,
                     page_idx: 0,
                     target_page: w_conf.target_page,
+                    lead,
+                    trail,
+                    lead_column: lead_column && w_conf.widget_type == "button",
+                    justify: global_justify,
                 });
             }
         }
@@ -642,7 +733,7 @@ impl cce_ui::widget::Paint for JsonLayoutWidget {
         // Children through the real paint walk, geometry only: bevel/recess/plate prims
         // survive to the tessellator where the old quad bridges flattened them to fills.
         // Text prims are skipped — every label, container-owned and child-owned alike,
-        // is served by `own_labels_with_bounds` below, and emitting the children's own
+        // is served by `labels_and_glyphs` below, and emitting the children's own
         // labels here as well would double them. Page filtering and the panel clip
         // mirror the dissolved `aggregate_quads` bounds.
         let (bx, by, bw, bh) = self.rect();
@@ -740,7 +831,16 @@ impl cce_ui::widget::Paint for JsonLayoutWidget {
                 pc.recess_edges(rect, (0.0, 0.0, 0.0, 0.0), seam_d, (true, false, true, false));
             });
         }
-        for (tl, bounds) in self.own_labels_with_bounds(&dummy) {
+        let (labels, glyphs) = self.labels_and_glyphs(&dummy);
+        // The buttons' glyphs (see `button_glyphs`), tinted the label's own
+        // colour. A missing icon set draws nothing in their place: the label
+        // beside each is the word it always was.
+        pc.clip(clip, |pc| {
+            for (name, rect, color) in glyphs {
+                pc.icon(name, rect, color);
+            }
+        });
+        for (tl, bounds) in labels {
             pc.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, None, bounds);
         }
     }
@@ -762,47 +862,100 @@ impl cce_ui::widget::Input for JsonLayoutWidget {
     }
 }
 
+/// A glyph to draw: its cce-icons name, rect and tint.
+type GlyphPrim = (&'static str, cce_ui::scene::layout::Rect, [f32; 4]);
+
 impl JsonLayoutWidget {
-    pub(crate) fn own_labels_with_bounds(&self, ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
+    /// Every label on the active page, moved aside for its button's glyphs,
+    /// and the glyphs themselves. A glyph is placed off its label — it is
+    /// sized from the label's font and tinted its colour, and the left one
+    /// stands where the label would have started (left-justified; centred,
+    /// label and glyph are centred together).
+    pub(crate) fn labels_and_glyphs(&self, ctx: &UiContext) -> (Vec<(TextLabel, Option<[f32; 4]>)>, Vec<GlyphPrim>) {
         let mut labels = Vec::new();
+        let mut glyphs = Vec::new();
         let (bx, by, bw, bh) = self.rect();
-
-        let active_page = self.active_page;
         let pad_x = cce_ui::layout::root_plate_inset();
         let content_bounds = Some([bx + pad_x - CLIP_SLACK, by, bx + bw, by + bh]);
-
         for w in &self.widgets {
-            if w.page_idx != active_page {
+            if w.page_idx != self.active_page {
                 continue;
             }
-            let w_labels = if w.widget_type == "checkbox" {
-                if let Some(tl) = &w.label_text {
-                    vec![tl.clone()]
-                } else {
-                    Vec::new()
-                }
-            } else {
-                // The trait text getters are gone: read the child's text off the paint
-                // walk (same prims, fonts dropped — this consumer shapes with its own
-                // control font, as the legacy getter path did).
-                let mut scratch = cce_ui::scene::paint::PaintCtx::new();
-                cce_ui::scene::painter::append_widget_text(ctx, w.widget.as_dyn(), &mut scratch);
-                scratch
-                    .finish()
-                    .items
-                    .into_iter()
-                    .filter_map(|item| match item.prim {
-                        cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, .. } => {
-                            Some(TextLabel { text, x, y, font_size, color })
-                        }
-                        _ => None,
-                    })
-                    .collect()
-            };
-            for l in w_labels {
-                labels.push((l, content_bounds));
+            let mut wl = self.widget_labels(ctx, w);
+            self.place_glyphs(w, wl.first_mut(), &mut glyphs);
+            labels.extend(wl.into_iter().map(|l| (l, content_bounds)));
+        }
+        (labels, glyphs)
+    }
+
+    /// A button's glyphs, placed off its label `tl` (which moves aside for
+    /// the left glyph column).
+    fn place_glyphs(&self, w: &JsonWidget, tl: Option<&mut TextLabel>, glyphs: &mut Vec<GlyphPrim>) {
+        use cce_ui::scene::layout::Rect;
+        if w.widget_type != "button" {
+            return;
+        }
+        let Some(tl) = tl else { return };
+        let size = tl.font_size;
+        let color = [tl.color[0] as f32 / 255.0, tl.color[1] as f32 / 255.0, tl.color[2] as f32 / 255.0, 1.0];
+        let column = if w.lead_column { mark_side(size) + GLYPH_GAP } else { 0.0 };
+        // Level with the text's capitals, not with the row: the button sets
+        // its label a little below the row's middle, and a capital's middle
+        // stands below its line box's (measured in a shadow: the line box's
+        // middle put the glyphs 2 px high at the 12 px button font).
+        let mid = tl.y + size * CAP_MIDDLE;
+        // Where the left column starts, and the label moved aside for it.
+        let col_x = match w.justify {
+            Justification::Left => {
+                let x = tl.x;
+                tl.x += column;
+                x
+            }
+            Justification::Center => {
+                let x = tl.x - column / 2.0;
+                tl.x += column / 2.0;
+                x
             }
+            Justification::Right => tl.x - column,
+        };
+        if let Some(name) = w.lead {
+            let side = if name == "chevron-left" { chevron_side(size) } else { mark_side(size) };
+            let rect = Rect {
+                x: col_x + (mark_side(size) - side) / 2.0,
+                y: mid - side / 2.0,
+                width: side,
+                height: side,
+            };
+            glyphs.push((name, rect, color));
+        }
+        if let Some(name) = w.trail {
+            let side = chevron_side(size);
+            // The button's own text inset from its right edge.
+            let rect = Rect { x: w.x + w.w - 8.0 - side, y: mid - side / 2.0, width: side, height: side };
+            glyphs.push((name, rect, color));
         }
-        labels
+    }
+
+    /// One widget's labels, read off the paint walk.
+    fn widget_labels(&self, ctx: &UiContext, w: &JsonWidget) -> Vec<TextLabel> {
+        if w.widget_type == "checkbox" {
+            return w.label_text.iter().cloned().collect();
+        }
+        // The trait text getters are gone: read the child's text off the paint
+        // walk (same prims, fonts dropped — this consumer shapes with its own
+        // control font, as the legacy getter path did).
+        let mut scratch = cce_ui::scene::paint::PaintCtx::new();
+        cce_ui::scene::painter::append_widget_text(ctx, w.widget.as_dyn(), &mut scratch);
+        scratch
+            .finish()
+            .items
+            .into_iter()
+            .filter_map(|item| match item.prim {
+                cce_ui::scene::paint::Prim::Text { text, x, y, font_size, color, .. } => {
+                    Some(TextLabel { text, x, y, font_size, color })
+                }
+                _ => None,
+            })
+            .collect()
     }
 }
diff --git a/src/main.rs b/src/main.rs
index 019acd9..0ffabf3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1775,6 +1775,20 @@ fn json_widget_desired_width(widget_type: &str, text: &str) -> f32 {
     }
 }
 
+/// [`json_widget_desired_width`] for a widget as configured on page
+/// `page_idx` of `page` (its widgets): a button's width also holds its
+/// glyphs (`json_layout::button_glyphs`), and its text is measured without
+/// the mark the glyph replaces.
+fn json_conf_desired_width(w_conf: &crate::json_layout::JsonWidgetConfig, page: &[crate::json_layout::JsonWidgetConfig], page_idx: usize) -> f32 {
+    use crate::json_layout::{button_font_size, button_glyphs, glyph_room, page_has_lead};
+    if w_conf.widget_type != "button" {
+        return json_widget_desired_width(&w_conf.widget_type, &w_conf.text);
+    }
+    let (_, text, trail) = button_glyphs(&w_conf.text, page_idx, w_conf.target_page);
+    json_widget_desired_width("button", text)
+        + glyph_room(page_has_lead(page, page_idx), trail.is_some(), button_font_size())
+}
+
 impl State {
     fn new(
         conn: &Connection,
@@ -1810,15 +1824,15 @@ impl State {
                     let mut max_widget_w = 120.0f32; // fallback minimum
                     if let Some(ref widgets) = config.widgets {
                         for w_conf in widgets {
-                            let w_w = json_widget_desired_width(&w_conf.widget_type, &w_conf.text);
+                            let w_w = json_conf_desired_width(w_conf, widgets, 0);
                             if w_w > max_widget_w {
                                 max_widget_w = w_w;
                             }
                         }
                     } else if let Some(ref pages) = config.pages {
-                        for page in pages {
+                        for (page_idx, page) in pages.iter().enumerate() {
                             for w_conf in &page.widgets {
-                                let w_w = json_widget_desired_width(&w_conf.widget_type, &w_conf.text);
+                                let w_w = json_conf_desired_width(w_conf, &page.widgets, page_idx);
                                 if w_w > max_widget_w {
                                     max_widget_w = w_w;
                                 }
@@ -2236,7 +2250,16 @@ impl State {
                     if w.page_idx != active_page {
                         continue;
                     }
-                    let w_w = json_widget_desired_width(&w.widget_type, &w.text);
+                    // `w.text` is a button's label without its mark; the
+                    // glyphs take their own room beside it.
+                    let mut w_w = json_widget_desired_width(&w.widget_type, &w.text);
+                    if w.widget_type == "button" {
+                        w_w += crate::json_layout::glyph_room(
+                            w.lead_column,
+                            w.trail.is_some(),
+                            crate::json_layout::button_font_size(),
+                        );
+                    }
                     if w_w > max_widget_w {
                         max_widget_w = w_w;
                     }
@@ -4657,6 +4680,61 @@ mod tests {
         }
     }
 
+    /// A JSON menu's marks and page turns are cce-icons glyphs, by the
+    /// toolkit's context-menu conventions: the label's leading mark is
+    /// drawn as its glyph and not as text, a button turning to a later page
+    /// ends in chevron-right, one turning back starts with chevron-left, and
+    /// the labels of a page with a left glyph share one edge.
+    #[test]
+    fn json_menu_marks_and_page_turns_are_glyphs() {
+        use crate::json_layout::button_glyphs;
+        assert_eq!(button_glyphs("✓ Wrap", 0, None), (Some("check"), "Wrap", None));
+        assert_eq!(button_glyphs("● Tiled", 1, None), (Some("circle"), "Tiled", None));
+        assert_eq!(button_glyphs("○ Floating", 1, None), (Some("circle-outline"), "Floating", None));
+        assert_eq!(button_glyphs("Window Mode", 0, Some(1)), (None, "Window Mode", Some("chevron-right")));
+        assert_eq!(button_glyphs("Back", 1, Some(0)), (Some("chevron-left"), "Back", None));
+        assert_eq!(button_glyphs("Terminal", 0, None), (None, "Terminal", None));
+
+        let json = r#"{"pages": [
+            {"title": "menu", "justify": "left", "widgets": [
+                {"type": "button", "text": "Window Mode", "id": "mode_page", "target_page": 1},
+                {"type": "button", "text": "Close Window", "id": "close"}
+            ]},
+            {"title": "Window Mode", "justify": "left", "widgets": [
+                {"type": "button", "text": "○ Floating", "id": "floating"},
+                {"type": "button", "text": "● Tiled", "id": "tiled"},
+                {"type": "button", "text": "Back", "id": "back", "target_page": 0}
+            ]}
+        ]}"#;
+        let config: JsonLayoutConfig = serde_json::from_str(json).unwrap();
+        let mut layout = JsonLayoutWidget::new(&config);
+        layout.set_rect(0.0, 0.0, 240.0, 300.0);
+        let ctx = cce_ui::context::UiContext::new();
+
+        let (labels, glyphs) = layout.labels_and_glyphs(&ctx);
+        let texts: Vec<&str> = labels.iter().map(|(l, _)| l.text.as_str()).collect();
+        assert_eq!(texts, ["Window Mode", "Close Window"]);
+        let names: Vec<&str> = glyphs.iter().map(|g| g.0).collect();
+        assert_eq!(names, ["chevron-right"]);
+        // The chevron stands at the row's right end.
+        let row = &layout.widgets[0];
+        assert!(glyphs[0].1.x > row.x + row.w / 2.0);
+
+        layout.active_page = 1;
+        layout.layout_children();
+        let (labels, glyphs) = layout.labels_and_glyphs(&ctx);
+        let texts: Vec<&str> = labels.iter().map(|(l, _)| l.text.as_str()).collect();
+        assert_eq!(texts, ["Floating", "Tiled", "Back"]);
+        let names: Vec<&str> = glyphs.iter().map(|g| g.0).collect();
+        assert_eq!(names, ["circle-outline", "circle", "chevron-left"]);
+        // Every label starts after its glyph, on one edge.
+        let edge = labels[0].0.x;
+        for ((l, _), g) in labels.iter().zip(&glyphs) {
+            assert!((l.x - edge).abs() < 0.01, "{} is not on the page's text edge", l.text);
+            assert!(g.1.x + g.1.width <= l.x, "the {} glyph overlaps its label", g.0);
+        }
+    }
+
     #[test]
     fn test_json_layout_parsing() {
         let json_str = r#"{