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
@@ -67,3 +67,7 @@ sync_github_name = true [people.AganzeFelicite] sync_github_name = true++[people.amorsoftlab]+real_name = "Jaseem"+sync_github_name = true@@ -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"@@ -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"@@ -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() {@@ -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);+}@@ -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,@@ -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;