Repository: LiveSplit/livesplit-core
Pull request #863: Initial drop shadow support
Author: login: Skajdrowski
Labels: name: enhancement
description: An improvement for livesplit-core.
name: rendering
description: The issue or pull request is affecting the rendering.
name: feature
description: A new user visible feature for livesplit-core.
name: parsing
description: This is about one of the parsers.
name: text engine
description: This is about the text engine implementation.
Created: 2025-03-14T22:00:40Z | Merged: 2025-03-20T18:41:50Z

Transparent background renders everything without a shadow, which obviously causes bright text to be unreadable on bright backgrounds.

Offset of the shadows is hard coded, no idea how to handle it being elegant

Thx @AlexKnauth for helping me putting together this PR.


Diff:
diff --git a/capi/src/server_protocol.rs b/capi/src/server_protocol.rs
index f0e457f2f..129f80da7 100644
--- a/capi/src/server_protocol.rs
+++ b/capi/src/server_protocol.rs
@@ -19,7 +19,8 @@ pub struct ServerProtocol {}
 impl ServerProtocol {
     /// Handles an incoming command and returns the response to be sent.
     pub async unsafe fn handleCommand(command: &str, commandSink: *const CommandSink) -> String {
-        server_protocol::handle_command(command, &*commandSink).await
+        // SAFETY: The caller must ensure that the pointer is valid.
+        server_protocol::handle_command(command, unsafe { &*commandSink }).await
     }
 
     /// Encodes an event that happened to be sent.
diff --git a/capi/src/web_rendering.rs b/capi/src/web_rendering.rs
index 684bfb796..621f36a86 100644
--- a/capi/src/web_rendering.rs
+++ b/capi/src/web_rendering.rs
@@ -41,6 +41,9 @@ impl WebRenderer {
         state: *const LayoutState,
         image_cache: *const ImageCache,
     ) -> Option<Box<[f32]>> {
-        self.inner.render(&*state, &*image_cache).map(Box::from)
+        // SAFETY: The caller must ensure that the pointers are valid.
+        unsafe {
+            self.inner.render(&*state, &*image_cache).map(Box::from)
+        }
     }
 }
diff --git a/src/layout/general_settings.rs b/src/layout/general_settings.rs
index f63bc2748..cb979d524 100644
--- a/src/layout/general_settings.rs
+++ b/src/layout/general_settings.rs
@@ -22,6 +22,8 @@ pub struct GeneralSettings {
     /// The font to use for regular text. `None` means a default font should be
     /// used.
     pub text_font: Option<Font>,
+    /// The color to use for drawn shadows.
+    pub text_shadow: Option<Color>,
     /// The background to show behind the layout.
     pub background: LayoutBackground,
     /// The color to use for when the runner achieved a best segment.
@@ -59,6 +61,7 @@ impl Default for GeneralSettings {
             timer_font: None,
             times_font: None,
             text_font: None,
+            text_shadow: Some(Color::hsla(0.0, 0.0, 0.0, 0.5)),
             background: LayoutBackground::Gradient(Gradient::Plain(Color::hsla(
                 0.0, 0.0, 0.06, 1.0,
             ))),
@@ -109,6 +112,11 @@ impl GeneralSettings {
                     .into(),
                 self.text_font.clone().into(),
             ),
+            Field::new(
+                "Text Shadow".into(),
+                "Allows you to optionally specify a color for text shadows.".into(),
+                self.text_shadow.into(),
+            ),
             Field::new(
                 "Background".into(),
                 "The background shown behind the entire layout.".into(),
@@ -189,18 +197,19 @@ impl GeneralSettings {
             1 => self.timer_font = value.into(),
             2 => self.times_font = value.into(),
             3 => self.text_font = value.into(),
-            4 => self.background = LayoutBackground::from(value).from_cache(image_cache),
-            5 => self.best_segment_color = value.into(),
-            6 => self.ahead_gaining_time_color = value.into(),
-            7 => self.ahead_losing_time_color = value.into(),
-            8 => self.behind_gaining_time_color = value.into(),
-            9 => self.behind_losing_time_color = value.into(),
-            10 => self.not_running_color = value.into(),
-            11 => self.personal_best_color = value.into(),
-            12 => self.paused_color = value.into(),
-            13 => self.thin_separators_color = value.into(),
-            14 => self.separators_color = value.into(),
-            15 => self.text_color = value.into(),
+            4 => self.text_shadow = value.into(),
+            5 => self.background = LayoutBackground::from(value).from_cache(image_cache),
+            6 => self.best_segment_color = value.into(),
+            7 => self.ahead_gaining_time_color = value.into(),
+            8 => self.ahead_losing_time_color = value.into(),
+            9 => self.behind_gaining_time_color = value.into(),
+            10 => self.behind_losing_time_color = value.into(),
+            11 => self.not_running_color = value.into(),
+            12 => self.personal_best_color = value.into(),
+            13 => self.paused_color = value.into(),
+            14 => self.thin_separators_color = value.into(),
+            15 => self.separators_color = value.into(),
+            16 => self.text_color = value.into(),
             _ => panic!("Unsupported Setting Index"),
         }
     }
diff --git a/src/layout/layout_state.rs b/src/layout/layout_state.rs
index 8ec0ace34..9fd61e97a 100644
--- a/src/layout/layout_state.rs
+++ b/src/layout/layout_state.rs
@@ -22,6 +22,8 @@ pub struct LayoutState {
     /// The font to use for regular text. `None` means a default font should be
     /// used.
     pub text_font: Option<Font>,
+    /// An optional text shadow color.
+    pub text_shadow: Option<Color>,
     /// The background to show behind the layout.
     pub background: LayoutBackground<ImageId>,
     /// The color of thin separators.
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index c187184ba..a9ed174b7 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -119,6 +119,7 @@ impl Layout {
         state.separators_color = settings.separators_color;
         state.text_color = settings.text_color;
         state.direction = settings.direction;
+        state.text_shadow = settings.text_shadow;
     }
 
     /// Calculates the layout's state based on the timer provided. You can use
diff --git a/src/layout/parser/mod.rs b/src/layout/parser/mod.rs
index 17e0c8798..705f20a03 100644
--- a/src/layout/parser/mod.rs
+++ b/src/layout/parser/mod.rs
@@ -3,7 +3,7 @@
 use super::{Component, Layout, LayoutDirection};
 use crate::{
     component::{separator, timer::DeltaGradient},
-    platform::{math::f32::powf, prelude::*},
+    platform::{math::f32::stable_powf, prelude::*},
     settings::{
         Alignment, BackgroundImage, Color, Font, FontStretch, FontStyle, FontWeight, Gradient,
         Image, LayoutBackground, ListGradient,
@@ -321,8 +321,8 @@ where
         // black. Because of that, we use 1.75 as the exponent denominator for
         // the white on black case instead of the usual 2.2 for sRGB.
         let lightness = (r + g + b) * (1.0 / 3.0);
-        color.alpha =
-            (1.0 - lightness) * (1.0 - powf(1.0 - a, 1.0 / 2.2)) + lightness * powf(a, 1.0 / 1.75);
+        color.alpha = (1.0 - lightness) * (1.0 - stable_powf(1.0 - a, 1.0 / 2.2))
+            + lightness * stable_powf(a, 1.0 / 1.75);
 
         func(color);
         Ok(())
@@ -708,6 +708,8 @@ fn parse_general_settings(layout: &mut Layout, reader: &mut Reader<'_>) -> Resul
     let mut background_image = None;
     let mut image_opacity = 1.0;
     let mut image_blur = 0.0;
+    let mut has_text_shadow = false;
+    let mut text_shadow_color = Color::transparent();
 
     parse_children(reader, |reader, tag, _| match tag.name() {
         "TextColor" => color(reader, |color| {
@@ -779,9 +781,13 @@ fn parse_general_settings(layout: &mut Layout, reader: &mut Reader<'_>) -> Resul
         }),
         "ImageOpacity" => percentage(reader, |v| image_opacity = v),
         "ImageBlur" => percentage(reader, |v| image_blur = v),
+        "DropShadows" => parse_bool(reader, |b| has_text_shadow = b),
+        "ShadowsColor" => color(reader, |color| text_shadow_color = color),
         _ => end_tag(reader),
     })?;
 
+    settings.text_shadow = has_text_shadow.then_some(text_shadow_color);
+
     settings.background = match background_builder.build() {
         Some(gradient) => LayoutBackground::Gradient(gradient),
         None => match background_image {
diff --git a/src/platform/math.rs b/src/platform/math.rs
index 857c9c1f0..f403aeb5f 100644
--- a/src/platform/math.rs
+++ b/src/platform/math.rs
@@ -1,20 +1,37 @@
-cfg_if::cfg_if! {
-    if #[cfg(all(feature = "std", not(test)))] {
-        pub mod f32 {
+pub mod f32 {
+    cfg_if::cfg_if! {
+        if #[cfg(all(feature = "std"))] {
             #[inline(always)]
             #[allow(clippy::missing_const_for_fn)] // Can't do this for the libm counterpart.
             pub fn abs(x: f32) -> f32 {
                 x.abs()
             }
+        } else {
+            pub use libm::fabsf as abs;
+        }
+    }
 
+    // FIXME: For our tests we need to ensure that the `powf` function behaves
+    // consistently across all platforms. Ideally we would only use it during
+    // testing, but cfg(test) apparently doesn't work for integration tests. We
+    // ensure consistent results by increasing the precision for the powf by
+    // using f64 internally.
+    #[inline(always)]
+    pub fn stable_powf(x: f32, y: f32) -> f32 {
+        super::f64::powf(x as f64, y as f64) as f32
+    }
+}
+
+mod f64 {
+    cfg_if::cfg_if! {
+        if #[cfg(all(feature = "std"))] {
             #[inline(always)]
-            pub fn powf(x: f32, y: f32) -> f32 {
+            #[allow(clippy::missing_const_for_fn)] // Can't do this for the libm counterpart.
+            pub fn powf(x: f64, y: f64) -> f64 {
                 x.powf(y)
             }
-        }
-    } else {
-        pub mod f32 {
-            pub use libm::{fabsf as abs, powf};
+        } else {
+            pub use libm::pow as powf;
         }
     }
 }
diff --git a/src/rendering/component/detailed_timer.rs b/src/rendering/component/detailed_timer.rs
index ca981ace2..ea169fae6 100644
--- a/src/rendering/component/detailed_timer.rs
+++ b/src/rendering/component/detailed_timer.rs
@@ -2,12 +2,13 @@ use crate::{
     component::detailed_timer::State,
     layout::LayoutState,
     rendering::{
+        RenderContext,
         component::timer,
-        consts::{vertical_padding, BOTH_PADDINGS, PADDING},
+        consts::{BOTH_PADDINGS, PADDING, vertical_padding},
         font::CachedLabel,
         resource::ResourceAllocator,
         scene::Layer,
-        solid, RenderContext,
+        solid,
     },
 };
 
diff --git a/src/rendering/component/key_value.rs b/src/rendering/component/key_value.rs
index 7a00752d2..39ffe087f 100644
--- a/src/rendering/component/key_value.rs
+++ b/src/rendering/component/key_value.rs
@@ -2,9 +2,9 @@ use crate::{
     component::key_value::State,
     layout::{LayoutDirection, LayoutState},
     rendering::{
+        RenderContext,
         font::{AbbreviatedLabel, CachedLabel},
         resource::ResourceAllocator,
-        RenderContext,
     },
 };
 
@@ -30,6 +30,7 @@ pub(in crate::rendering) fn render<A: ResourceAllocator>(
     layout_state: &LayoutState,
 ) {
     context.render_background(dim, &component.background);
+
     context.render_key_value_component(
         &component.key,
         &component.key_abbreviations,
diff --git a/src/rendering/component/text.rs b/src/rendering/component/text.rs
index 3d5fbb51c..11dfcdb10 100644
--- a/src/rendering/component/text.rs
+++ b/src/rendering/component/text.rs
@@ -2,10 +2,11 @@ use crate::{
     component::text::{State, TextState},
     layout::{LayoutDirection, LayoutState},
     rendering::{
+        RenderContext,
         consts::{DEFAULT_TEXT_SIZE, PADDING, TEXT_ALIGN_TOP},
         font::{AbbreviatedLabel, CachedLabel},
         resource::ResourceAllocator,
-        solid, RenderContext,
+        solid,
     },
 };
 
@@ -31,6 +32,7 @@ pub(in crate::rendering) fn render<A: ResourceAllocator>(
     layout_state: &LayoutState,
 ) {
     context.render_background([width, height], &component.background);
+
     match &component.text {
         TextState::Center(text) => context.render_text_centered(
             text,
diff --git a/src/rendering/component/timer.rs b/src/rendering/component/timer.rs
index 99ac45efe..8380a227d 100644
--- a/src/rendering/component/timer.rs
+++ b/src/rendering/component/timer.rs
@@ -1,8 +1,8 @@
 use crate::{
     component::timer::State,
     rendering::{
-        consts::PADDING, font::CachedLabel, resource::ResourceAllocator, scene::Layer, FillShader,
-        RenderContext,
+        FillShader, RenderContext, consts::PADDING, font::CachedLabel, resource::ResourceAllocator,
+        scene::Layer,
     },
 };
 
@@ -27,11 +27,14 @@ pub(in crate::rendering) fn render<A: ResourceAllocator>(
     component: &State,
 ) -> f32 {
     context.render_background([width, height], &component.background);
+
     let shader = FillShader::VerticalGradient(
         component.top_color.to_array(),
         component.bottom_color.to_array(),
     );
+
     let render_target = Layer::from_updates_frequently(component.updates_frequently);
+
     let x = context.render_timer(
         &component.fraction,
         &mut cache.fraction,
@@ -40,6 +43,7 @@ pub(in crate::rendering) fn render<A: ResourceAllocator>(
         0.7 * height,
         shader,
     );
+
     context.render_timer(
         &component.time,
         &mut cache.time,
diff --git a/src/rendering/component/title.rs b/src/rendering/component/title.rs
index 39f4882c5..83a6ac778 100644
--- a/src/rendering/component/title.rs
+++ b/src/rendering/component/title.rs
@@ -5,13 +5,14 @@ use crate::{
     layout::LayoutState,
     platform::prelude::*,
     rendering::{
+        Layer, RenderContext,
         consts::{
-            vertical_padding, BOTH_PADDINGS, DEFAULT_TEXT_SIZE, PADDING, TEXT_ALIGN_BOTTOM,
-            TEXT_ALIGN_CENTER, TEXT_ALIGN_TOP,
+            BOTH_PADDINGS, DEFAULT_TEXT_SIZE, PADDING, TEXT_ALIGN_BOTTOM, TEXT_ALIGN_CENTER,
+            TEXT_ALIGN_TOP, vertical_padding,
         },
         font::{AbbreviatedLabel, CachedLabel},
         resource::ResourceAllocator,
-        solid, Layer, RenderContext,
+        solid,
     },
 };
 
@@ -41,6 +42,7 @@ pub(in crate::rendering) fn render<A: ResourceAllocator>(
     layout_state: &LayoutState,
 ) {
     context.render_background([width, height], &component.background);
+
     let text_color = component.text_color.unwrap_or(layout_state.text_color);
     let text_color = solid(&text_color);
 
diff --git a/src/rendering/consts.rs b/src/rendering/consts.rs
index f5cb238d6..4828d49b6 100644
--- a/src/rendering/consts.rs
+++ b/src/rendering/consts.rs
@@ -16,6 +16,7 @@ pub const SEPARATOR_THICKNESS: f32 = 0.1;
 pub const THIN_SEPARATOR_THICKNESS: f32 = SEPARATOR_THICKNESS / 2.0;
 pub const PSEUDO_PIXELS: f32 = 1.0 / 24.0;
 pub const DEFAULT_VERTICAL_WIDTH: f32 = 11.5;
+pub const SHADOW_OFFSET: f32 = 0.05;
 
 pub fn vertical_padding(height: f32) -> f32 {
     (ICON_MIN_VERTICAL_PADDING * height).min(PADDING)
diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs
index 2d519b59c..2e16e2aac 100644
--- a/src/rendering/mod.rs
+++ b/src/rendering/mod.rs
@@ -90,8 +90,8 @@ pub mod web;
 
 use self::{
     consts::{
-        DEFAULT_TEXT_SIZE, DEFAULT_VERTICAL_WIDTH, PADDING, TEXT_ALIGN_BOTTOM, TEXT_ALIGN_TOP,
-        TWO_ROW_HEIGHT,
+        DEFAULT_TEXT_SIZE, DEFAULT_VERTICAL_WIDTH, PADDING, SHADOW_OFFSET, TEXT_ALIGN_BOTTOM,
+        TEXT_ALIGN_TOP, TWO_ROW_HEIGHT,
     },
     font::{AbbreviatedLabel, CachedLabel, FontCache},
     icon::{CachedImage, ImageHandle},
@@ -284,6 +284,7 @@ impl<P: SharedOwnership, I: SharedOwnership, F, L: SharedOwnership> SceneManager
             fonts: &mut self.fonts,
             images: &mut self.images,
             image_cache,
+            state,
         };
 
         let background = context.decode_layout_background(&state.background, resolution);
@@ -357,6 +358,7 @@ impl<P: SharedOwnership, I: SharedOwnership, F, L: SharedOwnership> SceneManager
             fonts: &mut self.fonts,
             images: &mut self.images,
             image_cache,
+            state,
         };
 
         let background = context.decode_layout_background(&state.background, resolution);
@@ -404,6 +406,7 @@ struct RenderContext<'b, A: ResourceAllocator> {
     fonts: &'b mut FontCache<A::Font>,
     images: &'b mut ImageCache<CachedImage<A::Image>>,
     image_cache: &'b ImageCache,
+    state: &'b LayoutState,
 }
 
 impl<A: ResourceAllocator> RenderContext<'_, A> {
@@ -603,6 +606,18 @@ impl<A: ResourceAllocator> RenderContext<'_, A> {
             (max_x - x) / scale,
         );
 
+        if let Some(text_shadow) = &self.state.text_shadow {
+            self.scene.bottom_layer_mut().push(Entity::Label(
+                label.share(),
+                solid(text_shadow),
+                font::left_aligned(
+                    &self.transform.pre_translate(SHADOW_OFFSET, SHADOW_OFFSET),
+                    pos,
+                    scale,
+                ),
+            ));
+        }
+
         self.scene.bottom_layer_mut().push(Entity::Label(
             label.share(),
             shader,
@@ -628,6 +643,18 @@ impl<A: ResourceAllocator> RenderContext<'_, A> {
             Some((max_x - x) / scale),
         );
 
+        if let Some(text_shadow) = &self.state.text_shadow {
+            self.scene.bottom_layer_mut().push(Entity::Label(
+                label.share(),
+                solid(text_shadow),
+                font::left_aligned(
+                    &self.transform.pre_translate(SHADOW_OFFSET, SHADOW_OFFSET),
+                    pos,
+                    scale,
+                ),
+            ));
+        }
+
         self.scene.bottom_layer_mut().push(Entity::Label(
             label.share(),
             shader,
@@ -654,6 +681,21 @@ impl<A: ResourceAllocator> RenderContext<'_, A> {
             Some((max_x - min_x) / scale),
         );
 
+        if let Some(text_shadow) = &self.state.text_shadow {
+            self.scene.bottom_layer_mut().push(Entity::Label(
+                label.share(),
+                solid(text_shadow),
+                font::centered(
+                    &self.transform.pre_translate(SHADOW_OFFSET, SHADOW_OFFSET),
+                    pos,
+                    scale,
+                    label.width(scale),
+                    min_x,
+                    max_x,
+                ),
+            ));
+        }
+
         self.scene.bottom_layer_mut().push(Entity::Label(
             label.share(),
             shader,
@@ -685,6 +727,21 @@ impl<A: ResourceAllocator> RenderContext<'_, A> {
             (max_x - min_x) / scale,
         );
 
+        if let Some(text_shadow) = &self.state.text_shadow {
+            self.scene.bottom_layer_mut().push(Entity::Label(
+                label.share(),
+                solid(text_shadow),
+                font::centered(
+                    &self.transform.pre_translate(SHADOW_OFFSET, SHADOW_OFFSET),
+                    pos,
+                    scale,
+                    label.width(scale),
+                    min_x,
+                    max_x,
+                ),
+            ));
+        }
+
         self.scene.bottom_layer_mut().push(Entity::Label(
             label.share(),
             shader,
@@ -711,6 +768,18 @@ impl<A: ResourceAllocator> RenderContext<'_, A> {
         let label = label.update(text, &mut self.handles, &mut self.fonts.text.font, None);
         let width = label.width(scale);
 
+        if let Some(text_shadow) = &self.state.text_shadow {
+            self.scene.layer_mut(layer).push(Entity::Label(
+                label.share(),
+                solid(text_shadow),
+                font::left_aligned(
+                    &self.transform.pre_translate(SHADOW_OFFSET, SHADOW_OFFSET),
+                    pos,
+                    scale,
+                ),
+            ));
+        }
+
         self.scene.layer_mut(layer).push(Entity::Label(
             label.share(),
             shader,
@@ -758,6 +827,18 @@ impl<A: ResourceAllocator> RenderContext<'_, A> {
         let label = label.update(text, &mut self.handles, &mut self.fonts.times.font, None);
         let width = label.width(scale);
 
+        if let Some(text_shadow) = &self.state.text_shadow {
+            self.scene.layer_mut(layer).push(Entity::Label(
+                label.share(),
+                solid(text_shadow),
+                font::right_aligned(
+                    &self.transform.pre_translate(SHADOW_OFFSET, SHADOW_OFFSET),
+                    pos,
+                    scale,
+                    width,
+                ),
+            ));
+        }
         self.scene.layer_mut(layer).push(Entity::Label(
             label.share(),
             shader,
@@ -779,6 +860,19 @@ impl<A: ResourceAllocator> RenderContext<'_, A> {
         let label = label.update(text, &mut self.handles, &mut self.fonts.timer.font, None);
         let width = label.width(scale);
 
+        if let Some(text_shadow) = &self.state.text_shadow {
+            self.scene.layer_mut(layer).push(Entity::Label(
+                label.share(),
+                solid(text_shadow),
+                font::right_aligned(
+                    &self.transform.pre_translate(SHADOW_OFFSET, SHADOW_OFFSET),
+                    pos,
+                    scale,
+                    width,
+                ),
+            ));
+        }
+
         self.scene.layer_mut(layer).push(Entity::Label(
             label.share(),
             shader,
diff --git a/tests/rendering.rs b/tests/rendering.rs
index 7b1ad0eb4..1ccdbec27 100644
--- a/tests/rendering.rs
+++ b/tests/rendering.rs
@@ -9,12 +9,12 @@ mod run_files;
 mod tests_helper;
 
 use livesplit_core::{
+    Run, Segment, TimeSpan, Timer, TimingMethod,
     component::{self, timer},
     layout::{self, Component, ComponentState, Layout, LayoutDirection, LayoutState},
     rendering,
     run::parser::{livesplit, llanfair, wsplit},
     settings::ImageCache,
-    Run, Segment, TimeSpan, Timer, TimingMethod,
 };
 use std::{fs, path::PathBuf};
 
@@ -44,8 +44,8 @@ fn default() {
     check(
         &state,
         &image_cache,
-        "e32259a84233e364",
-        "0520a7a32958470a",
+        "c0ad948aed86074f",
+        "b48906628d71c0aa",
         "default",
     );
 }
@@ -167,8 +167,8 @@ fn font_fallback() {
         &state,
         &image_cache,
         [320, 750],
-        "a0bc9c666c55d82c",
-        "ba3897221844a4ca",
+        "8ebc585aec6909e2",
+        "4371062136f5eb3b",
         "font_fallback",
     );
 }
@@ -183,8 +183,8 @@ fn actual_split_file() {
     check(
         &layout.state(&mut image_cache, &timer.snapshot()),
         &image_cache,
-        "c1e9e757c15a35ae",
-        "4fe65a630b531c54",
+        "5b731b527871c67d",
+        "e43f5bf67ada675c",
         "actual_split_file",
     );
 }
@@ -256,8 +256,8 @@ fn all_components() {
         &state,
         &image_cache,
         [300, 800],
-        "7e7aa83a3b80e1da",
-        "fa3c0357219389d8",
+        "cb6ddee1b2cb296e",
+        "3935895199fbf883",
         "all_components",
     );
 
@@ -265,8 +265,8 @@ fn all_components() {
         &state,
         &image_cache,
         [150, 800],
-        "97afa51bfd8a8597",
-        "eda169eec3995eeb",
+        "70c4e38cea684e91",
+        "33b14971943c049c",
         "all_components_thin",
     );
 }
@@ -293,8 +293,8 @@ fn score_split() {
         &state,
         &image_cache,
         [300, 400],
-        "f5dadfe58e621e7b",
-        "c7fec6cb25b993bc",
+        "c39b32c0e13ced4c",
+        "73de0c5abaa29100",
         "score_split",
     );
 }
@@ -311,7 +311,7 @@ fn dark_layout() {
         &layout.state(&mut image_cache, &timer.snapshot()),
         &image_cache,
         "a47c590792c1bab5",
-        "91a89f563eb4f43d",
+        "3a3a4f53c146d472",
         "dark_layout",
     );
 }
@@ -334,8 +334,8 @@ fn subsplits_layout() {
         &layout.state(&mut image_cache, &timer.snapshot()),
         &image_cache,
         [300, 800],
-        "39ab965781d0ceee",
-        "405baac87e52acc5",
+        "620825a1f684554a",
+        "355e302202735dbe",
         "subsplits_layout",
     );
 }
@@ -358,8 +358,8 @@ fn background_image() {
         &layout.state(&mut image_cache, &timer.snapshot()),
         &image_cache,
         [300, 300],
-        "b5238ec57ba70c3a",
-        "e4df7276b1603cd5",
+        "044e60eab4dc7faf",
+        "0ba24da5eb4cd8a6",
         "background_image",
     );
 }
@@ -385,8 +385,8 @@ fn display_two_rows() {
         &layout.state(&mut image_cache, &timer.snapshot()),
         &image_cache,
         [200, 100],
-        "d174c2f9a0c54d66",
-        "12b6ab31502b1baa",
+        "029a6bfd857f3820",
+        "aa626e5a8b7c5997",
         "display_two_rows",
     );
 }
@@ -412,8 +412,8 @@ fn single_line_title() {
         &layout.state(&mut image_cache, &timer.snapshot()),
         &image_cache,
         [300, 60],
-        "db305e29ec814f33",
-        "1848cdb01676cfec",
+        "d4a0f104cc3b83c1",
+        "a57d9897ebecbc2c",
         "single_line_title",
     );
 }
@@ -450,8 +450,8 @@ fn horizontal() {
         &layout.state(&mut image_cache, &timer.snapshot()),
         &image_cache,
         [1500, 40],
-        "4a9c12d00233f3c1",
-        "9353fac22b4cfde4",
+        "25cf654aa17999af",
+        "af5ed8ad60fd9b22",
         "horizontal",
     );
 }


Reviews:
user: login: CryZe
body: Ideally the shadows should be something that is done by the actual rendering engine (for example HTML Canvas can directly support text shadows), but I think this is fine for an initial implementation.
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T20:42:38Z
user: login: CryZe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T18:24:17Z
user: login: CryZe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-19T18:27:13Z
user: login: Skajdrowski
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T18:35:10Z
user: login: CryZe
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-20T18:39:52Z

Review comments:
diff_hunk: @@ -779,6 +779,12 @@ fn parse_general_settings(layout: &mut Layout, reader: &mut Reader<'_>) -> Resul
         }),
         "ImageOpacity" => percentage(reader, |v| image_opacity = v),
         "ImageBlur" => percentage(reader, |v| image_blur = v),
+        "DropShadow" => {
+            parse_bool(reader, |b| settings.drop_shadow = b)
+        }
+        "ShadowColor" => color(reader, |color| {
+            settings.shadow_color = color;
+        }),
path: src/layout/parser/mod.rs
user: login: CryZe
body: These should be "DropShadows" and "ShadowsColor" respectively, at which point the parsing will actually start working and a lot of rendering tests will start rendering out the shadows properly (resulting in you needing to update their hashes).
created_at: 2025-03-18T20:40:28Z
author_association: COLLABORATOR
diff_hunk: @@ -30,6 +30,10 @@ pub struct LayoutState {
     pub separators_color: Color,
     /// The text color to use for text that doesn't specify its own color.
     pub text_color: Color,
+    /// Draws drop shadow
+    pub drop_shadow: bool,
+    /// The color to use for drawn shadows.
+    pub shadow_color: Color,
path: src/layout/layout_state.rs
user: login: CryZe
body: This should just be an `Option<Color>`, similarly in the layout settings.
created_at: 2025-03-18T20:41:38Z
author_association: COLLABORATOR
diff_hunk: @@ -24,6 +24,10 @@ pub struct GeneralSettings {
     pub text_font: Option<Font>,
     /// The background to show behind the layout.
     pub background: LayoutBackground,
+    /// The color to use for drawn shadows.
+    pub shadow_color: Color,
+    /// Draw drop shadow
+    pub drop_shadow: bool,
path: src/layout/general_settings.rs
user: login: CryZe
body: This should also be `Option<Color>`.
created_at: 2025-03-19T18:24:17Z
author_association: COLLABORATOR
diff_hunk: @@ -24,6 +24,10 @@ pub struct GeneralSettings {
     pub text_font: Option<Font>,
     /// The background to show behind the layout.
     pub background: LayoutBackground,
+    /// The color to use for drawn shadows.
+    pub shadow_color: Color,
+    /// Draw drop shadow
+    pub drop_shadow: bool,
path: src/layout/general_settings.rs
user: login: CryZe
body: If you want, I can make that change instead of you, considering I probably want to do a small cleanup pass at the end, before merging anyway.
created_at: 2025-03-19T18:27:13Z
author_association: COLLABORATOR
diff_hunk: @@ -24,6 +24,10 @@ pub struct GeneralSettings {
     pub text_font: Option<Font>,
     /// The background to show behind the layout.
     pub background: LayoutBackground,
+    /// The color to use for drawn shadows.
+    pub shadow_color: Color,
+    /// Draw drop shadow
+    pub drop_shadow: bool,
path: src/layout/general_settings.rs
user: login: Skajdrowski
body: Sure.
created_at: 2025-03-19T18:35:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: Skajdrowski
created_at: 2025-03-19T12:12:04Z
body: If it's gonna keep failing cuz of wrong hashes, I don't know how to fix that.
I was testing stuff on Windows and everything was ok, now it's screaming about wrong hashes on Linux ¯\_(ツ)_/¯
author_association: CONTRIBUTOR
user: login: CryZe
created_at: 2025-03-19T13:14:15Z
body: The problem seems to be that importing the alpha values from *.lsl involves this code for adjusting the gamma:
https://github.com/LiveSplit/livesplit-core/blob/63748917543cd1ebc44d29677d6325b3e03aac23/src/layout/parser/mod.rs#L325

And powf is unfortunately inconsistent across different libm implementations of the different platforms. I'll push a fix later today.
author_association: COLLABORATOR
user: login: CryZe
created_at: 2025-03-19T17:54:06Z
body: I pushed a fix. I don't particularly like it, but it seems to be necessary.
author_association: COLLABORATOR
user: login: rodrigosiqueiradev
created_at: 2025-03-19T21:39:08Z
body: > a, 1.0 / 1.75
Hey @CryZe, would you like to attempt an implementation based of the exponential formula with with logarithm? 
Since pow_f can give different results this one should be stable since it follows the IEEE 754 standard for floating-point arithmetic.

`use std::ops::Mul;

fn custom_powf(base: f64, exp: f64) -> f64 {
    if base.is_nan() || exp.is_nan() {
        return f64::NAN;
    }

    if base == 0.0 && exp == 0.0 {
        return f64::NAN;
    }

    if base == 0.0 {
        return if exp < 0.0 { f64::INFINITY } else { 0.0 };
    }

    if base < 0.0 && exp.fract() != 0.0 {
        return f64::NAN;
    }

    base.ln().mul(exp).exp()
}
`

author_association: NONE
user: login: CryZe
created_at: 2025-03-19T21:56:21Z
body: The problem is that all libm functions that don't directly map to CPU instructions are implemented with differing implementations with different levels of accuracy. So `ln` and `exp` have all the same problems.
author_association: COLLABORATOR
user: login: rodrigosiqueiradev
created_at: 2025-03-19T22:40:21Z
body: I see, from what I was reading, and I'm not familiar with how this project is built at all, so point it out if I'm talking nonsense. 
There are mostly differences between libm in UNIX systems, and Windows that uses CRT implementation. Since powf has indeed optimizations to speed up the calculation, makes sense such differences show up, so I expected that if we'd undo such optimizations by going through the extended math with this `base.ln().mul(exp).exp()` operation, where in theory we'd lose these platform-specific optimizations trading performance for consistency. 

I can't access right now to my UNIX machine, or I'd gladly try to do some more in depth research into this matter. 
Still might be worth the shot going down a level on implementation. Or alternatively, do away with math and build a pre-computed LUT. 
author_association: NONE
user: login: Skajdrowski
created_at: 2025-03-19T22:45:08Z
body: @CryZe There's now 2 buttons toggling shadows, which is confusing, which should stay 😅
My boolean toggle was preventing from text being rendered twice. I'm not sure if the other button does the same.
Plus the new button resets the colors, each time we toggle shadows off
author_association: CONTRIBUTOR
user: login: rodrigosiqueiradev
created_at: 2025-03-19T22:55:54Z
body: > I see, from what I was reading, and I'm not familiar with how this project is built at all, so point it out if I'm talking nonsense. There are mostly differences between libm in UNIX systems, and Windows that uses CRT implementation. Since powf has indeed optimizations to speed up the calculation, makes sense such differences show up, so I expected that if we'd undo such optimizations by going through the extended math with this `base.ln().mul(exp).exp()` operation, where in theory we'd lose these platform-specific optimizations trading performance for consistency.
> 
> I can't access right now to my UNIX machine, or I'd gladly try to do some more in depth research into this matter. Still might be worth the shot going down a level on implementation. Or alternatively, do away with math and build a pre-computed LUT.

This looks interesting:
![image](https://github.com/user-attachments/assets/73c4f9fe-f97c-4f78-a232-be97645a3173)

The diagram illustrates the key differences in floating-point calculation paths:
    - SSE Instructions operate at standard double precision (64 bits) throughout the calculation
    - FPU Instructions perform calculations in extended precision (80 bits) before final truncation
    - Early truncation means the result is rounded to double precision immediately, while late truncation preserves more precision until the final storage

--> For Linux/GCC
RUSTFLAGS="-C target-feature=+sse2,-fma" cargo build

--> For Windows/MSVC
cargo rustc -- -C target-feature=+sse2,-fma
author_association: NONE