diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index 541460f4..deac06f4 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -1142,6 +1142,88 @@ fn segmented_progress_reserves_gaps_only_between_segments() { assert!(mask[33]); } +#[test] +fn segmented_progress_snaps_segments_and_gaps_to_whole_pixels() { + // The classic widget bar is 10 segments of 10 logical px with 1 px gaps. + for (scale, segment, gap) in [ + (1.0, 10, 1), + (1.25, 12, 1), + (1.5, 14, 2), + (1.75, 17, 2), + (2.0, 20, 2), + ] { + let extent = (109.0_f64 * scale).round() as u32; + let layout = SegmentLayout::new(extent, 10, scale).unwrap(); + assert_eq!( + (layout.segment, layout.gap), + (segment, gap), + "scale={scale}" + ); + assert!(layout.extent() <= extent, "scale={scale}"); + + let mut runs = Vec::new(); + for position in 0..extent { + let visible = segmented_position_visible(position, extent, 10, scale); + match runs.last_mut() { + Some((kind, length)) if *kind == visible => *length += 1, + _ => runs.push((visible, 1)), + } + } + if runs.last().is_some_and(|(visible, _)| !visible) { + runs.pop(); + } + assert_eq!(runs.len(), 19, "scale={scale}"); + for (index, (visible, length)) in runs.into_iter().enumerate() { + assert_eq!(visible, index % 2 == 0, "scale={scale}"); + assert_eq!(length, if visible { segment } else { gap }, "scale={scale}"); + } + } +} + +#[test] +fn right_to_left_segments_anchor_at_the_right_edge() { + let white = Rgba { + r: 255, + g: 255, + b: 255, + a: 255, + }; + // 164 px at 150%: 10 x 14 px segments with 2 px gaps use 158 px. + let mut pixels = vec![0; 164]; + draw_progress( + &mut pixels, + 164, + 1, + white, + 0.0, + 1.0, + ProgressDirection::RightToLeft, + 10, + 1.5, + ); + assert!(pixels[..6].iter().all(|pixel| *pixel == 0)); + assert_ne!(pixels[6], 0); + assert_ne!(pixels[163], 0); + assert_eq!(pixels[163 - 14], 0); + + // Partial fills are measured against the drawn bar, not the box. + let mut pixels = vec![0; 164]; + draw_progress( + &mut pixels, + 164, + 1, + white, + 0.0, + 0.5, + ProgressDirection::LeftToRight, + 10, + 1.5, + ); + // Half of 158 px ends at pixel 78, inside the gap after segment five. + assert_ne!(pixels[77], 0); + assert!(pixels[78..].iter().all(|pixel| *pixel == 0)); +} + #[test] fn segmented_progress_preserves_both_rounded_outer_edges() { let mut pixels = vec![0; 34 * 12]; diff --git a/src/theme_engine/theme_rendering.rs b/src/theme_engine/theme_rendering.rs index 5c88137b..9fb6643a 100644 --- a/src/theme_engine/theme_rendering.rs +++ b/src/theme_engine/theme_rendering.rs @@ -1549,64 +1549,103 @@ pub(super) fn draw_progress( segments: u16, gap: f64, ) { - let mut fill = vec![0u32; pixels.len()]; - fill_rounded(&mut fill, width, height, color, radius); - let count = segments as u32; + let horizontal = matches!( + direction, + ProgressDirection::LeftToRight | ProgressDirection::RightToLeft + ); + let extent = if horizontal { width } else { height }; + let layout = SegmentLayout::new(extent, segments as u32, gap); + // Segmented bars may end short of the box, so round the bar that is + // actually drawn, anchored at the direction's starting edge. + let used = layout.map_or(extent, |layout| layout.extent()); + let start = match direction { + ProgressDirection::RightToLeft | ProgressDirection::BottomToTop => extent - used, + ProgressDirection::LeftToRight | ProgressDirection::TopToBottom => 0, + }; + let (fill_width, fill_height) = if horizontal { + (used, height) + } else { + (width, used) + }; + let mut fill = vec![0u32; fill_width as usize * fill_height as usize]; + fill_rounded(&mut fill, fill_width, fill_height, color, radius); for y in 0..height { for x in 0..width { - let progress = match direction { - ProgressDirection::LeftToRight => (x + 1) as f64 / width as f64, - ProgressDirection::RightToLeft => (width - x) as f64 / width as f64, - ProgressDirection::TopToBottom => (y + 1) as f64 / height as f64, - ProgressDirection::BottomToTop => (height - y) as f64 / height as f64, + let position = if horizontal { x } else { y }; + if position < start || position >= start + used { + continue; + } + let local = position - start; + // Distance from the edge where the progress starts filling. + let along = match direction { + ProgressDirection::LeftToRight | ProgressDirection::TopToBottom => local, + ProgressDirection::RightToLeft | ProgressDirection::BottomToTop => used - 1 - local, }; - let mut visible = progress <= amount; - if visible && count > 1 { - let extent = if matches!( - direction, - ProgressDirection::LeftToRight | ProgressDirection::RightToLeft - ) { - width - } else { - height - }; - let position = if matches!( - direction, - ProgressDirection::LeftToRight | ProgressDirection::RightToLeft - ) { - x + let progress = (along + 1) as f64 / used as f64; + let visible = progress <= amount && layout.is_none_or(|layout| layout.contains(along)); + if visible { + let fill_index = if horizontal { + y * fill_width + local } else { - y + local * fill_width + x }; - visible = segmented_position_visible(position, extent, count, gap); - } - if visible { - let index = (y * width + x) as usize; - blend(&mut pixels[index], fill[index], 1.0); + blend( + &mut pixels[(y * width + x) as usize], + fill[fill_index as usize], + 1.0, + ); } } } } -pub(super) fn segmented_position_visible(position: u32, extent: u32, count: u32, gap: f64) -> bool { - if count <= 1 || extent <= 1 { - return true; +/// Whole-pixel geometry for a segmented progress bar. Both the segment and +/// the gap are snapped to physical pixels so every segment and every gap has +/// the same width at fractional DPI scales. The gap is rounded and the +/// segment floored, so the bar never exceeds its box and may end a few +/// pixels short of it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct SegmentLayout { + pub(super) count: u32, + pub(super) segment: u32, + pub(super) gap: u32, +} + +impl SegmentLayout { + pub(super) fn new(extent: u32, count: u32, gap: f64) -> Option { + if count <= 1 || extent <= 1 { + return None; + } + // Clamp pathological inputs so every segment keeps at least one + // physical pixel when the bar is wide enough. + let count = count.min(extent); + let gap = if gap.is_finite() { + gap.max(0.0).round().min(u32::MAX as f64) as u32 + } else { + 0 + }; + let gap = gap.min((extent - count) / (count - 1)); + let segment = (extent - gap * (count - 1)) / count; + Some(Self { + count, + segment, + gap, + }) } - // Gaps exist only between segments. Clamp pathological inputs so every - // segment can retain at least one physical pixel when the bar is wide - // enough, then sample each pixel at its centre against cumulative bounds. - // This keeps both outer edges intact and distributes DPI rounding across - // the internal segments and gaps instead of dropping the final pixel. - let count = count.min(extent); - let gap = if gap.is_finite() { gap.max(0.0) } else { 0.0 }; - let max_gap = (extent - count) as f64 / (count - 1) as f64; - let gap = gap.min(max_gap); - let segment_extent = (extent as f64 - gap * (count - 1) as f64) / count as f64; - let stride = segment_extent + gap; - let pixel_center = position.min(extent - 1) as f64 + 0.5; - let segment = ((pixel_center / stride).floor() as u32).min(count - 1); - segment == count - 1 || pixel_center - segment as f64 * stride < segment_extent + pub(super) fn extent(self) -> u32 { + self.segment * self.count + self.gap * (self.count - 1) + } + + /// Whether the pixel `along` the bar from its starting edge is inside a segment. + pub(super) fn contains(self, along: u32) -> bool { + along < self.extent() && along % (self.segment + self.gap) < self.segment + } +} + +#[cfg(test)] +pub(super) fn segmented_position_visible(position: u32, extent: u32, count: u32, gap: f64) -> bool { + SegmentLayout::new(extent, count, gap).is_none_or(|layout| layout.contains(position)) } pub(super) fn premultiply(color: Rgba) -> u32 {