From 3692b9d2334c19a25a67e7cef57fe47f80bb327f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 17:30:03 +0700 Subject: [PATCH] M3.24: basic appearance, Layers target dots, Mixed Appearances and Clear resets opacity An appearance is basic with at most one fill and one stroke, none hidden or with its own opacity, blend mode or effects, and no object effects; Reduce to Basic keeps the topmost visible fill and stroke. The Layers panel fills an object's target circle for any non-default transparency (blend mode, isolation, knockout or an opacity mask: Node::has_default_transparency). A multi-selection whose objects differ shows "Mixed Appearances" with no rows. The Appearance panel's object row and the Control bar name the selection from one source, and type objects are "Type". Expand Appearance is only in the Object menu. Clear Appearance also resets the object's opacity and blend mode. Tests cover addFill/addStroke, setItem/removeItem, clear, reduceToBasic and graphicStyle.apply. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/appearance.rs | 34 +++++++-- crates/doc/src/node.rs | 25 ++++++- crates/engine/src/cmd/appearance.rs | 33 +++++++-- crates/engine/src/tests_appearance.rs | 74 +++++++++++++++++++ crates/ui-egui/src/chrome.rs | 14 +--- crates/ui-egui/src/menus.rs | 11 ++- crates/ui-egui/src/panels/appearance.rs | 30 ++++++-- crates/ui-egui/src/panels/layers.rs | 41 +++++++++- crates/ui-egui/src/panels/tests_appearance.rs | 27 +++++++ 9 files changed, 256 insertions(+), 33 deletions(-) diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index fb845a8e..894b33bb 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -326,15 +326,14 @@ impl Appearance { pub fn stroke_width(&self) -> f64 { self.stroke().filter(|s| !s.paint.is_none()).map(|s| s.width).unwrap_or(0.0) } - /// Is this the basic one-fill-one-stroke appearance without effects? + /// Is this a basic appearance: at most one fill and one stroke, none hidden or with its own + /// opacity, blend mode or effects, and no object effects? pub fn is_basic(&self) -> bool { + let count = |fill: bool| self.items.iter().filter(|i| i.is_fill() == fill).count(); self.effects.is_empty() - && self.items.len() <= 2 - && self.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() <= 1 - && self.items.iter().all(|i| match i { - AppearanceItem::Fill(f) => f.effects.is_empty() && f.opacity == 1.0 && f.blend == BlendMode::Normal, - AppearanceItem::Stroke(s) => s.effects.is_empty() && s.opacity == 1.0 && s.blend == BlendMode::Normal, - }) + && count(true) <= 1 + && count(false) <= 1 + && self.items.iter().all(|i| i.visible() && i.effects().is_empty() && i.opacity() == 1.0 && i.blend() == BlendMode::Normal) } /// Fill item `index`, or the topmost fill for `None`. `None` when that item is not a fill. pub fn fill_at(&self, index: Option) -> Option<&FillLayer> { @@ -488,6 +487,27 @@ mod tests { assert_eq!((a.items[1].kind_name(), a.items[2].kind_name()), ("stroke", "fill")); } + #[test] + fn basic_means_one_plain_fill_and_stroke() { + let stroke = || AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 1.0)); + let mut a = Appearance { items: vec![stroke(), stroke()], effects: vec![] }; + assert!(!a.is_basic(), "two strokes"); + a.items.pop(); + assert!(a.is_basic() && Appearance::default().is_basic()); + let mut b = Appearance::default_art(); + b.stroke_mut().unwrap().visible = false; + assert!(!b.is_basic(), "a hidden stroke"); + let mut c = Appearance::default_art(); + c.fill_mut().unwrap().opacity = 0.5; + assert!(!c.is_basic(), "fill opacity"); + let mut d = Appearance::default_art(); + d.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(!d.is_basic(), "two fills"); + let mut e = Appearance::default_art(); + e.effects.push(Effect { id: "distort.roughen".into(), params: serde_json::Value::Null, visible: true }); + assert!(!e.is_basic(), "an effect"); + } + #[test] fn set_creates_missing() { let mut a = Appearance::default(); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index d85a7929..0a81fd3e 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -357,6 +357,11 @@ impl Node { pub fn is_container(&self) -> bool { self.children().is_some() } + /// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel + /// fills an object's target circle otherwise). + pub fn has_default_transparency(&self) -> bool { + self.opacity >= 1.0 && self.blend == BlendMode::Normal && !self.isolate && !self.knockout && self.mask.is_none() + } /// Kind name as the Layers panel / Properties panel shows it. pub fn kind_label(&self) -> &'static str { match &self.kind { @@ -367,7 +372,7 @@ impl Node { NodeKind::Path { guide: true, .. } => "Guide", NodeKind::Path { .. } => "Path", NodeKind::Compound { .. } => "Compound Path", - NodeKind::Text(_) => "Text", + NodeKind::Text(_) => "Type", NodeKind::Image(_) => "Image", NodeKind::SymbolInstance { .. } => "Symbol", NodeKind::Blend { .. } => "Blend", @@ -545,6 +550,24 @@ mod tests { assert_eq!(l.label(), "Rectangle"); } + #[test] + fn default_transparency() { + let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + assert!(p().has_default_transparency()); + let tweaks: [fn(&mut Node); 5] = [ + |n| n.opacity = 0.5, + |n| n.blend = BlendMode::Multiply, + |n| n.isolate = true, + |n| n.knockout = true, + |n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))), + ]; + for tweak in tweaks { + let mut n = p(); + tweak(&mut n); + assert!(!n.has_default_transparency(), "{n:?}"); + } + } + #[test] fn transform_group_recurses() { let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 6460199f..18a00cdc 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -38,8 +38,24 @@ pub fn specs() -> Vec { has_selection, |s, p| add_item(s, p, false) ), - cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, clear_appearance), - cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{ids?}", has_selection, reduce_basic), + cmd!( + "appearance.clear", + "Clear Appearance", + ["Window", "Appearance"], + None, + "{ids?} leave each object one empty fill and stroke (None) and reset its opacity and blend mode", + has_selection, + clear_appearance + ), + cmd!( + "appearance.reduceToBasic", + "Reduce to Basic Appearance", + ["Window", "Appearance"], + None, + "{ids?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects, and drop the object's effects", + has_selection, + reduce_basic + ), cmd!( "appearance.setItem", "Appearance Item", @@ -261,7 +277,10 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { s.edit("Clear Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { + // No fill, no stroke, and the object's Opacity row back to Default. n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0); + n.opacity = 1.0; + n.blend = BlendMode::Normal; } } Ok(()) @@ -275,10 +294,12 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { s.edit("Reduce to Basic Appearance", |d, _| { for id in &ids { if let Some(n) = d.node_mut(*id) { - let f = n.appearance.fill_paint(); - let st = n.appearance.stroke().cloned(); - n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] }; - if let Some(mut st) = st { + // The topmost visible fill and stroke stay, without their own transparency or effects. + let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(); + let fill = top(true).map_or(Paint::None, |f| f.paint().clone()); + let stroke = top(false); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(fill))], effects: vec![] }; + if let Some(AppearanceItem::Stroke(mut st)) = stroke { st.effects.clear(); st.opacity = 1.0; st.blend = BlendMode::Normal; diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index fb39cdfd..413dac6c 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -267,3 +267,77 @@ fn text_fill_items_composite_with_their_opacity() { let px = render(&s).pixel(x, y); assert!((110..=145).contains(&px[0]) && px[1] < 10, "{px:?}"); } + +#[test] +fn add_fill_and_stroke_copy_the_top_ones() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "stroke.set", json!({"weight": 4})); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke] + let n = node(&s, id); + assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::>(), ["fill", "stroke", "fill", "stroke"]); + assert_eq!(fill_paint(&n, 2).color().unwrap().to_hex(), "#ff0000"); + assert_eq!(stroke(&n, 3).width, 4.0); + assert!(!n.appearance.is_basic()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, id).appearance.items.len(), 3); +} + +#[test] +fn set_item_and_remove_item() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 50, "blend": "screen", "weight": 3, "color": "#0000ff"})); + let n = node(&s, id); + let st = stroke(&n, 1); + assert_eq!((st.opacity, st.blend, st.width), (0.5, BlendMode::Screen, 3.0)); + assert_eq!(st.paint.color().unwrap().to_hex(), "#0000ff"); + run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false, "opacity": 0.25})); + let n = node(&s, id); + assert!(!n.appearance.items[0].visible() && n.appearance.items[0].opacity() == 0.25); + assert!(s.execute("appearance.setItem", &json!({})).is_err()); + run(&mut s, "appearance.removeItem", json!({"index": 0})); + let n = node(&s, id); + assert_eq!(n.appearance.items.len(), 1); + assert!(n.appearance.fill().is_none() && n.appearance.stroke().is_some()); +} + +#[test] +fn clear_and_reduce_to_basic() { + let mut s = session(); + let id = rect(&mut s, 100.0); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 2, "visible": false, "color": "#00ff00"})); + run(&mut s, "appearance.setItem", json!({"index": 1, "opacity": 0.5})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen", "item": 1})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "item": null})); + run(&mut s, "transparency.set", json!({"opacity": 40, "blend": "multiply"})); + // Reduce keeps the topmost visible fill and stroke, plain, and the object's transparency. + run(&mut s, "appearance.reduceToBasic", json!({})); + let n = node(&s, id); + assert!(n.appearance.is_basic(), "{:?}", n.appearance); + assert_eq!(stroke(&n, 1).paint, Paint::solid(Color::BLACK)); + assert_eq!((n.opacity, n.blend), (0.4, BlendMode::Multiply)); + // Clear leaves one None fill and stroke, and resets the object's Opacity row. + run(&mut s, "appearance.clear", json!({})); + let n = node(&s, id); + assert_eq!(n.appearance, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0)); + assert_eq!((n.opacity, n.blend), (1.0, BlendMode::Normal)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, id).opacity, 0.4); +} + +#[test] +fn graphic_style_apply_replaces_the_stack() { + let mut s = session(); + let a = rect(&mut s, 100.0); + run(&mut s, "appearance.addFill", json!({})); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + let name = run(&mut s, "graphicStyle.new", json!({"name": "Rough"}))["name"].clone(); + let b = rect(&mut s, 250.0); + run(&mut s, "graphicStyle.apply", json!({ "name": name })); + assert_eq!(node(&s, b).appearance, node(&s, a).appearance); + assert!(s.execute("graphicStyle.apply", &json!({"name": "nope"})).is_err()); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aaac5e0e..8be42e7d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -92,16 +92,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let units = st.doc.units; let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); let anchor_mode = !st.selection.anchors.is_empty(); - let label = match (&first, sel.len()) { - (None, _) => "No Selection".to_string(), - (_, n) if n > 1 => "Mixed Objects".to_string(), - (Some(_), _) if anchor_mode => "Anchor Point".to_string(), - (Some(n), _) => match &n.kind { - NodeKind::Text(_) => "Type".into(), - NodeKind::Image(_) => "Embedded".into(), - NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(), - _ => n.kind_label().to_string(), - }, + let label = match &first { + Some(_) if sel.len() == 1 && anchor_mode => "Anchor Point", + Some(n) if sel.len() == 1 && matches!(n.kind, NodeKind::Image(_)) => "Embedded", + _ => crate::panels::appearance::object_label(app), }; ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3f8048e9..55488867 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1638,8 +1638,6 @@ fn effect_menu() -> Vec { for e in cat.iter().filter(|e| !e.menu.last().is_some_and(|m| order.contains(m))) { out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); } - out.push(Sep); - out.push(c("Expand Appearance", "effect.expandAppearance")); out } @@ -1771,4 +1769,13 @@ mod tests { } assert_eq!(pretty_shortcut(""), ""); } + + #[test] + fn expand_appearance_is_an_object_menu_item_only() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let paths: Vec> = + menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("effect.expandAppearance")).map(|e| e.path).collect(); + assert_eq!(paths, [vec!["Object".to_string()]]); + } } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index afa6ac34..1d7f86c0 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -173,18 +173,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { select_row(app, ui.ctx(), Sel::None); } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border)); - let label = match (&node, super::selection_len(app)) { - (None, _) => "No Selection".to_string(), - (Some(_), n) if n > 1 => "Mixed Objects".to_string(), - (Some(n), _) => n.kind_label().to_string(), - }; + let mixed = mixed_appearances(app); + let label = if mixed { "Mixed Appearances" } else { object_label(app) }; if !hide_thumb { let th = Rect::from_min_size(r.left_center() + vec2(6.0, -12.0), vec2(24.0, 24.0)); let fill = node.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or_else(|| app.session.paint.fill.clone()); chip(ui, th, &fill); } - text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), &label, true); + text(ui, r.left_center() + vec2(if hide_thumb { 8.0 } else { 46.0 }, 0.0), label, true); match &node { + // Objects that differ have no common stack to list. + Some(_) if mixed => {} Some(n) => stack(app, ui, n, sel), None => default_stack(app, ui), } @@ -194,6 +193,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { bottom(app, ui, node.as_ref(), sel); } +/// The selection as the Control bar and the object row name it: "No Selection", "Mixed Objects" +/// or the object's kind ("Path", "Type", …). +pub(crate) fn object_label(app: &VectorcraftApp) -> &'static str { + let Some(st) = app.session.active() else { return "No Selection" }; + match st.selection.objects.as_slice() { + [] => "No Selection", + [id] => st.doc.node(*id).map_or("No Selection", Node::kind_label), + _ => "Mixed Objects", + } +} + +/// Do the selected objects differ in appearance (fills, strokes, effects, opacity or blend mode)? +fn mixed_appearances(app: &VectorcraftApp) -> bool { + let Some(st) = app.session.active() else { return false }; + let mut nodes = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)); + let Some(first) = nodes.next() else { return false }; + nodes.any(|n| n.appearance != first.appearance || n.opacity != first.opacity || n.blend != first.blend) +} + /// The defaults for new art when nothing is selected (read-only rows). fn default_stack(app: &mut VectorcraftApp, ui: &mut Ui) { let p = app.session.paint.clone(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 547bc7f6..af0a676f 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -221,7 +221,7 @@ fn row( let col_x = r.right() - 43.5; ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border)); let tc = egui::pos2(r.right() - 28.0, r.center().y); - let styled = !n.appearance.is_basic() || n.opacity < 1.0; + let styled = has_styled_target(n); ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon)); if is_sel { ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon)); @@ -292,6 +292,12 @@ fn row( /// A real rendered thumbnail, cached by node identity (unchanged nodes keep their `Arc` /// allocation, so the address is a free change detector). Only rendered for visible rows. +/// Whether an object's target circle is filled: its appearance is not basic or its transparency +/// (opacity, blend mode, isolation, knockout or an opacity mask) is not the default. +fn has_styled_target(n: &Node) -> bool { + !n.appearance.is_basic() || !n.has_default_transparency() +} + fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r: egui::Rect) -> bool { use std::cell::RefCell; use std::collections::HashMap; @@ -351,3 +357,36 @@ fn thumb(ui: &Ui, n: &Node, r: egui::Rect) { } }); } + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use super::*; + + /// Filled target circles drawn by one headless frame of the panel. + fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.shapes.iter().filter(|c| matches!(&c.shape, egui::Shape::Circle(cs) if cs.radius == 3.2 && cs.fill != Color32::TRANSPARENT)).count() + } + + #[test] + fn target_circle_fills_for_non_default_transparency() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let id = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [id] })); + let ctx = egui::Context::default(); + assert_eq!(filled_targets(&mut app, &ctx), 0); + run(&mut app, "transparency.set", json!({"blend": "multiply"})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "a Multiply object"); + run(&mut app, "transparency.set", json!({"blend": "normal", "knockout": true})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "a knockout group"); + run(&mut app, "transparency.set", json!({"knockout": false})); + run(&mut app, "appearance.addStroke", json!({})); + assert_eq!(filled_targets(&mut app, &ctx), 1, "two strokes"); + } +} diff --git a/crates/ui-egui/src/panels/tests_appearance.rs b/crates/ui-egui/src/panels/tests_appearance.rs index a49bfcad..fdfc53d4 100644 --- a/crates/ui-egui/src/panels/tests_appearance.rs +++ b/crates/ui-egui/src/panels/tests_appearance.rs @@ -157,3 +157,30 @@ fn item_opacity_popup_sets_the_items_opacity() { assert_eq!(n.appearance.items[1].opacity(), 0.5); assert_eq!((n.appearance.items[0].opacity(), n.opacity), (1.0, 1.0)); } + +#[test] +fn object_row_names_the_selection_like_the_control_bar() { + let mut app = app_with_rect(); + let a = first_selected(&app).unwrap().id.0; + let ctx = egui::Context::default(); + assert!(frame_in(&ctx, &mut app, appearance::show).contains(&"Rectangle".to_string())); + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].clone(); + // Objects with one appearance list it under "Mixed Objects". + run(&mut app, "select.set", json!({"ids": [a, b]})); + let texts = frame_in(&ctx, &mut app, appearance::show); + assert!(texts.contains(&"Mixed Objects".to_string()) && texts.contains(&"Stroke:".to_string()), "{texts:?}"); + // Objects that differ list nothing. + run(&mut app, "select.set", json!({"ids": [b]})); + run(&mut app, "transparency.set", json!({"opacity": 50})); + run(&mut app, "select.set", json!({"ids": [a, b]})); + let texts = frame_in(&ctx, &mut app, appearance::show); + assert!(texts.contains(&"Mixed Appearances".to_string()) && !texts.contains(&"Stroke:".to_string()), "{texts:?}"); + // Type objects are "Type" in both places. + let t = run(&mut app, "text.create", json!({"x": 10, "y": 150, "text": "Hi"}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [t] })); + assert_eq!(appearance::object_label(&app), "Type"); + assert!(frame_in(&ctx, &mut app, appearance::show).contains(&"Type".to_string())); + let fonts = egui::Context::default(); + crate::theme::install_fonts(&fonts); + assert!(frame_in(&fonts, &mut app, crate::chrome::control_bar).contains(&"Type".to_string())); +}