fix(engine): new text is readable on the frame it is drawn in (#362)

The colour of new text in the app was chosen from the page alone, so
on a dark page text drawn in a white frame (a new frame's default) was
white and invisible. readable_text_fills now looks at what the text is
drawn on: the nearest layer from its parent out whose top visible fill
is a mostly opaque solid colour, else the page. The Text tool and
text.create / node.create pass the text's parent.

The two tests that asserted the page-only rule in a frame now expect
black text in a white frame; new cases cover dark, unfilled, nested,
faint and stacked fills, and the Text tool.
This commit is contained in:
vxMacDev
2026-10-14 18:35:21 -05:00
parent 833f0d2c27
commit 12d4dbfbc9
4 changed files with 62 additions and 18 deletions
+1 -1
View File
@@ -242,7 +242,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
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));
+47 -8
View File
@@ -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<NodeId>, fills: Option<Value>| -> 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());
}
+3 -2
View File
@@ -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)]);
}
+11 -7
View File
@@ -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<Vec<figcraft_color::Paint>> {
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<Vec<figcraft_color::Paint>> {
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) {