From ef15c381bae13130daf682a124819e26f94c5efe Mon Sep 17 00:00:00 2001 From: Colin Hayes Date: Wed, 2 Sep 2026 21:17:28 -0500 Subject: [PATCH 1/3] core: let a font ask for tracking Cosmic Text has carried letter spacing all along; nothing above it could ask for it. A design that specifies tracking on its labels had no way to say so, and uppercasing a label is not the same thing. Font gains a Tracking, expressed as a fraction of the type size so a heading and a caption asking for the same tracking stay proportionate. Cosmic Text takes em as well, so the value passes through unscaled, and it is left unset when it would change nothing -- a font asking for no tracking keeps whatever the face itself specifies. Font is a hash key throughout the text pipeline, so Tracking compares and hashes by bits, canonicalizing the two values that would otherwise break the Eq/Hash agreement: NaN, which is never equal to itself, and negative zero, which is equal to zero while their bits differ. That is the same treatment Cosmic Text gives its own wrapper. Co-Authored-By: Claude Opus 5 --- core/src/font.rs | 117 +++++++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 2 +- graphics/src/text.rs | 13 ++++- 3 files changed, 129 insertions(+), 3 deletions(-) diff --git a/core/src/font.rs b/core/src/font.rs index 8462c90e4d..77c20ca68a 100644 --- a/core/src/font.rs +++ b/core/src/font.rs @@ -1,6 +1,55 @@ //! Load and use fonts. use std::hash::Hash; +/// Extra space between glyphs, as a fraction of the type size. +/// +/// Tracking is expressed relative to the size rather than in pixels, so a +/// heading and a caption asking for the same tracking stay proportionate to +/// one another. A design that says `0.06em` means [`Tracking(0.06)`]. +/// +/// [`Tracking(0.06)`]: Tracking +#[derive(Debug, Clone, Copy, Default)] +pub struct Tracking(pub f32); + +impl Tracking { + /// No extra space. Glyphs sit at their natural advance. + pub const NONE: Self = Self(0.0); + + /// Whether this tracking would change anything. + pub fn is_none(self) -> bool { + self.0 == 0.0 + } +} + +// `Font` is a hash key throughout the text pipeline, so tracking has to be +// comparable and hashable by value. Compare and hash the bits, canonicalizing +// the two values that would otherwise break the `Eq`/`Hash` agreement: NaN is +// never equal to itself, and `-0.0 == 0.0` while their bits differ. +impl PartialEq for Tracking { + fn eq(&self, other: &Self) -> bool { + if self.0.is_nan() { + other.0.is_nan() + } else { + self.0 == other.0 + } + } +} + +impl Eq for Tracking {} + +impl std::hash::Hash for Tracking { + fn hash(&self, hasher: &mut H) { + const CANONICAL_NAN: u32 = 0x7fc0_0000; + + let bits = if self.0.is_nan() { + CANONICAL_NAN + } else { + (self.0 + 0.0).to_bits() + }; + bits.hash(hasher); + } +} + /// A font. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub struct Font { @@ -12,6 +61,8 @@ pub struct Font { pub stretch: Stretch, /// The [`Style`] of the [`Font`]. pub style: Style, + /// The [`Tracking`] of the [`Font`]. + pub tracking: Tracking, } impl Font { @@ -21,6 +72,7 @@ impl Font { weight: Weight::Normal, stretch: Stretch::Normal, style: Style::Normal, + tracking: Tracking::NONE, }; /// A monospaced font with normal [`Weight`]. @@ -55,6 +107,11 @@ impl Font { Self { stretch, ..self } } + /// Sets the [`Tracking`] of the [`Font`]. + pub const fn tracking(self, tracking: Tracking) -> Self { + Self { tracking, ..self } + } + /// Sets the [`Style`] of the [`Font`]. pub const fn style(self, style: Style) -> Self { Self { style, ..self } @@ -198,3 +255,63 @@ pub enum Style { /// A font error. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Error {} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + fn hash(font: Font) -> u64 { + let mut hasher = DefaultHasher::new(); + font.hash(&mut hasher); + hasher.finish() + } + + #[test] + fn a_font_defaults_to_the_face_s_own_tracking() { + assert!(Font::DEFAULT.tracking.is_none()); + assert!(Font::MONOSPACE.tracking.is_none()); + assert!(Font::new("Archivo").tracking.is_none()); + } + + #[test] + fn tracking_takes_part_in_equality_and_hashing() { + let plain = Font::new("JetBrains Mono"); + let tracked = plain.tracking(Tracking(0.06)); + + assert_ne!(plain, tracked); + assert_ne!(hash(plain), hash(tracked)); + assert_eq!( + tracked, + Font::new("JetBrains Mono").tracking(Tracking(0.06)) + ); + assert_eq!( + hash(tracked), + hash(Font::new("JetBrains Mono").tracking(Tracking(0.06))) + ); + } + + #[test] + fn equality_and_hashing_agree_on_the_awkward_values() { + // A hash key must never claim two values are equal while hashing them + // differently. Negative zero and NaN are the two that would. + let zero = Font::DEFAULT.tracking(Tracking(0.0)); + let negative_zero = Font::DEFAULT.tracking(Tracking(-0.0)); + assert_eq!(zero, negative_zero); + assert_eq!(hash(zero), hash(negative_zero)); + + let nan = Font::DEFAULT.tracking(Tracking(f32::NAN)); + let other_nan = Font::DEFAULT.tracking(Tracking(-f32::NAN)); + assert_eq!(nan, other_nan); + assert_eq!(hash(nan), hash(other_nan)); + } + + #[test] + fn only_a_real_tracking_counts_as_set() { + assert!(Tracking::NONE.is_none()); + assert!(Tracking(0.0).is_none()); + assert!(Tracking(-0.0).is_none()); + assert!(!Tracking(0.06).is_none()); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index a6b67b8de2..718ac57142 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -61,7 +61,7 @@ pub use color::Color; pub use content_fit::ContentFit; pub use element::Element; pub use event::Event; -pub use font::Font; +pub use font::{Font, Tracking}; pub use gradient::Gradient; pub use image::Image; pub use input_method::InputMethod; diff --git a/graphics/src/text.rs b/graphics/src/text.rs index 58a7aa2130..0a7a0a7868 100644 --- a/graphics/src/text.rs +++ b/graphics/src/text.rs @@ -293,11 +293,20 @@ pub fn align( /// Returns the attributes of the given [`Font`]. pub fn to_attributes(font: Font) -> cosmic_text::Attrs<'static> { - cosmic_text::Attrs::new() + let attributes = cosmic_text::Attrs::new() .family(to_family(font.family)) .weight(to_weight(font.weight)) .stretch(to_stretch(font.stretch)) - .style(to_style(font.style)) + .style(to_style(font.style)); + + // Cosmic Text also takes tracking in em, so it passes through unscaled. + // Left unset when it would change nothing, so a font that asks for no + // tracking keeps whatever the face itself specifies. + if font.tracking.is_none() { + attributes + } else { + attributes.letter_spacing(font.tracking.0) + } } fn to_family(family: font::Family) -> cosmic_text::Family<'static> { From 0fa9927cc4664d5ac484c24b2d0e3e1d336bc069 Mon Sep 17 00:00:00 2001 From: Colin Hayes Date: Thu, 3 Sep 2026 11:02:35 -0500 Subject: [PATCH 2/3] widget: animate buttons and support shared box edges --- core/src/border.rs | 62 +++++++++ widget/src/button.rs | 302 +++++++++++++++++++++++++++++++++++----- widget/src/container.rs | 88 ++++++++++++ 3 files changed, 416 insertions(+), 36 deletions(-) diff --git a/core/src/border.rs b/core/src/border.rs index 232211db73..fa8b859c9f 100644 --- a/core/src/border.rs +++ b/core/src/border.rs @@ -14,6 +14,68 @@ pub struct Border { pub radius: Radius, } +/// A single edge of a box border. +/// +/// Unlike [`Border`], which describes one uniform outline, a `Side` can be +/// used by widgets that need CSS-like dividers without drawing a second box. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Side { + /// The color of the edge. + pub color: Color, + + /// The width of the edge. + pub width: f32, +} + +/// Independent border edges for a box. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Sides { + /// The top edge. + pub top: Side, + /// The right edge. + pub right: Side, + /// The bottom edge. + pub bottom: Side, + /// The left edge. + pub left: Side, +} + +/// Creates a box-border [`Side`]. +pub fn side(color: Color, width: impl Into) -> Side { + Side { + color, + width: width.into().0, + } +} + +impl Sides { + /// Sets the top edge. + pub fn top(self, side: Side) -> Self { + Self { top: side, ..self } + } + + /// Sets the right edge. + pub fn right(self, side: Side) -> Self { + Self { + right: side, + ..self + } + } + + /// Sets the bottom edge. + pub fn bottom(self, side: Side) -> Self { + Self { + bottom: side, + ..self + } + } + + /// Sets the left edge. + pub fn left(self, side: Side) -> Self { + Self { left: side, ..self } + } +} + /// Creates a new [`Border`] with the given [`Radius`]. /// /// ``` diff --git a/widget/src/button.rs b/widget/src/button.rs index f00c49d137..e38e71aea9 100644 --- a/widget/src/button.rs +++ b/widget/src/button.rs @@ -16,12 +16,14 @@ //! button("Press me!").on_press(Message::ButtonPressed).into() //! } //! ``` -use crate::core::border::{self, Border}; +use crate::core::animation::{Animation, Easing}; +use crate::core::border::{self, Border, Radius}; use crate::core::layout; use crate::core::mouse; use crate::core::overlay; use crate::core::renderer; use crate::core::theme::palette; +use crate::core::time::{Duration, Instant}; use crate::core::touch; use crate::core::widget::Operation; use crate::core::widget::tree::{self, Tree}; @@ -80,7 +82,6 @@ where padding: Padding, clip: bool, class: Theme::Class<'a>, - status: Option, } enum OnPress<'a, Message> { @@ -114,7 +115,6 @@ where padding: DEFAULT_PADDING, clip: false, class: Theme::default(), - status: None, } } @@ -192,9 +192,27 @@ where } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone)] struct State { is_pressed: bool, + hovered: Animation, + pressed: Animation, + now: Instant, + initialized: bool, +} + +impl Default for State { + fn default() -> Self { + let now = Instant::now(); + + Self { + is_pressed: false, + hovered: control_animation(false), + pressed: control_animation(false), + now, + initialized: false, + } + } } impl<'a, Message, Theme, Renderer> Widget @@ -337,9 +355,29 @@ where Status::Active }; - if let Event::Window(window::Event::RedrawRequested(_now)) = event { - self.status = Some(current_status); - } else if self.status.is_some_and(|status| status != current_status) { + let now = match event { + Event::Window(window::Event::RedrawRequested(now)) => *now, + _ => Instant::now(), + }; + let state = tree.state.downcast_mut::(); + let hovered = matches!(current_status, Status::Hovered | Status::Pressed); + let pressed = matches!(current_status, Status::Pressed); + + if state.initialized { + if state.hovered.value() != hovered { + state.hovered.go_mut(hovered, now); + } + if state.pressed.value() != pressed { + state.pressed.go_mut(pressed, now); + } + } else { + state.hovered = control_animation(hovered); + state.pressed = control_animation(pressed); + state.initialized = true; + } + state.now = now; + + if state.hovered.is_animating(now) || state.pressed.is_animating(now) { shell.request_redraw(); } } @@ -356,39 +394,46 @@ where ) { let bounds = layout.bounds(); let content_layout = layout.children().next().unwrap(); - let style = theme.style(&self.class, self.status.unwrap_or(Status::Disabled)); - - if style.background.is_some() || style.border.width > 0.0 || style.shadow.color.a > 0.0 { - renderer.fill_quad( - renderer::Quad { - bounds, - border: style.border, - shadow: style.shadow, - snap: style.snap, - }, - style - .background - .unwrap_or(Background::Color(Color::TRANSPARENT)), - ); - } + let state = tree.state.downcast_ref::(); + let style = if self.on_press.is_none() { + theme.style(&self.class, Status::Disabled) + } else { + let hover = state.hovered.interpolate(0.0, 1.0, state.now); + let press = state.pressed.interpolate(0.0, 1.0, state.now); + let active = theme.style(&self.class, Status::Active); + let hovered = theme.style(&self.class, Status::Hovered); + let pressed = theme.style(&self.class, Status::Pressed); - let viewport = if self.clip { - bounds.intersection(viewport).unwrap_or(*viewport) + interpolate_style(interpolate_style(active, hovered, hover), pressed, press) + }; + let press_offset = if self.on_press.is_some() { + state.pressed.interpolate(0.0, PRESS_TRANSLATE, state.now) } else { - *viewport + 0.0 }; - self.content.as_widget().draw( - &tree.children[0], - renderer, - theme, - &renderer::Style { - text_color: style.text_color, - }, - content_layout, - cursor, - &viewport, - ); + renderer.with_translation(Vector::new(press_offset, press_offset), |renderer| { + draw( + renderer, + &style, + bounds, + viewport, + self.clip, + |renderer, viewport| { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + &renderer::Style { + text_color: style.text_color, + }, + content_layout, + cursor, + viewport, + ); + }, + ); + }); } fn mouse_interaction( @@ -426,6 +471,130 @@ where } } +fn draw( + renderer: &mut Renderer, + style: &Style, + bounds: Rectangle, + viewport: &Rectangle, + clip: bool, + draw_content: impl FnOnce(&mut Renderer, &Rectangle), +) where + Renderer: crate::core::Renderer, +{ + if style.background.is_some() || style.border.width > 0.0 || style.shadow.color.a > 0.0 { + renderer.fill_quad( + renderer::Quad { + bounds, + border: style.border, + shadow: style.shadow, + snap: style.snap, + }, + style + .background + .unwrap_or(Background::Color(Color::TRANSPARENT)), + ); + } + + let viewport = if clip { + bounds.intersection(viewport).unwrap_or(*viewport) + } else { + *viewport + }; + + draw_content(renderer, &viewport); +} + +const CONTROL_DURATION: Duration = Duration::from_millis(150); +const PRESS_TRANSLATE: f32 = 1.0; + +fn control_animation(value: bool) -> Animation { + Animation::new(value) + .duration(CONTROL_DURATION) + .easing(Easing::Custom(control_ease_out)) +} + +// CSS cubic-bezier(0.2, 0, 0.2, 1), evaluated by inverting x(t). +fn control_ease_out(x: f32) -> f32 { + let mut low = 0.0; + let mut high = 1.0; + + for _ in 0..12 { + let t = (low + high) * 0.5; + let inverse = 1.0 - t; + let curve_x = 3.0 * inverse * inverse * t * 0.2 + 3.0 * inverse * t * t * 0.2 + t * t * t; + + if curve_x < x { + low = t; + } else { + high = t; + } + } + + let t = (low + high) * 0.5; + t * t * (3.0 - 2.0 * t) +} + +fn interpolate_style(from: Style, to: Style, amount: f32) -> Style { + let amount = amount.clamp(0.0, 1.0); + + Style { + background: interpolate_background(from.background, to.background, amount), + text_color: from.text_color.mix(to.text_color, amount), + border: Border { + color: from.border.color.mix(to.border.color, amount), + width: lerp(from.border.width, to.border.width, amount), + radius: interpolate_radius(from.border.radius, to.border.radius, amount), + }, + shadow: Shadow { + color: from.shadow.color.mix(to.shadow.color, amount), + offset: Vector::new( + lerp(from.shadow.offset.x, to.shadow.offset.x, amount), + lerp(from.shadow.offset.y, to.shadow.offset.y, amount), + ), + blur_radius: lerp(from.shadow.blur_radius, to.shadow.blur_radius, amount), + }, + snap: if amount < 0.5 { from.snap } else { to.snap }, + } +} + +fn interpolate_background( + from: Option, + to: Option, + amount: f32, +) -> Option { + match (from, to) { + (Some(Background::Color(from)), Some(Background::Color(to))) => { + Some(Background::Color(from.mix(to, amount))) + } + (None, Some(Background::Color(to))) => { + Some(Background::Color(Color { a: 0.0, ..to }.mix(to, amount))) + } + (Some(Background::Color(from)), None) => Some(Background::Color( + from.mix(Color { a: 0.0, ..from }, amount), + )), + (from, to) => { + if amount < 0.5 { + from + } else { + to + } + } + } +} + +fn interpolate_radius(from: Radius, to: Radius, amount: f32) -> Radius { + Radius { + top_left: lerp(from.top_left, to.top_left, amount), + top_right: lerp(from.top_right, to.top_right, amount), + bottom_right: lerp(from.bottom_right, to.bottom_right, amount), + bottom_left: lerp(from.bottom_left, to.bottom_left, amount), + } +} + +fn lerp(from: f32, to: f32, amount: f32) -> f32 { + from + (to - from) * amount +} + impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where @@ -724,3 +893,64 @@ fn disabled(style: Style) -> Style { ..style } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_easing_is_an_ease_out_curve() { + assert!(control_ease_out(0.0) < 0.001); + assert!(control_ease_out(0.5) > 0.5); + assert!((control_ease_out(1.0) - 1.0).abs() < 0.001); + } + + #[test] + fn controls_animate_for_the_design_system_duration() { + let started = Instant::now(); + let mut animation = control_animation(false); + animation.go_mut(true, started); + + let middle = animation.interpolate(0.0, 1.0, started + Duration::from_millis(75)); + assert!(middle > 0.5 && middle < 1.0); + assert!(animation.is_animating(started + Duration::from_millis(149))); + assert!(!animation.is_animating(started + CONTROL_DURATION)); + assert_eq!(PRESS_TRANSLATE, 1.0); + } + + #[test] + fn control_styles_interpolate_colors_and_geometry() { + let from = Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: Color::BLACK, + border: Border { + color: Color::BLACK, + width: 0.0, + radius: 2.0.into(), + }, + ..Style::default() + }; + let to = Style { + background: Some(Background::Color(Color::WHITE)), + text_color: Color::WHITE, + border: Border { + color: Color::WHITE, + width: 2.0, + radius: 6.0.into(), + }, + ..Style::default() + }; + + let middle = interpolate_style(from, to, 0.5); + let Some(Background::Color(background)) = middle.background else { + panic!("solid control backgrounds remain solid while interpolating"); + }; + + assert!(background.r > 0.0 && background.r < 1.0); + assert_eq!(background.a, 0.5); + assert!(middle.text_color.r > 0.0 && middle.text_color.r < 1.0); + assert_eq!(middle.text_color.a, 1.0); + assert_eq!(middle.border.width, 1.0); + assert_eq!(middle.border.radius, 4.0.into()); + } +} diff --git a/widget/src/container.rs b/widget/src/container.rs index 4862dbaab5..9940332970 100644 --- a/widget/src/container.rs +++ b/widget/src/container.rs @@ -409,6 +409,62 @@ where .unwrap_or(Background::Color(Color::TRANSPARENT)), ); } + + let draw_side = |renderer: &mut Renderer, bounds: Rectangle, side: border::Side| { + if side.width <= 0.0 || side.color.a <= 0.0 { + return; + } + + renderer.fill_quad( + renderer::Quad { + bounds, + snap: style.snap, + ..renderer::Quad::default() + }, + side.color, + ); + }; + + draw_side( + renderer, + Rectangle { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: style.border_sides.top.width, + }, + style.border_sides.top, + ); + draw_side( + renderer, + Rectangle { + x: bounds.x + bounds.width - style.border_sides.right.width, + y: bounds.y, + width: style.border_sides.right.width, + height: bounds.height, + }, + style.border_sides.right, + ); + draw_side( + renderer, + Rectangle { + x: bounds.x, + y: bounds.y + bounds.height - style.border_sides.bottom.width, + width: bounds.width, + height: style.border_sides.bottom.width, + }, + style.border_sides.bottom, + ); + draw_side( + renderer, + Rectangle { + x: bounds.x, + y: bounds.y, + width: style.border_sides.left.width, + height: bounds.height, + }, + style.border_sides.left, + ); } /// The appearance of a container. @@ -420,6 +476,12 @@ pub struct Style { pub background: Option, /// The [`Border`] of the container. pub border: Border, + /// Independent box edges, drawn inside the bounds after the background. + /// + /// Use these for separators shared by adjacent surfaces. A single top + /// edge, for example, avoids nesting a complete square border inside a + /// rounded parent. + pub border_sides: border::Sides, /// The [`Shadow`] of the container. pub shadow: Shadow, /// Whether the container should be snapped to the pixel grid. @@ -432,6 +494,7 @@ impl Default for Style { text_color: None, background: None, border: Border::default(), + border_sides: border::Sides::default(), shadow: Shadow::default(), snap: renderer::CRISP, } @@ -455,6 +518,14 @@ impl Style { } } + /// Updates the independent box edges of the [`Style`]. + pub fn border_sides(self, border_sides: border::Sides) -> Self { + Self { + border_sides, + ..self + } + } + /// Updates the background of the [`Style`]. pub fn background(self, background: impl Into) -> Self { Self { @@ -612,3 +683,20 @@ fn style(pair: theme::palette::Pair) -> Style { ..Style::default() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_container_can_own_one_shared_edge_without_a_second_box() { + let edge = border::side(Color::WHITE, 1.0); + let style = Style::default().border_sides(border::Sides::default().top(edge)); + + assert_eq!(style.border.width, 0.0); + assert_eq!(style.border_sides.top, edge); + assert_eq!(style.border_sides.right.width, 0.0); + assert_eq!(style.border_sides.bottom.width, 0.0); + assert_eq!(style.border_sides.left.width, 0.0); + } +} From 1021994b8a155e8285ca5ea18239467616387fbd Mon Sep 17 00:00:00 2001 From: Colin Hayes Date: Thu, 3 Sep 2026 11:14:10 -0500 Subject: [PATCH 3/3] widget: keep idle controls out of transform layers --- widget/src/button.rs | 10 ++++++++-- widget/src/container.rs | 17 +++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/widget/src/button.rs b/widget/src/button.rs index e38e71aea9..2076c25aba 100644 --- a/widget/src/button.rs +++ b/widget/src/button.rs @@ -412,7 +412,7 @@ where 0.0 }; - renderer.with_translation(Vector::new(press_offset, press_offset), |renderer| { + let draw_button = |renderer: &mut Renderer| { draw( renderer, &style, @@ -433,7 +433,13 @@ where ); }, ); - }); + }; + + if press_offset > 0.0 { + renderer.with_translation(Vector::new(press_offset, press_offset), draw_button); + } else { + draw_button(renderer); + } } fn mouse_interaction( diff --git a/widget/src/container.rs b/widget/src/container.rs index 9940332970..8aef80f8a0 100644 --- a/widget/src/container.rs +++ b/widget/src/container.rs @@ -425,22 +425,27 @@ where ); }; + let top = style.border_sides.top.width.min(bounds.height); + let right = style.border_sides.right.width.min(bounds.width); + let bottom = style.border_sides.bottom.width.min(bounds.height); + let left = style.border_sides.left.width.min(bounds.width); + draw_side( renderer, Rectangle { x: bounds.x, y: bounds.y, width: bounds.width, - height: style.border_sides.top.width, + height: top, }, style.border_sides.top, ); draw_side( renderer, Rectangle { - x: bounds.x + bounds.width - style.border_sides.right.width, + x: bounds.x + bounds.width - right, y: bounds.y, - width: style.border_sides.right.width, + width: right, height: bounds.height, }, style.border_sides.right, @@ -449,9 +454,9 @@ where renderer, Rectangle { x: bounds.x, - y: bounds.y + bounds.height - style.border_sides.bottom.width, + y: bounds.y + bounds.height - bottom, width: bounds.width, - height: style.border_sides.bottom.width, + height: bottom, }, style.border_sides.bottom, ); @@ -460,7 +465,7 @@ where Rectangle { x: bounds.x, y: bounds.y, - width: style.border_sides.left.width, + width: left, height: bounds.height, }, style.border_sides.left,