mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Merge pull request #3612 from Taha-Mahmoodi/fix/3370-stroke-scrub-bounds
Fix #3370: stepping a number field stops at its bounds: a stroke weight at 0, a miter limit at 1
This commit is contained in:
@@ -76,8 +76,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
m => {
|
||||
let steps = d.f64("steps", 5.0);
|
||||
ui.add_enabled_ui(m == "steps", |ui| {
|
||||
if let Some(n) = widgets::plain_field(ui, "blend-steps", steps, "", 0, 72.0) {
|
||||
d.fields.insert("steps".into(), json!(n.round().clamp(1.0, 1000.0)));
|
||||
if let Some(n) = widgets::range_field(ui, "blend-steps", steps, 1.0..=1000.0, "", 0, 72.0) {
|
||||
d.fields.insert("steps".into(), json!(n));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ fn number(ui: &mut Ui, fields: &mut serde_json::Map<String, Value>, key: &str, l
|
||||
let mut changed = false;
|
||||
widgets::label_row(ui, tl!(label), 96.0, |ui| {
|
||||
let value = fields.get(key).and_then(Value::as_f64).unwrap_or_default();
|
||||
if let Some(v) = widgets::plain_field(ui, ("dimension", key), value, suffix, 2, 90.0) {
|
||||
fields.insert(key.into(), json!(v.clamp(range.0, range.1)));
|
||||
if let Some(v) = widgets::range_field(ui, ("dimension", key), value, range.0..=range.1, suffix, 2, 90.0) {
|
||||
fields.insert(key.into(), json!(v));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -135,8 +135,8 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
/// A whole number field row clamped to 1..50.
|
||||
fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
widgets::label_row(ui, label, LABEL_W, |ui| {
|
||||
if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) {
|
||||
set(d, key, json!(n.round().clamp(1.0, 50.0)));
|
||||
if let Some(n) = widgets::range_field(ui, ("env-count", key), d.f64(key, 4.0), 1.0..=50.0, "", 0, 60.0) {
|
||||
set(d, key, json!(n));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,8 +205,8 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
|
||||
o.balance = b;
|
||||
}
|
||||
dim(ui, tl!("Vectors"));
|
||||
if let Some(v) = widgets::plain_field(ui, (id, "balance"), o.balance, "", 0, FIELD - 12.0) {
|
||||
o.balance = v.round().clamp(0.0, 100.0);
|
||||
if let Some(v) = widgets::range_field(ui, (id, "balance"), o.balance, 0.0..=100.0, "", 0, FIELD - 12.0) {
|
||||
o.balance = v;
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
@@ -215,8 +215,8 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
|
||||
widgets::dim_label(ui, label);
|
||||
ui.horizontal(|ui| {
|
||||
let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi };
|
||||
if let Some(n) = widgets::plain_field(ui, (id, key), *v, "", 0, FIELD) {
|
||||
*v = n.round().clamp(1.0, 2400.0);
|
||||
if let Some(n) = widgets::range_field(ui, (id, key), *v, 1.0..=2400.0, "", 0, FIELD) {
|
||||
*v = n;
|
||||
}
|
||||
dim(ui, "ppi");
|
||||
});
|
||||
|
||||
@@ -651,7 +651,7 @@ pub(super) fn slider_field_named(
|
||||
let v = d.f64(key, 0.0).clamp(min, max);
|
||||
let shown = crate::widgets::SliderValue::field(v, range.clone(), suffix, 0);
|
||||
slider_rail_valued(ui, d, key, name, range, Some(shown), track);
|
||||
if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) {
|
||||
if let Some(n) = crate::widgets::range_field(ui, ("dlg-field", key), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) {
|
||||
d.fields.insert(key.into(), json!(n));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix:
|
||||
let label_w = label_w(d);
|
||||
let v = d.f64(key, 0.0);
|
||||
widgets::label_row(ui, label, label_w, |ui| {
|
||||
if let Some(x) = widgets::plain_field(ui, ("freehand-field", key), v, suffix, decimals, 72.0) {
|
||||
d.fields.insert(key.into(), json!(x.clamp(range.0, range.1)));
|
||||
if let Some(x) = widgets::range_field(ui, ("freehand-field", key), v, range.0..=range.1, suffix, decimals, 72.0) {
|
||||
d.fields.insert(key.into(), json!(x));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -75,8 +75,8 @@ fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
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::plain_field(ui, "freehand-fidelity-field", v, "pt", 1, 64.0) {
|
||||
new = Some(x.clamp(0.05, 100.0));
|
||||
if let Some(x) = widgets::range_field(ui, "freehand-fidelity-field", v, 0.05..=100.0, "pt", 1, 64.0) {
|
||||
new = Some(x);
|
||||
}
|
||||
});
|
||||
if let Some(n) = new.filter(|n| *n != v) {
|
||||
|
||||
@@ -249,8 +249,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
nav(ui, "chevrons-left", tl!("First Page"), 1, current > 1),
|
||||
nav(ui, "chevron-left", tl!("Previous Page"), current.saturating_sub(1).max(1), current > 1),
|
||||
];
|
||||
if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) {
|
||||
current = (v.max(1.0) as usize).min(count.max(1));
|
||||
if let Some(v) = widgets::range_field(ui, "pdf-page", current as f64, 1.0..=count.max(1) as f64, "", 0, 40.0) {
|
||||
current = v as usize;
|
||||
}
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {count}"), &[("count", &count.to_string())])).color(t.text_dim));
|
||||
let go2 = [
|
||||
|
||||
@@ -126,8 +126,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
for (suffix, _) in COLUMNS {
|
||||
let key = format!("{prefix}{suffix}");
|
||||
let value = d.fields.get(&key).and_then(|v| v.as_f64());
|
||||
if let Some(v) = widgets::mixed_field(ui, ("justification", key.as_str()), value, "%", 0, 70.0) {
|
||||
d.fields.insert(key, json!(v.clamp(*lo, *hi)));
|
||||
if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), value, *lo..=*hi, "%", 0, 70.0) {
|
||||
d.fields.insert(key, json!(v));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
@@ -138,8 +138,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
label(ui, tl!("Auto Leading:"));
|
||||
let (lo, hi) = JustificationOptions::AUTO_LEADING;
|
||||
let leading = d.fields.get("autoLeading").and_then(|v| v.as_f64());
|
||||
if let Some(v) = widgets::mixed_field(ui, ("justification", "autoLeading"), leading, "%", 0, 64.0) {
|
||||
d.fields.insert("autoLeading".into(), json!(v.clamp(lo, hi)));
|
||||
if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), leading, lo..=hi, "%", 0, 64.0) {
|
||||
d.fields.insert("autoLeading".into(), json!(v));
|
||||
}
|
||||
ui.end_row();
|
||||
label(ui, tl!("Single Word Justification:"));
|
||||
|
||||
@@ -87,15 +87,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
}
|
||||
widgets::label_row(ui, tl!("Angle:"), LABEL_W, |ui| {
|
||||
if let Some(v) = widgets::plain_field(ui, "lq-angle", d.f64("angle", 0.0), "°", 1, 110.0) {
|
||||
d.fields.insert("angle".into(), json!(v.clamp(-360.0, 360.0)));
|
||||
if let Some(v) = widgets::range_field(ui, "lq-angle", d.f64("angle", 0.0), -360.0..=360.0, "°", 1, 110.0) {
|
||||
d.fields.insert("angle".into(), json!(v));
|
||||
}
|
||||
});
|
||||
let pressure = d.bool("usePressure");
|
||||
widgets::label_row(ui, tl!("Intensity:"), LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(!pressure, |ui| {
|
||||
if let Some(v) = widgets::plain_field(ui, "lq-intensity", d.f64("intensity", 50.0), "%", 0, 110.0) {
|
||||
d.fields.insert("intensity".into(), json!(v.clamp(1.0, 100.0)));
|
||||
if let Some(v) = widgets::range_field(ui, "lq-intensity", d.f64("intensity", 50.0), 1.0..=100.0, "%", 0, 110.0) {
|
||||
d.fields.insert("intensity".into(), json!(v));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,8 +67,8 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let (min, max) = LP_SELECT_WIDTH_RANGE;
|
||||
let v = d.f64("width", min);
|
||||
widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| {
|
||||
if let Some(x) = widgets::plain_field(ui, "lp-select-width", v, "px", 0, 72.0) {
|
||||
d.fields.insert("width".into(), json!(x.round().clamp(min, max)));
|
||||
if let Some(x) = widgets::range_field(ui, "lp-select-width", v, min..=max, "px", 0, 72.0) {
|
||||
d.fields.insert("width".into(), json!(x));
|
||||
}
|
||||
});
|
||||
false
|
||||
|
||||
@@ -72,8 +72,8 @@ fn split_grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
egui::Grid::new("dlg").num_columns(4).spacing([10.0, 8.0]).show(ui, |ui| {
|
||||
for (number, label, ..) in axes {
|
||||
form::row_label(ui, label);
|
||||
if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", number), d.f64(number, 2.0), "", 0, WIDTH) {
|
||||
d.fields.insert(number.into(), json!(v.round().clamp(1.0, 500.0)));
|
||||
if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", number), d.f64(number, 2.0), 1.0..=500.0, "", 0, WIDTH) {
|
||||
d.fields.insert(number.into(), json!(v));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
@@ -336,8 +336,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let (x, _, keyed) =
|
||||
widgets::color_slider_stepped(ui, "persp-opacity", tl!("Opacity:"), Some(shown), (v / 100.0) as f32, form::SLIDER_WIDTH, &track);
|
||||
let mut new = x.map(|x| (f64::from(x) * 100.0).round()).or(keyed);
|
||||
if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) {
|
||||
new = Some(x.round().clamp(0.0, 100.0));
|
||||
if let Some(x) = widgets::range_field(ui, "persp-opacity-field", v, 0.0..=100.0, "%", 0, 52.0) {
|
||||
new = Some(x);
|
||||
}
|
||||
if let Some(n) = new.filter(|n| *n != v) {
|
||||
d.fields.insert("opacity".into(), json!(n));
|
||||
|
||||
@@ -143,9 +143,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("__otherPpi".into(), json!(i == 3));
|
||||
}
|
||||
if preset.is_none()
|
||||
&& let Some(v) = widgets::plain_field(ui, "ro-ppi-other", ppi, " ppi", 0, 80.0)
|
||||
&& let Some(v) = widgets::range_field(ui, "ro-ppi-other", ppi, 1.0..=4608.0, " ppi", 0, 80.0)
|
||||
{
|
||||
d.fields.insert("ppi".into(), json!(v.round().clamp(1.0, 4608.0)));
|
||||
d.fields.insert("ppi".into(), json!(v));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
|
||||
@@ -558,8 +558,8 @@ fn art_texture(app: &mut VectorcraftApp, ctx: &egui::Context, layers: PrintLayer
|
||||
fn whole(ui: &mut egui::Ui, d: &mut Dialog, path: &str, range: std::ops::RangeInclusive<f64>, enabled: bool) {
|
||||
let v = get(d, path).as_f64().unwrap_or(*range.start());
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
if let Some(x) = widgets::plain_field(ui, path, v, "", 0, 56.0) {
|
||||
set(d, path, json!(x.round().clamp(*range.start(), *range.end()) as u64));
|
||||
if let Some(x) = widgets::range_field(ui, path, v, range, "", 0, 56.0) {
|
||||
set(d, path, json!(x as u64));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -520,7 +520,9 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64,
|
||||
if response.changed() {
|
||||
next = Some(value);
|
||||
}
|
||||
if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) {
|
||||
if let Some(n) =
|
||||
scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32, &(from(range.0)..=from(range.1)))
|
||||
{
|
||||
next = Some(n);
|
||||
}
|
||||
// Lengths' rails cover a useful working range; larger values can still be typed.
|
||||
|
||||
@@ -967,12 +967,12 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option
|
||||
d.fields.insert(key.into(), v);
|
||||
};
|
||||
widgets::label_row(ui, tl!("W:"), LABEL_W, |ui| {
|
||||
if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) {
|
||||
set(d, "width", json!(v.round().max(1.0)));
|
||||
if let Some(v) = widgets::range_field(ui, "sfw-w", f64::from(w), 1.0..=f64::INFINITY, " px", 0, 80.0) {
|
||||
set(d, "width", json!(v));
|
||||
}
|
||||
widgets::field_label(ui, tl!("H:"));
|
||||
if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) {
|
||||
set(d, "height", json!(v.round().max(1.0)));
|
||||
if let Some(v) = widgets::range_field(ui, "sfw-h", f64::from(h), 1.0..=f64::INFINITY, " px", 0, 80.0) {
|
||||
set(d, "height", json!(v));
|
||||
}
|
||||
});
|
||||
let percent = region.map_or(100.0, |(_, scale)| (scale * 100.0 * 100.0).round() / 100.0);
|
||||
|
||||
@@ -48,8 +48,10 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("gapLength".into(), json!(lengths.get(i).copied().unwrap_or(9.0)));
|
||||
}
|
||||
ui.add_enabled_ui(current == 3, |ui| {
|
||||
if let Some(n) = widgets::plain_field(ui, "shape-builder-gap-length", len, "pt", 1, 64.0) {
|
||||
d.fields.insert("gapLength".into(), json!(n.clamp(0.0, vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP)));
|
||||
if let Some(n) =
|
||||
widgets::range_field(ui, "shape-builder-gap-length", len, 0.0..=vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP, "pt", 1, 64.0)
|
||||
{
|
||||
d.fields.insert("gapLength".into(), json!(n));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,8 +144,8 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys:
|
||||
if widgets::radio_button(ui, labels[1], !by_size, enabled) {
|
||||
d.fields.insert(mode.into(), json!("count"));
|
||||
}
|
||||
if let Some(n) = widgets::plain_field(ui, ("divide", count), d.f64(count, 2.0), "", 0, 50.0) {
|
||||
d.fields.insert(count.into(), json!(n.round().max(1.0) as u64));
|
||||
if let Some(n) = widgets::range_field(ui, ("divide", count), d.f64(count, 2.0), 1.0..=f64::INFINITY, "", 0, 50.0) {
|
||||
d.fields.insert(count.into(), json!(n as u64));
|
||||
}
|
||||
widgets::dim_label(ui, labels[1]);
|
||||
});
|
||||
|
||||
@@ -220,7 +220,8 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
c2[i] = mode.value_at(i, nv);
|
||||
new = Some((from_components(mode, &c2), c2));
|
||||
}
|
||||
if let Some(fv) = widgets::plain_field(ui, ("swatch-field", i), v as f64, mode.suffix(i), 0, 52.0) {
|
||||
let bounds = f64::from(mode.min(i))..=f64::from(mode.max(i));
|
||||
if let Some(fv) = widgets::range_field(ui, ("swatch-field", i), v as f64, bounds, mode.suffix(i), 0, 52.0) {
|
||||
let mut c2 = comps.clone();
|
||||
c2[i] = (fv as f32).clamp(mode.min(i), mode.max(i));
|
||||
new = Some((from_components(mode, &c2), c2));
|
||||
|
||||
@@ -116,8 +116,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
for (key, text) in SLICE_KEYS.into_iter().zip([tl!("Left:"), tl!("Top:"), tl!("Right:"), tl!("Bottom:")]) {
|
||||
label(ui, text);
|
||||
let v = d.f64(key, 100.0 / 3.0);
|
||||
if let Some(n) = widgets::plain_field(ui, ("dlg-field", key), v, "%", 0, 64.0) {
|
||||
d.fields.insert(key.into(), json!(n.clamp(0.0, 100.0)));
|
||||
if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, 0.0..=100.0, "%", 0, 64.0) {
|
||||
d.fields.insert(key.into(), json!(n));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
@@ -643,8 +643,8 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
|
||||
ui.add_space(8.0);
|
||||
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, fw) {
|
||||
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
|
||||
if let Some(o) = widgets::range_field(ui, "ap-op", n.opacity as f64 * 100.0, 0.0..=100.0, "%", 0, fw) {
|
||||
app.run("object.setProps", json!({"opacity": o})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", tl!("Transparency"), false, 22.0).clicked() {
|
||||
app.ui.open_panel = Some("transparency".into());
|
||||
|
||||
@@ -179,8 +179,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
widgets::dim_label(ui, tl!("Limit:"));
|
||||
let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0);
|
||||
if !matches!(join, Some(LineJoin::Round | LineJoin::Bevel)) {
|
||||
if let Some(v) = widgets::mixed_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), " x", 0, 50.0) {
|
||||
set(app, json!({"miterLimit": v.clamp(1.0, 500.0)}));
|
||||
if let Some(v) = widgets::range_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), 1.0..=500.0, " x", 0, 50.0) {
|
||||
set(app, json!({"miterLimit": v}));
|
||||
}
|
||||
} else {
|
||||
ui.label(egui::RichText::new(format!("{lim:.0} x")).color(t.text_disabled));
|
||||
@@ -592,4 +592,61 @@ mod tests {
|
||||
assert_eq!(current_stroke(&app).map(|s| (s.join, s.width)), Some((LineJoin::Round, 4.0)));
|
||||
frame(&mut app);
|
||||
}
|
||||
|
||||
/// #3370: the wheel over the focused Weight field stops at 0 and over the focused Limit field
|
||||
/// at 1, in the field and in the document; it went on to -3 pt and 0 x.
|
||||
#[test]
|
||||
fn the_wheel_stops_weight_at_zero_and_limit_at_one() {
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, Pos2};
|
||||
use vectorcraft_engine::Session;
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 80})).unwrap();
|
||||
app.run("stroke.set", json!({"weight": 1, "join": "miter", "miterLimit": 2})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut time = 0.0;
|
||||
// One frame of the panel → the texts painted, with their rects.
|
||||
let mut frame = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
||||
time += 0.05;
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 600.0)));
|
||||
let raw = egui::RawInput { events, time: Some(time), screen_rect, focused: true, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.into_iter()
|
||||
.filter_map(|s| match s.shape {
|
||||
egui::Shape::Text(t) => Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// The weight and the limit.
|
||||
let stroke = |app: &VectorcraftApp| current_stroke(app).map(|s| [s.width, s.miter_limit]).unwrap();
|
||||
for (shown, after, which, low) in [("1 pt", "0 pt", 0, 0.0), ("2 x", "1 x", 1, 1.0)] {
|
||||
let texts = frame(&mut app, vec![]);
|
||||
let at = texts.iter().find(|(t, _)| t == shown).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {shown:?} in {texts:?}"));
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
frame(&mut app, vec![Event::PointerMoved(at), button(true)]);
|
||||
frame(&mut app, vec![button(false)]);
|
||||
frame(&mut app, vec![]);
|
||||
for _ in 0..4 {
|
||||
let notch = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, -1.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
};
|
||||
frame(&mut app, vec![Event::PointerMoved(at), notch]);
|
||||
}
|
||||
let texts = frame(&mut app, vec![]);
|
||||
assert_eq!(stroke(&app)[which], low, "{shown}: {:?}", stroke(&app));
|
||||
assert!(texts.iter().any(|(t, _)| t == after), "{shown} → {after}: {texts:?}");
|
||||
// At the bound, a step down and a step up in one frame end one step above it.
|
||||
let key = |key| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
|
||||
frame(&mut app, vec![key(egui::Key::ArrowDown), key(egui::Key::ArrowUp)]);
|
||||
let texts = frame(&mut app, vec![]);
|
||||
assert!(texts.iter().any(|(t, _)| t == shown), "{after}, down and up → {shown}: {texts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +308,8 @@ pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live
|
||||
let mut set = None;
|
||||
egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| {
|
||||
widgets::dim_label(ui, tl!("Sides:"));
|
||||
if let Some(s) = widgets::plain_field(ui, (id, "sides"), f64::from(*sides), "", 0, fw) {
|
||||
set = Some(("sides", json!(s.round().max(3.0) as u64)));
|
||||
if let Some(s) = widgets::range_field(ui, (id, "sides"), f64::from(*sides), 3.0..=f64::INFINITY, "", 0, fw) {
|
||||
set = Some(("sides", json!(s as u64)));
|
||||
}
|
||||
ui.end_row();
|
||||
widgets::dim_label(ui, tl!("Angle:"));
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
//! drag is one undo step and Escape takes it back. Preferences › General › Scrub Numeric Fields by
|
||||
//! Dragging turns it off.
|
||||
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use egui::{Context, CursorIcon, Id, Key, Rect, Response, Sense, Ui};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
@@ -131,11 +133,11 @@ fn take_label(ui: &Ui, field: Rect) -> Option<Rect> {
|
||||
}
|
||||
|
||||
/// Scrub numeric field `field`, its box at `rect`, showing `text`: `value` in its units, rounded
|
||||
/// to `decimals` places. → The value a drag on the field's label steps it to, or the value it
|
||||
/// started at when Escape cancels the drag. A click on the label (or a press held without
|
||||
/// stepping) focuses the field with all of `text` selected. Nothing for a field without a label,
|
||||
/// disabled or showing no value (objects that differ).
|
||||
pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option<f64>, decimals: i32) -> Option<f64> {
|
||||
/// to `decimals` places and kept within `bounds` (#3370). → The value a drag on the field's label
|
||||
/// steps it to, or the value it started at when Escape cancels the drag. A click on the label (or a
|
||||
/// press held without stepping) focuses the field with all of `text` selected. Nothing for a field
|
||||
/// without a label, disabled or showing no value (objects that differ).
|
||||
pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option<f64>, decimals: i32, bounds: &RangeInclusive<f64>) -> Option<f64> {
|
||||
let label = take_label(ui, rect)?;
|
||||
let value = value.filter(|v| v.is_finite())?;
|
||||
if !enabled(ui.ctx()) || !ui.is_enabled() {
|
||||
@@ -143,7 +145,7 @@ pub(crate) fn field(ui: &Ui, field: Id, rect: Rect, text: &str, value: Option<f6
|
||||
}
|
||||
// On top of the label, which may sense clicks itself (selectable text).
|
||||
let resp = ui.interact(label, field.with("scrub-label"), Sense::click_and_drag());
|
||||
drag(ui, &resp, field, text, value, decimals)
|
||||
drag(ui, &resp, field, text, value, decimals).map(|v| v.max(*bounds.start()).min(*bounds.end()))
|
||||
}
|
||||
|
||||
/// What a drag on `resp`, the label of field `field`, does this frame.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Shared widgets. Colours come from theme tokens.
|
||||
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::Unit;
|
||||
@@ -242,11 +244,20 @@ pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit {
|
||||
/// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in
|
||||
/// points in a picas field with Numbers Without Units Are Points on).
|
||||
pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, unit: Unit, width: f32) -> Option<f64> {
|
||||
unit_field(ui, id, value, unit, width, false)
|
||||
unit_field(ui, id, value, unit, width, false, ANY)
|
||||
}
|
||||
|
||||
/// Any number: the bounds of a field that has none.
|
||||
const ANY: RangeInclusive<f64> = f64::NEG_INFINITY..=f64::INFINITY;
|
||||
|
||||
/// `v` within `bounds` (bounds the wrong way round or not numbers don't panic, as `f64::clamp` would).
|
||||
fn within(v: f64, bounds: &RangeInclusive<f64>) -> f64 {
|
||||
v.max(*bounds.start()).min(*bounds.end())
|
||||
}
|
||||
|
||||
/// [`num_field`]; with `share_of_value` a percentage typed alone is that share of `value`
|
||||
/// ([`share_of`]).
|
||||
/// ([`share_of`]). ↑/↓, the wheel and a scrub stop at `bounds` (points), and the field shows where
|
||||
/// they stopped.
|
||||
fn unit_field(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
@@ -254,6 +265,7 @@ fn unit_field(
|
||||
unit: Unit,
|
||||
width: f32,
|
||||
share_of_value: bool,
|
||||
bounds: RangeInclusive<f64>,
|
||||
) -> Option<f64> {
|
||||
let id = ui.id().with(id);
|
||||
let shown = value.map(|v| unit.format(v)).unwrap_or_default();
|
||||
@@ -262,8 +274,9 @@ fn unit_field(
|
||||
select_all_on_focus(ui, &resp, &buf);
|
||||
// ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it.
|
||||
let read = typed_unit(ui.ctx(), unit);
|
||||
let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(read.parse(b)?)), |v| unit.format(unit.to_pt(v)));
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
|
||||
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 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()));
|
||||
if commit {
|
||||
@@ -310,12 +323,20 @@ fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 {
|
||||
/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf`
|
||||
/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step
|
||||
/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to
|
||||
/// the next tenth, and a Shift step adds or takes ten. The new value is applied at once, as in
|
||||
/// the next tenth, and a Shift step adds or takes ten. Steps stop at the field's `bounds` (#3370:
|
||||
/// a weight stops at 0, a miter limit at 1). The new value is applied at once, as in
|
||||
/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing
|
||||
/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused
|
||||
/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the
|
||||
/// wheel to the panel and the canvas.
|
||||
fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) -> Option<f64>, show: impl Fn(f64) -> String) -> Option<f64> {
|
||||
fn step_value(
|
||||
ui: &Ui,
|
||||
resp: &Response,
|
||||
buf: &mut String,
|
||||
bounds: &RangeInclusive<f64>,
|
||||
read: impl Fn(&str) -> Option<f64>,
|
||||
show: impl Fn(f64) -> String,
|
||||
) -> Option<f64> {
|
||||
// Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the
|
||||
// window to report keyboard focus.
|
||||
if !ui.memory(|m| m.has_focus(resp.id)) {
|
||||
@@ -369,9 +390,11 @@ fn step_value(ui: &Ui, resp: &Response, buf: &mut String, read: impl Fn(&str) ->
|
||||
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) };
|
||||
// Each step stops at the bounds, so a step back in the same frame starts from there.
|
||||
v = within(v, bounds);
|
||||
}
|
||||
// Rounded as fields show it.
|
||||
let v = round_to(v, STEP_DECIMALS);
|
||||
// Rounded as fields show it, within the field's bounds.
|
||||
let v = within(round_to(v, STEP_DECIMALS), bounds);
|
||||
*buf = show(v);
|
||||
ui.data_mut(|d| d.insert_temp(resp.id, buf.clone()));
|
||||
select_all(ui, resp.id, buf);
|
||||
@@ -429,7 +452,7 @@ pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value
|
||||
}
|
||||
});
|
||||
}
|
||||
unit_field(ui, id, value, unit, width, true)
|
||||
unit_field(ui, id, value, unit, width, true, ANY)
|
||||
}
|
||||
|
||||
/// Select all of `text` in text field `id`.
|
||||
@@ -650,20 +673,22 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu
|
||||
mixed_field(ui, id, Some(value), suffix, decimals, width)
|
||||
}
|
||||
|
||||
/// [`plain_field`] for a number kept within `range` (the values of dialogs and preferences): what is
|
||||
/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count).
|
||||
/// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels
|
||||
/// such as the Stroke panel's Limit); `None` shows a blank field, as [`mixed_field`] does. What is
|
||||
/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count), and
|
||||
/// ↑/↓, the wheel and a scrub stop at its ends in the field too (#3370).
|
||||
pub fn range_field(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
value: f64,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
value: impl Into<Option<f64>>,
|
||||
range: RangeInclusive<f64>,
|
||||
suffix: &str,
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
let (lo, hi) = (*range.start(), *range.end());
|
||||
let places = decimals.min(6) as i32;
|
||||
plain_field(ui, id, value.clamp(lo, hi), suffix, decimals, width).map(|v| round_to(v, places).clamp(lo, hi))
|
||||
let value = value.into().map(|v| within(v, &range));
|
||||
bounded_field(ui, id, value, suffix, decimals, width, &range).map(|v| within(round_to(v, places), &range))
|
||||
}
|
||||
|
||||
/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field.
|
||||
@@ -674,6 +699,19 @@ pub fn mixed_field(
|
||||
suffix: &str,
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
bounded_field(ui, id, value, suffix, decimals, width, &ANY)
|
||||
}
|
||||
|
||||
/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`.
|
||||
fn bounded_field(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
value: Option<f64>,
|
||||
suffix: &str,
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
bounds: &RangeInclusive<f64>,
|
||||
) -> Option<f64> {
|
||||
let id = ui.id().with(id);
|
||||
let show = |value: f64| field_text(value, suffix, decimals);
|
||||
@@ -690,8 +728,8 @@ pub fn mixed_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, read, show).map(|v| round_to(v, decimals));
|
||||
let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals);
|
||||
let stepped = step_value(ui, &resp, &mut buf, bounds, read, show).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) }
|
||||
}
|
||||
|
||||
@@ -1256,8 +1294,8 @@ pub fn opacity_blend(
|
||||
dim_label(ui, "Opacity:");
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
if let Some(o) = mixed_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), "%", 0, 50.0) {
|
||||
edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released));
|
||||
if let Some(o) = range_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), 0.0..=100.0, "%", 0, 50.0) {
|
||||
edit = Some(TransparencyEdit::Opacity(o, Live::Released));
|
||||
}
|
||||
});
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
@@ -1527,8 +1565,9 @@ fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool)
|
||||
/// Numeric field with an up/down spinner on the left and a preset dropdown on the right
|
||||
/// (Stroke weight, font size…): an editable combo box whose field keeps typing, ↑/↓ stepping and
|
||||
/// label scrubbing. `value` (points) shows in `unit`, blank when `None` (the selection's values
|
||||
/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. Returns the
|
||||
/// committed, stepped or picked value.
|
||||
/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. The spinner, ↑/↓,
|
||||
/// the wheel and a scrub stop at `min` (#3370), and a typed value below it applies `min`. Returns
|
||||
/// the committed, stepped or picked value.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spin_field(
|
||||
ui: &mut Ui,
|
||||
@@ -1574,7 +1613,9 @@ pub fn spin_field_auto(
|
||||
min: f64,
|
||||
presets: &[f64],
|
||||
) -> Option<SpinPick> {
|
||||
spin_generic(ui, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| num_field(ui, id, value, unit, fw))
|
||||
spin_generic(ui, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| {
|
||||
unit_field(ui, id, value, unit, fw, false, min..=f64::INFINITY)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix; a
|
||||
@@ -1593,8 +1634,10 @@ pub fn spin_plain(
|
||||
) -> Option<f64> {
|
||||
let value = value.into();
|
||||
let fmt = |v: f64| format!("{v}{suffix}");
|
||||
spin_generic(ui, value, None, width, step, min, presets, &fmt, &mut |ui, fw| mixed_field(ui, id, value, suffix, decimals, fw))
|
||||
.and_then(SpinPick::value)
|
||||
spin_generic(ui, value, None, width, step, min, presets, &fmt, &mut |ui, fw| {
|
||||
bounded_field(ui, id, value, suffix, decimals, fw, &(min..=f64::INFINITY))
|
||||
})
|
||||
.and_then(SpinPick::value)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -2144,7 +2187,8 @@ pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) ->
|
||||
/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without
|
||||
/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit (Enter, Shift+Enter or
|
||||
/// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units
|
||||
/// and arithmetic work as in [`num_field`].
|
||||
/// and arithmetic work as in [`num_field`]. The value is a length: ↑/↓, the wheel and a scrub stop
|
||||
/// at 0.
|
||||
pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, unit: Unit, width: f32) -> Option<Option<f64>> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = ui.id().with(id);
|
||||
@@ -2181,8 +2225,9 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
let resp = framed.inner;
|
||||
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 stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v)));
|
||||
let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS);
|
||||
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 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| {
|
||||
d.insert_temp(id, buf.clone());
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
|---|---|---|
|
||||
| Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | |
|
||||
| Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 |
|
||||
| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991) |
|
||||
| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a focused field; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 |
|
||||
| Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified |
|
||||
| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open |
|
||||
| Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864) |
|
||||
|
||||
Reference in New Issue
Block a user