cloud storage client
git clone https://git.lucas.co/cce-cloud.git
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#"{