Add the brush-outline half of #2516 on its own, so review and merge stay small; the last-brush persistence half follows separately. - paint: new `outline` module traces the tip shape as closed rings in radius units -- a computed ellipse for round tips (rotated/flattened), marching squares over the bitmap for sampled/stored tips, at the full tip or the 50 % alpha contour. Traced rings stay finite and bounded for hostile sizes; tested for the half-coverage contour, blank tips and hostile input. - ui-egui: new `brush_cursor` picks the traced outline when the tip has a shape and falls back to the plain circle otherwise; `tool_cursor` gained the `Outline` shape (bitmap rasterisation by segment splatting, painter fallback on non-Windows). - The Normal Brush Tip circle now goes through `half_coverage_radius` (#2744) so outline and circle agree on the real 50 % contour. Review fixes from #2516: - Merged main; docs/scorecard.md is main's version and was verified with `cargo xtask scorecard --check` (regeneration changes nothing). - Dropped the scratch benchmark `crates/paint/examples/tmp_outline_bench.rs` and the two `#[ignore = "temp bench"]` tests in tool_cursor/tests.rs. Co-authored-by: JJ <jj@dot.com>
This commit is contained in:
parent
17855ab3d1
commit
80b8deab0f
|
|
@ -22,6 +22,7 @@ pub mod bg_erase;
|
|||
pub mod brush;
|
||||
pub mod dynamics;
|
||||
pub mod mixer;
|
||||
pub mod outline;
|
||||
pub mod presets;
|
||||
pub mod procedural;
|
||||
pub mod render;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
//! Brush tip outlines for the painting cursor: closed rings in units of the brush radius
|
||||
//! (1 = size / 2), screen-oriented (x right, y down), ready for the UI to scale by the radius.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::brush::{BrushSettings, TipShape};
|
||||
use crate::tile::GrayTile;
|
||||
|
||||
/// Segments in the outline of a computed round tip.
|
||||
const ELLIPSE_SEGMENTS: usize = 64;
|
||||
/// Budget for all traced ring vertices; the smallest rings are dropped first.
|
||||
const MAX_POINTS: usize = 512;
|
||||
/// A traced ring smaller than this many bitmap pixels squared is noise.
|
||||
const MIN_AREA: f32 = 0.9;
|
||||
/// "Full size" outlines everything above 1/255; the normal cursor outlines the 50 % contour.
|
||||
const FULL_THRESHOLD: f32 = 1.0 / 255.0;
|
||||
const HALF_THRESHOLD: f32 = 0.5;
|
||||
|
||||
/// The tip outline as closed rings, or `None` when the tip has no usable shape. `full` outlines
|
||||
/// the whole tip; otherwise the 50 % contour (the Photoshop normal brush tip cursor).
|
||||
pub fn tip_outline(b: &BrushSettings, full: bool) -> Option<Vec<Vec<[f32; 2]>>> {
|
||||
if !b.size.is_finite() || b.size <= 0.0 || !b.angle.is_finite() || !b.roundness.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let ro = b.roundness.clamp(0.0, 1.0).max(0.5 / (b.size / 2.0)).min(1.0);
|
||||
let (sn, cs) = b.angle.to_radians().sin_cos();
|
||||
let (fx, fy) = (if b.flip_x { -1.0 } else { 1.0 }, if b.flip_y { -1.0 } else { 1.0 });
|
||||
match &b.tip {
|
||||
TipShape::Round => {
|
||||
// The normal cursor is the 50 % contour, matching the plain-circle path (#2744):
|
||||
// the soft tip falls off as (1 - t²)⁴, not the old smoothstep midpoint.
|
||||
let f = if full { 1.0 } else { crate::half_coverage_radius(1.0, b.hardness) };
|
||||
let ring = (0..ELLIPSE_SEGMENTS)
|
||||
.map(|i| {
|
||||
let t = std::f32::consts::TAU * i as f32 / ELLIPSE_SEGMENTS as f32;
|
||||
// Tip-local (y up), rotated CCW by the angle, then flipped to screen y down.
|
||||
let (lx, ly) = (t.cos() * f, t.sin() * f * ro);
|
||||
[lx * cs - ly * sn, -(lx * sn + ly * cs)]
|
||||
})
|
||||
.collect();
|
||||
Some(vec![ring])
|
||||
}
|
||||
TipShape::Sampled(_) | TipShape::Stored(_) => {
|
||||
let t = b.tip.bitmap()?;
|
||||
if !t.is_valid() {
|
||||
return None;
|
||||
}
|
||||
trace(t, if full { FULL_THRESHOLD } else { HALF_THRESHOLD }, (sn, cs, fx, fy, ro))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trace the contour(s) above `thr` (resampled ~2x, capped at 160 px a side) and map them with
|
||||
/// the same tip transform as `render::BrushContext::rasterize`.
|
||||
fn trace(t: &GrayTile, thr: f32, xf: (f32, f32, f32, f32, f32)) -> Option<Vec<Vec<[f32; 2]>>> {
|
||||
let (sn, cs, fx, fy, ro) = xf;
|
||||
let (tw, th) = (t.width as f32, t.height as f32);
|
||||
let big = tw.max(th);
|
||||
let f = if big <= 160.0 { 2.0 } else { 160.0 / big };
|
||||
let (gw, gh) = (((tw * f).ceil() as usize).max(1), ((th * f).ceil() as usize).max(1));
|
||||
let mut vals = vec![0.0f32; gw * gh];
|
||||
for gy in 0..gh {
|
||||
for gx in 0..gw {
|
||||
let (px, py) = ((gx as f32 + 0.5) / f - 0.5, (gy as f32 + 0.5) / f - 0.5);
|
||||
vals[gy * gw + gx] = sample(t, px, py);
|
||||
}
|
||||
}
|
||||
let mut rings: Vec<Vec<[f32; 2]>> = march(&vals, gw, gh, thr).into_iter().filter(|r| area(r) >= MIN_AREA * f * f).collect();
|
||||
if rings.is_empty() {
|
||||
return None;
|
||||
}
|
||||
rings.sort_by(|a, b| area(b).total_cmp(&area(a)));
|
||||
let mut tol = 0.5;
|
||||
let mut thin: Vec<Vec<[f32; 2]>>;
|
||||
loop {
|
||||
thin = rings.iter().map(|r| simplify(r, tol)).collect();
|
||||
if thin.iter().map(Vec::len).sum::<usize>() <= MAX_POINTS || tol >= 8.0 {
|
||||
break;
|
||||
}
|
||||
tol *= 1.5;
|
||||
}
|
||||
let mut budget = MAX_POINTS;
|
||||
thin.retain(|r| {
|
||||
let keep = r.len() >= 3 && r.len() <= budget;
|
||||
if keep {
|
||||
budget -= r.len();
|
||||
}
|
||||
keep
|
||||
});
|
||||
if thin.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let s = 2.0 / big;
|
||||
let map = |[gx, gy]: [f32; 2]| {
|
||||
let (px, py) = ((gx + 0.5) / f - 0.5, (gy + 0.5) / f - 0.5);
|
||||
let (u, v) = ((px - tw / 2.0) * s * fx, -(py - th / 2.0) * s * ro * fy);
|
||||
let (ux, uy) = (u * cs - v * sn, u * sn + v * cs);
|
||||
[ux, -uy]
|
||||
};
|
||||
let rings: Vec<Vec<[f32; 2]>> = thin.into_iter().map(|r| r.into_iter().map(map).collect()).collect();
|
||||
rings.iter().flatten().all(|p| p[0].is_finite() && p[1].is_finite()).then_some(rings)
|
||||
}
|
||||
|
||||
/// Bilinear sample with zero outside (tips fade to nothing at their edges).
|
||||
fn sample(t: &GrayTile, x: f32, y: f32) -> f32 {
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (tx, ty) = (x - x0, y - y0);
|
||||
let (x0, y0) = (x0 as i64, y0 as i64);
|
||||
let get = |x: i64, y: i64| if x < 0 || y < 0 || x >= t.width as i64 || y >= t.height as i64 { 0.0 } else { t.get(x as u32, y as u32) };
|
||||
let a = get(x0, y0) + (get(x0 + 1, y0) - get(x0, y0)) * tx;
|
||||
let b = get(x0, y0 + 1) + (get(x0 + 1, y0 + 1) - get(x0, y0 + 1)) * tx;
|
||||
a + (b - a) * ty
|
||||
}
|
||||
|
||||
/// Shoelace area of a ring.
|
||||
fn area(r: &[[f32; 2]]) -> f32 {
|
||||
if r.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut s = 0.0;
|
||||
for i in 0..r.len() {
|
||||
let (a, b) = (r[i], r[(i + 1) % r.len()]);
|
||||
s += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
(s * 0.5).abs()
|
||||
}
|
||||
|
||||
/// Link segments end-to-end into closed rings (quantised endpoint keys absorb float noise).
|
||||
fn stitch(segs: Vec<([f32; 2], [f32; 2])>) -> Vec<Vec<[f32; 2]>> {
|
||||
let key = |p: [f32; 2]| ((p[0] * 4096.0).round() as i64, (p[1] * 4096.0).round() as i64);
|
||||
let mut ends: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||
for (i, (a, b)) in segs.iter().enumerate() {
|
||||
ends.entry(key(*a)).or_default().push(i);
|
||||
ends.entry(key(*b)).or_default().push(i);
|
||||
}
|
||||
let mut used = vec![false; segs.len()];
|
||||
let mut rings = Vec::new();
|
||||
for s in 0..segs.len() {
|
||||
if used[s] {
|
||||
continue;
|
||||
}
|
||||
used[s] = true;
|
||||
let mut ring = vec![segs[s].0, segs[s].1];
|
||||
while let Some(tail) = ring.last().copied() {
|
||||
let Some(&next) = ends.get(&key(tail)).and_then(|c| c.iter().find(|&&i| !used[i])) else { break };
|
||||
used[next] = true;
|
||||
let (a, b) = segs[next];
|
||||
let p = if key(a) == key(tail) { b } else { a };
|
||||
if key(p) == key(ring[0]) {
|
||||
break;
|
||||
}
|
||||
ring.push(p);
|
||||
}
|
||||
if ring.len() >= 3 {
|
||||
rings.push(ring);
|
||||
}
|
||||
}
|
||||
rings
|
||||
}
|
||||
|
||||
/// Marching squares over `v` (w by h samples at lattice nodes), outside treated as 0 so every
|
||||
/// contour closes; returns rings in node coordinates.
|
||||
fn march(v: &[f32], w: usize, h: usize, thr: f32) -> Vec<Vec<[f32; 2]>> {
|
||||
let val = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w as i64 || y >= h as i64 { 0.0 } else { v[y as usize * w + x as usize] } };
|
||||
let lerp = |v0: f32, v1: f32, a: (f32, f32), b: (f32, f32)| -> [f32; 2] {
|
||||
let t = if (v1 - v0).abs() < 1e-9 { 0.5 } else { ((thr - v0) / (v1 - v0)).clamp(0.0, 1.0) };
|
||||
[a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t]
|
||||
};
|
||||
let mut segs: Vec<([f32; 2], [f32; 2])> = Vec::new();
|
||||
for y in -1..h as i64 {
|
||||
for x in -1..w as i64 {
|
||||
let (va, vb, vc, vd) = (val(x, y), val(x + 1, y), val(x + 1, y + 1), val(x, y + 1));
|
||||
let idx = u8::from(va >= thr) | (u8::from(vb >= thr) << 1) | (u8::from(vc >= thr) << 2) | (u8::from(vd >= thr) << 3);
|
||||
if idx == 0 || idx == 15 {
|
||||
continue;
|
||||
}
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
let top = lerp(va, vb, (fx, fy), (fx + 1.0, fy));
|
||||
let right = lerp(vb, vc, (fx + 1.0, fy), (fx + 1.0, fy + 1.0));
|
||||
let bottom = lerp(vd, vc, (fx, fy + 1.0), (fx + 1.0, fy + 1.0));
|
||||
let left = lerp(va, vd, (fx, fy), (fx, fy + 1.0));
|
||||
let mid = (va + vb + vc + vd) * 0.25 >= thr;
|
||||
match idx {
|
||||
1 | 14 => segs.push((top, left)),
|
||||
2 | 13 => segs.push((top, right)),
|
||||
3 | 12 => segs.push((right, left)),
|
||||
4 | 11 => segs.push((right, bottom)),
|
||||
6 | 9 => segs.push((top, bottom)),
|
||||
7 | 8 => segs.push((left, bottom)),
|
||||
5 | 10 => {
|
||||
let opposite = (idx == 5) != mid;
|
||||
if opposite {
|
||||
segs.push((top, right));
|
||||
segs.push((left, bottom));
|
||||
} else {
|
||||
segs.push((top, left));
|
||||
segs.push((right, bottom));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
stitch(segs)
|
||||
}
|
||||
/// Douglas-Peucker on a closed ring (split at the point farthest from the first).
|
||||
fn simplify(ring: &[[f32; 2]], tol: f32) -> Vec<[f32; 2]> {
|
||||
if ring.len() <= 4 {
|
||||
return ring.to_vec();
|
||||
}
|
||||
let (mut far, mut best) = (0, 0.0);
|
||||
for (i, p) in ring.iter().enumerate().skip(1) {
|
||||
let d = (p[0] - ring[0][0]).powi(2) + (p[1] - ring[0][1]).powi(2);
|
||||
if d > best {
|
||||
best = d;
|
||||
far = i;
|
||||
}
|
||||
}
|
||||
let mut open: Vec<[f32; 2]> = ring[far..].to_vec();
|
||||
open.push(ring[0]);
|
||||
let mut out = Vec::new();
|
||||
dp(&ring[..=far], tol, &mut out);
|
||||
out.pop();
|
||||
dp(&open, tol, &mut out);
|
||||
out.pop();
|
||||
out
|
||||
}
|
||||
|
||||
/// Iterative Douglas-Peucker: keep the points of `pts` farther than `tol` from the chord.
|
||||
fn dp(pts: &[[f32; 2]], tol: f32, out: &mut Vec<[f32; 2]>) {
|
||||
let n = pts.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if n < 3 {
|
||||
out.extend_from_slice(pts);
|
||||
return;
|
||||
}
|
||||
let mut keep = vec![false; n];
|
||||
keep[0] = true;
|
||||
keep[n - 1] = true;
|
||||
let mut stack = vec![(0usize, n - 1)];
|
||||
while let Some((i, j)) = stack.pop() {
|
||||
if j <= i + 1 {
|
||||
continue;
|
||||
}
|
||||
let (mut dmax, mut k) = (0.0f32, i);
|
||||
for m in i + 1..j {
|
||||
let d = seg_dist2(pts[m], pts[i], pts[j]);
|
||||
if d > dmax {
|
||||
dmax = d;
|
||||
k = m;
|
||||
}
|
||||
}
|
||||
if dmax > tol * tol {
|
||||
keep[k] = true;
|
||||
stack.push((i, k));
|
||||
stack.push((k, j));
|
||||
}
|
||||
}
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if keep[i] {
|
||||
out.push(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared distance from `p` to the segment `a`-`b`.
|
||||
fn seg_dist2(p: [f32; 2], a: [f32; 2], b: [f32; 2]) -> f32 {
|
||||
let (abx, aby) = (b[0] - a[0], b[1] - a[1]);
|
||||
let t = (((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / (abx * abx + aby * aby).max(1e-12)).clamp(0.0, 1.0);
|
||||
(p[0] - a[0] - abx * t).powi(2) + (p[1] - a[1] - aby * t).powi(2)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn max_radius(rings: &[Vec<[f32; 2]>]) -> f32 {
|
||||
rings.iter().flatten().fold(0.0f32, |m, p| m.max(p[0].hypot(p[1])))
|
||||
}
|
||||
|
||||
/// The normal cursor of a rotated or flattened round tip is the same 50 % contour as the
|
||||
/// plain-circle path (#2744), and the full cursor reaches the whole radius.
|
||||
#[test]
|
||||
fn round_outlines_sit_on_the_half_coverage_contour() {
|
||||
for hardness in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
let b = BrushSettings { size: 40.0, angle: 33.0, roundness: 0.4, hardness, ..Default::default() };
|
||||
let normal = tip_outline(&b, false).expect("a rotated round tip traces");
|
||||
let full = tip_outline(&b, true).expect("a rotated round tip traces");
|
||||
assert!((max_radius(&normal) - crate::half_coverage_radius(1.0, hardness)).abs() < 1e-4, "hardness {hardness}");
|
||||
assert!((max_radius(&full) - 1.0).abs() < 1e-4, "hardness {hardness}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A sampled tip traces its 50 % alpha contour, inside the tip's square; a blank tip has none.
|
||||
#[test]
|
||||
fn sampled_outlines_stay_inside_the_tip_and_blank_tips_have_none() {
|
||||
let disc = GrayTile::from_fn(32, 32, |x, y| {
|
||||
let (dx, dy) = (x as f32 - 15.5, y as f32 - 15.5);
|
||||
(1.0 - (dx * dx + dy * dy).sqrt() / 16.0).clamp(0.0, 1.0)
|
||||
});
|
||||
let b = BrushSettings { size: 20.0, tip: TipShape::Sampled(disc), ..Default::default() };
|
||||
let rings = tip_outline(&b, false).expect("a sampled disc traces");
|
||||
assert!(rings.iter().all(|r| r.len() >= 3));
|
||||
assert!(rings.iter().flatten().all(|p| p[0].is_finite() && p[1].is_finite() && p[0].hypot(p[1]) <= 1.001));
|
||||
let blank = GrayTile::from_fn(8, 8, |_, _| 0.0);
|
||||
let b = BrushSettings { size: 20.0, tip: TipShape::Sampled(blank), ..Default::default() };
|
||||
assert!(tip_outline(&b, false).is_none());
|
||||
}
|
||||
|
||||
/// Hostile brush data yields no outline instead of NaNs or a panic.
|
||||
#[test]
|
||||
fn hostile_settings_get_no_outline() {
|
||||
for size in [f32::NAN, f32::INFINITY, 0.0, -1.0] {
|
||||
let b = BrushSettings { size, angle: 33.0, roundness: 0.5, ..Default::default() };
|
||||
assert!(tip_outline(&b, false).is_none(), "size {size}");
|
||||
}
|
||||
for angle in [f32::NAN, f32::INFINITY] {
|
||||
let b = BrushSettings { size: 40.0, angle, roundness: 0.5, ..Default::default() };
|
||||
assert!(tip_outline(&b, false).is_none(), "angle {angle}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
//! The painting cursor for brushes with a tip shape: sampled tip outlines and rotated or
|
||||
//! flattened round tips. Everything else falls back to the plain circle (`tool_cursor`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{CursorIcon, Painter, Pos2, Vec2};
|
||||
use photocraft_paint::brush::{BrushSettings, TipShape};
|
||||
|
||||
/// Below this on-screen radius an outline is unreadable: show the crosshair instead.
|
||||
const MIN_RADIUS: f32 = 3.0;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cached {
|
||||
key: Key,
|
||||
rings: Option<Arc<Vec<Vec<[f32; 2]>>>>,
|
||||
}
|
||||
|
||||
/// The brush fields an outline depends on.
|
||||
#[derive(Clone)]
|
||||
struct Key {
|
||||
tip: TipShape,
|
||||
angle: f32,
|
||||
roundness: f32,
|
||||
hardness: f32,
|
||||
flip_x: bool,
|
||||
flip_y: bool,
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
fn of(b: &BrushSettings, full: bool) -> Self {
|
||||
Self { tip: b.tip.clone(), angle: b.angle, roundness: b.roundness, hardness: b.hardness, flip_x: b.flip_x, flip_y: b.flip_y, full }
|
||||
}
|
||||
fn matches(&self, b: &BrushSettings, full: bool) -> bool {
|
||||
self.tip == b.tip
|
||||
&& self.angle == b.angle
|
||||
&& self.roundness == b.roundness
|
||||
&& self.hardness == b.hardness
|
||||
&& self.flip_x == b.flip_x
|
||||
&& self.flip_y == b.flip_y
|
||||
&& self.full == full
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom);
|
||||
/// `normal` is Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip).
|
||||
pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, normal: bool, centre: bool) -> CursorIcon {
|
||||
if let Some(rings) = rings(painter.ctx(), brush, !normal) {
|
||||
let pts: Vec<Vec<Vec2>> = rings.iter().map(|r| r.iter().map(|p| Vec2::new(p[0], p[1]) * radius).collect()).collect();
|
||||
let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length()));
|
||||
if extent < MIN_RADIUS {
|
||||
return crate::tool_cursor::crosshair(painter, at, 4.0, 0.0);
|
||||
}
|
||||
return crate::tool_cursor::outline(painter, at, pts, centre);
|
||||
}
|
||||
let r = if normal { photocraft_paint::half_coverage_radius(radius, brush.hardness).max(1.0) } else { radius };
|
||||
crate::tool_cursor::circle(painter, at, r, centre)
|
||||
}
|
||||
|
||||
/// The tip outline in radius units, cached across frames; `None` when the plain circle says it all
|
||||
/// (a plain round tip) or the tip has no traceable shape.
|
||||
fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option<Arc<Vec<Vec<[f32; 2]>>>> {
|
||||
if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 {
|
||||
return None;
|
||||
}
|
||||
let id = egui::Id::new("photocraft-tip-outline");
|
||||
if let Some(c) = ctx.data(|d| d.get_temp::<Cached>(id))
|
||||
&& c.key.matches(brush, full)
|
||||
{
|
||||
return c.rings;
|
||||
}
|
||||
let traced = photocraft_paint::outline::tip_outline(brush, full).map(Arc::new);
|
||||
ctx.data_mut(|d| d.insert_temp(id, Cached { key: Key::of(brush, full), rings: traced.clone() }));
|
||||
traced
|
||||
}
|
||||
|
|
@ -3419,9 +3419,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
|||
}
|
||||
egui::CursorIcon::None
|
||||
}
|
||||
// A shaped tip (sampled bitmap, or a rotated/flattened round tip)
|
||||
// traces its outline; a plain round tip keeps the circle.
|
||||
_ => {
|
||||
let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r);
|
||||
crate::tool_cursor::circle(&painter, p, r, centre)
|
||||
crate::brush_cursor::cursor(&painter, p, brush, full, cur.painting == PaintingCursor::NormalTip, centre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ pub mod artboard_ui;
|
|||
mod blend_keys;
|
||||
pub(crate) mod blend_preview;
|
||||
mod brand;
|
||||
pub(crate) mod brush_cursor;
|
||||
pub mod brush_panel;
|
||||
pub mod brush_picker;
|
||||
pub mod brush_preview;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, vec2};
|
||||
use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2};
|
||||
|
||||
mod hand;
|
||||
|
||||
|
|
@ -19,11 +19,13 @@ mod hand;
|
|||
const MAX_SIDE: u16 = if cfg!(target_os = "windows") { 2048 } else { 255 };
|
||||
|
||||
/// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
/// `Outline` is a brush tip outline: closed rings of logical-point offsets from the hotspot.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Shape {
|
||||
Circle { radius: f32, centre: bool },
|
||||
Crosshair { length: f32, gap: f32 },
|
||||
Hand { closed: bool },
|
||||
Outline { rings: Arc<[Vec<Vec2>]>, centre: bool },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -69,6 +71,10 @@ pub(crate) fn crosshair(painter: &Painter, at: Pos2, length: f32, gap: f32) -> C
|
|||
show(painter, at, Shape::Crosshair { length, gap })
|
||||
}
|
||||
|
||||
pub(crate) fn outline(painter: &Painter, at: Pos2, rings: Vec<Vec<Vec2>>, centre: bool) -> CursorIcon {
|
||||
show(painter, at, Shape::Outline { rings: rings.into(), centre })
|
||||
}
|
||||
|
||||
/// Can this pass show an OS cursor bitmap? eframe's immediate viewports and its web integration
|
||||
/// don't upload cursor images; every desktop root viewport does (winit custom cursors).
|
||||
fn native(ctx: &egui::Context) -> bool {
|
||||
|
|
@ -91,7 +97,7 @@ pub(crate) fn hand(ctx: &egui::Context, closed: bool) -> CursorIcon {
|
|||
}
|
||||
|
||||
fn hand_with(ctx: &egui::Context, closed: bool, bitmap: bool) -> CursorIcon {
|
||||
if bitmap && let Some(image) = cached_image(ctx, Shape::Hand { closed }, native_scale(ctx)) {
|
||||
if bitmap && let Some(image) = cached_image(ctx, &Shape::Hand { closed }, native_scale(ctx)) {
|
||||
ctx.set_cursor_image(Some(image));
|
||||
return CursorIcon::None;
|
||||
}
|
||||
|
|
@ -103,36 +109,36 @@ fn show(painter: &Painter, at: Pos2, shape: Shape) -> CursorIcon {
|
|||
// Where cursor images can't be uploaded (immediate viewports, the web), paint the shape.
|
||||
if native(ctx) {
|
||||
let scale = native_scale(ctx);
|
||||
if let Some(image) = cached_image(ctx, shape, scale) {
|
||||
if let Some(image) = cached_image(ctx, &shape, scale) {
|
||||
ctx.set_cursor_image(Some(image));
|
||||
return CursorIcon::None;
|
||||
}
|
||||
// An enormous tip cannot fit an OS cursor. Preserve its full-size outline, with an OS
|
||||
// crosshair at the hotspot so the user still gets immediate position feedback.
|
||||
paint(painter, at, shape);
|
||||
if let Some(image) = cached_image(ctx, Shape::Crosshair { length: 6.0, gap: 2.0 }, scale) {
|
||||
paint(painter, at, &shape);
|
||||
if let Some(image) = cached_image(ctx, &Shape::Crosshair { length: 6.0, gap: 2.0 }, scale) {
|
||||
ctx.set_cursor_image(Some(image));
|
||||
}
|
||||
return CursorIcon::None;
|
||||
}
|
||||
paint(painter, at, shape);
|
||||
paint(painter, at, &shape);
|
||||
CursorIcon::None
|
||||
}
|
||||
|
||||
fn cached_image(ctx: &egui::Context, shape: Shape, scale: f32) -> Option<CustomCursorImage> {
|
||||
fn cached_image(ctx: &egui::Context, shape: &Shape, scale: f32) -> Option<CustomCursorImage> {
|
||||
let id = egui::Id::new("photocraft-tool-cursor");
|
||||
if let Some(cached) = ctx.data(|d| d.get_temp::<Cached>(id))
|
||||
&& cached.shape == shape
|
||||
&& cached.shape == *shape
|
||||
&& cached.scale == scale
|
||||
{
|
||||
return Some(cached.image);
|
||||
}
|
||||
let image = rasterize(shape, scale)?;
|
||||
ctx.data_mut(|d| d.insert_temp(id, Cached { shape, scale, image: image.clone() }));
|
||||
ctx.data_mut(|d| d.insert_temp(id, Cached { shape: shape.clone(), scale, image: image.clone() }));
|
||||
Some(image)
|
||||
}
|
||||
|
||||
fn paint(painter: &Painter, at: Pos2, shape: Shape) {
|
||||
fn paint(painter: &Painter, at: Pos2, shape: &Shape) {
|
||||
if extent(shape).is_none() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -141,12 +147,22 @@ fn paint(painter: &Painter, at: Pos2, shape: Shape) {
|
|||
let stroke = Stroke::new(width, color);
|
||||
match shape {
|
||||
Shape::Circle { radius, centre } => {
|
||||
painter.circle_stroke(at, radius, stroke);
|
||||
if centre {
|
||||
painter.circle_stroke(at, *radius, stroke);
|
||||
if *centre {
|
||||
paint_crosshair(painter, at, 3.0, 0.0, stroke);
|
||||
}
|
||||
}
|
||||
Shape::Crosshair { length, gap } => paint_crosshair(painter, at, *length, *gap, stroke),
|
||||
Shape::Outline { rings, centre } => {
|
||||
for ring in rings.iter() {
|
||||
if ring.len() >= 2 {
|
||||
painter.add(egui::Shape::closed_line(ring.iter().map(|v| at + *v).collect(), stroke));
|
||||
}
|
||||
}
|
||||
if *centre {
|
||||
paint_crosshair(painter, at, 3.0, 0.0, stroke);
|
||||
}
|
||||
}
|
||||
Shape::Crosshair { length, gap } => paint_crosshair(painter, at, length, gap, stroke),
|
||||
// Only ever a bitmap: without one the OS draws its own grab cursors.
|
||||
Shape::Hand { .. } => {}
|
||||
}
|
||||
|
|
@ -166,42 +182,81 @@ fn cross_distance(x: f32, y: f32, length: f32, gap: f32) -> f32 {
|
|||
horizontal.min(vertical)
|
||||
}
|
||||
|
||||
fn extent(shape: Shape) -> Option<f32> {
|
||||
fn extent(shape: &Shape) -> Option<f32> {
|
||||
Some(match shape {
|
||||
Shape::Circle { radius, centre } => {
|
||||
if !radius.is_finite() || radius <= 0.0 {
|
||||
if !radius.is_finite() || *radius <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if centre { radius.max(3.0) } else { radius }
|
||||
if *centre { radius.max(3.0) } else { *radius }
|
||||
}
|
||||
Shape::Crosshair { length, gap } => {
|
||||
if !length.is_finite() || !gap.is_finite() || gap < 0.0 || length < gap {
|
||||
if !length.is_finite() || !gap.is_finite() || *gap < 0.0 || length < gap {
|
||||
return None;
|
||||
}
|
||||
length
|
||||
*length
|
||||
}
|
||||
Shape::Outline { rings, centre } => {
|
||||
let mut e = 0.0f32;
|
||||
for ring in rings.iter() {
|
||||
for p in ring {
|
||||
if !p.x.is_finite() || !p.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
e = e.max(p.x.hypot(p.y));
|
||||
}
|
||||
}
|
||||
if e <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if *centre { e.max(3.0) } else { e }
|
||||
}
|
||||
Shape::Hand { .. } => hand::HALF,
|
||||
})
|
||||
}
|
||||
|
||||
/// Distance from (`x`, `y`) to the segment `a`-`b`, all in the same units.
|
||||
fn seg_distance(x: f32, y: f32, a: Vec2, b: Vec2) -> f32 {
|
||||
let (abx, aby) = (b.x - a.x, b.y - a.y);
|
||||
let t = (((x - a.x) * abx + (y - a.y) * aby) / (abx * abx + aby * aby).max(1e-12)).clamp(0.0, 1.0);
|
||||
((x - a.x - abx * t).powi(2) + (y - a.y - aby * t).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// How much of the pixel at `(dx, dy)` physical pixels from the hotspot the light fill covers and
|
||||
/// how much the dark outline around it does, each 0 to 1. A dark crease inside the hand takes
|
||||
/// from the fill, so the fill never covers it.
|
||||
fn coverage(shape: Shape, dx: f32, dy: f32, scale: f32) -> (f32, f32) {
|
||||
fn coverage(shape: &Shape, dx: f32, dy: f32, scale: f32) -> (f32, f32) {
|
||||
let (x, y) = (dx.abs(), dy.abs());
|
||||
match shape {
|
||||
Shape::Circle { radius, centre } => {
|
||||
let ring = (x.hypot(y) - radius * scale).abs();
|
||||
let distance = if centre { ring.min(cross_distance(x, y, 3.0 * scale, 0.0)) } else { ring };
|
||||
let distance = if *centre { ring.min(cross_distance(x, y, 3.0 * scale, 0.0)) } else { ring };
|
||||
stroke_coverage(distance, scale)
|
||||
}
|
||||
Shape::Crosshair { length, gap } => stroke_coverage(cross_distance(x, y, length * scale, gap * scale), scale),
|
||||
Shape::Outline { rings, centre } => {
|
||||
let (lx, ly) = (dx / scale, dy / scale);
|
||||
let mut distance = f32::INFINITY;
|
||||
for ring in rings.iter() {
|
||||
let n = ring.len();
|
||||
if n < 2 {
|
||||
continue;
|
||||
}
|
||||
for (i, &a) in ring.iter().enumerate() {
|
||||
distance = distance.min(seg_distance(lx, ly, a, ring[(i + 1) % n]));
|
||||
}
|
||||
}
|
||||
if *centre {
|
||||
distance = distance.min(cross_distance(dx, dy, 3.0 * scale, 0.0));
|
||||
}
|
||||
stroke_coverage(distance * scale, scale)
|
||||
}
|
||||
Shape::Hand { closed } => {
|
||||
let (lx, ly) = (dx / scale, dy / scale);
|
||||
let edge = hand::shape(closed, lx, ly) * scale;
|
||||
let edge = hand::shape(*closed, lx, ly) * scale;
|
||||
let inside = (0.5 - edge).clamp(0.0, 1.0);
|
||||
let outline = (scale + 0.5 - edge).clamp(0.0, 1.0);
|
||||
let crease = (0.45 * scale + 0.5 - hand::crease(closed, lx, ly) * scale).clamp(0.0, 1.0);
|
||||
let crease = (0.45 * scale + 0.5 - hand::crease(*closed, lx, ly) * scale).clamp(0.0, 1.0);
|
||||
(inside * (1.0 - crease), outline.max(inside * crease))
|
||||
}
|
||||
}
|
||||
|
|
@ -212,7 +267,57 @@ fn stroke_coverage(distance: f32, scale: f32) -> (f32, f32) {
|
|||
((0.5 * scale + 0.5 - distance).clamp(0.0, 1.0), (1.5 * scale + 0.5 - distance).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn rasterize(shape: Shape, scale: f32) -> Option<CustomCursorImage> {
|
||||
/// Splat the distance to one ring segment into an outline's side × side distance buffer.
|
||||
fn splat_segment(dist: &mut [f32], side: usize, hot: f32, a: Vec2, b: Vec2, reach: f32) {
|
||||
let n = side as f32;
|
||||
let (x0, x1) = (a.x.min(b.x) + hot - reach, a.x.max(b.x) + hot + reach);
|
||||
let (y0, y1) = (a.y.min(b.y) + hot - reach, a.y.max(b.y) + hot + reach);
|
||||
if x1 < 0.0 || y1 < 0.0 || x0 > n - 1.0 || y0 > n - 1.0 {
|
||||
return;
|
||||
}
|
||||
let (c0, c1) = (x0.max(0.0).floor() as i32, x1.min(n - 1.0).ceil() as i32);
|
||||
let (r0, r1) = (y0.max(0.0).floor() as i32, y1.min(n - 1.0).ceil() as i32);
|
||||
for row in r0..=r1 {
|
||||
let dy = row as f32 - hot;
|
||||
for col in c0..=c1 {
|
||||
let d = seg_distance(col as f32 - hot, dy, a, b);
|
||||
let i = row as usize * side + col as usize;
|
||||
if d < dist[i] {
|
||||
dist[i] = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold the centre crosshair (the circle cursor's) into the outline distances.
|
||||
fn splat_cross(dist: &mut [f32], side: usize, hot: f32, arm: f32) {
|
||||
let reach = arm + 2.0;
|
||||
let lo = (hot - reach).max(0.0).floor() as i32;
|
||||
let hi = ((hot + reach).min(side as f32 - 1.0)).ceil() as i32;
|
||||
for row in lo..=hi {
|
||||
let dy = row as f32 - hot;
|
||||
for col in lo..=hi {
|
||||
let dx = col as f32 - hot;
|
||||
let d = cross_distance(dx, dy, arm, 0.0);
|
||||
let i = row as usize * side + col as usize;
|
||||
if d < dist[i] {
|
||||
dist[i] = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One bitmap pixel from its distance to the stroke centre, in straight RGBA (winit form).
|
||||
fn push_stroke_pixel(rgba: &mut Vec<u8>, distance: f32, scale: f32, dark: egui::Color32, light: egui::Color32) {
|
||||
let (fill, outline) = stroke_coverage(distance, scale);
|
||||
let a_dark = outline * f32::from(dark.a()) / 255.0;
|
||||
let a_light = fill * f32::from(light.a()) / 255.0;
|
||||
let alpha = a_light + a_dark * (1.0 - a_light);
|
||||
let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 };
|
||||
rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]);
|
||||
}
|
||||
|
||||
fn rasterize(shape: &Shape, scale: f32) -> Option<CustomCursorImage> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -228,6 +333,30 @@ fn rasterize(shape: Shape, scale: f32) -> Option<CustomCursorImage> {
|
|||
let bytes = usize::from(side).checked_mul(usize::from(side))?.checked_mul(4)?;
|
||||
let mut rgba = Vec::with_capacity(bytes);
|
||||
let [dark, light] = crate::theme::Tokens::cursor_outline();
|
||||
if let Shape::Outline { rings, centre } = shape {
|
||||
// Splat each segment into its bounding box instead of measuring every ring per pixel:
|
||||
// tip outlines may carry hundreds of points.
|
||||
let hotf = f32::from(hot);
|
||||
let n = usize::from(side);
|
||||
let reach = 1.5 * scale + 2.0;
|
||||
let mut dist = vec![f32::INFINITY; n * n];
|
||||
for ring in rings.iter() {
|
||||
let len = ring.len();
|
||||
if len < 2 {
|
||||
continue;
|
||||
}
|
||||
for (i, &a) in ring.iter().enumerate() {
|
||||
splat_segment(&mut dist, n, hotf, a * scale, ring[(i + 1) % len] * scale, reach);
|
||||
}
|
||||
}
|
||||
if *centre {
|
||||
splat_cross(&mut dist, n, hotf, 3.0 * scale);
|
||||
}
|
||||
for &d in &dist {
|
||||
push_stroke_pixel(&mut rgba, d, scale, dark, light);
|
||||
}
|
||||
return Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] });
|
||||
}
|
||||
for row in 0..side {
|
||||
let dy = f32::from(row) - f32::from(hot);
|
||||
for col in 0..side {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ fn frame(ctx: &egui::Context, draw: impl FnMut(&mut egui::Ui)) -> egui::FullOutp
|
|||
fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() {
|
||||
for scale in [1.0, 1.25, 1.5, 2.0, 3.0] {
|
||||
let radius = 20.0;
|
||||
let image = rasterize(Shape::Circle { radius, centre: false }, scale).unwrap();
|
||||
let image = rasterize(&Shape::Circle { radius, centre: false }, scale).unwrap();
|
||||
let [x, y] = image.hotspot;
|
||||
assert_eq!(image.size, [2 * x + 1, 2 * y + 1]);
|
||||
assert_eq!(image.rgba.len(), usize::from(image.size[0]).pow(2) * 4);
|
||||
|
|
@ -38,13 +38,13 @@ fn bitmap_has_a_transparent_hotspot_and_the_correct_radius_at_each_dpi() {
|
|||
|
||||
#[test]
|
||||
fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() {
|
||||
let ring = rasterize(Shape::Circle { radius: 20.0, centre: true }, 1.0).unwrap();
|
||||
let ring = rasterize(&Shape::Circle { radius: 20.0, centre: true }, 1.0).unwrap();
|
||||
assert!(alpha(&ring, ring.hotspot[0], ring.hotspot[1]) > 230);
|
||||
let precise = rasterize(Shape::Crosshair { length: 8.0, gap: 2.0 }, 1.0).unwrap();
|
||||
let precise = rasterize(&Shape::Crosshair { length: 8.0, gap: 2.0 }, 1.0).unwrap();
|
||||
let [x, y] = precise.hotspot;
|
||||
assert_eq!(alpha(&precise, x, y), 0);
|
||||
assert!(alpha(&precise, x + 5, y) > 230);
|
||||
let solid = rasterize(Shape::Crosshair { length: 6.0, gap: 0.0 }, 1.0).unwrap();
|
||||
let solid = rasterize(&Shape::Crosshair { length: 6.0, gap: 0.0 }, 1.0).unwrap();
|
||||
assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230);
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() {
|
|||
/// larger one falls back to the painted outline. Windows takes far larger cursors.
|
||||
#[test]
|
||||
fn cursor_bitmaps_stay_within_the_platform_limit() {
|
||||
let fits = |radius: f32| rasterize(Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]);
|
||||
let fits = |radius: f32| rasterize(&Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]);
|
||||
assert!(fits(100.0).is_some_and(|side| side <= 255));
|
||||
assert_eq!(fits(200.0).is_some(), cfg!(target_os = "windows"));
|
||||
assert!(fits(200.0).is_none_or(|side| side <= 2048));
|
||||
|
|
@ -61,21 +61,21 @@ fn cursor_bitmaps_stay_within_the_platform_limit() {
|
|||
#[test]
|
||||
fn hostile_sizes_are_rejected_before_allocating() {
|
||||
for radius in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, 2048.0] {
|
||||
assert!(rasterize(Shape::Circle { radius, centre: false }, 1.0).is_none());
|
||||
assert!(rasterize(&Shape::Circle { radius, centre: false }, 1.0).is_none());
|
||||
}
|
||||
for scale in [f32::NAN, f32::INFINITY, -1.0, 0.0, f32::MAX] {
|
||||
assert!(rasterize(Shape::Circle { radius: 20.0, centre: false }, scale).is_none());
|
||||
assert!(rasterize(&Shape::Circle { radius: 20.0, centre: false }, scale).is_none());
|
||||
}
|
||||
for (length, gap) in [(1.0, 2.0), (8.0, -1.0), (f32::NAN, 0.0), (8.0, f32::NAN)] {
|
||||
assert!(rasterize(Shape::Crosshair { length, gap }, 1.0).is_none());
|
||||
assert!(rasterize(&Shape::Crosshair { length, gap }, 1.0).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_bitmaps_are_outlined_hands_around_a_centred_hotspot_at_each_dpi() {
|
||||
for scale in [1.0, 1.25, 1.5, 2.0, 3.0] {
|
||||
let open = rasterize(Shape::Hand { closed: false }, scale).unwrap();
|
||||
let fist = rasterize(Shape::Hand { closed: true }, scale).unwrap();
|
||||
let open = rasterize(&Shape::Hand { closed: false }, scale).unwrap();
|
||||
let fist = rasterize(&Shape::Hand { closed: true }, scale).unwrap();
|
||||
for image in [&open, &fist] {
|
||||
let [x, y] = image.hotspot;
|
||||
assert_eq!(image.size, [2 * x + 1, 2 * y + 1]);
|
||||
|
|
@ -93,7 +93,7 @@ fn hand_bitmaps_are_outlined_hands_around_a_centred_hotspot_at_each_dpi() {
|
|||
|
||||
#[test]
|
||||
fn hand_bitmap_is_a_straight_rgba_silhouette_outlined_outside_the_hand() {
|
||||
let image = rasterize(Shape::Hand { closed: false }, 1.0).unwrap();
|
||||
let image = rasterize(&Shape::Hand { closed: false }, 1.0).unwrap();
|
||||
let side = usize::from(image.size[0]);
|
||||
let px = |x: usize, y: usize| <[u8; 4]>::try_from(&image.rgba[(y * side + x) * 4..][..4]).unwrap();
|
||||
let mid = side / 2;
|
||||
|
|
@ -123,8 +123,8 @@ fn hand_bitmap_is_a_straight_rgba_silhouette_outlined_outside_the_hand() {
|
|||
#[test]
|
||||
fn hostile_scales_get_no_hand_bitmap() {
|
||||
for scale in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0, f32::MAX, 500.0] {
|
||||
assert!(rasterize(Shape::Hand { closed: false }, scale).is_none(), "{scale}");
|
||||
assert!(rasterize(Shape::Hand { closed: true }, scale).is_none(), "{scale}");
|
||||
assert!(rasterize(&Shape::Hand { closed: false }, scale).is_none(), "{scale}");
|
||||
assert!(rasterize(&Shape::Hand { closed: true }, scale).is_none(), "{scale}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() {
|
|||
ui.ctx().set_cursor_icon(icon);
|
||||
});
|
||||
let image = bitmap.platform_output.cursor_image.expect("Windows has no stock hand");
|
||||
assert_eq!(image.rgba, rasterize(Shape::Hand { closed }, 1.0).unwrap().rgba);
|
||||
assert_eq!(image.rgba, rasterize(&Shape::Hand { closed }, 1.0).unwrap().rgba);
|
||||
assert_eq!(bitmap.platform_output.cursor_icon, CursorIcon::Crosshair, "visible fallback if the OS rejects a bitmap");
|
||||
}
|
||||
let open = hand_with(&ctx, false, true);
|
||||
|
|
@ -157,13 +157,13 @@ fn the_hand_is_a_bitmap_only_where_the_os_has_none() {
|
|||
fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() {
|
||||
let ctx = egui::Context::default();
|
||||
let shape = Shape::Circle { radius: 20.0, centre: false };
|
||||
let a = cached_image(&ctx, shape, 1.0).unwrap();
|
||||
let b = cached_image(&ctx, shape, 1.0).unwrap();
|
||||
let a = cached_image(&ctx, &shape, 1.0).unwrap();
|
||||
let b = cached_image(&ctx, &shape, 1.0).unwrap();
|
||||
assert!(Arc::ptr_eq(&a.rgba, &b.rgba));
|
||||
let scaled = cached_image(&ctx, shape, 2.0).unwrap();
|
||||
let scaled = cached_image(&ctx, &shape, 2.0).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a.rgba, &scaled.rgba));
|
||||
assert!(scaled.size[0] > a.size[0]);
|
||||
let larger = cached_image(&ctx, Shape::Circle { radius: 40.0, centre: false }, 2.0).unwrap();
|
||||
let larger = cached_image(&ctx, &Shape::Circle { radius: 40.0, centre: false }, 2.0).unwrap();
|
||||
assert!(larger.size[0] > scaled.size[0]);
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ fn unchanged_geometry_reuses_the_os_upload_and_dpi_invalidates_it() {
|
|||
fn lifecycle_clears_sticky_images_and_respects_later_widgets() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.add_plugin(CursorLifecycle);
|
||||
let image = rasterize(Shape::Circle { radius: 20.0, centre: false }, 1.0).unwrap();
|
||||
let image = rasterize(&Shape::Circle { radius: 20.0, centre: false }, 1.0).unwrap();
|
||||
let output = frame(&ctx, |ui| {
|
||||
ui.ctx().set_cursor_image(Some(image.clone()));
|
||||
ui.ctx().set_cursor_icon(CursorIcon::None);
|
||||
|
|
@ -233,8 +233,8 @@ fn write_cursor_preview() {
|
|||
rgba[(y * width + x) * 4..(y * width + x) * 4 + 3].fill(background);
|
||||
}
|
||||
}
|
||||
for (row, &(shape, scale)) in cases.iter().enumerate() {
|
||||
let image = rasterize(shape, scale).unwrap();
|
||||
for (row, (shape, scale)) in cases.iter().enumerate() {
|
||||
let image = rasterize(shape, *scale).unwrap();
|
||||
let side = usize::from(image.size[0]);
|
||||
let x0 = col * 200 + 100 - usize::from(image.hotspot[0]);
|
||||
let y0 = row * 100 + 50 - usize::from(image.hotspot[1]);
|
||||
|
|
@ -306,7 +306,7 @@ fn brush_hover_uses_an_os_bitmap_that_survives_motion_but_not_tool_or_panel_chan
|
|||
let hand = h.output().platform_output.cursor_image.clone();
|
||||
if cfg!(target_os = "windows") {
|
||||
let hand = hand.expect("the Hand's open hand reaches the OS as a bitmap");
|
||||
assert_eq!(hand.rgba, rasterize(Shape::Hand { closed: false }, h.ctx.pixels_per_point()).unwrap().rgba);
|
||||
assert_eq!(hand.rgba, rasterize(&Shape::Hand { closed: false }, h.ctx.pixels_per_point()).unwrap().rgba);
|
||||
} else {
|
||||
assert!(hand.is_none(), "the OS draws the hand");
|
||||
assert_eq!(h.output().platform_output.cursor_icon, CursorIcon::Grab);
|
||||
|
|
|
|||
Loading…
Reference in New Issue