mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
M3.41: Stroke panel follows Units > Stroke, shows mixed values blank and reaches the Control bar
- Weights and dash lengths show in Units > Stroke in the Stroke panel, the Control bar and the Properties panel (Session::stroke_unit). - Values the selected objects don't share show blank (weight, miter limit, dashes) or light no button (cap, join, align), from a per-revision cached DocState::stroke_mixed. - Align Stroke Inside/Outside are disabled for open paths and type. - The Control bar weight is a preset spinner with a width profile dropdown, and its Stroke link (and the Properties panel's) opens the Stroke panel as an anchored popover instead of docking it. - The panels show a group's first painted object's stroke, as the Stroke proxy does (Session::shown_stroke). - stroke.set on a group skips images and symbol instances. - document.inspect reports each object's strokeOptions (cap, join, miterLimit, align, dash, dashOffset, alignDashes, arrows, arrowAlign, profile) in stroke.set terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
323b178ea7
commit
64d366e2ae
@@ -5,6 +5,7 @@ use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use crate::panels::stroke as stroke_panel;
|
||||
use crate::state::{ZOOM_STOPS, zoom_label};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{self, paint_chip};
|
||||
@@ -100,12 +101,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
let (fill, stroke) = crate::panels::current_paints(app);
|
||||
let (weight, opacity) = match &first {
|
||||
Some(_) => {
|
||||
(crate::panels::current_stroke(app).map_or(0.0, |s| s.width), crate::panels::current_transparency(app).map_or(1.0, |t| t.0))
|
||||
}
|
||||
None => (app.session.paint.stroke_width, 1.0),
|
||||
};
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
|
||||
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
|
||||
let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
|
||||
if chip_button(ui, &fill, false, "Fill").clicked() {
|
||||
app.run("paint.toggleActive", json!({})).ok();
|
||||
app.session.fill_active = true;
|
||||
@@ -115,11 +114,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.session.fill_active = false;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() {
|
||||
app.ui.open_panel = Some("stroke".into());
|
||||
}
|
||||
if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), vectorcraft_doc::Unit::Points, 64.0) {
|
||||
app.run("stroke.set", json!({"weight": w})).ok();
|
||||
// The link opens the Stroke panel as a popover under it; then the weight spinner
|
||||
// (with presets) and the width profile.
|
||||
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
|
||||
stroke_panel::popover(app, &link);
|
||||
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
|
||||
let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref()));
|
||||
if let Some(id) = stroke_panel::profile_dropdown(ui, profile) {
|
||||
app.run("stroke.set", json!({"profile": id})).ok();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
|
||||
@@ -40,6 +40,7 @@ use egui::{Rect, Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Node, StrokeLayer};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{Live, dim_label};
|
||||
@@ -214,6 +215,22 @@ pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option<StrokeLayer> {
|
||||
app.session.shown_stroke()
|
||||
}
|
||||
|
||||
/// Which Stroke panel values the selection doesn't share and whether Align Stroke applies
|
||||
/// ([`vectorcraft_engine::DocState::stroke_mixed`]), cached per document revision. While the Type
|
||||
/// tool edits text, the selected characters' stroke shows as it is.
|
||||
pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeMixed {
|
||||
let Some(st) = app.session.active().filter(|_| character::text_editing(app).is_none()) else { return StrokeMixed::default() };
|
||||
let key = (st.uid, st.revision);
|
||||
match pstate::<Option<((u64, u64), StrokeMixed)>>(ctx, "stroke-mixed") {
|
||||
Some((k, mixed)) if k == key => mixed,
|
||||
_ => {
|
||||
let mixed = st.stroke_mixed();
|
||||
set_pstate(ctx, "stroke-mixed", Some((key, mixed)));
|
||||
mixed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance
|
||||
/// panel's active item's, else the first selected object's.
|
||||
pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> {
|
||||
@@ -364,3 +381,5 @@ mod tests_appearance;
|
||||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
|
||||
@@ -266,10 +266,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(n) = first_selected(app) else { return };
|
||||
section_header(ui, "Appearance");
|
||||
let (fill, stroke) = super::current_paints(app);
|
||||
let weight = match &n.kind {
|
||||
NodeKind::Text(tx) if app.session.appearance_item().is_none() => tx.first_style().stroke_width,
|
||||
_ => super::current_stroke(app).map_or(0.0, |s| s.width),
|
||||
};
|
||||
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
|
||||
for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
@@ -287,12 +284,11 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.label(egui::RichText::new(label).size(12.0));
|
||||
if !is_fill {
|
||||
ui.add_space(8.0);
|
||||
if let Some(w) = widgets::num_field(ui, "ap-w", Some(weight), Unit::Points, 70.0) {
|
||||
if let Some(w) = widgets::num_field(ui, "ap-w", weight, app.session.stroke_unit(), 70.0) {
|
||||
app.run("stroke.set", json!({"weight": w})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0).clicked() {
|
||||
app.ui.open_panel = Some("stroke".into());
|
||||
}
|
||||
let more = widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0);
|
||||
super::stroke::popover(app, &more);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use super::{character, current_stroke, pstate, set_pstate};
|
||||
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
@@ -83,10 +84,34 @@ fn set(app: &mut VectorcraftApp, p: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The weight the Stroke panel, Control bar and Properties panel show: blank where the selected
|
||||
/// objects differ, the default for new art without a stroke.
|
||||
pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed: &StrokeMixed) -> Option<f64> {
|
||||
(!mixed.weight).then(|| st.map_or(app.session.paint.stroke_width, |s| s.width))
|
||||
}
|
||||
|
||||
/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed.
|
||||
pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option<f64>, width: f32) {
|
||||
if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) {
|
||||
set(app, json!({"weight": w}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties
|
||||
/// panel's Stroke links), which a click on it toggles.
|
||||
pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) {
|
||||
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
ui.set_width(260.0);
|
||||
show(app, ui);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let st = current_stroke(app);
|
||||
let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width);
|
||||
let mixed = stroke_mixed(app, ui.ctx());
|
||||
let unit = app.session.stroke_unit();
|
||||
let weight = shown_weight(app, st.as_ref(), &mixed);
|
||||
let hidden: bool = pstate(ui.ctx(), "stroke-hide-options");
|
||||
let label_w = 64.0;
|
||||
let row_label = |ui: &mut Ui, s: &str| {
|
||||
@@ -94,16 +119,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, "Weight:");
|
||||
if let Some(w) = widgets::spin_field(ui, "stroke-weight", Some(weight), Unit::Points, 120.0, 1.0, 0.0, &WEIGHT_PRESETS) {
|
||||
set(app, json!({"weight": w}));
|
||||
}
|
||||
weight_field(app, ui, "stroke-weight", weight, 120.0);
|
||||
});
|
||||
if hidden {
|
||||
return;
|
||||
}
|
||||
let cap = st.as_ref().map(|s| s.cap).unwrap_or_default();
|
||||
let join = st.as_ref().map(|s| s.join).unwrap_or_default();
|
||||
let align = st.as_ref().map(|s| s.align).unwrap_or_default();
|
||||
// Mixed values light no button.
|
||||
let cap = st.as_ref().map(|s| s.cap).filter(|_| !mixed.cap);
|
||||
let join = st.as_ref().map(|s| s.join).filter(|_| !mixed.join);
|
||||
let align = st.as_ref().map(|s| s.align).filter(|_| !mixed.align);
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, "Cap:");
|
||||
for (v, icon, tip, name) in [
|
||||
@@ -111,7 +135,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
(LineCap::Round, "dc-cap-round", "Round Cap", "round"),
|
||||
(LineCap::Square, "dc-cap-square", "Projecting Cap", "square"),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, cap == v, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, icon, tip, cap == Some(v), 24.0).clicked() {
|
||||
set(app, json!({"cap": name}));
|
||||
}
|
||||
}
|
||||
@@ -123,14 +147,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
(LineJoin::Round, "dc-join-round", "Round Join", "round"),
|
||||
(LineJoin::Bevel, "dc-join-bevel", "Bevel Join", "bevel"),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, join == v, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, icon, tip, join == Some(v), 24.0).clicked() {
|
||||
set(app, json!({"join": name}));
|
||||
}
|
||||
}
|
||||
widgets::dim_label(ui, "Limit:");
|
||||
let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0);
|
||||
if join == LineJoin::Miter {
|
||||
if let Some(v) = widgets::plain_field(ui, "stroke-limit", lim, " x", 0, 50.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)}));
|
||||
}
|
||||
} else {
|
||||
@@ -144,7 +168,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
(StrokeAlign::Inside, "dc-stroke-inside", "Align Stroke to Inside", "inside"),
|
||||
(StrokeAlign::Outside, "dc-stroke-outside", "Align Stroke to Outside", "outside"),
|
||||
] {
|
||||
if widgets::icon_button(ui, icon, tip, align == v, 24.0).clicked() {
|
||||
// Only closed paths take a stroke inside or outside (not open paths, not type).
|
||||
let enabled = v == StrokeAlign::Center || mixed.can_align;
|
||||
if widgets::icon_button_enabled(ui, icon, tip, align == Some(v), enabled, 24.0).clicked() {
|
||||
set(app, json!({"align": name}));
|
||||
}
|
||||
}
|
||||
@@ -153,6 +179,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
// Dashed line.
|
||||
let dash = st.as_ref().and_then(|s| s.dash.as_ref());
|
||||
let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last"));
|
||||
let shown = if mixed.dash { [None; 6] } else { fields };
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) {
|
||||
if dash.is_some() {
|
||||
@@ -174,10 +201,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0);
|
||||
let mut nf = fields;
|
||||
let mut nf = shown;
|
||||
let mut changed = false;
|
||||
for (i, f) in fields.iter().enumerate() {
|
||||
let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, fw)).inner;
|
||||
for (i, f) in shown.iter().enumerate() {
|
||||
let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, unit, fw)).inner;
|
||||
if let Some(x) = r {
|
||||
nf[i] = x.map(|v| v.max(0.0));
|
||||
changed = true;
|
||||
@@ -349,7 +376,7 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color
|
||||
ui.painter().add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
|
||||
pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 2, t.input);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Headless frames of the Stroke panel's reach: Units > Stroke in the panel, the Control bar and
|
||||
//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type, and the
|
||||
//! weight presets.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::tests_appearance::{app_with_rect, click, frame_events, run, text_rect};
|
||||
use super::*;
|
||||
|
||||
type Texts = Vec<(String, egui::Rect)>;
|
||||
|
||||
fn texts(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut Ui)) -> Texts {
|
||||
frame_events(ctx, app, vec![], f)
|
||||
}
|
||||
|
||||
fn shows(t: &Texts, s: &str) -> bool {
|
||||
t.iter().any(|(x, _)| x == s)
|
||||
}
|
||||
|
||||
/// The centre of the `n`th (0-based) 24 px button right of a Stroke panel row label.
|
||||
fn row_button(ctx: &egui::Context, t: &Texts, label: &str, n: usize) -> egui::Pos2 {
|
||||
let r = text_rect(t, label);
|
||||
let sp = ctx.global_style().spacing.item_spacing.x;
|
||||
egui::pos2(r.right() + sp + n as f32 * (24.0 + sp) + 12.0, r.center().y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weights_and_dashes_show_in_the_stroke_unit_everywhere() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = app_with_rect();
|
||||
app.session.prefs.units_stroke = "millimeters".into();
|
||||
run(&mut app, "stroke.set", json!({"dash": [12, 6]}));
|
||||
let mm = vectorcraft_doc::Unit::Millimeters.format(1.0);
|
||||
assert_eq!(mm, "0.353 mm");
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, &mm), "Stroke panel: {t:?}");
|
||||
assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}");
|
||||
assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel");
|
||||
assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_weights_show_blank() {
|
||||
let ctx = egui::Context::default();
|
||||
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);
|
||||
assert!(!shows(&t, "1 pt") && !shows(&t, "3 pt"), "{t:?}");
|
||||
run(&mut app, "select.set", json!({ "ids": [b] }));
|
||||
assert!(shows(&texts(&ctx, &mut app, stroke::show), "3 pt"), "the cache follows the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_and_outside_are_off_for_open_paths_and_type() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_rect();
|
||||
let align = |app: &VectorcraftApp| current_stroke(app).unwrap().align;
|
||||
let at = row_button(&ctx, &texts(&ctx, &mut app, stroke::show), "Align Stroke:", 1);
|
||||
click(&ctx, &mut app, at, stroke::show);
|
||||
assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Inside, "a closed path takes an inside stroke");
|
||||
run(&mut app, "shape.line", json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150}));
|
||||
click(&ctx, &mut app, at, stroke::show);
|
||||
assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Center, "an open path doesn't");
|
||||
let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hi"}))["id"].as_u64().unwrap();
|
||||
run(&mut app, "select.set", json!({ "ids": [id] }));
|
||||
run(&mut app, "paint.setStroke", json!({"color": "#000000"}));
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
click(&ctx, &mut app, at, stroke::show);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "type doesn't");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() {
|
||||
assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0));
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_rect();
|
||||
// The chevron at the right end of the 120 px weight spinner opens them.
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
let r = text_rect(&t, "Weight:");
|
||||
let sp = ctx.global_style().spacing.item_spacing.x;
|
||||
click(&ctx, &mut app, egui::pos2(r.right() + sp + 110.0, r.center().y), stroke::show);
|
||||
let t = texts(&ctx, &mut app, stroke::show);
|
||||
assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}");
|
||||
}
|
||||
@@ -911,12 +911,13 @@ pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool
|
||||
ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame(false)).clicked()
|
||||
}
|
||||
|
||||
/// A number field that may be empty (Stroke dash/gap). Returns `Some(new)` on commit, where
|
||||
/// `new` is `None` when the field was cleared.
|
||||
pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, width: f32) -> Option<Option<f64>> {
|
||||
/// 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, where `new` is `None`
|
||||
/// when the field was cleared; typed units and arithmetic work as in [`num_field`].
|
||||
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);
|
||||
let shown = value.map(|v| format!("{v}")).unwrap_or_default();
|
||||
let shown = value.map(|v| unit.format(v).trim_end_matches(unit.suffix()).trim_end().to_string()).unwrap_or_default();
|
||||
let editing = ui.memory(|m| m.has_focus(id));
|
||||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
|
||||
let enabled = ui.is_enabled();
|
||||
@@ -938,8 +939,8 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
if resp.lost_focus() && buf != shown {
|
||||
let s = buf.trim().trim_end_matches("pt").trim();
|
||||
if s.is_empty() { Some(None) } else { s.parse::<f64>().ok().map(Some) }
|
||||
let s = buf.trim();
|
||||
if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user