Painting primitives at non-integer pixel coordinates produces blurry output. Pixel snapping converts layout coordinates into integer device-pixel coordinates so painted edges land exactly on physical pixel boundaries. Non-integer coordinates can arise for several reasons, including: - flex distribution, percentages, centering, and text measurement can produce fractional element sizes and positions; - at fractional scale factors (for example 125% or 150%), integer logical-pixel values can map to non-integer device-pixel values. We pixel-snap by rounding in device-pixel space, after multiplying by `scale_factor`, so that snapping targets physical pixels. Bounds are divided by `scale_factor` before being returned to GPUI. Midpoints are rounded toward zero. This is a stylistic choice: a 1-logical-pixel line at 150% scale should render as 1 dp rather than 2 dp. Pixel snapping is done in two phases: 1. Pre-layout metric snapping. Before Taffy computes layout, all authored absolute lengths are rounded in `to_taffy`. This includes borders, padding, gaps, and explicit sizes. Custom-measured leaf nodes have their measured sizes rounded up to integer device-pixel lengths. 2. Post-layout edge snapping. After Taffy resolves the tree, layout relationships such as flex shares, grid tracks, percentages, and centering can produce new fractional edge positions. Boxes now have edges in absolute coordinates, and snapping must decide where those edges land on the device-pixel grid. Ideally, post-layout snapping would satisfy: - Edge closure. Two raw layout edges at the same absolute position should snap to the same pixel column. - Translation stability. A component's internal geometry should not change when it moves to a new absolute position. These goals are in tension because rounding is not associative. The simple local schemes make different tradeoffs: - Absolute edge rounding gives each window coordinate one answer, so coincident edges always close globally. But a span's snapped length is `round(far) - round(near)`, which may change by 1 dp as its absolute origin moves. - Parent-relative edge rounding rounds each child inside its parent's coordinate space. This guarantees translation stability, but a shared edge reached through different parents can accumulate different rounding, causing non-closure between cousins. - Length rounding rounds each width, height, and thickness independently and then places boxes from those rounded lengths. Sizes stay stable under translation, but neighboring boxes derive their shared boundary from different sources, so closure is not guaranteed. We apply absolute edge rounding for each element's outer box in post-layout rounding to preserve closure. Border and padding widths are not touched by post-layout rounding; they keep their pre-layout rounded value so that they remain stable under translation. This gives both closure and translation stability in the case that all local metrics are integer device-pixel lengths. Pre-layout rounding covers that in most cases. The exception is metrics resolved by layout relationships, such as percentages. Outer box edges will still close globally, and painted border widths are still snapped independently, but the raw content-box origin can carry a 1 dp residual into descendants. --- Fixes https://github.com/zed-industries/zed/issues/46360 Fixes https://github.com/zed-industries/zed/issues/44528 Fixes https://github.com/zed-industries/zed/issues/40282 Fixes https://github.com/zed-industries/zed/issues/42257 --- Release Notes: - Fixed potentially blurry appearance of UI elements when using fractional display scaling.
253 lines
8.0 KiB
Rust
253 lines
8.0 KiB
Rust
use crate::{BackgroundExecutor, Task};
|
|
use std::{
|
|
future::Future,
|
|
pin::Pin,
|
|
sync::atomic::{AtomicUsize, Ordering::SeqCst},
|
|
task,
|
|
time::Duration,
|
|
};
|
|
|
|
/// A helper trait for building complex objects with imperative conditionals in a fluent style.
|
|
pub trait FluentBuilder {
|
|
/// Imperatively modify self with the given closure.
|
|
fn map<U>(self, f: impl FnOnce(Self) -> U) -> U
|
|
where
|
|
Self: Sized,
|
|
{
|
|
f(self)
|
|
}
|
|
|
|
/// Conditionally modify self with the given closure.
|
|
fn when(self, condition: bool, then: impl FnOnce(Self) -> Self) -> Self
|
|
where
|
|
Self: Sized,
|
|
{
|
|
self.map(|this| if condition { then(this) } else { this })
|
|
}
|
|
|
|
/// Conditionally modify self with the given closure.
|
|
fn when_else(
|
|
self,
|
|
condition: bool,
|
|
then: impl FnOnce(Self) -> Self,
|
|
else_fn: impl FnOnce(Self) -> Self,
|
|
) -> Self
|
|
where
|
|
Self: Sized,
|
|
{
|
|
self.map(|this| if condition { then(this) } else { else_fn(this) })
|
|
}
|
|
|
|
/// Conditionally unwrap and modify self with the given closure, if the given option is Some.
|
|
fn when_some<T>(self, option: Option<T>, then: impl FnOnce(Self, T) -> Self) -> Self
|
|
where
|
|
Self: Sized,
|
|
{
|
|
self.map(|this| {
|
|
if let Some(value) = option {
|
|
then(this, value)
|
|
} else {
|
|
this
|
|
}
|
|
})
|
|
}
|
|
/// Conditionally unwrap and modify self with the given closure, if the given option is None.
|
|
fn when_none<T>(self, option: &Option<T>, then: impl FnOnce(Self) -> Self) -> Self
|
|
where
|
|
Self: Sized,
|
|
{
|
|
self.map(|this| if option.is_some() { this } else { then(this) })
|
|
}
|
|
}
|
|
|
|
/// Extensions for Future types that provide additional combinators and utilities.
|
|
pub trait FutureExt {
|
|
/// Requires a Future to complete before the specified duration has elapsed.
|
|
/// Similar to tokio::timeout.
|
|
fn with_timeout(self, timeout: Duration, executor: &BackgroundExecutor) -> WithTimeout<Self>
|
|
where
|
|
Self: Sized;
|
|
}
|
|
|
|
impl<T: Future> FutureExt for T {
|
|
fn with_timeout(self, timeout: Duration, executor: &BackgroundExecutor) -> WithTimeout<Self>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
WithTimeout {
|
|
future: self,
|
|
timer: executor.timer(timeout),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[pin_project::pin_project]
|
|
pub struct WithTimeout<T> {
|
|
#[pin]
|
|
future: T,
|
|
#[pin]
|
|
timer: Task<()>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
#[error("Timed out before future resolved")]
|
|
/// Error returned by with_timeout when the timeout duration elapsed before the future resolved
|
|
pub struct Timeout;
|
|
|
|
impl<T: Future> Future for WithTimeout<T> {
|
|
type Output = Result<T::Output, Timeout>;
|
|
|
|
fn poll(self: Pin<&mut Self>, cx: &mut task::Context) -> task::Poll<Self::Output> {
|
|
let this = self.project();
|
|
|
|
if let task::Poll::Ready(output) = this.future.poll(cx) {
|
|
task::Poll::Ready(Ok(output))
|
|
} else if this.timer.poll(cx).is_ready() {
|
|
task::Poll::Ready(Err(Timeout))
|
|
} else {
|
|
task::Poll::Pending
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Increment the given atomic counter if it is not zero.
|
|
/// Return the new value of the counter.
|
|
pub(crate) fn atomic_incr_if_not_zero(counter: &AtomicUsize) -> usize {
|
|
let mut loaded = counter.load(SeqCst);
|
|
loop {
|
|
if loaded == 0 {
|
|
return 0;
|
|
}
|
|
match counter.compare_exchange_weak(loaded, loaded + 1, SeqCst, SeqCst) {
|
|
Ok(x) => return x + 1,
|
|
Err(actual) => loaded = actual,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rounds to the nearest integer with 0.5 ties toward zero.
|
|
#[inline]
|
|
pub(crate) fn round_half_toward_zero(value: f32) -> f32 {
|
|
(value.abs() - 0.5).ceil().copysign(value)
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn round_half_toward_zero_f64(value: f64) -> f64 {
|
|
(value.abs() - 0.5).ceil().copysign(value)
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn round_to_device_pixel(logical: f32, scale_factor: f32) -> f32 {
|
|
round_half_toward_zero(logical * scale_factor)
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn round_stroke_to_device_pixel(logical: f32, scale_factor: f32) -> f32 {
|
|
if logical == 0.0 {
|
|
0.0
|
|
} else {
|
|
round_to_device_pixel(logical.max(0.0), scale_factor).max(1.0)
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn floor_to_device_pixel(logical: f32, scale_factor: f32) -> f32 {
|
|
(logical * scale_factor).floor()
|
|
}
|
|
|
|
#[inline]
|
|
pub(crate) fn ceil_to_device_pixel(logical: f32, scale_factor: f32) -> f32 {
|
|
(logical * scale_factor).ceil()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::TestAppContext;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_round_half_toward_zero() {
|
|
// Midpoint ties go toward zero
|
|
assert_eq!(round_half_toward_zero(0.5), 0.0);
|
|
assert_eq!(round_half_toward_zero(1.5), 1.0);
|
|
assert_eq!(round_half_toward_zero(2.5), 2.0);
|
|
assert_eq!(round_half_toward_zero(-0.5), 0.0);
|
|
assert_eq!(round_half_toward_zero(-1.5), -1.0);
|
|
assert_eq!(round_half_toward_zero(-2.5), -2.0);
|
|
|
|
// Non-midpoint values round to nearest
|
|
assert_eq!(round_half_toward_zero(1.5001), 2.0);
|
|
assert_eq!(round_half_toward_zero(1.4999), 1.0);
|
|
assert_eq!(round_half_toward_zero(-1.5001), -2.0);
|
|
assert_eq!(round_half_toward_zero(-1.4999), -1.0);
|
|
|
|
// Integers are unchanged
|
|
assert_eq!(round_half_toward_zero(0.0), 0.0);
|
|
assert_eq!(round_half_toward_zero(3.0), 3.0);
|
|
assert_eq!(round_half_toward_zero(-3.0), -3.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_device_pixel_helpers() {
|
|
// Snap uses half-toward-zero: 1.0 * 1.5 = 1.5 ties toward 1.0.
|
|
assert_eq!(round_to_device_pixel(1.0, 1.5), 1.0);
|
|
// Below the tie rounds down, above rounds up.
|
|
assert_eq!(round_to_device_pixel(0.3, 2.0), 1.0);
|
|
assert_eq!(round_to_device_pixel(1.4, 1.0), 1.0);
|
|
assert_eq!(round_to_device_pixel(1.6, 1.0), 2.0);
|
|
|
|
// Stroke uses snap, but clamps non-zero input up to at least 1dp.
|
|
assert_eq!(round_stroke_to_device_pixel(0.0, 1.0), 0.0);
|
|
assert_eq!(round_stroke_to_device_pixel(0.4, 1.0), 1.0);
|
|
assert_eq!(round_stroke_to_device_pixel(0.5, 1.0), 1.0);
|
|
assert_eq!(round_stroke_to_device_pixel(1.0, 1.5), 1.0);
|
|
assert_eq!(round_stroke_to_device_pixel(1.6, 1.0), 2.0);
|
|
|
|
// Cover's near edge floors, far edge ceils. Together they form a strict superset.
|
|
assert_eq!(floor_to_device_pixel(0.3, 2.0), 0.0);
|
|
assert_eq!(ceil_to_device_pixel(0.3, 2.0), 1.0);
|
|
assert_eq!(floor_to_device_pixel(2.1, 1.0), 2.0);
|
|
assert_eq!(ceil_to_device_pixel(2.1, 1.0), 3.0);
|
|
|
|
// Integer device-pixel inputs are stable under all three.
|
|
assert_eq!(round_to_device_pixel(2.0, 2.0), 4.0);
|
|
assert_eq!(floor_to_device_pixel(2.0, 2.0), 4.0);
|
|
assert_eq!(ceil_to_device_pixel(2.0, 2.0), 4.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_round_half_toward_zero_f64() {
|
|
assert_eq!(round_half_toward_zero_f64(0.5), 0.0);
|
|
assert_eq!(round_half_toward_zero_f64(-0.5), 0.0);
|
|
assert_eq!(round_half_toward_zero_f64(1.5), 1.0);
|
|
assert_eq!(round_half_toward_zero_f64(-1.5), -1.0);
|
|
assert_eq!(round_half_toward_zero_f64(2.5001), 3.0);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_with_timeout(cx: &mut TestAppContext) {
|
|
Task::ready(())
|
|
.with_timeout(Duration::from_secs(1), &cx.executor())
|
|
.await
|
|
.expect("Timeout should be noop");
|
|
|
|
let long_duration = Duration::from_secs(6000);
|
|
let short_duration = Duration::from_secs(1);
|
|
cx.executor()
|
|
.timer(long_duration)
|
|
.with_timeout(short_duration, &cx.executor())
|
|
.await
|
|
.expect_err("timeout should have triggered");
|
|
|
|
let fut = cx
|
|
.executor()
|
|
.timer(long_duration)
|
|
.with_timeout(short_duration, &cx.executor());
|
|
cx.executor().advance_clock(short_duration * 2);
|
|
futures::FutureExt::now_or_never(fut)
|
|
.unwrap_or_else(|| panic!("timeout should have triggered"))
|
|
.expect_err("timeout");
|
|
}
|
|
}
|