diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 63442b3c..61b0f8b4 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -242,7 +242,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let doc = ds.doc_mut(); let nid = doc.alloc_id(); let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) }; - if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, doc.page_of(parent).unwrap_or(page)) { + if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, parent) { node.fills = fills; } node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1)); diff --git a/crates/engine/src/dark_page_text_tests.rs b/crates/engine/src/dark_page_text_tests.rs index c4348430..b46bd0c7 100644 --- a/crates/engine/src/dark_page_text_tests.rs +++ b/crates/engine/src/dark_page_text_tests.rs @@ -1,6 +1,6 @@ -//! Text made by commands in the app (the interface reported its theme) is readable on its page -//! like text drawn with the Text tool: white on a dark page unless a fill is named. A session -//! without an interface (CLI, MCP, exports) keeps the model's black everywhere. +//! Text made by commands in the app (the interface reported its theme) is readable where it is +//! drawn, like text drawn with the Text tool: white on a dark page or a dark frame unless a fill is +//! named. A session without an interface (CLI, MCP, exports) keeps the model's black everywhere. use figcraft_color::{Color, Paint}; use figcraft_doc::NodeId; @@ -62,12 +62,51 @@ fn text_made_by_commands_is_readable_on_its_page() { } #[test] -fn text_in_a_frame_follows_the_page_it_is_on() { - // As with the Text tool: the page decides, also inside a frame; made in one undo step. +fn text_in_a_frame_is_readable_on_the_frame() { + // #362: what the text is drawn on decides: the nearest layer around it with a solid fill (a + // new frame's white too), else its page; made in one undo step. + let (white, black) = (vec![Paint::solid(Color::WHITE)], vec![Paint::solid(Color::BLACK)]); let mut s = session(Some(true)); - let frame: NodeId = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap(); - let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": frame})); - assert_eq!(fills(&s, &t), vec![Paint::solid(Color::WHITE)]); + let frame = |s: &mut Session, x: i64, parent: Option, fills: Option| -> NodeId { + let mut p = json!({"x": x, "y": 0, "width": 200, "height": 100}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + let f = id_of(&ex(s, "frame.create", p)["id"]).unwrap(); + if let Some(fills) = fills { + ex(s, "node.set", json!({"id": f, "props": {"fills": fills}})); + } + f + }; + let light = frame(&mut s, 0, None, None); + let dark = frame(&mut s, 300, None, Some(json!([{"type": "solid", "color": "#202020"}]))); + let bare = frame(&mut s, 600, None, Some(json!([]))); + let inner = frame(&mut s, 0, Some(light), Some(json!([]))); + let faint = frame(&mut s, 900, None, Some(json!([{"type": "solid", "color": "#FFFFFF", "opacity": 0.2}]))); + let stacked = frame(&mut s, 1200, None, Some(json!([{"type": "solid", "color": "#202020"}, {"type": "solid", "color": "#FFFFFF"}]))); + let cases = [ + (light, &black, "a white frame on a dark page: black text"), + (dark, &white, "a dark frame: white"), + (bare, &white, "a frame without a fill: the page decides"), + (inner, &black, "a frame without a fill in a white one: the white one"), + (faint, &white, "a faint fill lets the page show through: the page decides"), + (stacked, &black, "the fill on top counts"), + ]; + for (parent, want, why) in cases { + let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": parent})); + assert_eq!(&fills(&s, &t), want, "{why}"); + } + // The Text tool reads it the same way. + use crate::tools::{PointerEvent, PointerKind, ToolId}; + let at = s.doc().unwrap().world_bounds(light).unwrap().center(); + s.set_tool(ToolId::Text); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::at(kind, at.x, at.y, 1.0)).unwrap(); + } + let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id; + assert_eq!(s.doc().unwrap().node(typed).unwrap().fills, black, "the Text tool in a white frame"); + crate::textedit::end(&mut s).unwrap(); + let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": light})); ex(&mut s, "edit.undo", json!({})); assert!(s.doc().unwrap().node(id_of(&t["id"]).unwrap()).is_none()); } diff --git a/crates/engine/src/theme_pages_tests.rs b/crates/engine/src/theme_pages_tests.rs index e4c1071a..e80c8d32 100644 --- a/crates/engine/src/theme_pages_tests.rs +++ b/crates/engine/src/theme_pages_tests.rs @@ -118,13 +118,14 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { let t = draw_text(&mut s, 40.0, 40.0); assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}"); } - // In a frame on a dark page too; so is text a command makes in the app (`dark_page_text_tests`). + // In a (white) frame on a dark page, readable on the frame (#362); so is text a command makes + // in the app (`dark_page_text_tests`). let mut s = Session::empty(); s.set_ui_dark(true); ex(&mut s, "file.new", json!({})); ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200})); let t = draw_text(&mut s, 50.0, 50.0); - assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::WHITE)]); + assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::BLACK)]); let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176d..ec727c37 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -530,12 +530,16 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str { ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") } -/// The fill new text takes to be readable where it is drawn: white on a dark `page` (None: the -/// model's default, black). For text made in the app: by the Text tool, or by a command that -/// names no fill while the interface is up. -pub(crate) fn readable_text_fills(doc: &Document, page: NodeId) -> Option> { - matches!(doc.node(page).map(|p| &p.kind), Some(NodeKind::Page { background, .. }) if background.is_dark()) - .then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) } const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; @@ -1878,7 +1882,7 @@ impl Session { let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); n.transform = pw.inverse() * Affine::translate(at.to_vec2()); n.name = String::new(); - if let Some(fills) = readable_text_fills(d, page) { + if let Some(fills) = readable_text_fills(d, parent) { n.fills = fills; } if let Some((w, h)) = figcraft_text::auto_size(&n) {