collective

Browse / github.com/storytold/figcraft / patch

feat(ui): add context menu to customize highlight colors for main frames and layers

feat(ui): add context menu to customize highlight colors for main frames and layers ## Summary - Highlights top-level frames, sections, and groups in the layers panel so they are easily distinguishable when child layers are expanded. - Adds a **Highlight color** submenu to the layer context menu (in both the layers panel and canvas) with a curated palette of harmonious colors (Default, Blue, Purple, Pink, Red, Orange, Amber, Green, Teal, Gray). - Supports setting a highlight color for selected layer(s), applying a color to all main frames, and resetting to default (scoped cleanly to selected layers and main frames). - Uses theme-aware dynamic accent colors for the default se

Status
open
Score
0.00
Adopters
0 other operators
Author
amorsoftlab (imported from pull request #941, open)
Base
d9c924c3ba7114dff9141c667aec56d3764f3af9
Size
+199 −11 in 7 files
Review
pending
Shared
2026-10-28
Id
89414b7903471d6f1c1ff822

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/ui-egui/locales/en.toml crates/ui-egui/locales/es.toml crates/ui-egui/src/layers.rs crates/ui-egui/src/layers_behaviour_tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -67,3 +67,7 @@ sync_github_name = true  [people.AganzeFelicite] sync_github_name = true++[people.amorsoftlab]+real_name = "Jaseem"+sync_github_name = truediff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml--- a/crates/ui-egui/locales/en.toml+++ b/crates/ui-egui/locales/en.toml@@ -96,6 +96,7 @@ name = "English" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" "Alt+drag" = "Alt+drag"+"Amber" = "Amber" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "An analytics screen on a grid: figures, charts drawn from vectors and a table" "An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins" "Anchor" = "Anchor"@@ -112,6 +113,7 @@ name = "English" "Anyone with this code can look at the file, not change it." = "Anyone with this code can look at the file, not change it." "Appearance" = "Appearance" "Apply a component property" = "Apply a component property"+"Apply to all main frames" = "Apply to all main frames" "Apply variable" = "Apply variable" "Apply variable to text" = "Apply variable to text" "Apply variable to visibility" = "Apply variable to visibility"@@ -588,6 +590,7 @@ name = "English" "Hide section contents" = "Hide section contents" "Hide these tips (Getting started in the Help menu shows them again)" = "Hide these tips (Getting started in the Help menu shows them again)" "Hide when publishing" = "Hide when publishing"+"Highlight color" = "Highlight color" "Highlight layers on hover" = "Highlight layers on hover" "Highlights" = "Highlights" "Hold Alt" = "Hold Alt"@@ -989,6 +992,7 @@ name = "English" "Pick one font family first" = "Pick one font family first" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Pick the Frame tool, then drag on the canvas or choose a device size" "Pin a property" = "Pin a property"+"Pink" = "Pink" "Pinch" = "Pinch" "Pixel grid" = "Pixel grid" "Pixel preview 1x" = "Pixel preview 1x"@@ -1047,6 +1051,7 @@ name = "English" "Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publishing to a server needs the desktop app; in the browser you can join a server's files" "Publishing with the server's key." = "Publishing with the server's key." "Publishing…" = "Publishing…"+"Purple" = "Purple" "Push" = "Push" "Push changes to main component" = "Push changes to main component" "Put text on path" = "Put text on path"@@ -1120,6 +1125,7 @@ name = "English" "Reset all overrides on this layer" = "Reset all overrides on this layer" "Reset overrides" = "Reset overrides" "Reset slot" = "Reset slot"+"Reset to default" = "Reset to default" "Reset this override" = "Reset this override" "Reset to style" = "Reset to style" "Reset to the inherited value" = "Reset to the inherited value"@@ -1357,6 +1363,7 @@ name = "English" "Take the values from the first selected layer" = "Take the values from the first selected layer" "Taper" = "Taper" "Taper both ends" = "Taper both ends"+"Teal" = "Teal" "Tell the server's operator what is wrong with it" = "Tell the server's operator what is wrong with it" "Temperature" = "Temperature" "Template" = "Template"diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml--- a/crates/ui-egui/locales/es.toml+++ b/crates/ui-egui/locales/es.toml@@ -99,6 +99,7 @@ status = "draft — needs native review" "Alpha" = "Alfa" "Alphabetical" = "Alfabético" "Alt+drag" = "Alt+arrastrar"+"Amber" = "Ámbar" "An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Una pantalla de analítica en cuadrícula: cifras, gráficos dibujados con vectores y una tabla" "An invite link that opens FigCraft and joins" = "Un enlace de invitación que abre FigCraft y se une" "Anchor" = "Ancla"@@ -115,6 +116,7 @@ status = "draft — needs native review" "Anyone with this code can look at the file, not change it." = "Cualquiera con este código puede ver el archivo, no cambiarlo." "Appearance" = "Apariencia" "Apply a component property" = "Aplicar una propiedad de componente"+"Apply to all main frames" = "Aplicar a todos los marcos principales" "Apply variable" = "Aplicar variable" "Apply variable to text" = "Aplicar variable al texto" "Apply variable to visibility" = "Aplicar variable a la visibilidad"@@ -589,6 +591,7 @@ status = "draft — needs native review" "Hide section contents" = "Ocultar contenido de la sección" "Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estos consejos (Primeros pasos en el menú Ayuda los vuelve a mostrar)" "Hide when publishing" = "Ocultar al publicar"+"Highlight color" = "Color de resaltado" "Highlight layers on hover" = "Resaltar capas al pasar el cursor" "Highlights" = "Luces" "Hold Alt" = "Mantén Alt"@@ -990,6 +993,7 @@ status = "draft — needs native review" "Pick one font family first" = "Elige primero una familia de fuentes" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Elige la herramienta Marco y arrastra en el lienzo o escoge un tamaño de dispositivo" "Pin a property" = "Fijar una propiedad"+"Pink" = "Rosa" "Pinch" = "Pellizco" "Pixel grid" = "Cuadrícula de píxeles" "Pixel preview 1x" = "Vista previa de píxeles 1x"@@ -1048,6 +1052,7 @@ status = "draft — needs native review" "Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar en un servidor necesitas la app de escritorio; en el navegador puedes unirte a archivos de un servidor" "Publishing with the server's key." = "Se publica con la clave del servidor." "Publishing…" = "Publicando…"+"Purple" = "Morado" "Push" = "Empujar" "Push changes to main component" = "Enviar cambios al componente principal" "Put text on path" = "Poner texto en trazado"@@ -1121,6 +1126,7 @@ status = "draft — needs native review" "Reset all overrides on this layer" = "Restablecer todas las modificaciones de esta capa" "Reset overrides" = "Restablecer modificaciones" "Reset slot" = "Restablecer ranura"+"Reset to default" = "Restablecer a predeterminado" "Reset this override" = "Restablecer esta modificación" "Reset to style" = "Restablecer al estilo" "Reset to the inherited value" = "Restablecer al valor heredado"@@ -1358,6 +1364,7 @@ status = "draft — needs native review" "Take the values from the first selected layer" = "Tomar los valores de la primera capa seleccionada" "Taper" = "Afinado" "Taper both ends" = "Afinar ambos extremos"+"Teal" = "Verde azulado" "Tell the server's operator what is wrong with it" = "Explica al operador del servidor qué tiene de malo" "Temperature" = "Temperatura" "Template" = "Plantilla"diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs--- a/crates/ui-egui/src/layers.rs+++ b/crates/ui-egui/src/layers.rs@@ -690,6 +690,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut     } } +/// The layers list panel: tree view of nodes under the current page with search, expand/collapse,+/// visibility and lock toggles, renaming, reordering, and context menu dispatch. fn layers(app: &mut FigApp, ui: &mut Ui) {     let t = Tokens::get(ui.ctx());     let w = ui.available_width();@@ -710,6 +712,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {         search_field(ui, &t, w, search_id, tr("Find layers"), &mut app.ui.layer_filter, &mut app.ui.layer_search, sresp.clicked());     }     let Ok(ds) = app.session.ds() else { return };+    let doc_uid = ds.uid;     let doc = ds.doc.clone();     let page = ds.page;     let sel = ds.selection.clone();@@ -849,6 +852,13 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {             if over {                 hovered_row = Some(row.id);             }+            let is_main_group = crate::menus::is_main_level_node(&doc, row.id);+            let custom_color = app+                .ui+                .layer_colors+                .get(&(doc_uid, row.id))+                .copied()+                .or_else(|| is_main_group.then(|| app.ui.main_group_color.get(&doc_uid).copied().unwrap_or(t.accent_text)));             let x0 = row_x0(rr, row.depth);             // Expand triangle.             if row.has_kids {@@ -856,7 +866,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {                 let tresp = ui.interact(tri, ui.id().with(("tri", row.id)), Sense::click());                 let open = app.ui.expanded.contains(&row.id);                 a11y::button(&tresp, if open { tr("Collapse") } else { tr("Expand") });-                icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary);+                let tri_col = custom_color.unwrap_or(t.icon_secondary);+                icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, tri_col);                 if tresp.clicked() {                     if ui.input(|i| i.modifiers.alt) {                         deep = Some((row.id, !open));@@ -867,8 +878,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {             }             let icon = node_icon(&doc, row.id);             let shown = n.visible && !row.dimmed;-            let icon_col = if is_comp || icon == Icon::Slot {+            let icon_col = if let Some(col) = app.ui.layer_colors.get(&(doc_uid, row.id)).copied() {+                col.gamma_multiply(if shown { 1.0 } else { 0.5 })+            } else if is_comp || icon == Icon::Slot {                 COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })+            } else if let Some(col) = custom_color {+                col.gamma_multiply(if shown { 1.0 } else { 0.5 })             } else if shown {                 t.icon             } else {@@ -881,20 +896,18 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {                     rename_done = Some((row.id, new));                 }             } else {-                let col = if is_comp {+                let col = if let Some(col) = app.ui.layer_colors.get(&(doc_uid, row.id)).copied() {+                    col.gamma_multiply(if shown { 1.0 } else { 0.5 })+                } else if is_comp {                     COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })+                } else if let Some(col) = custom_color {+                    col.gamma_multiply(if shown { 1.0 } else { 0.5 })                 } else if shown {                     t.text                 } else {                     t.text_tertiary                 };-                let font = if doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page())-                    && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))-                {-                    semibold(11.0)-                } else {-                    regular(11.0)-                };+                let font = if custom_color.is_some() || is_comp { semibold(11.0) } else { regular(11.0) };                 let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() };                 // Room left of the lock and eye buttons.                 let max_w = (rr.max.x - 56.0 - name_x).max(20.0);@@ -949,7 +962,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {                     clicked = Some((row.id, egui::Modifiers::NONE));                 }                 let pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(r.center());-                app.ui.context_menu = Some((pos, vec![row.id]));+                let menu_ids = if sel.contains(&row.id) { sel.clone() } else { vec![row.id] };+                app.ui.context_menu = Some((pos, menu_ids));             }             // Drag to reorder.             if resp.drag_started() {diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs--- a/crates/ui-egui/src/layers_behaviour_tests.rs+++ b/crates/ui-egui/src/layers_behaviour_tests.rs@@ -507,3 +507,25 @@ fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() {     // A panel too short for both keeps the three rows.     assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW); }++#[test]+fn layer_highlight_colors_are_scoped_by_document_and_main_level() {+    let mut s = Session::new();+    let rect = id(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap());+    let frame = id(s.execute("object.frameSelection", &json!({"ids": [rect]})).unwrap());+    let doc = s.doc().unwrap();+    assert!(crate::menus::is_main_level_node(&doc, frame));+    assert!(!crate::menus::is_main_level_node(&doc, rect));++    let uid1 = s.docs[s.active].uid;+    let mut u = Ui::new(s);+    let color = egui::Color32::from_rgb(0x3B, 0x82, 0xF6);+    u.app.ui.layer_colors.insert((uid1, rect), color);+    u.app.ui.main_group_color.insert(uid1, color);++    let uid2 = uid1 + 999;+    assert_eq!(u.app.ui.layer_colors.get(&(uid1, rect)).copied(), Some(color));+    assert_eq!(u.app.ui.layer_colors.get(&(uid2, rect)).copied(), None);+    assert_eq!(u.app.ui.main_group_color.get(&uid1).copied(), Some(color));+    assert_eq!(u.app.ui.main_group_color.get(&uid2).copied(), None);+}diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -551,6 +551,10 @@ pub struct UiState {     pub preview: Option<present::Pane>,     pub picker: Option<colorpicker::PickerTarget>,     pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,+    /// Custom layer highlight colors assigned by the user in the context menu, per open file (tab `uid`).+    pub layer_colors: std::collections::HashMap<(u64, NodeId), egui::Color32>,+    /// Highlight color for the top-level frames, sections and groups of each open file (tab `uid`).+    pub main_group_color: std::collections::HashMap<u64, egui::Color32>,     /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the     /// focused layer row or the canvas selection and takes the keyboard.     pub key_menu: bool,@@ -700,6 +704,8 @@ impl Default for UiState {             preview: None,             picker: None,             context_menu: None,+            layer_colors: std::collections::HashMap::new(),+            main_group_color: std::collections::HashMap::new(),             key_menu: false,             key_box: None,             layer_search: false,diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -757,6 +757,7 @@ const TOP_MENUS: &[&str] = &[     tr_noop("Help"), ]; +/// Draws active popups: the main menu, the layer and canvas context menus, and the Actions palette. pub fn popups(app: &mut FigApp, ctx: &egui::Context) {     let t = Tokens::get(ctx);     // A closed menu forgets its open submenus (and a context menu opened somewhere else starts@@ -910,6 +911,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {                 {                     entries.insert(at + 1, entry);                 }+                if has_sel && let Some(at) = entries.iter().rposition(|e| e.1 == "object.toggleLock" || e.1 == "section.toggleContents") {+                    entries.insert(at + 1, (tr("Highlight color"), "ui.highlightColor"));+                }                 // A right-clicked ruler guide (selected, with no layers) can be removed.                 if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) {                     entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);@@ -919,6 +923,12 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {                         separator(ui);                         continue;                     }+                    if *id == "ui.highlightColor" {+                        if highlight_color_menu(app, ui, &ids, &t) {+                            close = true;+                        }+                        continue;+                    }                     let sc = crate::shortcuts::shortcut(id).unwrap_or("");                     if item(ui, label, sc, available(id), false, &t) {                         if *id == "edit.paste" {@@ -994,6 +1004,124 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens)     } } +/// A menu item with a color swatch indicator on the left.+fn color_item(ui: &mut Ui, label: &str, color: Color32, checked: bool, t: &Tokens) -> bool {+    let w = ui.available_width();+    let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());+    a11y::menu_item(&resp, label, "", checked.then_some(true));+    a11y::own_focus(&resp);+    let hov = plain_row_hover(ui, &resp);+    if hov {+        ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);+    }+    let swatch_rect = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(12.0, 12.0));+    ui.painter().rect_filled(swatch_rect, CornerRadius::same(3), color);+    if checked {+        icons::paint(ui.painter(), swatch_rect.shrink(1.0), Icon::Check, Color32::WHITE);+    }+    let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), t.menu_text);+    need_width(ui, 32.0 + galley.size().x + 20.0);+    ui.painter().galley(pos2(r.min.x + 30.0, r.center().y - galley.size().y / 2.0), galley, t.menu_text);+    resp.clicked()+}++/// Returns true if a node is a top-level frame-like, section, or group directly on a page.+pub(crate) fn is_main_level_node(doc: &figcraft_doc::Document, id: figcraft_doc::NodeId) -> bool {+    doc.node(id).is_some_and(|n| {+        n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page())+            && (n.kind.is_frame_like() || matches!(n.kind, figcraft_doc::NodeKind::Section { .. } | figcraft_doc::NodeKind::Group))+    })+}++/// The context menu's "Highlight color" submenu to customize layer / main frame colors.+fn highlight_color_menu(app: &mut FigApp, ui: &mut Ui, ids: &[figcraft_doc::NodeId], t: &Tokens) -> bool {+    enum Action {+        Set(Option<Color32>),+        ApplyAll(Option<Color32>),+        Reset,+    }+    let doc_uid = app.session.ds().ok().map(|ds| ds.uid).unwrap_or(0);+    let first_id =+        ids.iter().copied().find(|id| app.session.doc().ok().is_some_and(|doc| is_main_level_node(doc, *id))).or_else(|| ids.first().copied());+    let first_is_main = first_id.is_some_and(|id| app.session.doc().ok().is_some_and(|doc| is_main_level_node(doc, id)));+    let current_custom = first_id.and_then(|id| app.ui.layer_colors.get(&(doc_uid, id)).copied()).or(if first_is_main {+        app.ui.main_group_color.get(&doc_uid).copied()+    } else {+        None+    });++    let picked = submenu_at(ui, Id::new("ctx_highlight_color"), "highlight_color", tr("Highlight color"), 4.0, true, t, |ui| {+        let mut act = None;+        let colors: [(&str, Option<Color32>, Color32); 10] = [+            (tr("Default"), None, t.accent_text),+            (tr("Blue"), Some(Color32::from_rgb(0x3B, 0x82, 0xF6)), Color32::from_rgb(0x3B, 0x82, 0xF6)),+            (tr("Purple"), Some(Color32::from_rgb(0xA8, 0x55, 0xF7)), Color32::from_rgb(0xA8, 0x55, 0xF7)),+            (tr("Pink"), Some(Color32::from_rgb(0xEC, 0x48, 0x99)), Color32::from_rgb(0xEC, 0x48, 0x99)),+            (tr("Red"), Some(Color32::from_rgb(0xEF, 0x44, 0x44)), Color32::from_rgb(0xEF, 0x44, 0x44)),+            (tr("Orange"), Some(Color32::from_rgb(0xF9, 0x73, 0x16)), Color32::from_rgb(0xF9, 0x73, 0x16)),+            (tr("Amber"), Some(Color32::from_rgb(0xF5, 0x9E, 0x0B)), Color32::from_rgb(0xF5, 0x9E, 0x0B)),+            (tr("Green"), Some(Color32::from_rgb(0x10, 0xB9, 0x81)), Color32::from_rgb(0x10, 0xB9, 0x81)),+            (tr("Teal"), Some(Color32::from_rgb(0x14, 0xB8, 0xA6)), Color32::from_rgb(0x14, 0xB8, 0xA6)),+            (tr("Gray"), Some(Color32::from_rgb(0x94, 0xA3, 0xB8)), Color32::from_rgb(0x94, 0xA3, 0xB8)),+        ];+        for (name, val, swatch_col) in colors {+            let is_checked = current_custom == val;+            if color_item(ui, name, swatch_col, is_checked, t) {+                act = Some(Action::Set(val));+            }+        }+        separator(ui);+        if item(ui, tr("Apply to all main frames"), "", true, false, t) {+            act = Some(Action::ApplyAll(current_custom));+        }+        if item(ui, tr("Reset to default"), "", true, false, t) {+            act = Some(Action::Reset);+        }+        act+    });++    if let Some(act) = picked {+        match act {+            Action::Set(val) => {+                for id in ids {+                    if let Some(col) = val {+                        app.ui.layer_colors.insert((doc_uid, *id), col);+                    } else {+                        app.ui.layer_colors.remove(&(doc_uid, *id));+                    }+                }+            }+            Action::ApplyAll(val) => {+                if let Some(col) = val {+                    app.ui.main_group_color.insert(doc_uid, col);+                } else {+                    app.ui.main_group_color.remove(&doc_uid);+                }+                if let Ok(doc) = app.session.doc() {+                    app.ui.layer_colors.retain(|(uid, id), _| {+                        if *uid != doc_uid {+                            return true;+                        }+                        !is_main_level_node(doc, *id)+                    });+                }+            }+            Action::Reset => {+                for id in ids {+                    app.ui.layer_colors.remove(&(doc_uid, *id));+                }+                let touches_main = app.session.doc().ok().is_some_and(|doc| ids.iter().any(|i| is_main_level_node(doc, *i)));+                if touches_main {+                    app.ui.main_group_color.remove(&doc_uid);+                }+            }+        }+        true+    } else {+        false+    }+}+ /// The Actions palette's content height (the frame adds its own 8 px margins). const PALETTE_H: f32 = 450.0;