Преглед на файлове

app/text: add text shadow/glow

x преди 2 седмици
родител
ревизия
c3ea7f017e
променени са 5 файла, в които са добавени 382 реда и са изтрити 32 реда
  1. 37 0
      bin/app/src/app/node.rs
  2. 145 17
      bin/app/src/text/atlas.rs
  3. 4 1
      bin/app/src/text/mod.rs
  4. 148 11
      bin/app/src/text/render.rs
  5. 48 3
      bin/app/src/ui/text.rs

+ 37 - 0
bin/app/src/app/node.rs

@@ -564,6 +564,43 @@ pub fn create_text(name: &str) -> SceneNode {
     prop.set_defaults_str(vec!["normal".to_string()]).unwrap();
     node.add_property(prop).unwrap();
 
+    let mut prop = Property::new(
+        "text_shadow_color",
+        PropertyType::Float32,
+        PropertySubType::Color,
+        PERM_STYLE,
+    );
+    prop.set_ui_text("Text Shadow Color", "Shadow/glow color behind the text (alpha 0 disables)");
+    prop.set_array_len(4);
+    prop.set_range_f32(0., 1.);
+    prop.set_defaults_f32(vec![0., 0., 0., 0.]).unwrap();
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new(
+        "text_shadow_offset",
+        PropertyType::Float32,
+        PropertySubType::Pixel,
+        PERM_STYLE,
+    );
+    prop.set_ui_text("Text Shadow Offset", "Shadow offset from the text - x, y");
+    prop.set_array_len(2);
+    prop.set_defaults_f32(vec![0., 0.]).unwrap();
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
+    let mut prop = Property::new(
+        "text_shadow_blur",
+        PropertyType::Float32,
+        PropertySubType::Pixel,
+        PERM_STYLE,
+    );
+    prop.set_ui_text("Text Shadow Blur", "Shadow blur radius (0 for a hard shadow)");
+    prop.set_range_f32(0., f32::MAX);
+    prop.set_defaults_f32(vec![0.]).unwrap();
+    prop.allow_exprs();
+    node.add_property(prop).unwrap();
+
     let prop = Property::new("z_index", PropertyType::Uint32, PropertySubType::Null, PERM_APP);
     node.add_property(prop).unwrap();
 

+ 145 - 17
bin/app/src/text/atlas.rs

@@ -135,6 +135,9 @@ impl AtlasLayout {
 pub struct Atlas<'a> {
     glyph_keys: Vec<GlyphKey>,
     sprites: Vec<swash::scale::image::Image>,
+    shadow_keys: Vec<GlyphKey>,
+    shadow_sprites: Vec<swash::scale::image::Image>,
+
     layout: AtlasLayout,
 
     renderer: &'a Renderer,
@@ -152,6 +155,8 @@ impl<'a> Atlas<'a> {
         Self {
             glyph_keys: vec![],
             sprites: vec![],
+            shadow_keys: vec![],
+            shadow_sprites: vec![],
             layout: AtlasLayout::new(max_row_width),
 
             renderer,
@@ -193,6 +198,37 @@ impl<'a> Atlas<'a> {
         self.layout.push(glyph_width, glyph_height);
     }
 
+    pub fn push_shadow_glyph(
+        &mut self,
+        glyph_id: swash::GlyphId,
+        run_idx: RunIdx,
+        scaler: &mut swash::scale::Scaler,
+        blur_px: f32,
+    ) {
+        let glyph_key = (glyph_id, run_idx);
+        if self.shadow_keys.contains(&glyph_key) {
+            return
+        }
+
+        let Some(mask) = swash::scale::Render::new(&[swash::scale::Source::Outline])
+            .format(zeno::Format::Alpha)
+            .render(scaler, glyph_id)
+        else {
+            return
+        };
+        if mask.data.is_empty() {
+            return
+        }
+
+        let blurred = blur_mask(&mask, blur_px);
+        let width = blurred.placement.width as usize;
+        let height = blurred.placement.height as usize;
+
+        self.shadow_keys.push(glyph_key);
+        self.shadow_sprites.push(blurred);
+        self.layout.push(width, height);
+    }
+
     fn render(&self) -> Vec<u8> {
         let mut atlas = vec![255, 255, 255, 0].repeat(self.layout.width * self.layout.height);
         // For drawing debug lines we want a single white pixel.
@@ -204,7 +240,9 @@ impl<'a> Atlas<'a> {
 
         // Copy all the sprites to our atlas.
         // They should have ATLAS_GAP spacing on all sides to avoid bleeding.
-        for (sprite, (x, y)) in self.sprites.iter().zip(self.layout.positions.iter()) {
+        for (sprite, (x, y)) in
+            self.sprites.iter().chain(&self.shadow_sprites).zip(self.layout.positions.iter())
+        {
             copy_image(sprite, *x, *y, &mut atlas, self.layout.width);
         }
 
@@ -213,18 +251,18 @@ impl<'a> Atlas<'a> {
 
     fn compute_uvs(&self) -> Vec<Rectangle> {
         // UV coords are in the range [0, 1]
-        let mut uvs = Vec::with_capacity(self.sprites.len());
-
-        for (i, sprite) in self.sprites.iter().enumerate() {
-            let uv_rect = self.layout.uv_rect(
-                i,
-                sprite.placement.width as usize,
-                sprite.placement.height as usize,
-            );
-            uvs.push(uv_rect);
-        }
-
-        uvs
+        self.sprites
+            .iter()
+            .chain(&self.shadow_sprites)
+            .enumerate()
+            .map(|(i, sprite)| {
+                self.layout.uv_rect(
+                    i,
+                    sprite.placement.width as usize,
+                    sprite.placement.height as usize,
+                )
+            })
+            .collect()
     }
 
     /// Debug method
@@ -247,7 +285,8 @@ impl<'a> Atlas<'a> {
         //}
 
         assert_eq!(self.glyph_keys.len(), self.sprites.len());
-        assert_eq!(self.glyph_keys.len(), self.layout.positions.len());
+        assert_eq!(self.shadow_keys.len(), self.shadow_sprites.len());
+        assert_eq!(self.glyph_keys.len() + self.shadow_keys.len(), self.layout.positions.len());
 
         if self.layout.max_row_width != usize::MAX {
             assert!(
@@ -275,18 +314,92 @@ impl<'a> Atlas<'a> {
 
         let uv_rects = self.compute_uvs();
         let glyph_keys = self.glyph_keys;
+        let shadow_keys = self.shadow_keys;
+        let (main_uvs, shadow_uvs) = uv_rects.split_at(self.sprites.len());
 
         let mut infos = Vec::with_capacity(self.sprites.len());
-        for (uv_rect, sprite) in uv_rects.into_iter().zip(self.sprites.into_iter()) {
+        for (uv_rect, sprite) in main_uvs.iter().zip(self.sprites.iter()) {
             let is_color = match sprite.content {
                 swash::scale::image::Content::Mask => false,
                 swash::scale::image::Content::SubpixelMask => unimplemented!(),
                 swash::scale::image::Content::Color => true,
             };
-            infos.push(GlyphInfo { uv_rect, place: sprite.placement, is_color });
+            infos.push(GlyphInfo { uv_rect: *uv_rect, place: sprite.placement, is_color });
         }
 
-        RenderedAtlas { glyph_keys, infos, texture }
+        let shadow_infos = shadow_uvs
+            .iter()
+            .zip(self.shadow_sprites.iter())
+            .map(|(uv_rect, sprite)| GlyphInfo {
+                uv_rect: *uv_rect,
+                place: sprite.placement,
+                is_color: false,
+            })
+            .collect();
+
+        RenderedAtlas { glyph_keys, infos, shadow_keys, shadow_infos, texture }
+    }
+}
+
+/// Blur a glyph alpha mask.
+fn blur_mask(mask: &swash::scale::image::Image, blur_px: f32) -> swash::scale::image::Image {
+    let radius = (blur_px / 2.).round().max(1.) as usize;
+    let pad = if blur_px > 0. { 2 * radius } else { 0 };
+
+    let in_w = mask.placement.width as usize;
+    let in_h = mask.placement.height as usize;
+    let place = zeno::Placement {
+        left: mask.placement.left - pad as i32,
+        top: mask.placement.top + pad as i32,
+        width: mask.placement.width + 2 * pad as u32,
+        height: mask.placement.height + 2 * pad as u32,
+    };
+
+    // Pad the mask into the output buffer; the border ring stays zero
+    let w = place.width as usize;
+    let h = place.height as usize;
+    let mut buf = vec![0u8; w * h];
+    for row in 0..in_h {
+        let src = row * in_w;
+        let dst = (row + pad) * w + pad;
+        buf[dst..dst + in_w].copy_from_slice(&mask.data[src..src + in_w]);
+    }
+
+    if pad > 0 {
+        box_blur_pass(&mut buf, w, h, radius);
+        box_blur_pass(&mut buf, w, h, radius);
+    }
+
+    swash::scale::image::Image {
+        source: mask.source,
+        content: swash::scale::image::Content::Mask,
+        placement: place,
+        data: buf,
+    }
+}
+
+/// In-place box blur pass (horizontal then vertical).
+fn box_blur_pass(buf: &mut [u8], w: usize, h: usize, radius: usize) {
+    let div = (2 * radius + 1) as u32;
+    let mut tmp = vec![0u8; buf.len()];
+
+    for y in 0..h {
+        let row = y * w;
+        for x in 0..w {
+            let lo = x.saturating_sub(radius);
+            let hi = (x + radius).min(w - 1);
+            let acc: u32 = buf[row + lo..=row + hi].iter().map(|v| *v as u32).sum();
+            tmp[row + x] = (acc / div) as u8;
+        }
+    }
+
+    for x in 0..w {
+        for y in 0..h {
+            let lo = y.saturating_sub(radius);
+            let hi = (y + radius).min(h - 1);
+            let acc: u32 = (lo..=hi).map(|yy| tmp[yy * w + x] as u32).sum();
+            buf[y * w + x] = (acc / div) as u8;
+        }
     }
 }
 
@@ -357,6 +470,8 @@ pub struct GlyphInfo {
 pub struct RenderedAtlas {
     glyph_keys: Vec<GlyphKey>,
     infos: Vec<GlyphInfo>,
+    shadow_keys: Vec<GlyphKey>,
+    shadow_infos: Vec<GlyphInfo>,
     /// Allocated atlas texture.
     pub texture: ManagedTexturePtr,
 }
@@ -375,6 +490,19 @@ impl RenderedAtlas {
         }
         None
     }
+
+    /// Get UV coords for a glyph's blurred shadow silhouette.
+    pub fn fetch_uv_shadow(&self, glyph_id: swash::GlyphId, run_idx: RunIdx) -> Option<&GlyphInfo> {
+        assert_eq!(self.shadow_keys.len(), self.shadow_infos.len());
+
+        let glyph_key = (glyph_id, run_idx);
+        for i in 0..self.shadow_keys.len() {
+            if self.shadow_keys[i] == glyph_key {
+                return Some(&self.shadow_infos[i])
+            }
+        }
+        None
+    }
 }
 
 #[cfg(test)]

+ 4 - 1
bin/app/src/text/mod.rs

@@ -69,7 +69,10 @@ pub use editor::Editor;
 mod render;
 #[cfg(not(target_os = "android"))]
 pub use render::render_raw_layout;
-pub use render::{render_backgrounds, render_layout, render_layout_with_opts, DebugRenderOptions};
+pub use render::{
+    render_backgrounds, render_layout, render_layout_shadowed, render_layout_with_opts,
+    DebugRenderOptions, Shadow,
+};
 mod string_atlas;
 pub use string_atlas::make_string_atlas;
 

+ 148 - 11
bin/app/src/text/render.rs

@@ -22,7 +22,7 @@ use crate::{
 };
 
 use super::{
-    atlas::{Atlas, RenderedAtlas, RunIdx},
+    atlas::{Atlas, GlyphInfo, RenderedAtlas, RunIdx},
     TextLayout,
 };
 
@@ -60,6 +60,15 @@ pub fn render_layout(
     render_layout_with_opts(layout, DebugRenderOptions::OFF, renderer, tag)
 }
 
+/// A text shadow/glow effect. `offset=(0,0)` + `blur>0` gives an outer
+/// glow, an offset gives a drop shadow; blur 0 gives a hard edge.
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct Shadow {
+    pub color: Color,
+    pub offset: Point,
+    pub blur: f32,
+}
+
 /// Render a raw parley layout that was built with the given scale. Used
 /// by editors that own their layout internally (e.g. `PlainEditor`).
 #[cfg(not(target_os = "android"))]
@@ -69,7 +78,7 @@ pub fn render_raw_layout(
     renderer: &Renderer,
     tag: DebugTag,
 ) -> Vec<DrawInstruction> {
-    render_raw_layout_impl(layout, scale, DebugRenderOptions::OFF, renderer, tag).0
+    render_raw_layout_impl(layout, scale, DebugRenderOptions::OFF, None, renderer, tag).0
 }
 
 /// Draw a filled (and optionally outlined) background box behind every glyph run
@@ -135,7 +144,17 @@ pub fn render_layout_with_opts(
     renderer: &Renderer,
     tag: DebugTag,
 ) -> Vec<DrawInstruction> {
-    render_raw_layout_impl(layout, layout.scale(), opts, renderer, tag).0
+    render_raw_layout_impl(layout, layout.scale(), opts, None, renderer, tag).0
+}
+
+pub fn render_layout_shadowed(
+    layout: &TextLayout,
+    shadow: Option<&Shadow>,
+    opts: DebugRenderOptions,
+    renderer: &Renderer,
+    tag: DebugTag,
+) -> Vec<DrawInstruction> {
+    render_raw_layout_impl(layout, layout.scale(), opts, shadow, renderer, tag).0
 }
 
 /// Layout coordinates are physical (scale is baked in by parley) while
@@ -147,9 +166,13 @@ fn render_raw_layout_impl(
     layout: &parley::Layout<Color>,
     scale: f32,
     opts: DebugRenderOptions,
+    shadow: Option<&Shadow>,
     renderer: &Renderer,
     tag: DebugTag,
 ) -> (Vec<DrawInstruction>, Rectangle) {
+    let has_shadow = shadow.map_or(false, |s| s.color[3] > 0.);
+    let shadow_blur_px = shadow.map_or(0., |s| s.blur * scale);
+
     // First pass to create atlas
     let mut scale_ctx = swash::scale::ScaleContext::new();
     let mut atlas = Atlas::new(renderer, tag);
@@ -159,6 +182,16 @@ fn render_raw_layout_impl(
             match item {
                 parley::PositionedLayoutItem::GlyphRun(glyph_run) => {
                     push_glyphs(&mut atlas, &glyph_run, run_idx, &mut scale_ctx);
+                    // Skip shadow if it has a fully transparent color
+                    if has_shadow {
+                        push_shadow_glyphs(
+                            &mut atlas,
+                            &glyph_run,
+                            run_idx,
+                            &mut scale_ctx,
+                            shadow_blur_px,
+                        );
+                    }
                     run_idx += 1;
                 }
                 parley::PositionedLayoutItem::InlineBox(_) => {}
@@ -177,8 +210,16 @@ fn render_raw_layout_impl(
         for item in line.items() {
             match item {
                 parley::PositionedLayoutItem::GlyphRun(glyph_run) => {
-                    let (mesh, run_bounds) =
-                        render_glyph_run(&glyph_run, run_idx, opts, &atlas, scale, renderer, tag);
+                    let (mesh, run_bounds) = render_glyph_run(
+                        &glyph_run,
+                        run_idx,
+                        opts,
+                        shadow.filter(|_| has_shadow),
+                        &atlas,
+                        scale,
+                        renderer,
+                        tag,
+                    );
                     bounds.join(run_bounds);
                     instrs.push(DrawInstruction::Draw(mesh));
                     run_idx += 1;
@@ -214,10 +255,46 @@ pub(super) fn push_glyphs(
     }
 }
 
+// TODO: deduplicate code with push_glyphs()
+pub(super) fn push_shadow_glyphs(
+    atlas: &mut Atlas,
+    glyph_run: &parley::GlyphRun<'_, Color>,
+    run_idx: RunIdx,
+    scale_ctx: &mut swash::scale::ScaleContext,
+    blur_px: f32,
+) {
+    let run = glyph_run.run();
+    let font = run.font();
+    let font_size = run.font_size();
+    let normalized_coords = run.normalized_coords();
+    let font_ref = swash::FontRef::from_index(font.data.as_ref(), font.index as usize).unwrap();
+
+    let mut scaler = scale_ctx
+        .builder(font_ref)
+        .size(font_size)
+        .hint(true)
+        .normalized_coords(normalized_coords)
+        .build();
+
+    for glyph in glyph_run.glyphs() {
+        atlas.push_shadow_glyph(glyph.id as u16, run_idx, &mut scaler, blur_px);
+    }
+}
+
+fn glyph_rect(glyph_x: f32, glyph_y: f32, glyph_inf: &GlyphInfo, scale: f32) -> Rectangle {
+    Rectangle::new(
+        (glyph_x + glyph_inf.place.left as f32) / scale,
+        (glyph_y - glyph_inf.place.top as f32) / scale,
+        glyph_inf.place.width as f32 / scale,
+        glyph_inf.place.height as f32 / scale,
+    )
+}
+
 fn render_glyph_run(
     glyph_run: &parley::GlyphRun<'_, Color>,
     run_idx: usize,
     opts: DebugRenderOptions,
+    shadow: Option<&Shadow>,
     atlas: &RenderedAtlas,
     scale: f32,
     renderer: &Renderer,
@@ -236,6 +313,24 @@ fn render_glyph_run(
         render_underline(underline, glyph_run, scale, &mut mesh);
     }
 
+    // Shadow quads first so glyphs draw on top within the same mesh
+    if let Some(shadow) = shadow {
+        for glyph in glyph_run.glyphs() {
+            let glyph_x = run_x + glyph.x;
+            let glyph_y = run_y - glyph.y;
+            run_x += glyph.advance;
+
+            // Color glyphs have no alpha-mask silhouette in the atlas
+            let Some(glyph_inf) = atlas.fetch_uv_shadow(glyph.id as u16, run_idx) else { continue };
+
+            let shadow_rect = glyph_rect(glyph_x, glyph_y, glyph_inf, scale) + shadow.offset;
+            bounds.add(shadow_rect);
+            mesh.draw_box(&shadow_rect, shadow.color, &glyph_inf.uv_rect);
+        }
+
+        run_x = glyph_run.offset();
+    }
+
     for glyph in glyph_run.glyphs() {
         let glyph_inf = atlas.fetch_uv(glyph.id as u16, run_idx).expect("missing glyph UV rect");
 
@@ -243,12 +338,7 @@ fn render_glyph_run(
         let glyph_y = run_y - glyph.y;
         run_x += glyph.advance;
 
-        let glyph_rect = Rectangle::new(
-            (glyph_x + glyph_inf.place.left as f32) / scale,
-            (glyph_y - glyph_inf.place.top as f32) / scale,
-            glyph_inf.place.width as f32 / scale,
-            glyph_inf.place.height as f32 / scale,
-        );
+        let glyph_rect = glyph_rect(glyph_x, glyph_y, glyph_inf, scale);
 
         bounds.add(glyph_rect);
 
@@ -301,3 +391,50 @@ fn render_underline(
 
     mesh.draw_line(start, end, color, width / scale);
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{gfx::Renderer, text::make_layout};
+
+    /// Count quads (6 indices each) across all emitted draw instrs.
+    fn count_quads(instrs: &[DrawInstruction], num_elements_of: impl Fn(&DrawMesh) -> i32) -> i32 {
+        let mut quads = 0;
+        for instr in instrs {
+            let DrawInstruction::Draw(mesh) = instr else { continue };
+            quads += num_elements_of(mesh) / 6;
+        }
+        quads
+    }
+
+    #[test]
+    fn test_shadow_quads_double_and_alpha_zero_is_noop() {
+        let (method_send, _method_recv) = async_channel::unbounded();
+        let renderer = Renderer::new(method_send);
+
+        let layout = make_layout("Hello", COLOR_WHITE, 40., 1., 1., None, &[]);
+
+        let plain = render_layout(&layout, &renderer, None);
+        let plain_quads = count_quads(&plain, |m| m.num_elements);
+
+        let off_shadow = Shadow { color: [1., 0., 0., 0.], offset: Point::new(2., 2.), blur: 4. };
+        let off = render_layout_shadowed(
+            &layout,
+            Some(&off_shadow),
+            DebugRenderOptions::OFF,
+            &renderer,
+            None,
+        );
+        assert_eq!(count_quads(&off, |m| m.num_elements), plain_quads);
+
+        let shadow = Shadow { color: [0., 0., 0., 1.], offset: Point::new(2., 2.), blur: 4. };
+        let shadowed = render_layout_shadowed(
+            &layout,
+            Some(&shadow),
+            DebugRenderOptions::OFF,
+            &renderer,
+            None,
+        );
+        assert_eq!(count_quads(&shadowed, |m| m.num_elements), plain_quads * 2);
+    }
+}

+ 48 - 3
bin/app/src/ui/text.rs

@@ -23,11 +23,11 @@ use std::sync::Arc;
 use tracing::instrument;
 
 use crate::{
-    gfx::{gfxtag, DrawCall, DrawInstruction, EpochCache, Rectangle, RenderApi, Renderer},
+    gfx::{gfxtag, DrawCall, DrawInstruction, EpochCache, Point, Rectangle, RenderApi, Renderer},
     mesh::MeshBuilder,
     prop::{
         PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyEnum, PropertyFloat32,
-        PropertyRect, PropertyStr, PropertyUint32, Role,
+        PropertyPoint, PropertyRect, PropertyStr, PropertyUint32, Role,
     },
     scene::{Pimpl, SceneNodeWeak},
     text,
@@ -58,6 +58,9 @@ pub struct Text {
     lineheight: PropertyFloat32,
     text_align: PropertyEnum,
     overflow_wrap: PropertyEnum,
+    text_shadow_color: PropertyColor,
+    text_shadow_offset: PropertyPoint,
+    text_shadow_blur: PropertyFloat32,
     use_i18n: PropertyBool,
     debug: PropertyBool,
 
@@ -88,6 +91,12 @@ impl Text {
         let text_align = PropertyEnum::wrap(node_ref, Role::Internal, "text_align", 0).unwrap();
         let overflow_wrap =
             PropertyEnum::wrap(node_ref, Role::Internal, "overflow_wrap", 0).unwrap();
+        let text_shadow_color =
+            PropertyColor::wrap(node_ref, Role::Internal, "text_shadow_color").unwrap();
+        let text_shadow_offset =
+            PropertyPoint::wrap(node_ref, Role::Internal, "text_shadow_offset").unwrap();
+        let text_shadow_blur =
+            PropertyFloat32::wrap(node_ref, Role::Internal, "text_shadow_blur", 0).unwrap();
         let use_i18n = PropertyBool::wrap(node_ref, Role::Internal, "use_i18n", 0).unwrap();
         let debug = PropertyBool::wrap(node_ref, Role::Internal, "debug", 0).unwrap();
 
@@ -111,6 +120,9 @@ impl Text {
             lineheight,
             text_align,
             overflow_wrap,
+            text_shadow_color,
+            text_shadow_offset,
+            text_shadow_blur,
             use_i18n,
             debug,
 
@@ -167,13 +179,31 @@ impl Text {
         )
     }
 
+    /// Shadow/glow settings from the style props. `None` when the
+    /// shadow color alpha is 0, which disables the effect entirely.
+    fn get_shadow(&self) -> Option<text::Shadow> {
+        let color = self.text_shadow_color.get();
+        (color[3] > 0.).then(|| text::Shadow {
+            color,
+            offset: self.text_shadow_offset.get(),
+            blur: self.text_shadow_blur.get(),
+        })
+    }
+
     fn regen_mesh(&self, layout: &text::TextLayout) -> Vec<DrawInstruction> {
         let mut debug_opts = text::DebugRenderOptions::OFF;
         if self.debug.get() {
             debug_opts |= text::DebugRenderOptions::BASELINE;
         }
 
-        text::render_layout_with_opts(layout, debug_opts, &self.renderer, gfxtag!("text"))
+        let shadow = self.get_shadow();
+        text::render_layout_shadowed(
+            layout,
+            shadow.as_ref(),
+            debug_opts,
+            &self.renderer,
+            gfxtag!("text"),
+        )
     }
 
     fn get_draw_calls(
@@ -197,6 +227,9 @@ impl Text {
         self.text_color.eval(atom).expect("text_color");
         self.font_size.eval(atom).expect("font_size");
         self.lineheight.eval(atom).expect("lineheight");
+        self.text_shadow_color.eval(atom).expect("text_shadow_color");
+        self.text_shadow_offset.eval(atom).expect("text_shadow_offset");
+        self.text_shadow_blur.eval(atom).expect("text_shadow_blur");
 
         // Layout depends on the width, so a rect change invalidates the
         // layout even if the text itself did not change. Compute under the
@@ -273,6 +306,18 @@ impl UIObject for Text {
             self_.draw_cache.clear();
             self_.redraw.trigger();
         });
+        on_modify.when_change_external(self.text_shadow_color.prop(), |self_, _| async move {
+            self_.draw_cache.clear();
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.text_shadow_offset.prop(), |self_, _| async move {
+            self_.draw_cache.clear();
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.text_shadow_blur.prop(), |self_, _| async move {
+            self_.draw_cache.clear();
+            self_.redraw.trigger();
+        });
         on_modify.when_change_external(self.debug.prop(), |self_, _| async move {
             self_.draw_cache.clear();
             self_.redraw.trigger();