mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Fix #3328: Pencil Fidelity down to 0.01 pt, with a logarithmic slider and key steps that fit the range
Squashed from #4196 onto main.
This commit is contained in:
@@ -549,7 +549,7 @@ fn path_kind(path: PathData) -> NodeKind {
|
||||
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
|
||||
/// corners) and simplify it to `tol`.
|
||||
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
let min = (tol * 0.3).max(0.05);
|
||||
let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIDELITY * 0.3);
|
||||
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
|
||||
let mut kept: Vec<Point> = vec![p0];
|
||||
for &p in pts.iter().skip(1) {
|
||||
@@ -684,7 +684,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let pressure = stroke_pressure(&samples);
|
||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0);
|
||||
let tol = number_or(p, "fidelity", 1.5, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0);
|
||||
// The tolerance the stroke is fitted at; with `fit: false` it is not fitted.
|
||||
let fit_tol = switch_or(p, "fit", true, C)?.then_some(tol);
|
||||
let closed = switch_or(p, "closed", false, C)?;
|
||||
@@ -1429,7 +1429,7 @@ fn smooth_region(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.smoothRegion";
|
||||
let pts = points_param(p, "points", C)?;
|
||||
let radius = number_or(p, "radius", 12.0, C)?.max(0.0);
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0);
|
||||
let tol = number_or(p, "fidelity", 2.5, C)?.clamp(vectorcraft_tools::draw2::SMOOTH_MIN_FIDELITY, 100.0);
|
||||
let st = s.doc()?;
|
||||
let near = |q: Point| dist_to_polyline(&pts, q) <= radius;
|
||||
let mut plan = vec![];
|
||||
|
||||
@@ -2657,3 +2657,68 @@ fn open_anchor_delete_after_transform_keeps_mixed_selection_atomic() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchors of a closed, slightly wobbly blob drawn at `fidelity`: samples half a point apart,
|
||||
/// with a small tremor on top of the outline.
|
||||
fn blob_anchors(s: &mut Session, fidelity: f64) -> usize {
|
||||
let pts: Vec<_> = (0..1200)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 1200.0 * std::f64::consts::TAU;
|
||||
let r = 90.0 + 12.0 * (3.0 * a).sin() + 0.15 * (97.0 * a).sin();
|
||||
json!([400.0 + r * a.cos(), 300.0 + r * a.sin()])
|
||||
})
|
||||
.collect();
|
||||
let r = s.execute("path.freehand", &json!({"points": pts, "closed": true, "fidelity": fidelity, "keepSelected": false})).unwrap();
|
||||
path(s, NodeId(r["id"].as_u64().unwrap())).subpaths.iter().map(|sp| sp.anchors.len()).sum()
|
||||
}
|
||||
|
||||
/// Fidelity goes down to 0.01 pt (#3328), and a lower one never keeps fewer anchors.
|
||||
#[test]
|
||||
fn pencil_fidelity_goes_down_to_a_hundredth_and_lower_keeps_more() {
|
||||
let mut s = session();
|
||||
let counts: Vec<_> = [0.5, 0.1, 0.05, 0.02, 0.01].into_iter().map(|f| blob_anchors(&mut s, f)).collect();
|
||||
assert!(counts.windows(2).all(|w| w[0] <= w[1]), "{counts:?}");
|
||||
// Below the old floor of 0.05 the stroke keeps more of itself.
|
||||
assert!(counts[4] > counts[2], "{counts:?}");
|
||||
// Below 0.01 is read as 0.01.
|
||||
assert_eq!(blob_anchors(&mut s, 0.001), counts[4]);
|
||||
assert_eq!(blob_anchors(&mut s, 0.0), counts[4]);
|
||||
}
|
||||
|
||||
/// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never
|
||||
/// crash, and what they draw has finite anchors.
|
||||
#[test]
|
||||
fn pencil_at_the_lowest_fidelity_takes_degenerate_strokes() {
|
||||
let mut s = session();
|
||||
for points in [
|
||||
json!([[10, 10]]),
|
||||
json!([[10, 10], [10, 10]]),
|
||||
json!([[10, 10], [10, 10], [10, 10], [10, 10]]),
|
||||
json!([[10, 10], [10.001, 10]]),
|
||||
json!([[10, 10], [10.004, 10.003], [10.002, 10.001], [10.006, 10]]),
|
||||
json!([[10, 10], [20, 10], [10, 10]]),
|
||||
json!([[10, 10], [20, 10], [20, 10], [20, 10], [30, 10]]),
|
||||
] {
|
||||
for closed in [false, true] {
|
||||
let p = json!({"points": points, "closed": closed, "fidelity": 0.01, "keepSelected": false});
|
||||
let Ok(r) = s.execute("path.freehand", &p) else { continue };
|
||||
let Some(id) = r["id"].as_u64() else { continue };
|
||||
let Some(pd) = s.doc().unwrap().doc.node(NodeId(id)).and_then(|n| n.path_data().cloned()) else { continue };
|
||||
for a in pd.subpaths.iter().flat_map(|sp| &sp.anchors) {
|
||||
assert!([a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite()), "{points} closed {closed}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Pencil's Fidelity option takes 0.01, and the Smooth tool's stays at 0.05 or more.
|
||||
#[test]
|
||||
fn pencil_option_takes_a_hundredth_and_smooth_keeps_its_floor() {
|
||||
let mut s = session();
|
||||
for (tool, want) in [("pencil", 0.01), ("paintbrush", 0.01), ("smooth", 0.05)] {
|
||||
s.set_tool_option_cmd(&json!({"tool": tool, "values": {"fidelity": 0.001}})).unwrap();
|
||||
assert_eq!(s.tool_options_of(tool)["fidelity"].as_f64(), Some(want), "{tool}");
|
||||
s.set_tool_option_cmd(&json!({"tool": tool, "values": {"fidelity": 0.02}})).unwrap();
|
||||
assert_eq!(s.tool_options_of(tool)["fidelity"].as_f64(), Some(if tool == "smooth" { 0.05 } else { 0.02 }), "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,18 @@ pub use gesture::GestureTool;
|
||||
/// Colour of tool feedback (Illustrator's selection blue).
|
||||
pub const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||||
|
||||
/// The lowest Fidelity (points) the Pencil and Paintbrush take (#3328); the Smooth tool keeps
|
||||
/// [`SMOOTH_MIN_FIDELITY`].
|
||||
pub const MIN_FIDELITY: f64 = 0.01;
|
||||
|
||||
/// The lowest Fidelity (points) the Smooth tool takes.
|
||||
pub const SMOOTH_MIN_FIDELITY: f64 = 0.05;
|
||||
|
||||
/// The lowest Fidelity tool `id` takes.
|
||||
pub fn min_fidelity(id: &str) -> f64 {
|
||||
if id == "smooth" { SMOOTH_MIN_FIDELITY } else { MIN_FIDELITY }
|
||||
}
|
||||
|
||||
/// Create a drawing tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
|
||||
@@ -632,7 +632,7 @@ impl Tool for GestureTool {
|
||||
}
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
match key {
|
||||
"fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(0.05, 100.0),
|
||||
"fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(super::min_fidelity(self.id), 100.0),
|
||||
"size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0),
|
||||
"angle" => self.angle = v.as_f64().unwrap_or(self.angle).clamp(-180.0, 180.0),
|
||||
"roundness" => self.roundness = v.as_f64().unwrap_or(self.roundness).clamp(1.0, 100.0),
|
||||
|
||||
@@ -757,10 +757,21 @@ pub(super) fn slider_ends(ui: &mut egui::Ui, label_w: f32, ends: (&str, &str)) {
|
||||
});
|
||||
}
|
||||
|
||||
/// [`slider_ends`] under a rail `width` wide that starts at the left edge of the row, for a slider
|
||||
/// with its label on the line above.
|
||||
pub(super) fn slider_ends_below(ui: &mut egui::Ui, width: f32, ends: (&str, &str)) {
|
||||
ui.horizontal(|ui| rail_ends_w(ui, width, ends));
|
||||
}
|
||||
|
||||
/// The words of [`slider_ends`] under a rail that starts where the next widget does.
|
||||
fn rail_ends(ui: &mut egui::Ui, (left, right): (&str, &str)) {
|
||||
fn rail_ends(ui: &mut egui::Ui, ends: (&str, &str)) {
|
||||
rail_ends_w(ui, SLIDER_WIDTH, ends);
|
||||
}
|
||||
|
||||
/// [`rail_ends`] under a rail `width` wide.
|
||||
fn rail_ends_w(ui: &mut egui::Ui, width: f32, (left, right): (&str, &str)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(SLIDER_WIDTH, 14.0), egui::Sense::hover());
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(width, 14.0), egui::Sense::hover());
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, left, font.clone(), t.text_dim);
|
||||
ui.painter().text(r.right_center(), egui::Align2::RIGHT_CENTER, right, font, t.text_dim);
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{VectorcraftApp, widgets};
|
||||
/// The dialog kind of the freehand tools' Tool Options.
|
||||
pub const KIND: &str = "freehandOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 420.0, ..DialogSpec::FORM };
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: CONTENT_W, ..DialogSpec::FORM };
|
||||
|
||||
/// The tools the dialog serves.
|
||||
pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "eraser"];
|
||||
@@ -28,13 +28,69 @@ pub const TOOLS: [&str; 5] = ["pencil", "paintbrush", "smooth", "blobBrush", "er
|
||||
const KEYS: [&str; 11] =
|
||||
["fidelity", "angle", "roundness", "size", "fill", "altSmooth", "keepSelected", "mergeOnlySelection", "closeWithin", "editWithin", "liveFit"];
|
||||
|
||||
/// Width of the label column: wide enough for the tolerances' labels when there are any.
|
||||
fn label_w(d: &Dialog) -> f32 {
|
||||
if d.fields.contains_key("closeWithin") { 230.0 } else { 90.0 }
|
||||
/// Width of the dialog's rows: every number field ends at its right edge.
|
||||
const CONTENT_W: f32 = 330.0;
|
||||
|
||||
/// Width of the number fields, Fidelity's among them.
|
||||
const FIELD_W: f32 = 72.0;
|
||||
|
||||
/// Width of the label column of a row with a number field: what the field leaves of the row. The
|
||||
/// Fidelity slider takes the same width under its label.
|
||||
fn label_w(ui: &egui::Ui) -> f32 {
|
||||
CONTENT_W - FIELD_W - ui.spacing().item_spacing.x
|
||||
}
|
||||
|
||||
/// The range of the Fidelity slider (points); the field takes what the tool allows.
|
||||
const FIDELITY: (f64, f64) = (0.5, 20.0);
|
||||
/// The top of the Fidelity slider (points); it starts at the lowest Fidelity the tool takes, and
|
||||
/// the field takes up to 100.
|
||||
const FIDELITY_MAX: f64 = 20.0;
|
||||
|
||||
/// Fidelity `v` stepped `n` steps by the slider's and the field's keys: an arrow steps hundredths up to 0.1 pt,
|
||||
/// tenths up to 1 pt and whole points above (a step down from 0.1 or 1 takes the finer step below
|
||||
/// it); ten steps at once (Shift with an arrow, Page Up/Down) multiply or divide by ten, so they go
|
||||
/// 0.01 → 0.1 → 1 → 10.
|
||||
fn fidelity_step(v: f64, n: f64) -> f64 {
|
||||
if n.abs() >= 10.0 {
|
||||
let times = 10f64.powf((n / 10.0).round());
|
||||
return ((v * times) * 100.0).round() / 100.0;
|
||||
}
|
||||
// The step at `v` going up, or going down from it.
|
||||
let unit = |v: f64, up: bool| {
|
||||
let at = |edge: f64| if up { v < edge - 1e-9 } else { v <= edge + 1e-9 };
|
||||
if at(0.1) {
|
||||
0.01
|
||||
} else if at(1.0) {
|
||||
0.1
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
};
|
||||
let up = n > 0.0;
|
||||
let mut v = v;
|
||||
for _ in 0..(n.abs().min(1000.0) as usize) {
|
||||
let u = unit(v, up);
|
||||
let q = v / u;
|
||||
let k = if up { (q + 1e-6).floor() + 1.0 } else { (q - 1e-6).ceil() - 1.0 };
|
||||
v = ((k * u) * 100.0).round() / 100.0;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Where Fidelity `v` sits on a slider over `(min, max)`, from 0 to 1. The rail is logarithmic, so
|
||||
/// the low values, where the stroke is followed closely, get as much of it as the high ones
|
||||
/// (0.01–1 pt takes most of a 0.01–20 rail).
|
||||
fn fidelity_at(v: f64, (min, max): (f64, f64)) -> f32 {
|
||||
if !(v.is_finite() && min > 0.0 && max > min) {
|
||||
return 0.0;
|
||||
}
|
||||
((v.clamp(min, max) / min).ln() / (max / min).ln()) as f32
|
||||
}
|
||||
|
||||
/// The Fidelity at `x` (0 to 1) on a slider over `(min, max)`: hundredths below 1 pt, tenths above.
|
||||
fn fidelity_from(x: f32, (min, max): (f64, f64)) -> f64 {
|
||||
let v = min * (max / min).powf(f64::from(x.clamp(0.0, 1.0)));
|
||||
let step = if v < 1.0 { 100.0 } else { 10.0 };
|
||||
((v * step).round() / step).clamp(min, max)
|
||||
}
|
||||
|
||||
fn heading(d: &Dialog) -> String {
|
||||
let label = vectorcraft_tools::tool_info(&d.str("tool")).map_or("", |t| t.label);
|
||||
@@ -55,41 +111,50 @@ pub fn open(app: &mut VectorcraftApp, tool: &str) -> Result<Value, String> {
|
||||
|
||||
/// A row with a number field for `d.fields[key]` (`suffix` is its unit), kept within `range`.
|
||||
fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: &str, decimals: usize, range: (f64, f64)) {
|
||||
let label_w = label_w(d);
|
||||
let label_w = label_w(ui);
|
||||
let v = d.f64(key, 0.0);
|
||||
widgets::label_row(ui, label, label_w, |ui| {
|
||||
if let Some(x) = widgets::range_field(ui, ("freehand-field", key), v, range.0..=range.1, suffix, decimals, 72.0) {
|
||||
if let Some(x) = widgets::range_field(ui, ("freehand-field", key), v, range.0..=range.1, suffix, decimals, FIELD_W) {
|
||||
d.fields.insert(key.into(), json!(x));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Fidelity row: a slider over [`FIDELITY`] in tenths, and a field.
|
||||
/// The Fidelity rows: the label on a line of its own, then a logarithmic slider from the tool's
|
||||
/// lowest Fidelity to [`FIDELITY_MAX`] across the row with the field at its end, both to two
|
||||
/// decimals, and Accurate and Smooth under the slider's ends.
|
||||
fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let label_w = label_w(d);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let v = d.f64("fidelity", 1.5);
|
||||
let (min, max) = FIDELITY;
|
||||
let rail = (vectorcraft_tools::draw2::min_fidelity(&d.str("tool")), FIDELITY_MAX);
|
||||
let rail_w = label_w(ui);
|
||||
let mut new = None;
|
||||
widgets::label_row(ui, tl!("Fidelity:"), label_w, |ui| {
|
||||
let at = ((v.clamp(min, max) - min) / (max - min)) as f32;
|
||||
let shown = widgets::SliderValue::field(v, min..=max, "pt", 1);
|
||||
ui.label(egui::RichText::new(tl!("Fidelity:")).size(12.5).color(t.text));
|
||||
ui.horizontal(|ui| {
|
||||
let shown = widgets::SliderValue::field(v, rail.0..=rail.1, "pt", 2).with_step(fidelity_step);
|
||||
let (x, _, keyed) =
|
||||
widgets::color_slider_stepped(ui, "freehand-fidelity", tl!("Fidelity:"), Some(shown), at, form::SLIDER_WIDTH, &|_| t.input_border);
|
||||
new = x.map(|x| ((min + x as f64 * (max - min)) * 10.0).round() / 10.0).or(keyed);
|
||||
if let Some(x) = widgets::range_field(ui, "freehand-fidelity-field", v, 0.05..=100.0, "pt", 1, 64.0) {
|
||||
widgets::color_slider_stepped(ui, "freehand-fidelity", tl!("Fidelity:"), Some(shown), fidelity_at(v, rail), rail_w, &|_| t.input_border);
|
||||
new = x.map(|x| fidelity_from(x, rail)).or(keyed);
|
||||
if let Some(x) = widgets::range_field_stepped(ui, "freehand-fidelity-field", v, rail.0..=100.0, "pt", 2, FIELD_W, fidelity_step) {
|
||||
new = Some(x);
|
||||
}
|
||||
});
|
||||
if let Some(n) = new.filter(|n| *n != v) {
|
||||
d.fields.insert("fidelity".into(), json!(n));
|
||||
}
|
||||
form::slider_ends(ui, label_w, (tl!("Accurate"), tl!("Smooth")));
|
||||
ui.label(
|
||||
egui::RichText::new(tl!("A lower value follows the stroke closely; a higher one gives fewer points and smoother curves."))
|
||||
.size(11.5)
|
||||
.color(t.text_dim),
|
||||
);
|
||||
form::slider_ends_below(ui, rail_w, (tl!("Accurate"), tl!("Smooth")));
|
||||
ui.add_space(4.0);
|
||||
// Wrapped to the rows' width, so the sentence doesn't widen the dialog.
|
||||
ui.allocate_ui(egui::vec2(CONTENT_W, 0.0), |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(tl!("A lower value follows the stroke closely; a higher one gives fewer points and smoother curves."))
|
||||
.size(11.5)
|
||||
.color(t.text_dim),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
@@ -154,6 +219,55 @@ mod tests {
|
||||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fidelity_rail_is_logarithmic_and_round_trips_two_decimals() {
|
||||
let rail = (0.01, FIDELITY_MAX);
|
||||
assert_eq!((fidelity_at(0.01, rail), fidelity_at(20.0, rail)), (0.0, 1.0));
|
||||
assert_eq!((fidelity_from(0.0, rail), fidelity_from(1.0, rail)), (0.01, 20.0));
|
||||
// 0.01–1 pt gets more than half of the rail.
|
||||
assert!(fidelity_at(1.0, rail) > 0.5, "{}", fidelity_at(1.0, rail));
|
||||
// The values a user picks come back as they were, and the rail only ever rises.
|
||||
for v in [0.01, 0.02, 0.05, 0.1, 0.25, 0.5, 1.5, 6.5, 12.0, 20.0] {
|
||||
assert_eq!(fidelity_from(fidelity_at(v, rail), rail), v, "{v}");
|
||||
}
|
||||
let steps: Vec<f64> = (0..=200).map(|i| fidelity_from(i as f32 / 200.0, rail)).collect();
|
||||
assert!(steps.windows(2).all(|w| w[0] <= w[1]), "{steps:?}");
|
||||
// Past the ends and on bad input it stays on the rail.
|
||||
assert_eq!((fidelity_at(0.0, rail), fidelity_at(50.0, rail), fidelity_at(f64::NAN, rail)), (0.0, 1.0, 0.0));
|
||||
assert_eq!((fidelity_from(-1.0, rail), fidelity_from(2.0, rail)), (0.01, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fidelity_keys_step_finely_at_the_low_end() {
|
||||
let up: Vec<f64> = std::iter::successors(Some(0.01), |&v| Some(fidelity_step(v, 1.0))).take(21).collect();
|
||||
assert_eq!(up, [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 2.0, 3.0]);
|
||||
// Down retraces the same steps.
|
||||
let down: Vec<f64> = std::iter::successors(Some(3.0), |&v| Some(fidelity_step(v, -1.0))).take(21).collect();
|
||||
assert_eq!(down, up.iter().rev().copied().collect::<Vec<_>>());
|
||||
// Ten steps at once (Shift) multiply or divide by ten.
|
||||
assert_eq!((fidelity_step(0.01, 10.0), fidelity_step(0.1, 10.0), fidelity_step(1.0, 10.0)), (0.1, 1.0, 10.0));
|
||||
assert_eq!(
|
||||
(fidelity_step(10.0, -10.0), fidelity_step(1.0, -10.0), fidelity_step(0.1, -10.0), fidelity_step(0.03, 10.0)),
|
||||
(1.0, 0.1, 0.01, 0.3)
|
||||
);
|
||||
// A value between steps goes to the next one in its direction.
|
||||
assert_eq!(
|
||||
(fidelity_step(0.035, 1.0), fidelity_step(0.035, -1.0), fidelity_step(1.5, 1.0), fidelity_step(1.5, -1.0)),
|
||||
(0.04, 0.03, 2.0, 1.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fidelity_field_takes_a_hundredth_for_the_pencil() {
|
||||
let mut app = app();
|
||||
app.run("tool.options", json!({"tool": "pencil"})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("fidelity".into(), json!(0.02));
|
||||
let text = painted(&mut app);
|
||||
assert!(text.contains("0.02"), "{text}");
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.tool_options_of("pencil")["fidelity"].as_f64(), Some(0.02));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_tool_shows_its_options_and_ok_sets_them() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1442,7 +1442,13 @@ fn dialog_sliders_report_the_numbers_their_fields_show() {
|
||||
app.run("tool.options", json!({"tool": "pencil"})).unwrap();
|
||||
set(&mut app, "fidelity", json!(2.5));
|
||||
let shown = tree(|ui| show(&mut app, ui.ctx()));
|
||||
assert_eq!(slider_value(&shown, "Fidelity:"), v(2.5, (0.5, 20.0), "2.5pt"));
|
||||
assert_eq!(slider_value(&shown, "Fidelity:"), v(2.5, (0.01, 20.0), "2.5pt"));
|
||||
// The Smooth tool's rail starts at its own lowest Fidelity.
|
||||
app.ui.dialog = None;
|
||||
app.run("tool.options", json!({"tool": "smooth"})).unwrap();
|
||||
set(&mut app, "fidelity", json!(0.25));
|
||||
let shown = tree(|ui| show(&mut app, ui.ctx()));
|
||||
assert_eq!(slider_value(&shown, "Fidelity:"), v(0.25, (0.05, 20.0), "0.25pt"));
|
||||
app.ui.dialog = None;
|
||||
app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap();
|
||||
app.run("ui.colorPicker", json!({})).unwrap();
|
||||
|
||||
@@ -45,6 +45,37 @@ fn a_focused_field_follows_a_value_changed_from_outside() {
|
||||
assert_eq!(frame(50.0, vec![button(away, false)]).0, None);
|
||||
}
|
||||
|
||||
/// A field with steps of its own (the Pencil's Fidelity, #3328): ↑/↓ take them instead of whole
|
||||
/// numbers, ten at once with Shift, and Ctrl/Cmd steps as plainly as no modifier does.
|
||||
#[test]
|
||||
fn a_stepped_field_takes_its_own_steps() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut time = 0.0;
|
||||
let mut frame = |value: f64, events: Vec<Event>| {
|
||||
time += 0.05;
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)));
|
||||
let mut got = (None, Rect::NOTHING);
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), screen_rect, focused: true, ..Default::default() }, |ui| {
|
||||
got.0 = widgets::range_field_stepped(ui, "f", value, 0.01..=100.0, "pt", 2, 80.0, |v, n| ((v + 0.01 * n) * 100.0).round() / 100.0);
|
||||
got.1 = ui.min_rect();
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
got
|
||||
};
|
||||
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
let at = frame(0.02, vec![]).1.center();
|
||||
frame(0.02, vec![Event::PointerMoved(at), button(at, true)]);
|
||||
frame(0.02, vec![button(at, false)]);
|
||||
assert_eq!(frame(0.02, vec![key(Key::ArrowUp, Modifiers::NONE)]).0, Some(0.03), "↑ takes one of its steps, not a whole point");
|
||||
assert_eq!(frame(0.03, vec![key(Key::ArrowDown, Modifiers::NONE)]).0, Some(0.02));
|
||||
assert_eq!(frame(0.02, vec![key(Key::ArrowUp, Modifiers::SHIFT)]).0, Some(0.12), "Shift takes ten");
|
||||
assert_eq!(frame(0.12, vec![key(Key::ArrowUp, Modifiers::COMMAND)]).0, Some(0.13), "Cmd takes one");
|
||||
// The steps stop at the field's range.
|
||||
assert_eq!(frame(0.01, vec![key(Key::ArrowDown, Modifiers::NONE)]).0, None, "nothing below 0.01");
|
||||
}
|
||||
|
||||
/// #1692: a hex field that the focus only passes through shows the color as it is now and commits
|
||||
/// nothing. Tab out of an edited field gives the hex field after it the focus in the frame the edit
|
||||
/// is committed, and the color changes after the hex field is drawn, as in the Color Picker.
|
||||
|
||||
+118
-22
@@ -384,7 +384,7 @@ fn unit_field(
|
||||
// ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it.
|
||||
let read = typed_unit(ui.ctx(), unit);
|
||||
let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end());
|
||||
let stepped = step_value(ui, &resp, &mut buf, &bounds, |b| Some(unit.from_pt(read.parse(b)?)), |v| unit.format(unit.to_pt(v)));
|
||||
let stepped = step_value(ui, &resp, &mut buf, &bounds, (|b: &str| Some(unit.from_pt(read.parse(b)?)), |v| unit.format(unit.to_pt(v))), None);
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds);
|
||||
let commit = commit && buf != shown;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
@@ -443,8 +443,8 @@ fn step_value(
|
||||
resp: &Response,
|
||||
buf: &mut String,
|
||||
bounds: &RangeInclusive<f64>,
|
||||
read: impl Fn(&str) -> Option<f64>,
|
||||
show: impl Fn(f64) -> String,
|
||||
(read, show): (impl Fn(&str) -> Option<f64>, impl Fn(f64) -> String),
|
||||
step: Option<fn(f64, f64) -> f64>,
|
||||
) -> Option<f64> {
|
||||
// Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the
|
||||
// window to report keyboard focus.
|
||||
@@ -498,7 +498,12 @@ fn step_value(
|
||||
}
|
||||
let mut v = read(buf)?;
|
||||
for (mods, size, n) in steps {
|
||||
v = if mods == egui::Modifiers::SHIFT { v + size * n } else { step_to_multiple(v, size, n) };
|
||||
v = match step {
|
||||
// The field's own steps: ten at once with Shift, one with or without Ctrl/Cmd.
|
||||
Some(step) => step(v, if mods == egui::Modifiers::SHIFT { 10.0 * n } else { n }),
|
||||
None if mods == egui::Modifiers::SHIFT => v + size * n,
|
||||
None => step_to_multiple(v, size, n),
|
||||
};
|
||||
// Each step stops at the bounds, so a step back in the same frame starts from there.
|
||||
v = within(v, bounds);
|
||||
}
|
||||
@@ -861,7 +866,27 @@ pub fn range_field_named(
|
||||
let places = decimals.min(6) as i32;
|
||||
let value = value.into().map(|v| within(v, &range));
|
||||
let label = take_previous_label(ui);
|
||||
bounded_field(ui, id, label, name, value, suffix, decimals, width, &range).map(|v| within(round_to(v, places), &range))
|
||||
bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None).map(|v| within(round_to(v, places), &range))
|
||||
}
|
||||
|
||||
/// [`range_field`] whose ↑/↓ and wheel take `step`'s steps instead of whole numbers (see
|
||||
/// [`SliderValue::step`]), for a number whose useful values are fractions, such as the Pencil's
|
||||
/// Fidelity.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn range_field_stepped(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash,
|
||||
value: impl Into<Option<f64>>,
|
||||
range: RangeInclusive<f64>,
|
||||
suffix: &str,
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
step: fn(f64, f64) -> f64,
|
||||
) -> Option<f64> {
|
||||
let places = decimals.min(6) as i32;
|
||||
let value = value.into().map(|v| within(v, &range));
|
||||
let label = take_previous_label(ui);
|
||||
bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step)).map(|v| within(round_to(v, places), &range))
|
||||
}
|
||||
|
||||
/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field.
|
||||
@@ -880,10 +905,11 @@ pub fn mixed_field_named(
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
let label = take_previous_label(ui);
|
||||
bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY)
|
||||
bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None)
|
||||
}
|
||||
|
||||
/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`.
|
||||
/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`; ↑/↓ and the wheel take `step`'s steps
|
||||
/// when it is given (see [`SliderValue::step`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bounded_field(
|
||||
ui: &mut Ui,
|
||||
@@ -895,6 +921,7 @@ fn bounded_field(
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
bounds: &RangeInclusive<f64>,
|
||||
step: Option<fn(f64, f64) -> f64>,
|
||||
) -> Option<f64> {
|
||||
let id = id_with(ui.scope_id(), id);
|
||||
let show = |value: f64| field_text(value, suffix, decimals);
|
||||
@@ -911,7 +938,7 @@ fn bounded_field(
|
||||
// ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on
|
||||
// its label scrubs it.
|
||||
let decimals = decimals.min(6) as i32;
|
||||
let stepped = step_value(ui, &resp, &mut buf, bounds, read, show).map(|v| round_to(v, decimals));
|
||||
let stepped = step_value(ui, &resp, &mut buf, bounds, (read, show), step).map(|v| round_to(v, decimals));
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals, bounds);
|
||||
if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) }
|
||||
}
|
||||
@@ -1923,7 +1950,7 @@ pub fn spin_plain_named(
|
||||
let value = value.into();
|
||||
let fmt = |v: f64| format!("{v}{suffix}");
|
||||
spin_generic(ui, label, value, None, width, step, min, presets, &fmt, &mut |ui, fw, label| {
|
||||
bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY))
|
||||
bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None)
|
||||
})
|
||||
.and_then(SpinPick::value)
|
||||
}
|
||||
@@ -2040,31 +2067,49 @@ pub fn color_slider_named(
|
||||
|
||||
/// The value a slider gives screen readers: the number the field beside it shows, the range of the
|
||||
/// slider's rail and the field's text.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SliderValue {
|
||||
pub value: f64,
|
||||
pub range: std::ops::RangeInclusive<f64>,
|
||||
/// The field's text, such as "50%"; `None` gives the number alone.
|
||||
pub text: Option<String>,
|
||||
/// How the keys and screen readers step the number ([`color_slider_stepped`]): the number `n`
|
||||
/// steps from `value` (±1 for an arrow, ±10 with Shift or Page Up/Down). `None` steps to whole
|
||||
/// numbers.
|
||||
pub step: Option<fn(f64, f64) -> f64>,
|
||||
}
|
||||
|
||||
/// Two values are the same when they show the same number, range and text, whatever their keys' steps
|
||||
/// (function pointers don't compare reliably).
|
||||
impl PartialEq for SliderValue {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(self.value, &self.range, &self.text) == (other.value, &other.range, &other.text)
|
||||
}
|
||||
}
|
||||
|
||||
impl SliderValue {
|
||||
/// `value` on a rail over `range`, without a text.
|
||||
pub fn number(value: f64, range: std::ops::RangeInclusive<f64>) -> Self {
|
||||
Self { value, range, text: None }
|
||||
Self { value, range, text: None, step: None }
|
||||
}
|
||||
|
||||
/// `value` on a rail over `range` as [`plain_field`] shows it with `suffix` and at most
|
||||
/// `decimals` places: the number it shows (`value` rounded) and its text.
|
||||
pub fn field(value: f64, range: std::ops::RangeInclusive<f64>, suffix: &str, decimals: usize) -> Self {
|
||||
let number = field_text(value, "", decimals);
|
||||
Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")) }
|
||||
Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")), step: None }
|
||||
}
|
||||
|
||||
/// `value` on a rail over `range`, with `text`, for a field that writes its own text (a length
|
||||
/// in the document's unit, an egui `DragValue`).
|
||||
pub fn text(value: f64, range: std::ops::RangeInclusive<f64>, text: String) -> Self {
|
||||
Self { value, range, text: Some(text) }
|
||||
Self { value, range, text: Some(text), step: None }
|
||||
}
|
||||
|
||||
/// This value, stepped by the keys with `step` instead of to whole numbers (see
|
||||
/// [`SliderValue::step`]), for a rail whose useful numbers are fractions.
|
||||
pub fn with_step(self, step: fn(f64, f64) -> f64) -> Self {
|
||||
Self { step: Some(step), ..self }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2104,7 +2149,8 @@ pub fn color_slider_valued(
|
||||
}
|
||||
|
||||
/// [`color_slider_valued`] for a dialog's rail beside a number field, which also responds to the
|
||||
/// keyboard. A click or a drag gives the rail the keyboard focus, which an outline around it shows.
|
||||
/// keyboard. A click or a drag gives the rail the keyboard focus; an outline around it shows the
|
||||
/// focus once a key has stepped it, or when the focus came from the keyboard (Tab).
|
||||
/// While it has the focus, → and ↑ raise the number `shown` holds to the next whole number and ←
|
||||
/// and ↓ lower it to the next whole number, Shift with an arrow, Page Up and Page Down add or take
|
||||
/// ten, and Home and End set the start and the end of `shown`'s range. Screen readers' increment
|
||||
@@ -2124,13 +2170,16 @@ pub fn color_slider_stepped(
|
||||
) -> (Option<f32>, Live, Option<f64>) {
|
||||
let id = id_with(ui.scope_id(), id);
|
||||
let (resp, picked, phase) = color_rail(ui, id, name, shown.clone(), value, width, track);
|
||||
// Focus a click or a drag gave shows no outline until a key steps the rail: the outline tells
|
||||
// a keyboard user where the focus is, and after a drag it only framed the rail.
|
||||
let by_pointer = id.with("focused-by-pointer");
|
||||
if resp.drag_started() || resp.clicked() {
|
||||
resp.request_focus();
|
||||
ui.data_mut(|d| d.insert_temp(by_pointer, true));
|
||||
}
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
if focused {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(resp.rect, 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
if !focused {
|
||||
ui.data_mut(|d| d.remove_temp::<bool>(by_pointer));
|
||||
}
|
||||
let stepped = shown.filter(|_| ui.is_enabled()).and_then(|s| {
|
||||
ui.ctx().accesskit_node_builder(id, |node| {
|
||||
@@ -2140,6 +2189,13 @@ pub fn color_slider_stepped(
|
||||
});
|
||||
rail_step(ui, id, focused, &s)
|
||||
});
|
||||
if stepped.is_some() {
|
||||
ui.data_mut(|d| d.remove_temp::<bool>(by_pointer));
|
||||
}
|
||||
if focused && !ui.data(|d| d.get_temp::<bool>(by_pointer)).unwrap_or(false) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(resp.rect, 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
(picked, phase, stepped.filter(|_| picked.is_none()))
|
||||
}
|
||||
|
||||
@@ -2154,6 +2210,12 @@ fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option<f6
|
||||
}
|
||||
let from = s.value.clamp(lo, hi);
|
||||
let mut v = from;
|
||||
// `n` steps from `v`: the rail's own steps, or to whole numbers (ten at a time with Shift).
|
||||
let by = |v: f64, n: f64| match s.step {
|
||||
Some(step) => step(v, n),
|
||||
None if n.abs() >= 10.0 => v + n,
|
||||
None => step_to_multiple(v, 1.0, n),
|
||||
};
|
||||
// Home or End: an end of the range, which also replaces a number past it.
|
||||
let mut end = false;
|
||||
if focused {
|
||||
@@ -2173,12 +2235,12 @@ fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option<f6
|
||||
v = if *key == Key::Home { lo } else { hi };
|
||||
end = true;
|
||||
}
|
||||
Key::PageUp | Key::PageDown if modifiers.is_none() => v = (v + 10.0 * n).clamp(lo, hi),
|
||||
Key::PageUp | Key::PageDown if modifiers.is_none() => v = by(v, 10.0 * n).clamp(lo, hi),
|
||||
Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.shift_only() => {
|
||||
v = (v + 10.0 * n).clamp(lo, hi);
|
||||
v = by(v, 10.0 * n).clamp(lo, hi);
|
||||
}
|
||||
Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.is_none() => {
|
||||
v = step_to_multiple(v, 1.0, n).clamp(lo, hi);
|
||||
v = by(v, n).clamp(lo, hi);
|
||||
arrows = true;
|
||||
}
|
||||
_ => return true,
|
||||
@@ -2192,7 +2254,7 @@ fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option<f6
|
||||
}
|
||||
ui.input(|i| {
|
||||
let n = i.num_accesskit_action_requests(id, Action::Increment) as f64 - i.num_accesskit_action_requests(id, Action::Decrement) as f64;
|
||||
v = step_to_multiple(v, 1.0, n).clamp(lo, hi);
|
||||
v = by(v, n).clamp(lo, hi);
|
||||
});
|
||||
(v != from || (end && v != s.value)).then_some(round_to(v, STEP_DECIMALS).clamp(lo, hi))
|
||||
}
|
||||
@@ -2522,7 +2584,7 @@ pub fn opt_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it.
|
||||
let lengths = 0.0..=f64::INFINITY;
|
||||
let stepped = step_value(ui, &resp, &mut buf, &lengths, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v)));
|
||||
let stepped = step_value(ui, &resp, &mut buf, &lengths, (|b: &str| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v))), None);
|
||||
let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &lengths);
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
ui.data_mut(|d| {
|
||||
@@ -3455,6 +3517,40 @@ pub(crate) mod look_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dialog slider that a click focused shows no focus outline; once an arrow key steps it, the
|
||||
/// outline shows, so a keyboard user sees where the focus is.
|
||||
#[test]
|
||||
fn a_slider_focused_by_a_click_shows_its_outline_only_after_a_key() {
|
||||
let ctx = themed(Default::default());
|
||||
let t = Tokens::get(&ctx);
|
||||
let rail = std::cell::Cell::new(Rect::NOTHING);
|
||||
let mut add = |ui: &mut Ui| {
|
||||
let shown = SliderValue::number(5.0, 0.0..=10.0);
|
||||
let (_, _, _) = color_slider_stepped(ui, "outline-test", "Test", Some(shown), 0.5, 180.0, &|_| Color32::GRAY);
|
||||
rail.set(ui.min_rect());
|
||||
};
|
||||
// An outline: a stroked rect in the accent around the rail.
|
||||
let outlined = |shapes: &[egui::Shape]| {
|
||||
shapes
|
||||
.iter()
|
||||
.any(|s| matches!(s, egui::Shape::Rect(r) if r.stroke.color == t.accent && r.stroke.width > 0.0 && r.fill == Color32::TRANSPARENT))
|
||||
};
|
||||
frame(&ctx, 0.0, vec![], &mut add);
|
||||
let at = rail.get().center();
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
frame(&ctx, 0.1, vec![egui::Event::PointerMoved(at), button(true)], &mut add);
|
||||
frame(&ctx, 0.2, vec![button(false)], &mut add);
|
||||
let shapes = frame(&ctx, 0.3, vec![], &mut add);
|
||||
assert!(ctx.memory(|m| m.focused().is_some()), "the click focused the rail");
|
||||
assert!(!outlined(&shapes), "no outline after a click");
|
||||
let arrow =
|
||||
|pressed| egui::Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame(&ctx, 0.4, vec![arrow(true), arrow(false)], &mut add);
|
||||
let shapes = frame(&ctx, 0.5, vec![], &mut add);
|
||||
assert!(outlined(&shapes), "an arrow key shows the outline");
|
||||
}
|
||||
|
||||
// Regression test for #2083: the rows of the panels' menus didn't light up under the pointer, as
|
||||
// the menu bar's items do.
|
||||
#[test]
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user