diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 5966cf7d4..f3c7d7e35 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -98,14 +98,13 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) }); number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit)); ui.horizontal(|ui| { - let mut cap = d.bool("cap"); + let cap = flag(d, "cap", true); let tip = tl!("Close the circular ends of open profiles. Partial sweeps keep their cut sides open."); - if widgets::check_tip(ui, c3("Cap"), cap, true, tip) { - cap = !cap; - d.fields.insert("cap".into(), json!(cap)); + if widgets::check3_tip(ui, c3("Cap"), cap, true, tip) { + d.fields.insert("cap".into(), json!(cap != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "cap", !cap, "true") { + if reset_dot(ui, "cap", cap != Some(true), "true") { d.fields.insert("cap".into(), json!(true)); } }); @@ -123,17 +122,17 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) } ui.add_space(6.0); ui.horizontal(|ui| { - let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true); + let visible = flag(d, "expandVisibleOnly", true); let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces."); let toggled = ui.allocate_ui_with_layout( vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip) + widgets::check3_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip) }, ).inner; if toggled { - d.fields.insert("expandVisibleOnly".into(), json!(!visible)); + d.fields.insert("expandVisibleOnly".into(), json!(visible != Some(true))); } - if reset_dot(ui, "expandVisibleOnly", !visible, "true") { + if reset_dot(ui, "expandVisibleOnly", visible != Some(true), "true") { d.fields.insert("expandVisibleOnly".into(), json!(true)); } }); @@ -253,7 +252,9 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) { fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { let id = d.str("__effect"); let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null); - let value = d.fields.get(key).cloned().unwrap_or_else(|| default.clone()); + // A mixed setting (null) shows its default, with its reset dot filled. + let mixed = d.fields.get(key).is_some_and(Value::is_null); + let value = d.fields.get(key).filter(|v| !v.is_null()).cloned().unwrap_or_else(|| default.clone()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; label(ui, name); @@ -266,39 +267,48 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[( d.fields.insert(key.into(), v.clone()); } let default_label = options.iter().find(|(v, _)| *v == default).map_or("", |(_, l)| *l); - if reset_dot(ui, key, value != default, default_label) { + if reset_dot(ui, key, mixed || value != default, default_label) { d.fields.insert(key.into(), default.clone()); } }); } -/// A checkbox for `key`, with its reset dot. +/// The checkbox setting `key`: `None` when it is null (the 3D and Materials panel's selected +/// effects hold it with different values), else its value, or `default` when it has none. +fn flag(d: &Dialog, key: &str, default: bool) -> Option { + match d.fields.get(key) { + Some(Value::Null) => None, + value => Some(value.and_then(Value::as_bool).unwrap_or(default)), + } +} + +/// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on. fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { ui.horizontal(|ui| { - let on = d.bool(key); - if widgets::check(ui, name, on, ui.is_enabled()) { - d.fields.insert(key.into(), json!(!on)); + let on = flag(d, key, default); + if widgets::check3(ui, name, on, ui.is_enabled()) { + d.fields.insert(key.into(), json!(on != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - let mixed = d.fields.get(key).is_some_and(Value::is_null); - if reset_dot(ui, key, mixed || on != default, if default { "true" } else { "false" }) { + if reset_dot(ui, key, on != Some(default), if default { "true" } else { "false" }) { d.fields.insert(key.into(), json!(default)); } }); } fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { + let shade = flag(d, "shade", true); ui.horizontal(|ui| { - let shade = d.bool("shade"); - if widgets::check(ui, tl!("Shading"), shade, true) { - d.fields.insert("shade".into(), json!(!shade)); + if widgets::check3(ui, tl!("Shading"), shade, true) { + d.fields.insert("shade".into(), json!(shade != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "shade", !shade, "true") { + if reset_dot(ui, "shade", shade != Some(true), "true") { d.fields.insert("shade".into(), json!(true)); } }); - ui.add_enabled_ui(d.bool("shade"), |ui| { + // A mixed Shading leaves the light settings on, for the effects that shade. + ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| { lighting_presets(ui, d); light_list(ui, d); let mut selected = selected_light(d); @@ -419,8 +429,10 @@ fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { super::revolve_gizmo::clear(ui.ctx()); } }); + // Add Light is off while a setting of the single light is mixed (null). + let mixed = explicit_lights(d).is_none() && LIGHT_KEYS.iter().any(|(_, key)| d.fields.get(*key).is_some_and(Value::is_null)); ui.horizontal(|ui| { - if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS, egui::Button::new(tl!("Add Light"))).clicked() { + if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS && !mixed, egui::Button::new(tl!("Add Light"))).clicked() { let mut added = selected_light(d); let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({})); for (_, key) in LIGHT_KEYS { @@ -464,7 +476,8 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) { } ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "lightColor", rgb != [255; 3], "#FFFFFF") { + let mixed = d.fields.get("lightColor").is_some_and(Value::is_null); + if reset_dot(ui, "lightColor", mixed || rgb != [255; 3], "#FFFFFF") { d.fields.insert("lightColor".into(), json!("#ffffff")); } }); diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 44ed256f6..3c94bf88a 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -337,6 +337,70 @@ fn a_drag_ends_when_the_panel_is_no_longer_drawn() { assert_eq!(effect(&app).1["depth"], 90.0); } +/// The panel's controls, drawn once with the accessibility tree on. +fn controls(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() +} + +fn named<'a>(nodes: &'a [egui::accesskit::Node], label: &str) -> &'a egui::accesskit::Node { + nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}")) +} + +/// A checkbox setting the selected effects hold with different values shows a dash, leaves the +/// controls it guards available, and another change leaves it as each effect has it. +#[test] +fn a_mixed_checkbox_shows_a_dash_and_another_change_keeps_each_effects_value() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"shade": false}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show); + let nodes = controls(&mut app, &ctx); + assert_eq!(named(&nodes, "Shading").toggled(), Some(egui::accesskit::Toggled::Mixed), "a mixed Shading shows a dash"); + let behind = named(&nodes, "Light behind the object"); + assert!(!behind.is_disabled(), "a mixed Shading leaves the light settings available"); + assert!(!named(&nodes, "Add Light").is_disabled()); + let at = behind.bounds().map(|r| egui::pos2(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)).unwrap(); + let history = undo_len(&app); + click(&ctx, &mut app, at, show); + assert_eq!(undo_len(&app), history + 1); + let params = |id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params.clone(); + let merged = |id: u64| vectorcraft_effects::merged_params("threeD.extrude", ¶ms(id)); + assert_eq!((merged(a)["shade"].clone(), merged(b)["shade"].clone()), (json!(true), json!(false))); + assert_eq!((merged(a)["lightBehind"].clone(), merged(b)["lightBehind"].clone()), (json!(true), json!(true))); + // A single light whose settings differ: Add Light is off. + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"lightIntensity": 40.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(named(&controls(&mut app, &ctx), "Add Light").is_disabled()); +} + +/// Revolve's Cap checkbox shows a dash when the selected effects disagree on it. +#[test] +fn a_mixed_revolve_cap_shows_a_dash() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "revolve").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.revolve", "item": null, "params": {"cap": false}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let history = undo_len(&app); + assert_eq!(named(&controls(&mut app, &ctx), "Cap").toggled(), Some(egui::accesskit::Toggled::Mixed)); + assert_eq!(undo_len(&app), history); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index cf6515396..25dfaa684 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -246,7 +246,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { /// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect /// of another selected object has with another value. The controls then show it at its default -/// with its reset dot filled, and its reset dot, a menu choice, a checkbox click, or a number other +/// (a checkbox shows a dash) with its reset dot filled, and its reset dot, a menu choice, a checkbox click, or a number other /// than the default shown is a change sent to every effect. The light list (`lights`, where null /// is the single light) is left as it is. pub(crate) fn mark_mixed(app: &VectorcraftApp, fields: &mut Map) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1d0031e4e..689f28ac5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -70,7 +70,7 @@ pub use text::{ exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, text_presets_named, }; -pub use toggles::{check, check_box, check_name, check_tip, check3, radio, radio_button}; +pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button}; /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs index 8dac694ec..5cac2023e 100644 --- a/crates/ui-egui/src/widgets/toggles.rs +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -26,7 +26,12 @@ pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { /// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { - let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled); + check3_tip(ui, label, Some(value), enabled, tip) +} + +/// [`check3`] with tooltip `tip` on the box and its label. Returns true when clicked. +pub fn check3_tip(ui: &mut Ui, label: &str, value: Option, enabled: bool, tip: &str) -> bool { + let (clicked, resp) = check_row(ui, tl!(label), value, enabled); resp.on_hover_text(tip); clicked } diff --git a/docs/three-d.md b/docs/three-d.md index 93e831e01..bb0995315 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -33,11 +33,13 @@ pages describe it. and ranges, showing the 3D effect of the first selected object (its own effects first, then those of its fills and strokes). A changed setting goes to the 3D effect of each selected object whose effect takes that setting; other effects and objects without a 3D effect do not change. A setting the selected 3D effects hold with - different values shows its default with its reset dot filled. Its reset dot, a choice from its menu or a click - on its checkbox sets it on all of them; a typed or dragged number sets it when it differs from the default - shown. + different values shows its default with its reset dot filled; a checkbox shows a dash instead, and a mixed + Shading leaves the light settings available. Its reset dot, a choice from its menu or a click on its checkbox + (which turns a mixed one on) sets it on all of them; a typed or dragged number sets it when it differs from the + default shown. Other changes leave it as each effect has it. The list of lights shows the first object's lights, and an edit to it (Add Light, Delete Light, or a setting - of one light in a list) reaches only the objects whose list of lights matches the first object's. + of one light in a list) reaches only the objects whose list of lights matches the first object's. Add Light is + unavailable while a setting of a single light differs between the selected effects. - Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. - The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on