M3: the text caret takes the text's fill colour (#2156)

Figma draws the text caret in the text's solid fill colour (measured in the web app, 2026-11-09). The overlay took the character before the caret, its run's fills or the layer's, and drew the topmost visible solid opaque; gradient, image and hidden fills keep the interface colour. Width, height, selection and collaborators' carets are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured in Figma and wrote at medium
This commit is contained in:
Trevor Brown
2026-11-09 08:38:05 -05:00
co-authored by Claude Sonnet 5.5
parent 79c363466a
commit 2beec8b371
2 changed files with 88 additions and 3 deletions
+85 -1
View File
@@ -209,6 +209,26 @@ fn layout_grids(app: &FigApp, painter: &egui::Painter, doc: &Document, c: NodeId
}
}
/// The solid colour of the fill the next typed character gets (the character before the caret,
/// the first one's at 0, the base style for an empty text): the style's own fills when it has
/// them, else the layer's. `None` when the topmost visible paint isn't a solid (gradient, image,
/// no visible fill), so the caller keeps the interface colour. Opaque on purpose: Figma's caret
/// is one colour, and the fill's and paint's opacity weren't measured, so it must stay visible.
fn caret_fill(n: &figcraft_doc::Node, caret: usize) -> Option<figcraft_color::Color> {
let t = n.kind.text()?;
let style = t.style_at(caret.saturating_sub(1));
let paints = style.fills.as_ref().unwrap_or(&n.fills);
paints.iter().rev().find(|p| p.visible).and_then(|p| match &p.kind {
figcraft_color::PaintKind::Solid { color } => Some(color.with_alpha(1.0)),
_ => None,
})
}
/// [`caret_fill`] as a screen colour.
fn caret_color(ctx: &egui::Context, n: &figcraft_doc::Node, caret: usize) -> Option<Color32> {
caret_fill(n, caret).map(|c| doc_c32(ctx, c))
}
fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, te: &figcraft_engine::TextEdit, zoom: f64) {
let preedit = app.preedit_view();
let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(doc);
@@ -242,7 +262,8 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do
let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
if blink || a != b {
painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
let col = edoc.node(te.id).and_then(|n| caret_color(painter.ctx(), n, caret)).unwrap_or(t.text);
painter.line_segment(text.caret(app, caret, zoom), stroke(col, 1.0));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
}
@@ -782,3 +803,66 @@ fn pick_outline(app: &FigApp, painter: &egui::Painter, doc: &Document) {
draw_outline(app, painter, doc, p, stroke(crate::handles::PINK, 2.0));
}
}
#[cfg(test)]
mod caret_tests {
use super::*;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Node, NodeId, NodeKind, StyleRun, TextData};
fn text(chars: &str, fills: Vec<Paint>) -> Node {
let mut n = Node::new(NodeId(1), NodeKind::Text(TextData { characters: chars.into(), ..TextData::default() }), "Text", 100.0, 20.0);
n.fills = fills;
n
}
fn hex(s: &str) -> Color {
Color::from_hex(s).unwrap()
}
#[test]
fn red_text_has_a_red_caret() {
let n = text("Caret", vec![Paint::solid(hex("#FF0000"))]);
assert_eq!(caret_fill(&n, 2), Some(hex("#FF0000")));
assert_eq!(caret_color(&egui::Context::default(), &n, 2), Some(Color32::from_rgb(255, 0, 0)));
}
#[test]
fn light_grey_text_keeps_its_own_colour_whatever_is_behind_it() {
// Inside a #222222 frame: the frame's fill plays no part.
let n = text("Caret", vec![Paint::solid(hex("#DDDDDD"))]);
assert_eq!(caret_fill(&n, 5), Some(hex("#DDDDDD")));
assert_eq!(caret_color(&egui::Context::default(), &n, 0), Some(Color32::from_rgb(0xDD, 0xDD, 0xDD)));
}
#[test]
fn the_caret_follows_the_run_of_the_character_before_it() {
let mut n = text("ab cd", vec![Paint::solid(hex("#000000"))]);
if let Some(t) = n.kind.text_mut() {
let mut style = t.style.clone();
style.fills = Some(vec![Paint::solid(hex("#FF0000"))]);
t.runs.push(StyleRun { start: 3, end: 5, style });
}
assert_eq!(caret_fill(&n, 2), Some(hex("#000000")), "after a black character");
assert_eq!(caret_fill(&n, 4), Some(hex("#FF0000")), "after a red character");
assert_eq!(caret_fill(&n, 0), Some(hex("#000000")), "at the start: the first character's");
}
#[test]
fn the_caret_is_opaque_whatever_the_fill_opacity() {
let mut p = Paint::solid(hex("#336699"));
p.opacity = 0.2;
assert_eq!(caret_fill(&text("x", vec![p]), 1).map(|c| c.a), Some(1.0));
}
#[test]
fn gradient_or_missing_fills_keep_the_interface_colour() {
let mut g = Paint::solid(hex("#FF0000"));
g.kind = figcraft_color::PaintKind::Gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, hex("#FF0000")));
assert_eq!(caret_fill(&text("Caret", vec![g]), 2), None);
assert_eq!(caret_fill(&text("Caret", vec![]), 2), None);
let mut hidden = Paint::solid(hex("#FF0000"));
hidden.visible = false;
assert_eq!(caret_fill(&text("Caret", vec![hidden]), 2), None);
}
}
+3 -2
View File
File diff suppressed because one or more lines are too long