Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/theme_engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
131 changes: 85 additions & 46 deletions src/theme_engine/theme_rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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 {
Expand Down