collective

Browse / github.com/storytold/vectorcraft / patch

Type : Improve Text Selection Highlighting and Canvas Rendering

Type : Improve Text Selection Highlighting and Canvas Rendering <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Visual Updates** * Text-selection highlights now invert the colors beneath selected text, making selections easier to distinguish from the canvas. * If inverted highlighting is unavailable or the highlighted area is too large, the selection appears as a translucent black overlay instead. * **Other Changes** * Added a contributor to the project’s contributor list. <!-- end of auto-generated comment: release notes by coderabbit.ai -->

Status
open
Score
0.00
Adopters
0 other operators
Author
MajedAlaejem (imported from pull request #3375, open)
Base
e2a8280134a7c3c9b4e117ee668b9b3e7eb3e315
Size
+305 −3 in 11 files
Review
pending
Shared
2026-10-28
Id
986ba3b881de7516c8f98ad3

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/vectorcraft-web/src/web.rs apps/vectorcraft/src/main.rs contributors/people.toml crates/engine/src/tests_eyedropper.rs crates/tools/src/lib.rs crates/tools/src/text.rs crates/tools/src/text_tests.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/gpu_invert.rs crates/ui-egui/src/lib.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -6350,6 +6350,7 @@ dependencies = [  "craft-ui",  "craft-update",  "egui",+ "egui-wgpu",  "egui_extras",  "egui_kittest",  "image",@@ -6377,6 +6378,7 @@ dependencies = [  "vectorcraft-tools",  "wat",  "web-time",+ "wgpu", ]  [[package]]diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs--- a/apps/vectorcraft-web/src/web.rs+++ b/apps/vectorcraft-web/src/web.rs@@ -115,6 +115,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi                 watch_canvas(&page, &loss, ctx);                 if let Some(rs) = &cc.wgpu_render_state {                     log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);+                    vectorcraft_ui_egui::gpu_invert::setup(rs);                     let (loss, ctx) = (loss.clone(), ctx.clone());                     rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));                 }diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs--- a/apps/vectorcraft/src/main.rs+++ b/apps/vectorcraft/src/main.rs@@ -1099,6 +1099,7 @@ fn main() -> std::process::ExitCode {                     let summary = adapter_summary(&rs.adapter.get_info());                     log::info!("rendering with {summary} (power preference {power:?})");                     created.created(&cc.egui_ctx);+                    vectorcraft_ui_egui::gpu_invert::setup(rs);                     if !gpu::skipped().is_empty() {                         // A processor chosen in Settings can be the one that can't show the window.                         let hint = if chosen_gpu {diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -155,6 +155,10 @@ display_name = "Don Omondi" [people.alpha-rock] display_name = "Aditya Sisodiya" +[people.MajedAlaejem]+real_name = "Majed Alaejem"+sync_github_name = true+ [people.Ronfo2013] real_name = "Angelo Bernardini" display_name = "Angelo Bernardini"diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs--- a/crates/engine/src/tests_eyedropper.rs+++ b/crates/engine/src/tests_eyedropper.rs@@ -657,7 +657,7 @@ fn eyedropper_shift_color_preserves_other_characters_and_clears_on_deselect() {     assert_eq!(t.runs[2].style, before.first_style());     assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#00ff00");     assert_eq!(t.runs[1].style.size, before.first_style().size);-    assert!(s.overlays(view).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })), "the highlighted range remains visible");+    assert!(s.overlays(view).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Invert { .. } | vectorcraft_tools::Overlay::Highlight { .. })), "the highlighted range remains visible");     s.execute("select.none", &json!({})).unwrap();     assert!(s.tool_options().get("editing").is_none_or(Value::is_null));     s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs--- a/crates/tools/src/lib.rs+++ b/crates/tools/src/lib.rs@@ -469,6 +469,8 @@ pub enum Overlay {     Distance { a: Point, b: Point, text: String, color: [u8; 3] },     /// Translucent filled quad (text selection highlight), RGBA.     Highlight { quad: [Point; 4], color: [u8; 4] },+    /// Inverting quad (inverse-video text selection highlight, matching reference behaviour).+    Invert { quad: [Point; 4] },     /// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected.     Swatch { p: Point, color: [u8; 4], selected: bool },     /// A hairline in a translucent colour (perspective gridlines), RGBA.diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs--- a/crates/tools/src/text.rs+++ b/crates/tools/src/text.rs@@ -1486,7 +1486,7 @@ impl Tool for TypeTool {             o.push(Overlay::Line { a: t.xf * p, b: t.xf * q, color: [0, 0, 0], dashed: false });         } else {             for q in vectorcraft_text::selection_quads(&lay, a, b) {-                o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] });+                o.push(Overlay::Invert { quad: q.map(|p| t.xf * p) });             }         }         if let Some(id) = self.editing.filter(|id| shown && ported(cx.doc, *id).is_some()) {diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs--- a/crates/tools/src/text_tests.rs+++ b/crates/tools/src/text_tests.rs@@ -342,7 +342,7 @@ fn drag_selects_and_overlay_highlights() {     tool.pointer(&c, &PointerEvent::new(PointerKind::Up, b.x + 0.1, b.y));     assert_eq!(tool.sel(), (7, 9));     let o = tool.overlays(&c);-    assert!(o.iter().any(|o| matches!(o, Overlay::Highlight { .. })));+    assert!(o.iter().any(|o| matches!(o, Overlay::Invert { .. } | Overlay::Highlight { .. })));     let opts = tool.options();     assert_eq!((opts["start"].as_u64(), opts["end"].as_u64()), (Some(7), Some(9))); }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@@ -3404,6 +3404,22 @@ fn names_an_artboard(text: &str) -> bool { }  fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {+    let invert_quads: Vec<[egui::Pos2; 4]> = overlays+        .iter()+        .filter_map(|o| match o {+            Overlay::Invert { quad } => Some([+                xf.to_screen(quad[0]),+                xf.to_screen(quad[1]),+                xf.to_screen(quad[2]),+                xf.to_screen(quad[3]),+            ]),+            _ => None,+        })+        .collect();+    if !invert_quads.is_empty() {+        crate::gpu_invert::paint_invert_quads(p, &invert_quads);+    }+     for o in overlays {         match o {             // Where the tools select: the document rectangle, turned with the view.@@ -3444,6 +3460,7 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, l                 let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);                 p.add(Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), c, Stroke::NONE));             }+            Overlay::Invert { .. } => {}             Overlay::Measure { p: pt, text } => {                 let sp = xf.to_screen(*pt) + vec2(14.0, 14.0);                 let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0);diff --git a/crates/ui-egui/src/gpu_invert.rs b/crates/ui-egui/src/gpu_invert.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/gpu_invert.rs@@ -0,0 +1,274 @@+use egui_wgpu::CallbackTrait;++const MAX_QUADS: usize = 64;++const INVERT_WGSL: &str = r#"+struct VertexOutput {+    @builtin(position) clip_position: vec4<f32>,+};++struct Quad {+    v0: vec2<f32>,+    v1: vec2<f32>,+    v2: vec2<f32>,+    v3: vec2<f32>,+};++struct QuadsUniform {+    quads: array<Quad, 64>,+};++@group(0) @binding(0)+var<uniform> u_data: QuadsUniform;++@vertex+fn vs_main(@builtin(vertex_index) v_idx: u32, @builtin(instance_index) i_idx: u32) -> VertexOutput {+    let q = u_data.quads[i_idx];+    var p = vec2<f32>(0.0, 0.0);+    switch (v_idx) {+        case 0u: { p = q.v0; }+        case 1u: { p = q.v1; }+        case 2u: { p = q.v2; }+        case 3u: { p = q.v0; }+        case 4u: { p = q.v2; }+        default: { p = q.v3; }+    }+    var out: VertexOutput;+    out.clip_position = vec4<f32>(p.x, p.y, 0.0, 1.0);+    return out;+}++@fragment+fn fs_main() -> @location(0) vec4<f32> {+    return vec4<f32>(1.0, 1.0, 1.0, 1.0);+}+"#;++#[repr(C)]+#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]+pub struct GpuQuad {+    pub v0: [f32; 2],+    pub v1: [f32; 2],+    pub v2: [f32; 2],+    pub v3: [f32; 2],+}++pub struct InvertResources {+    pub pipeline: wgpu::RenderPipeline,+    pub uniform_buffer: wgpu::Buffer,+    pub bind_group: wgpu::BindGroup,+}++impl InvertResources {+    pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {+        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {+            label: Some("invert_shader"),+            source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(INVERT_WGSL)),+        });++        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {+            label: Some("invert_quads_ubo"),+            size: (MAX_QUADS * std::mem::size_of::<GpuQuad>()) as u64,+            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,+            mapped_at_creation: false,+        });++        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {+            label: Some("invert_bind_group_layout"),+            entries: &[wgpu::BindGroupLayoutEntry {+                binding: 0,+                visibility: wgpu::ShaderStages::VERTEX,+                ty: wgpu::BindingType::Buffer {+                    ty: wgpu::BufferBindingType::Uniform,+                    has_dynamic_offset: false,+                    min_binding_size: None,+                },+                count: None,+            }],+        });++        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {+            label: Some("invert_bind_group"),+            layout: &bind_group_layout,+            entries: &[wgpu::BindGroupEntry {+                binding: 0,+                resource: uniform_buffer.as_entire_binding(),+            }],+        });++        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {+            label: Some("invert_pipeline_layout"),+            bind_group_layouts: &[Some(&bind_group_layout)],+            immediate_size: 0,+        });++        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {+            label: Some("invert_pipeline"),+            layout: Some(&pipeline_layout),+            vertex: wgpu::VertexState {+                module: &shader,+                entry_point: Some("vs_main"),+                compilation_options: Default::default(),+                buffers: &[],+            },+            fragment: Some(wgpu::FragmentState {+                module: &shader,+                entry_point: Some("fs_main"),+                compilation_options: Default::default(),+                targets: &[Some(wgpu::ColorTargetState {+                    format,+                    blend: Some(wgpu::BlendState {+                        color: wgpu::BlendComponent {+                            src_factor: wgpu::BlendFactor::OneMinusDst,+                            dst_factor: wgpu::BlendFactor::Zero,+                            operation: wgpu::BlendOperation::Add,+                        },+                        alpha: wgpu::BlendComponent {+                            src_factor: wgpu::BlendFactor::Zero,+                            dst_factor: wgpu::BlendFactor::One,+                            operation: wgpu::BlendOperation::Add,+                        },+                    }),+                    write_mask: wgpu::ColorWrites::ALL,+                })],+            }),+            primitive: wgpu::PrimitiveState {+                topology: wgpu::PrimitiveTopology::TriangleList,+                strip_index_format: None,+                front_face: wgpu::FrontFace::Ccw,+                cull_mode: None,+                unclipped_depth: false,+                polygon_mode: wgpu::PolygonMode::Fill,+                conservative: false,+            },+            depth_stencil: None,+            multisample: wgpu::MultisampleState {+                count: 1,+                mask: !0,+                alpha_to_coverage_enabled: false,+            },+            multiview_mask: None,+            cache: None,+        });++        Self {+            pipeline,+            uniform_buffer,+            bind_group,+        }+    }+}++pub struct InvertCallback {+    pub quads: Vec<[egui::Pos2; 4]>,+}++impl CallbackTrait for InvertCallback {+    fn prepare(+        &self,+        _device: &wgpu::Device,+        queue: &wgpu::Queue,+        screen_descriptor: &egui_wgpu::ScreenDescriptor,+        _egui_encoder: &mut wgpu::CommandEncoder,+        callback_resources: &mut egui_wgpu::CallbackResources,+    ) -> Vec<wgpu::CommandBuffer> {+        let Some(res) = callback_resources.get::<InvertResources>() else {+            return Vec::new();+        };++        let w = screen_descriptor.size_in_pixels[0] as f32 / screen_descriptor.pixels_per_point.max(0.001);+        let h = screen_descriptor.size_in_pixels[1] as f32 / screen_descriptor.pixels_per_point.max(0.001);+        let to_ndc = |p: egui::Pos2| -> [f32; 2] {+            [(p.x / w) * 2.0 - 1.0, 1.0 - (p.y / h) * 2.0]+        };++        let mut gpu_quads = [GpuQuad {+            v0: [0.0; 2],+            v1: [0.0; 2],+            v2: [0.0; 2],+            v3: [0.0; 2],+        }; MAX_QUADS];++        let count = self.quads.len().min(MAX_QUADS);+        for i in 0..count {+            let q = &self.quads[i];+            gpu_quads[i] = GpuQuad {+                v0: to_ndc(q[0]),+                v1: to_ndc(q[1]),+                v2: to_ndc(q[2]),+                v3: to_ndc(q[3]),+            };+        }++        queue.write_buffer(&res.uniform_buffer, 0, bytemuck::cast_slice(&gpu_quads[..count]));++        Vec::new()+    }++    fn paint(+        &self,+        info: egui::PaintCallbackInfo,+        render_pass: &mut wgpu::RenderPass<'static>,+        callback_resources: &egui_wgpu::CallbackResources,+    ) {+        let count = self.quads.len().min(MAX_QUADS);+        if count == 0 {+            return;+        }+        let Some(res) = callback_resources.get::<InvertResources>() else {+            return;+        };++        let clip = info.clip_rect_in_pixels();+        let x = clip.left_px.max(0) as u32;+        let y = clip.top_px.max(0) as u32;+        let width = (clip.width_px.max(0) as u32).min(info.screen_size_px[0].saturating_sub(x));+        let height = (clip.height_px.max(0) as u32).min(info.screen_size_px[1].saturating_sub(y));+        if width == 0 || height == 0 {+            return;+        }++        render_pass.set_viewport(+            0.0,+            0.0,+            info.screen_size_px[0] as f32,+            info.screen_size_px[1] as f32,+            0.0,+            1.0,+        );+        render_pass.set_scissor_rect(x, y, width, height);++        render_pass.set_pipeline(&res.pipeline);+        render_pass.set_bind_group(0, &res.bind_group, &[]);+        render_pass.draw(0..6, 0..(count as u32));+    }+}++pub fn setup(rs: &egui_wgpu::RenderState) {+    let resources = InvertResources::new(&rs.device, rs.target_format);+    rs.renderer.write().callback_resources.insert(resources);+}++pub fn paint_invert_quads(painter: &egui::Painter, quads: &[[egui::Pos2; 4]]) {+    if quads.is_empty() {+        return;+    }+    let mut min = egui::Pos2::new(f32::INFINITY, f32::INFINITY);+    let mut max = egui::Pos2::new(f32::NEG_INFINITY, f32::NEG_INFINITY);+    for q in quads {+        for pt in q {+            min.x = min.x.min(pt.x);+            min.y = min.y.min(pt.y);+            max.x = max.x.max(pt.x);+            max.y = max.y.max(pt.y);+        }+    }+    let rect = egui::Rect::from_min_max(min, max);+    let cb = egui_wgpu::Callback::new_paint_callback(+        rect,+        InvertCallback {+            quads: quads.to_vec(),+        },+    );+    painter.add(cb);+}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@@ -30,6 +30,7 @@ pub mod find_font; pub mod font_menu; mod frame_rate; mod free_transform;+pub mod gpu_invert; pub mod graphics; pub mod i18n; pub mod icon_data;