mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
M3.76: Attributes panel with overprint, centre point, path direction, fill rule, image map, URL and note
Window > Attributes (Cmd+F11) shows Overprint Fill/Stroke, Show/Don't Show Center, Reverse Path Direction Off/On, the non-zero / even-odd fill rule, Image Map, a URL field with recent URLs and a Browser button, and a note. New commands attributes.set and path.setFillRule; path.reverse takes ids and reversed; attributes.info reports every value. Objects keep the attributes in Node.attrs. SVG export wraps linked objects in <a>, and import reads links back. The canvas draws the centre point of selected objects that show it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9fcefde3f9
commit
4591c85845
@@ -886,6 +886,12 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
);
|
||||
}
|
||||
});
|
||||
// Centre point (Attributes panel → Show Center).
|
||||
if n.shows_center()
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
anchor_square(p, xf.to_screen(b.center()), color, true, 4.0);
|
||||
}
|
||||
// Text: baseline marker.
|
||||
if let NodeKind::Text(tx) = &n.kind {
|
||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||
|
||||
@@ -54,12 +54,16 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("dc-cap-butt", include_bytes!("../../../assets/icons/dc-cap-butt.svg")),
|
||||
("dc-cap-round", include_bytes!("../../../assets/icons/dc-cap-round.svg")),
|
||||
("dc-cap-square", include_bytes!("../../../assets/icons/dc-cap-square.svg")),
|
||||
("dc-center-hide", include_bytes!("../../../assets/icons/dc-center-hide.svg")),
|
||||
("dc-center-show", include_bytes!("../../../assets/icons/dc-center-show.svg")),
|
||||
("dc-clear", include_bytes!("../../../assets/icons/dc-clear.svg")),
|
||||
("dc-color-guide", include_bytes!("../../../assets/icons/dc-color-guide.svg")),
|
||||
("dc-crystallize", include_bytes!("../../../assets/icons/dc-crystallize.svg")),
|
||||
("dc-cube", include_bytes!("../../../assets/icons/dc-cube.svg")),
|
||||
("dc-dash-align", include_bytes!("../../../assets/icons/dc-dash-align.svg")),
|
||||
("dc-dash-exact", include_bytes!("../../../assets/icons/dc-dash-exact.svg")),
|
||||
("dc-dir-off", include_bytes!("../../../assets/icons/dc-dir-off.svg")),
|
||||
("dc-dir-on", include_bytes!("../../../assets/icons/dc-dir-on.svg")),
|
||||
("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")),
|
||||
("dc-dist-bottom", include_bytes!("../../../assets/icons/dc-dist-bottom.svg")),
|
||||
("dc-dist-hcenter", include_bytes!("../../../assets/icons/dc-dist-hcenter.svg")),
|
||||
@@ -137,6 +141,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("dc-reverse", include_bytes!("../../../assets/icons/dc-reverse.svg")),
|
||||
("dc-rotate-view", include_bytes!("../../../assets/icons/dc-rotate-view.svg")),
|
||||
("dc-rounded-rect", include_bytes!("../../../assets/icons/dc-rounded-rect.svg")),
|
||||
("dc-rule-evenodd", include_bytes!("../../../assets/icons/dc-rule-evenodd.svg")),
|
||||
("dc-rule-nonzero", include_bytes!("../../../assets/icons/dc-rule-nonzero.svg")),
|
||||
("dc-scallop", include_bytes!("../../../assets/icons/dc-scallop.svg")),
|
||||
("dc-screen-mode", include_bytes!("../../../assets/icons/dc-screen-mode.svg")),
|
||||
("dc-selection", include_bytes!("../../../assets/icons/dc-selection.svg")),
|
||||
|
||||
@@ -315,6 +315,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step",
|
||||
),
|
||||
(
|
||||
"attributes.openUrl",
|
||||
"Browser",
|
||||
"",
|
||||
"{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button) → {url}",
|
||||
),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
@@ -682,6 +688,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
None => Err("no such user library".into()),
|
||||
},
|
||||
"ui.expandDialog" => crate::dialogs::expand::open(app),
|
||||
"attributes.openUrl" => crate::panels::attributes::open_url(app, p),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
@@ -1417,7 +1424,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
panel("Appearance", "appearance"),
|
||||
panel("Artboards", "artboards"),
|
||||
todo("Asset Export"),
|
||||
todo("Attributes"),
|
||||
panel("Attributes", "attributes"),
|
||||
panel("Brushes", "brushes"),
|
||||
panel("Color", "color"),
|
||||
panel("Color Guide", "colorGuide"),
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Attributes panel: Overprint Fill / Overprint Stroke, Show / Don't Show Center, Reverse Path
|
||||
//! Direction Off / On, the fill rule (non-zero winding or even-odd), Image Map, URL (with the
|
||||
//! recent URLs and a Browser button) and the note. The menu hides the options and shows the note.
|
||||
//! Everything reads `attributes.info` ([`vectorcraft_engine::Session::attributes_info`]) and acts
|
||||
//! through `attributes.set`, `path.reverse` and `path.setFillRule`.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ImageMap, NodeId, NodeKind};
|
||||
use vectorcraft_engine::cmd::AttributesInfo;
|
||||
use vectorcraft_geom::FillRule;
|
||||
|
||||
use super::{pstate, selection_len, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
/// Panel state: the options (centre, direction, fill rule, image map, URL) are hidden.
|
||||
const HIDE_OPTIONS: &str = "attr-hide-options";
|
||||
/// Panel state: the note field is hidden.
|
||||
const HIDE_NOTE: &str = "attr-hide-note";
|
||||
const LABEL_W: f32 = 70.0;
|
||||
|
||||
/// The panel's values and whether the targets hold paths (direction and fill rule apply),
|
||||
/// recomputed only when the document or the selection changes.
|
||||
fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (AttributesInfo, bool) {
|
||||
type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>);
|
||||
let Some(st) = app.session.active() else { return Default::default() };
|
||||
let sel = &st.selection.objects;
|
||||
let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied());
|
||||
let id = egui::Id::new("attr-state");
|
||||
if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (AttributesInfo, bool))>(id))
|
||||
&& k == key
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let info = app.session.attributes_info();
|
||||
let mut paths = false;
|
||||
for n in info.ids.iter().filter_map(|id| st.doc.node(*id)) {
|
||||
n.walk(&mut |c| paths |= matches!(c.kind, NodeKind::Path { guide: false, .. }));
|
||||
}
|
||||
let v = (info, paths);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
/// Run `attributes.set` with `p` on the selection (errors show in the status bar).
|
||||
fn set(app: &mut VectorcraftApp, p: Value) {
|
||||
app.run("attributes.set", p).ok();
|
||||
}
|
||||
|
||||
/// A pair of icon buttons choosing one of two values (`current`: the targets' value, `None` where
|
||||
/// they differ); returns the value clicked.
|
||||
fn pair<T: Copy + PartialEq>(ui: &mut Ui, enabled: bool, current: Option<T>, items: [(&str, &str, T); 2]) -> Option<T> {
|
||||
let mut clicked = None;
|
||||
for (icon, tip, v) in items {
|
||||
if widgets::icon_button_enabled(ui, icon, tip, enabled && current == Some(v), enabled, 24.0).clicked() {
|
||||
clicked = Some(v);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let (info, paths) = state(app, ui.ctx());
|
||||
let has = !info.ids.is_empty();
|
||||
ui.horizontal(|ui| {
|
||||
for (label, key, value) in
|
||||
[("Overprint Fill", "overprintFill", info.overprint_fill), ("Overprint Stroke", "overprintStroke", info.overprint_stroke)]
|
||||
{
|
||||
if widgets::check3(ui, label, value, has) {
|
||||
set(app, json!({ key: value != Some(true) }));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
if !pstate::<bool>(ui.ctx(), HIDE_OPTIONS) {
|
||||
widgets::divider(ui);
|
||||
ui.horizontal(|ui| {
|
||||
let center = [("dc-center-hide", "Don't Show Center", false), ("dc-center-show", "Show Center", true)];
|
||||
if let Some(v) = pair(ui, has, info.show_center, center) {
|
||||
set(app, json!({ "showCenter": v }));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let dir = [("dc-dir-off", "Reverse Path Direction Off", false), ("dc-dir-on", "Reverse Path Direction On", true)];
|
||||
if let Some(v) = pair(ui, paths, info.reversed, dir) {
|
||||
app.run("path.reverse", json!({ "reversed": v })).ok();
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let rules = [
|
||||
("dc-rule-nonzero", "Use Non-Zero Winding Fill Rule", FillRule::NonZero),
|
||||
("dc-rule-evenodd", "Use Even-Odd Fill Rule", FillRule::EvenOdd),
|
||||
];
|
||||
if let Some(r) = pair(ui, paths, info.fill_rule, rules) {
|
||||
app.run("path.setFillRule", json!({ "rule": if r == FillRule::EvenOdd { "evenOdd" } else { "nonZero" } })).ok();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let url = info.url.clone().filter(|u| !u.is_empty());
|
||||
widgets::label_row(ui, "Image Map:", LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
let labels = ImageMap::ALL.map(ImageMap::label);
|
||||
if let Some(i) = widgets::dropdown(ui, "attr-map", info.image_map.map_or("", ImageMap::label), &labels, 100.0) {
|
||||
set(app, json!({ "imageMap": ImageMap::ALL[i] }));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let r = ui.add_enabled_ui(url.is_some(), |ui| widgets::flat_button(ui, "Browser", 64.0)).inner;
|
||||
if r.on_hover_text("Open the URL in the web browser").clicked() {
|
||||
app.run("attributes.openUrl", json!({})).ok();
|
||||
}
|
||||
});
|
||||
widgets::label_row(ui, "URL:", LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
let width = (ui.available_width() - 26.0).max(80.0);
|
||||
if let Some(u) = widgets::text_field(ui, "attr-url", info.url.as_deref(), width, 1) {
|
||||
set(app, json!({ "url": u }));
|
||||
}
|
||||
recent_urls(app, ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
if !pstate::<bool>(ui.ctx(), HIDE_NOTE) {
|
||||
widgets::divider(ui);
|
||||
widgets::subheader(ui, "Note:");
|
||||
ui.add_enabled_ui(has, |ui| {
|
||||
if let Some(n) = widgets::text_field(ui, "attr-note", info.note.as_deref(), ui.available_width(), 3) {
|
||||
set(app, json!({ "note": n }));
|
||||
}
|
||||
});
|
||||
}
|
||||
if !has {
|
||||
widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" });
|
||||
}
|
||||
}
|
||||
|
||||
/// The recent URLs button: a menu of the URLs given lately; picking one sets it.
|
||||
fn recent_urls(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let resp = widgets::icon_button_enabled(ui, "chevron-down", "Recent URLs", false, !app.session.recent_urls.is_empty(), 20.0);
|
||||
let mut chosen = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
for u in &app.session.recent_urls {
|
||||
if menu_item(ui, u, true, false) {
|
||||
chosen = Some(u.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(u) = chosen {
|
||||
set(app, json!({ "url": u }));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
for (key, hide, show) in [(HIDE_OPTIONS, "Hide Options", "Show Options"), (HIDE_NOTE, "Hide Note", "Show Note")] {
|
||||
let hidden: bool = pstate(ui.ctx(), key);
|
||||
if menu_item(ui, if hidden { show } else { hide }, true, false) {
|
||||
set_pstate(ui.ctx(), key, !hidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `attributes.openUrl {url?}`: open `url`, else the selection's URL, in the web browser.
|
||||
pub(crate) fn open_url(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let url = match p.get("url").and_then(Value::as_str) {
|
||||
Some(u) => u.to_string(),
|
||||
None => app.session.attributes_info().url.filter(|u| !u.is_empty()).ok_or("the selection has no URL (or they differ)")?,
|
||||
};
|
||||
app.open_url(&url);
|
||||
Ok(json!({ "url": url }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// Run the panel and its menu for one headless frame: the texts drawn.
|
||||
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.session.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_shows_the_selections_attributes() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(texts.iter().any(|t| t == "No Selection") && texts.iter().any(|t| t == "Overprint Fill"), "{texts:?}");
|
||||
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||||
run(&mut app, "attributes.set", json!({"url": "https://example.com/a", "note": "first note", "imageMap": "polygon"}));
|
||||
let texts = frame(&mut app);
|
||||
for want in ["https://example.com/a", "first note", "Polygon", "Image Map:", "URL:", "Browser", " Hide Options", " Hide Note"] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want} in {texts:?}");
|
||||
}
|
||||
assert!(!texts.iter().any(|t| t == "No Selection"));
|
||||
// The URL button opens the selection's URL.
|
||||
let (info, paths) = state(&app, &egui::Context::default());
|
||||
assert!(paths && info.url.as_deref() == Some("https://example.com/a"));
|
||||
assert_eq!(open_url(&mut app, &json!({})).unwrap()["url"], "https://example.com/a");
|
||||
// Two objects whose values differ show blanks.
|
||||
let b = run(&mut app, "shape.ellipse", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone();
|
||||
let a = app.session.active().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
run(&mut app, "select.set", json!({"ids": [a, b]}));
|
||||
let texts = frame(&mut app);
|
||||
assert!(!texts.iter().any(|t| t == "https://example.com/a" || t == "first note"), "{texts:?}");
|
||||
assert!(open_url(&mut app, &json!({})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod actions;
|
||||
pub mod align;
|
||||
pub mod appearance;
|
||||
pub mod artboards;
|
||||
pub mod attributes;
|
||||
pub mod brushes;
|
||||
pub mod character;
|
||||
pub mod color;
|
||||
@@ -90,6 +91,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
"magicWand" => magic_wand::show(app, ui),
|
||||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
"attributes" => attributes::show(app, ui),
|
||||
_ => {
|
||||
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
|
||||
}
|
||||
@@ -129,6 +131,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
"magicWand" => magic_wand::menu(app, ui),
|
||||
"tabs" => tabs::menu(app, ui),
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
"attributes" => attributes::menu(app, ui),
|
||||
_ => {
|
||||
ui.add_enabled(false, egui::Button::new("No options").frame(false));
|
||||
}
|
||||
|
||||
@@ -23,12 +23,7 @@ fn editing(app: &VectorcraftApp) -> Option<(PatternDef, Unit)> {
|
||||
}
|
||||
|
||||
fn row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(96.0, 24.0), egui::Sense::hover());
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
|
||||
add(ui);
|
||||
});
|
||||
widgets::label_row(ui, label, 96.0, add);
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, name: &str, mut p: Value) {
|
||||
|
||||
@@ -89,6 +89,7 @@ pub const PANEL_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("stroke", "Cmd+F10"),
|
||||
("gradient", "Cmd+F9"),
|
||||
("transparency", "Cmd+Shift+F10"),
|
||||
("attributes", "Cmd+F11"),
|
||||
];
|
||||
|
||||
/// Default shortcut of an entry key (`tool:<id>`, `panel:<id>` or a command id).
|
||||
|
||||
@@ -89,6 +89,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
("imageTrace", "Image Trace", "image"),
|
||||
("magicWand", "Magic Wand", "wand-sparkles"),
|
||||
(crate::panels::flattener_preview::ID, "Flattener Preview", "eye"),
|
||||
("attributes", "Attributes", "settings"),
|
||||
];
|
||||
|
||||
/// Groups of icon panels separated by dividers in the collapsed column.
|
||||
|
||||
@@ -130,6 +130,59 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||
if commit { unit.parse(&buf) } else { None }
|
||||
}
|
||||
|
||||
/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line),
|
||||
/// its text kept under `id` while it has focus. Returns the text (as edited) and the response.
|
||||
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
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.to_string()) } else { shown.to_string() };
|
||||
let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32;
|
||||
let resp = ui
|
||||
.allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(CornerRadius::same(2))
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) };
|
||||
ui.add(
|
||||
edit.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(width - 14.0)
|
||||
.min_size(vec2(width - 14.0, 0.0))
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
(buf, resp)
|
||||
}
|
||||
|
||||
/// A recessed text field showing `value` (blank when `None`: the selection's values differ),
|
||||
/// `rows` lines tall (1: single line, which Enter commits). Returns the new text, trimmed, when a
|
||||
/// change is committed (Enter or focus loss).
|
||||
pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option<String> {
|
||||
let id = ui.id().with(id);
|
||||
let shown = value.unwrap_or_default();
|
||||
let (buf, resp) = recessed_text(ui, id, shown, width, rows);
|
||||
(resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string())
|
||||
}
|
||||
|
||||
/// A form row: `label` in a column `label_width` wide, then what `add` draws.
|
||||
pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// A plain number field (percent, degrees, counts) with optional suffix.
|
||||
pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option<f64> {
|
||||
mixed_field(ui, id, Some(value), suffix, decimals, width)
|
||||
@@ -144,7 +197,6 @@ pub fn mixed_field(
|
||||
decimals: usize,
|
||||
width: f32,
|
||||
) -> Option<f64> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = ui.id().with(id);
|
||||
let shown = value
|
||||
.map(|value| {
|
||||
@@ -153,31 +205,7 @@ pub fn mixed_field(
|
||||
format!("{s}{suffix}")
|
||||
})
|
||||
.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 resp = ui
|
||||
.allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(width);
|
||||
egui::Frame::NONE
|
||||
.fill(t.input)
|
||||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||||
.corner_radius(CornerRadius::same(2))
|
||||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||||
.show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut buf)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(width - 14.0)
|
||||
.min_size(vec2(width - 14.0, 0.0))
|
||||
.font(egui::FontId::proportional(12.5))
|
||||
.text_color(t.text_strong),
|
||||
)
|
||||
})
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||||
let (buf, resp) = recessed_text(ui, id, &shown, width, 1);
|
||||
if resp.lost_focus() && buf != shown {
|
||||
buf.trim().trim_end_matches(suffix.trim()).trim().trim_end_matches(['%', '°']).parse::<f64>().ok()
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user