diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index 4ab96f35a..0be81114c 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -243,3 +243,57 @@ fn a_spinner_step_from_a_weight_shown_as_a_preset_moves_past_it() { } assert_eq!(app.session.shown_stroke().unwrap().width, 1.0); } + +/// With objects of different weights selected the Weight field is blank, and its spinner steps +/// from 0.25 pt as Illustrator does: up gives 0.5 pt and down gives 0 pt, for all of them. +#[test] +fn the_spinner_of_a_blank_weight_steps_from_a_quarter_point() { + use super::tests_appearance::frame_raw; + + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (up, after) in [(true, 0.5), (false, 0.0)] { + let mut app = app_with_rect(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "Weight:"); + let sp = ctx.global_style().spacing.item_spacing.x; + let pos = egui::pos2(r.right() + sp + 8.0, r.center().y + if up { -5.0 } else { 5.0 }); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + for event in [egui::Event::PointerMoved(pos), button(true), button(false)] { + frame_raw(&ctx, &mut app, egui::RawInput { events: vec![event], ..Default::default() }, stroke::show); + } + for id in [a.0, b] { + run(&mut app, "select.set", json!({ "ids": [id] })); + assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, up: {up}"); + } + } +} + +/// ↑/↓ in the focused blank Weight field step from 0.25 pt too, for every selected object. +#[test] +fn the_arrow_keys_in_a_blank_weight_step_from_a_quarter_point() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (key, after) in [(egui::Key::ArrowUp, 0.5), (egui::Key::ArrowDown, 0.0)] { + let mut app = app_with_rect(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "Weight:"); + let sp = ctx.global_style().spacing.item_spacing.x; + // The blank field, right of the 16 px spinner. + click(&ctx, &mut app, egui::pos2(r.right() + 2.0 * sp + 16.0 + 30.0, r.center().y), stroke::show); + let press = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame_events(&ctx, &mut app, vec![press], stroke::show); + for id in [a.0, b] { + run(&mut app, "select.set", json!({ "ids": [id] })); + assert_eq!(app.session.shown_stroke().unwrap().width, after, "object {id}, {key:?}"); + } + } +} diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 91cd18eeb..eac0f5502 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -100,6 +100,26 @@ fn unit_field( bounds: RangeInclusive, stops: &[f64], live: bool, +) -> Option { + unit_field_from(ui, id, label, name, value, unit, width, share_of_value, bounds, stops, live, None) +} + +/// [`unit_field`] whose blank field (`value` is `None`) steps from `blank_from` (points) with +/// ↑/↓ and the wheel instead of doing nothing. +#[allow(clippy::too_many_arguments)] +fn unit_field_from( + ui: &mut Ui, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + share_of_value: bool, + bounds: RangeInclusive, + stops: &[f64], + live: bool, + blank_from: Option, ) -> Option { let id = id_with(ui.scope_id(), id); let shown = value.map(|v| unit.format(v)).unwrap_or_default(); @@ -111,8 +131,13 @@ fn unit_field( let read = |b: &str| typed.parse(&decimal_point(b)); let bounds_unit = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); let stops: Vec = stops.iter().map(|&s| unit.from_pt(s)).collect(); - let stepped = - step_value(ui, &resp, &mut buf, &bounds_unit, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None, &stops); + let blank_from = blank_from.filter(|_| value.is_none()); + let read_unit = |b: &str| match read(b) { + Some(v) => Some(unit.from_pt(v)), + None if b.trim().is_empty() => blank_from.map(|v| unit.from_pt(v)), + None => None, + }; + let stepped = step_value(ui, &resp, &mut buf, &bounds_unit, (read_unit, |v| unit.format(unit.to_pt(v))), None, &stops); let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds_unit); let commit = commit && buf != shown; if commit { @@ -566,12 +591,18 @@ pub fn spin_field_stops_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, None, None, None, width, step, min, presets, presets, &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false) + let blank_from = BLANK_WEIGHT_STEPS_FROM; + let fmt = |v: f64| unit.format(v); + spin_generic(ui, label, value, Some(blank_from), None, None, None, width, step, min, presets, presets, &fmt, &mut |ui, fw, label| { + unit_field_from(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, presets, false, Some(blank_from)) }) .and_then(SpinPick::value) } +/// Where a blank Stroke Weight (the selected objects differ) steps from, in points: up goes to +/// 0.5 pt and down to 0 pt, as in Illustrator. +const BLANK_WEIGHT_STEPS_FROM: f64 = 0.25; + /// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. #[derive(Clone, Copy, Debug, PartialEq)] pub enum SpinPick { @@ -623,7 +654,7 @@ pub fn spin_field_auto_named( presets: &[f64], ) -> Option { let label = take_previous_label(ui); - spin_generic(ui, label, value, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { + spin_generic(ui, label, value, None, auto, None, None, width, step, min, presets, &[], &|v| unit.format(v), &mut |ui, fw, label| { unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY, &[], false) }) } @@ -662,7 +693,7 @@ pub fn spin_plain_named( let label = take_previous_label(ui); let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, label, value, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { + spin_generic(ui, label, value, None, None, None, None, width, step, min, presets, &[], &fmt, &mut |ui, fw, label| { bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None, false, false) }) .and_then(SpinPick::value) @@ -686,6 +717,7 @@ pub fn spin_kerning( ui, label, value, + None, automatic, optical, Some(optical_label), @@ -706,6 +738,7 @@ fn spin_generic( ui: &mut Ui, label: Option, value: Option, + blank_from: Option, auto: Option, optical: Option, optical_label: Option<&str>, @@ -744,12 +777,12 @@ fn spin_generic( ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); - // A blank field (values that differ) has nothing to step from. A click steps once; an + // A blank field (values that differ) steps from `blank_from`, if any. A click steps once; an // arrow held down steps again and again after a pause, as a spin button does (#4267). let repeat = held_repeat(ui, &sresp); if (repeat == Some(true) || (sresp.clicked() && repeat.is_none())) && let Some(p) = sresp.interact_pointer_pos() - && let Some(v) = value + && let Some(v) = value.or(blank_from) { let modifiers = ui.input(|i| i.modifiers); out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt)));