From 088693cfd351c8f4056bb782a03d4d8d49d23c58 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:45:56 +0700 Subject: [PATCH] #880: canvas overlays take the green that stands out on the page; white text stays on the darker green --- crates/ui-egui/src/canvas.rs | 96 +++++++++++++++++------------- crates/ui-egui/src/frame_labels.rs | 2 +- crates/ui-egui/src/handles.rs | 19 +++--- crates/ui-egui/src/proto_canvas.rs | 27 ++++++--- crates/ui-egui/src/theme.rs | 36 +++++++++++ 5 files changed, 124 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d79..71dd9e8b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,7 +12,7 @@ use serde_json::json; use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::render_worker::{Done, Job, Worker}; -use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; +use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold}; pub struct CanvasState { pub rect: Rect, @@ -233,6 +233,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { }) .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); + crate::theme::set_canvas_page(&ctx, bg); if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); @@ -247,8 +248,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { let area = Rect::from_two_pos(start, p); if area.width() > 4.0 || area.height() > 4.0 { - painter.rect_filled(area, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_filled(area, CornerRadius::ZERO, accent(&painter).gamma_multiply(0.1)); + painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, accent(&painter)), StrokeKind::Inside); } } crate::dev_mode::draw(app, &painter); @@ -809,11 +810,11 @@ fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: Nod } /// The outline colour for a layer: purple for components, sets and instances and every layer in -/// them, blue otherwise. -fn outline_colour(doc: &Document, id: NodeId) -> Color32 { +/// them, the page's overlay `green` otherwise. +fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Color32 { use figcraft_engine::components::{owning_component, owning_instance}; let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. })); - if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { ACCENT } + if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green } } /// The outline path of shapes (ellipses, vectors) for hover/selection outlines. @@ -946,20 +947,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ && app.session.prefs.highlight_on_hover && app.session.tool.overlay.measure_box.is_none() { - draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5)); + draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5)); } // Inside a boolean group (an operand selected or in vector edit mode): every operand's outline, // so the shapes making the live result stay visible while one of them changes. let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id)); for id in operands.iter().filter(|o| !sel.contains(o)) { - draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0)); + draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0)); } // Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows. if view.mask_outlines || view.frame_outlines || view.show_slices { for id in doc.subtree(page) { let Some(n) = doc.node(id).filter(|n| n.visible) else { continue }; if view.mask_outlines && n.is_mask { - draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0)); + draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0)); } else if view.frame_outlines && n.kind.is_frame_like() { draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0)); } else if view.show_slices @@ -975,20 +976,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(d) = ov.drop_target && let Some(q) = node_quad(app, &doc, d) { - quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0)); + quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0)); } slot_badges(app, painter, &doc, &sel, ov.drop_target); if let Some(t) = ov.swap_target { swap_highlight(app, painter, &doc, t); } if let Some([x0, y0, x1, y1]) = ov.insert_line { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0)); + painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 2.0)); } if let Some([x0, y0, x1, y1]) = ov.drop_cell { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.12)); + painter.rect_filled(r, CornerRadius::same(2), accent(painter).gamma_multiply(0.12)); let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.5), 4.0, 3.0)); } // Vector edit mode, with the transform box around several selected points. // The selection's size label, which measurement labels keep clear of. @@ -1011,13 +1012,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, label); } } // Selection. let editing = ds.text_edit.clone(); if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() { - let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT }; + let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) }; for id in &sel { draw_outline(app, painter, &doc, *id, stroke(col, 1.0)); } @@ -1086,12 +1087,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ { let layout = &text.layout; if let Some(q) = node_quad(app, edoc, te.id) { - quad(painter, q, stroke(ACCENT, 1.0)); + quad(painter, q, stroke(accent(painter), 1.0)); } let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor)); if a != b && preedit.is_none() { for q in text.selection(app, a, b) { - painter.add(Shape::convex_polygon(q.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE)); + painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE)); } } if let Some(p) = preedit { @@ -1122,8 +1123,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // Marquee. if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::ZERO, accent(painter).gamma_multiply(0.1)); + painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside); } // Pen path. if !ov.pen.is_empty() { @@ -1138,7 +1139,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); - painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(*p, 3.5, stroke(accent(painter), 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); @@ -1150,11 +1151,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ [a, h, b, b], false, Color32::TRANSPARENT, - stroke(ACCENT, 1.0), + stroke(accent(painter), 1.0), )); } None => { - painter.line_segment([a, b], stroke(ACCENT, 1.0)); + painter.line_segment([a, b], stroke(accent(painter), 1.0)); } } } @@ -1165,7 +1166,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let c = app.to_screen(x, y); let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32; painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0)); - painter.circle_stroke(c, rs, stroke(ACCENT, 1.5)); + painter.circle_stroke(c, rs, stroke(accent(painter), 1.5)); } // Lasso (vector edit mode). if ov.lasso.len() > 1 { @@ -1173,7 +1174,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(first) = pts.first().copied() { pts.push(first); } - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0)); } // Guides (under the rulers). crate::guides::draw(app, painter); @@ -1207,7 +1208,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect { let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE); let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0)); - painter.rect_filled(r, CornerRadius::same(3), col); + painter.rect_filled(r, CornerRadius::same(3), crate::theme::under_white(col)); painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); r } @@ -1264,7 +1265,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]); let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| { let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1])); - let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT }; + let col = if ghost { accent(painter).gamma_multiply(0.5) } else { accent(painter) }; painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0)); painter.line_segment([a, b], stroke(col, 1.0)); for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] { @@ -1276,7 +1277,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let on = !ghost && ve.width_point == Some(h.index); let hovered = !ghost && hover == Some(WHit::Point { index: h.index }); let r = if hovered { 5.0 } else { 4.0 }; - painter.circle_filled(c, r, if on { ACCENT } else { Color32::WHITE }); + painter.circle_filled(c, r, if on { accent(painter) } else { Color32::WHITE }); painter.circle_stroke(c, r, stroke(col, 1.0)); }; for h in width_handles(doc, ve, zoom) { @@ -1292,9 +1293,9 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig /// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of /// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { - painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.line_segment([v, e], stroke(accent(painter), 1.0)); painter.circle_filled(e, r, fill); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + painter.circle_stroke(e, r, stroke(accent(painter), 1.0)); } /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and @@ -1313,7 +1314,7 @@ fn vector_overlay( // Paint bucket target: hatched like a fill to come. if active && !ov.face.is_empty() { let polys: Vec> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect(); - hatched(painter, &polys, ACCENT); + hatched(painter, &polys, accent(painter)); } if active { region_hatch(app, painter); @@ -1331,7 +1332,7 @@ fn vector_overlay( } else { 1.5 }; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w))); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(accent(painter), w))); } if !points { return; @@ -1339,7 +1340,7 @@ fn vector_overlay( for h in &o.handles { let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); let on = o.selected_handles.contains(&(h.seg, h.start)); - bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { accent(painter) } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); @@ -1347,14 +1348,14 @@ fn vector_overlay( let size = if hovered { 9.0 } else { 7.0 }; let r = Rect::from_center_size(p, vec2(size, size)); let on = o.selected_vertices.contains(&i); - painter.rect_filled(r, CornerRadius::same(5), if on { ACCENT } else { Color32::WHITE }); - painter.rect_stroke(r, CornerRadius::same(5), stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::same(5), if on { accent(painter) } else { Color32::WHITE }); + painter.rect_stroke(r, CornerRadius::same(5), stroke(accent(painter), 1.0), StrokeKind::Inside); if o.anchor == Some(i) { - painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0)); } } if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { - painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0)); } } @@ -1371,7 +1372,7 @@ fn region_hatch(app: &FigApp, painter: &egui::Painter) { let r = painter.clip_rect(); let clip = figcraft_geom::Rect::new(f64::from(r.min.x), f64::from(r.min.y), f64::from(r.max.x), f64::from(r.max.y)); for [a, b] in app.session.vector_region_hatch(view, Some(clip)) { - painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(ACCENT, REGION_HATCH_WIDTH)); + painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(accent(painter), REGION_HATCH_WIDTH)); } } @@ -1386,10 +1387,10 @@ fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figc for r in &arr.regions { for poly in screen(r) { painter.add(Shape::closed_line(poly.clone(), stroke(Color32::from_white_alpha(150), 2.5))); - painter.add(Shape::closed_line(poly, stroke(ACCENT, 1.0))); + painter.add(Shape::closed_line(poly, stroke(accent(painter), 1.0))); } } - let col = if ov.builder_remove { t.danger } else { ACCENT }; + let col = if ov.builder_remove { t.danger } else { accent(painter) }; for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) { hatched(painter, &screen(r), col); } @@ -1499,8 +1500,16 @@ fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel // Highlight the selection's span. if let Some(b) = doc.bounds_of(sel) { let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); - painter.rect_filled(Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); - painter.rect_filled(Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); + painter.rect_filled( + Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); + painter.rect_filled( + Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); } let mut x = (p0.x / step).floor() * step; while x <= p1.x { @@ -1528,3 +1537,8 @@ pub fn fmt(v: f64) -> String { #[cfg(test)] #[path = "zoom_preview_tests.rs"] mod zoom_preview_tests; + +/// The overlay green for the page this painter draws on ([`crate::theme::canvas_accent`]). +fn accent(p: &egui::Painter) -> Color32 { + crate::theme::canvas_accent(p.ctx()) +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 79557141..9e8b2e7a 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -103,7 +103,7 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod let col = if is_comp { COMPONENT } else if selected || hovered { - ACCENT + crate::theme::canvas_accent(painter.ctx()) } else { t.text_secondary }; diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ca38bee7..ec489bf8 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -11,7 +11,7 @@ use figcraft_geom::Point; use serde_json::json; use crate::colorpicker::PickerTarget; -use crate::theme::{ACCENT, semibold}; +use crate::theme::semibold; use crate::{FigApp, RightTab}; pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C); @@ -293,7 +293,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0))); painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); if i == selected { - painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside); + painter.rect_stroke( + r.expand(2.0), + CornerRadius::same(3), + Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())), + StrokeKind::Outside, + ); } } for (i, p) in [a, b, c].iter().enumerate() { @@ -301,7 +306,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { continue; } painter.circle_filled(*p, 5.0, Color32::WHITE); - painter.circle_stroke(*p, 5.0, Stroke::new(1.5, ACCENT)); + painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); } return; } @@ -311,8 +316,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); // The start is hollow (least blur), the end filled (most blur by default). painter.circle_filled(a, 5.0, Color32::WHITE); - painter.circle_stroke(a, 5.0, Stroke::new(1.5, ACCENT)); - painter.circle_filled(b, 5.0, ACCENT); + painter.circle_stroke(a, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); + painter.circle_filled(b, 5.0, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE)); return; } @@ -320,11 +325,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let Some(c) = crop_corners(app, id, r) else { return }; let outline = vec![c[0], c[1], c[2], c[3], c[0]]; painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); - painter.add(Shape::line(outline, Stroke::new(1.0, ACCENT))); + painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); for p in c { let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); - painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); } return; } diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1187944d..97acdeb1 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -8,7 +8,6 @@ use egui::{Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos use figcraft_doc::{Document, NodeId}; use serde_json::{Value, json}; -use crate::theme::ACCENT; use crate::{FigApp, RightTab}; const HANDLE_R: f32 = 7.0; @@ -140,7 +139,7 @@ fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) { } fn plus_handle(painter: &egui::Painter, c: Pos2, hot: bool) { - painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, ACCENT); + painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)], Stroke::new(1.5, Color32::WHITE)); @@ -161,7 +160,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let selected = sel.contains(&nd.source); let is_open = open == Some((nd.source, nd.index)); let near = dragging.is_none() && hover.is_some_and(|q| bezier_distance(&nd.pts, q) < 5.0); - let col = if selected || is_open || near { ACCENT } else { ACCENT.gamma_multiply(0.55) }; + let col = if selected || is_open || near { + crate::theme::canvas_accent(painter.ctx()) + } else { + crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.55) + }; let w = if is_open || near { 2.5 } else if selected { @@ -177,7 +180,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { .and_then(|it| figcraft_doc::proto::walk(&it.actions).find_map(|a| a.dest())) && let Some(r) = screen_rect(app, doc, dest) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, ACCENT.gamma_multiply(0.7)), StrokeKind::Outside); + painter.rect_stroke( + r, + CornerRadius::ZERO, + Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.7)), + StrokeKind::Outside, + ); } painter.add(egui::epaint::CubicBezierShape::from_points_stroke(nd.pts, false, Color32::TRANSPARENT, Stroke::new(w, col))); painter.circle_filled(nd.pts[0], 3.5, col); @@ -190,12 +198,17 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { && doc.top_level_frame(d.source) != Some(f) && let Some(r) = screen_rect(app, doc, f) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, ACCENT), StrokeKind::Outside); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Outside); } let dx = ((d.pos.x - d.from.x).abs() * 0.5).max(30.0) * if d.pos.x >= d.from.x { 1.0 } else { -1.0 }; let pts = [d.from, d.from + vec2(dx, 0.0), d.pos - vec2(dx, 0.0), d.pos]; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke(pts, false, Color32::TRANSPARENT, Stroke::new(2.0, ACCENT))); - arrow(painter, &pts, ACCENT); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + pts, + false, + Color32::TRANSPARENT, + Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), + )); + arrow(painter, &pts, crate::theme::canvas_accent(painter.ctx())); plus_handle(painter, d.from, true); } None => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 01b9e7f5..d81e1380 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -52,6 +52,27 @@ pub const BRAND: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96); pub const ACCENT: Color32 = Color32::from_rgb(0x00, 0x87, 0x5F); /// Ink on the bright green brand surface (white would not have enough contrast). pub const ACCENT_ON: Color32 = Color32::from_rgb(0x07, 0x35, 0x2A); +/// Id of the canvas overlays' green for this frame ([`set_canvas_page`]). +const CANVAS_ACCENT: &str = "figcraft-canvas-accent"; + +/// Remember the page colour the canvas shows. Its overlays (outlines, handles, arrows) take +/// whichever green contrasts more with it: the bright one on a dark page, the darker on a light one. +pub fn set_canvas_page(ctx: &Context, page: Color32) { + let c = |x: Color32| figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0); + let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT }; + ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick)); +} + +/// The green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows. +pub fn canvas_accent(ctx: &Context) -> Color32 { + ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT) +} + +/// A fill under white text: the darker green stands in for the bright one. +pub fn under_white(c: Color32) -> Color32 { + if c == BRAND { ACCENT } else { c } +} + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); @@ -426,6 +447,21 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 { mod tests { use super::*; + #[test] + fn canvas_overlays_take_the_green_that_stands_out_on_the_page() { + let ctx = Context::default(); + assert_eq!(canvas_accent(&ctx), ACCENT, "before any page shows"); + set_canvas_page(&ctx, Color32::from_rgb(0x1E, 0x1E, 0x1E)); + assert_eq!(canvas_accent(&ctx), BRAND, "the bright green on a dark page"); + set_canvas_page(&ctx, Color32::WHITE); + assert_eq!(canvas_accent(&ctx), ACCENT, "the darker green on a light page"); + set_canvas_page(&ctx, Color32::from_rgb(0xF5, 0xF5, 0xF5)); + assert_eq!(canvas_accent(&ctx), ACCENT); + // White text never sits on the bright green. + assert_eq!(under_white(BRAND), ACCENT); + assert_eq!(under_white(COMPONENT), COMPONENT); + } + #[test] fn ui_font_draws_every_mac_shortcut_glyph() { figcraft_text::scan_system_fonts();