collective

Browse / github.com/storytold/designcraft / patch

Text selection: invert what lies under the highlight

Selected white text turns black on a black highlight and can't be read ### What happens Selecting white text with the Type tool turns it black on a black highlight, so the selected text can't be read. The highlight is always a black bar and the selected letters are drawn in the inverse of their own colour, so white text becomes black on black. Text over a coloured fill or an image has the same problem whenever its inverse is close to black. ### Steps 1. Draw a text frame, give it a black fill and type some text. 2. Select the text and make it [Paper] (white). 3. Select part of the text with the Type tool. ### Expected The selected text stays readable: the hi

Status
open
Score
0.00
Adopters
0 other operators
Author
PiMinusMax (imported from pull request #1532, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+208 −43 in 2 files
Review
pending
Shared
2026-10-28
Id
38dbc64d596d4f086bd031be

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/lib.rs

diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -1043,6 +1043,18 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key:     if let Some(tex) = app.canvas.texture.as_mut() {         tex.set_partial([x0 as usize, y0 as usize], ci, egui::TextureOptions::LINEAR);     }+    // The same region in the canvas's own copy of its pixels.+    if let Some(all) = app.canvas.pixels.as_mut() {+        let all = std::sync::Arc::make_mut(all);+        let (aw, pw) = (all.width as usize, img.width as usize);+        for (row, src) in img.pixels.chunks_exact(pw * 4).enumerate() {+            let start = ((y0 as usize + row) * aw + x0 as usize) * 4;+            if let Some(dst) = all.pixels.get_mut(start..start + pw * 4) {+                dst.copy_from_slice(src);+            }+        }+        app.canvas.pixels_generation += 1;+    }     done(app);     app.canvas.patches += 1;     app.perf.render_ms = crate::now_ms() - t0;@@ -1067,6 +1079,8 @@ fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render:         Some(tex) => tex.set(ci, egui::TextureOptions::LINEAR),         None => app.canvas.texture = Some(ctx.load_texture("canvas", ci, egui::TextureOptions::LINEAR)),     }+    app.canvas.pixels = Some(std::sync::Arc::new(img));+    app.canvas.pixels_generation += 1; }  fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, gap: f32) {@@ -1656,8 +1670,8 @@ fn draw_text_selection(         draw_cell_selection(app, painter, xf, doc, layout, &cs, ts, cell);         return;     }-    let mut runs: Vec<(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)> = Vec::new();-    for (fi, ft) in cs.frames.iter().enumerate() {+    let mut runs: Vec<(Affine, Vec<[Point; 4]>)> = Vec::new();+    for ft in &cs.frames {         let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue };         let Some(it) = doc.item(ft.frame) else { continue };         let m = a * doc.text_xf(it);@@ -1691,8 +1705,7 @@ fn draw_text_selection(             continue;         }         let mut quads = Vec::new();-        let mut glyphs = Vec::new();-        for (li, l) in ft.lines.iter().enumerate() {+        for l in &ft.lines {             let s = range.start.max(l.range.start);             let e = range.end.min(l.range.end);             if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {@@ -1709,18 +1722,13 @@ fn draw_text_selection(                     quads.push([Point::new(a, r.y0), Point::new(b, r.y0), Point::new(b, r.y1), Point::new(a, r.y1)]);                 }             }-            for (gi, g) in l.glyphs.iter().enumerate() {-                if g.visible && g.len > 0 && g.byte >= s && g.byte < e {-                    glyphs.push((li, gi));-                }-            }         }         if !quads.is_empty() {-            runs.push((fi, m, quads, glyphs));+            runs.push((m, quads));         }     }     if !runs.is_empty() {-        draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs);+        draw_inverse_highlight(app, painter, xf, doc, layout, ts.story, &runs);     }     if ts.is_caret()         && let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus)@@ -1822,24 +1830,49 @@ fn draw_cell_selection(     } } -/// InDesign draws selected text inverted: a black highlight with the selected glyphs in the-/// inverse of their colour (white for black text). The highlight and glyph outlines are-/// rasterised with `vello_cpu` into a small screen-space texture over the selection.+thread_local! {+    /// Renders what lies under a text highlight, apart from the canvas renderer and its worker.+    static HIGHLIGHT_RENDERER: std::cell::RefCell<Option<designcraft_render::Renderer>> = const { std::cell::RefCell::new(None) };+}++/// The `w` × `h` premultiplied pixels of `img` from (`x0`, `y0`), the pasteboard colour `back`+/// where that reaches past the image.+fn crop(img: &designcraft_render::Rendered, x0: i64, y0: i64, w: u32, h: u32, back: [u8; 3]) -> Vec<u8> {+    let mut out = Vec::with_capacity(w as usize * h as usize * 4);+    for y in 0..i64::from(h) {+        for x in 0..i64::from(w) {+            let (sx, sy) = (x0 + x, y0 + y);+            let inside = sx >= 0 && sy >= 0 && sx < i64::from(img.width) && sy < i64::from(img.height);+            let px = inside.then(|| (sy as usize * img.width as usize + sx as usize) * 4).and_then(|i| img.pixels.get(i..i + 4));+            match px {+                Some(p) => out.extend_from_slice(p),+                None => out.extend_from_slice(&[back[0], back[1], back[2], 255]),+            }+        }+    }+    out+}++/// InDesign draws selected text inverted: whatever lies under the highlight (paper, the frame's+/// fill, a placed image, the selected glyphs themselves) shows in its inverse colour. Black text+/// on white paper turns white on black, and white text on a dark fill turns dark on a light+/// highlight instead of vanishing into a black bar. The area under the selection is rendered from+/// the document into a small screen-space texture and inverted inside the highlight quads. fn draw_inverse_highlight(     app: &DesignApp,     painter: &egui::Painter,     xf: &Xf,     doc: &Document,-    cs: &compose::ComposedStory,+    layout: &CanvasLayout,     story: designcraft_doc::StoryId,-    runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)],+    runs: &[(Affine, Vec<[Point; 4]>)], ) {     use designcraft_render::vello_cpu::{self, kurbo, peniko};     let ctx = painter.ctx();     let ppp = ctx.pixels_per_point();     let to_screen = |m: Affine, p: Point| xf.to_screen(m * p);     let mut bbox = Rect::NOTHING;-    for (_, m, quads, _) in runs {+    for (m, quads) in runs {         for q in quads {             for p in q {                 bbox.extend_with(to_screen(*m, *p));@@ -1857,18 +1890,38 @@ fn draw_inverse_highlight(     );     let (w, h) = (((bbox.width() * ppp).round() as u32).clamp(1, 8192), ((bbox.height() * ppp).round() as u32).clamp(1, 8192));     let Some(st) = app.session.active() else { return };+    let preview = app.ui.screen_mode != ScreenMode::Normal;+    let back = Tokens::get(ctx).pasteboard;+    // Everything that changes the colours the canvas shows changes the highlight too: the display+    // options the canvas texture is redrawn for (Rich Black, Overprint Preview, quality …) and+    // the proof setup and pasteboard behind them.+    let display = (format!("{:?}", display_key(app)), app.ui.proof_colors.then(|| format!("{:?}", app.ui.proof_setup)), back.to_array());+    // What the canvas shows under the highlight: cut from the canvas's own pixels when its texture+    // is at this zoom (no second render; Preview mode's clipping included), else rendered here.+    // The texture's pixel at the highlight's top left, kept in canvas space so scrolling (which+    // only shifts the texture) reuses the highlight.+    let from_canvas =+        app.canvas.shown.zip(app.canvas.pixels.clone()).filter(|(sh, _)| (sh.zoom - xf.zoom).abs() < 1e-9 && xf.rot == 0).map(|(sh, px)| {+            let c = xf.to_canvas(bbox.min);+            let k = sh.zoom * ppp as f64;+            (((c.x - sh.origin.x) * k).round() as i64, ((c.y - sh.origin.y) * k).round() as i64, px)+        });+    let at = from_canvas.as_ref().map(|(x, y, _)| (*x, *y));     let key = egui::Id::unique(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0))-        .with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::<usize>(), w, h))-        .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus))));+        .with(&display)+        .with((runs.iter().map(|r| r.1.len()).sum::<usize>(), w, h, preview))+        .with((at, app.canvas.pixels_generation, xf.zoom.to_bits(), xf.rot, st.selection.text.map(|t| (t.anchor, t.focus))))+        .with(at.is_none().then(|| (bbox.min.x.to_bits(), bbox.min.y.to_bits())));     let cache_id = egui::Id::unique("inverse_sel_tex");     let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id));     let tex = match cached {         Some((k, t)) if k == key => t,         _ => {             let view = xf.affine(ppp as f64, bbox.min);+            // How much of each pixel the highlight covers.             let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });-            rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));-            for (_, m, quads, _) in runs {+            rc.set_paint(peniko::Color::from_rgba8(255, 255, 255, 255));+            for (m, quads) in runs {                 rc.set_transform(view * *m);                 for q in quads {                     let mut bp = kurbo::BezPath::new();@@ -1880,30 +1933,35 @@ fn draw_inverse_highlight(                     rc.fill_path(&bp);                 }             }-            let db = designcraft_fonts::FontDb::global();-            for (fi, m, _, glyphs) in runs {-                let Some(ft) = cs.frames.get(*fi) else { continue };-                for &(li, gi) in glyphs {-                    let Some(l) = ft.lines.get(li) else { continue };-                    let Some(g) = l.glyphs.get(gi) else { continue };-                    let Some(style) = cs.styles.get(g.style as usize) else { continue };-                    let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue };-                    let [r, gg, b, _] = c.to_rgba8(1.0);-                    rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));-                    let outline = db.outline(&g.face, g.gid);-                    let skew =-                        if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };-                    let shatai = style.shatai_xf(g, l.baseline).unwrap_or(Affine::IDENTITY);-                    let ga = shatai * Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);-                    rc.set_transform(view * *m * ga);-                    rc.fill_path(&outline);-                }-            }             rc.flush();-            let mut pm = vello_cpu::Pixmap::new(w as u16, h as u16);+            let mut mask = vello_cpu::Pixmap::new(w as u16, h as u16);             let mut res = vello_cpu::Resources::new();-            rc.render(&mut pm, &mut res);-            let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pm.data_as_u8_slice());+            rc.render(&mut mask, &mut res);+            let under = match &from_canvas {+                Some((x0, y0, px)) => crop(px, *x0, *y0, w, h, [back.r(), back.g(), back.b()]),+                None => {+                    // Rendered with the canvas's own options and colours, on this thread only (a+                    // pool per frame of a drag would cost more than the render).+                    let placed: Vec<designcraft_render::Placed> =+                        layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect();+                    let opts = screen_options(app, layout, preview, ppp as f64);+                    let mut under = HIGHLIGHT_RENDERER.with(|r| {+                        let mut r = r.borrow_mut();+                        r.get_or_insert_with(|| {+                            let mut r = designcraft_render::Renderer::new();+                            r.threads = 0;+                            r+                        })+                        .render(doc, &app.session.cache, &placed, w, h, view, &opts)+                    });+                    if app.ui.proof_colors {+                        designcraft_render::proof_view(&mut under, &app.ui.proof_setup);+                    }+                    under.pixels+                }+            };+            let pixels = inverse_under(mask.data_as_u8_slice(), &under, [back.r(), back.g(), back.b()]);+            let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], &pixels);             let t = crate::widgets::load_texture(ctx, "inverse_selection", ci, egui::TextureOptions::NEAREST);             ctx.data_mut(|d| d.insert_temp(cache_id, (key, t.clone())));             t@@ -1912,6 +1970,26 @@ fn draw_inverse_highlight(     painter.image(tex.id(), bbox, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } +/// Premultiplied RGBA: the inverse of `under` (composited over `back` where it is transparent),+/// shown as far as `mask` (premultiplied white) covers each pixel.+fn inverse_under(mask: &[u8], under: &[u8], back: [u8; 3]) -> Vec<u8> {+    let mut out = vec![0u8; mask.len()];+    let under = under.as_chunks::<4>().0.iter().chain(std::iter::repeat(&[0u8; 4]));+    for ((o, m), u) in out.as_chunks_mut::<4>().0.iter_mut().zip(mask.as_chunks::<4>().0).zip(under) {+        let cover = u32::from(m[3]);+        if cover == 0 {+            continue;+        }+        let a = u32::from(u[3]);+        for k in 0..3 {+            let shown = (u32::from(u[k]) + u32::from(back[k]) * (255 - a) / 255).min(255);+            o[k] = ((255 - shown) * cover / 255) as u8;+        }+        o[3] = cover as u8;+    }+    out+}+ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {     if let (Some((z, c)), Some(rect)) = (app.power_zoom, app.canvas_rect) {         // The view that releasing returns to.@@ -4163,6 +4241,87 @@ mod tests {      use super::*; +    /// Selected text shows what lies under it inverted: paper turns black, black turns white,+    /// and a half-covered pixel is half inverted.+    #[test]+    fn the_text_highlight_inverts_what_lies_under_it() {+        let mask = [0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 128, 128, 128, 128, 255, 255, 255, 255];+        let under = [9, 9, 9, 255, 255, 255, 255, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 0];+        let out = inverse_under(&mask, &under, [80, 80, 80]);+        assert_eq!(&out[0..4], &[0, 0, 0, 0], "outside the highlight: untouched");+        assert_eq!(&out[4..8], &[0, 0, 0, 255], "white paper turns black");+        assert_eq!(&out[8..12], &[255, 255, 255, 255], "black turns white");+        assert_eq!(&out[12..16], &[128, 128, 128, 128], "half covered: half the inverse");+        assert_eq!(&out[16..20], &[175, 175, 175, 255], "transparent: the inverse of the pasteboard");+    }++    /// White text on a black frame used to turn black on a black highlight. Now the highlight is+    /// light there and the selected letters dark.+    #[test]+    fn selected_white_text_on_a_dark_fill_stays_readable() {+        let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+        app.run("file.new", serde_json::json!({})).unwrap();+        let id = app+            .run("frame.create", serde_json::json!({"rect": [72, 72, 400, 200], "content": "text", "text": "White text", "caret": true}))+            .unwrap()["id"]+            .as_u64()+            .unwrap();+        app.run("edit.selectAll", serde_json::json!({})).unwrap();+        app.run("type.char", serde_json::json!({"attrs": {"size": 28, "fill": "[Paper]"}})).unwrap();+        app.run("object.fill", serde_json::json!({"ids": [id], "swatch": "[Black]"})).unwrap();+        let mut h = crate::test_window::open(app, vec2(900.0, 600.0));+        h.run_steps(6);+        let app = &h.state().app;+        let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());+        let canvas = app.session.layout().xf(designcraft_doc::SpreadRef::Doc(0));+        let (a, b) = (xf.to_screen(canvas * Point::new(76.0, 78.0)), xf.to_screen(canvas * Point::new(180.0, 98.0)));+        let image = match h.render() {+            Ok(image) => image,+            // CI has a renderer: there a missing one is a failure, not a silent pass.+            Err(e) if std::env::var_os("CI").is_some() => panic!("no offscreen renderer in CI: {e}"),+            Err(e) => {+                eprintln!("skipped: no offscreen renderer ({e})");+                return;+            }+        };+        let (mut light, mut dark) = (0, 0);+        for y in a.y as u32..b.y as u32 {+            for x in a.x as u32..b.x as u32 {+                let p = image.get_pixel(x, y);+                let l = (u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2])) / 3;+                if l > 200 {+                    light += 1;+                } else if l < 60 {+                    dark += 1;+                }+            }+        }+        assert!(light > dark, "a light highlight under the selected white text: {light} light, {dark} dark");+        assert!(dark > 0, "with the letters dark on it");+    }++    /// The highlight is drawn from a render of what lies under it: a display option that changes+    /// that render (Overprint Preview here) draws it again instead of keeping the old pixels.+    #[test]+    fn the_text_highlight_follows_the_display_options() {+        let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+        app.run("file.new", serde_json::json!({})).unwrap();+        app.run("frame.create", serde_json::json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Some text", "caret": true})).unwrap();+        app.run("edit.selectAll", serde_json::json!({})).unwrap();+        let mut h = crate::test_window::open(app, vec2(900.0, 600.0));+        h.run_steps(6);+        let cached = |h: &Harness<'_, crate::test_window::Window>| {+            h.ctx.data(|d| d.get_temp::<(egui::Id, egui::TextureHandle)>(egui::Id::unique("inverse_sel_tex"))).map(|(k, _)| k)+        };+        let before = cached(&h).expect("the highlight is drawn");+        h.run_steps(2);+        assert_eq!(cached(&h), Some(before), "unchanged: the cached highlight is kept");+        h.state_mut().app.ui.overprint_preview = !h.state().app.ui.overprint_preview;+        h.run_steps(2);+        let after = cached(&h).expect("the highlight is drawn");+        assert_ne!(after, before, "a new display option: the highlight is drawn again");+    }+     #[test]     fn a_press_after_a_drag_is_not_a_double_click() {         let (delay, dist) = (0.3, 6.0);diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -620,6 +620,8 @@ impl CanvasCache {             patcher: designcraft_render::Renderer::new(),             patches: 0,             separations: separations::Preview::default(),+            pixels: None,+            pixels_generation: 0,         }     } }@@ -653,6 +655,10 @@ pub struct CanvasCache {     pub patches: u64,     /// View › Separations Preview: the visible spreads' plates.     pub separations: separations::Preview,+    /// The pixels the canvas texture shows (full resolution, as uploaded), and a counter that+    /// changes with them: a text highlight inverts what it covers from these, without rendering.+    pub pixels: Option<std::sync::Arc<designcraft_render::Rendered>>,+    pub pixels_generation: u64, }  #[derive(Default, Clone, Copy, Debug, Serialize)]