//! Pattern Options panel: active in pattern editing mode (Object → Pattern → Make / Edit //! Pattern). Name, Tile Type, Brick Offset, Width/Height, Size Tile to Art with H/V spacing, //! Overlap, Copies, Dim Copies, Show Tile Edge, Show Swatch Bounds; Save a Copy / Done / Cancel. Outside editing //! mode it lists the document's patterns with an Edit button. use egui::Ui; use serde_json::{Value, json}; use vectorcraft_doc::pattern::{PatternDef, RepeatKind, TileType}; use vectorcraft_doc::{NodeKind, Unit}; use super::{pstate, set_pstate}; use crate::VectorcraftApp; use crate::widgets::{self, menu_item}; const TILE_LABELS: [&str; 5] = ["Grid", "Brick by Row", "Brick by Column", "Hex by Column", "Hex by Row"]; const BRICK: [(&str, f64); 4] = [("1/2", 0.5), ("1/3", 1.0 / 3.0), ("1/4", 0.25), ("1/5", 0.2)]; const COPIES: [(&str, u32); 4] = [("3 x 3", 3), ("5 x 5", 5), ("7 x 7", 7), ("9 x 9", 9)]; fn editing(app: &VectorcraftApp) -> Option<(PatternDef, Unit)> { let st = app.session.active()?; let pe = st.doc.pattern_edit.as_ref()?; Some((st.doc.pattern(&pe.pattern)?.clone(), app.session.general_unit())) } fn row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) { widgets::label_row(ui, label, 96.0, add); } fn set(app: &mut VectorcraftApp, name: &str, mut p: Value) { p["name"] = json!(name); if let Err(e) = app.run("pattern.options", p) { app.status(e); } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.session.active().is_none() { super::empty_state(ui, "swatch-book", tl!("No document"), tl!("Open a document to edit its patterns.")); return; } let Some((def, unit)) = editing(app) else { idle(app, ui); return; }; let name = def.name.clone(); // Name (commit on Enter / focus loss). row(ui, tl!("Name:"), |ui| { let id = ui.id().with("po-name"); let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| name.clone()); if !ui.memory(|m| m.has_focus(id)) { buf = name.clone(); } let r = ui.add(egui::TextEdit::singleline(&mut buf).id(id).desired_width(150.0)); ui.data_mut(|d| d.insert_temp(id, buf.clone())); if r.lost_focus() && !buf.trim().is_empty() && buf != name { set(app, &name, json!({"newName": buf.trim()})); } }); let tt_index = TileType::IDS.iter().position(|i| *i == def.tile_type.id()).unwrap_or(0); row(ui, tl!("Tile Type:"), |ui| { if let Some(i) = widgets::dropdown(ui, "po-tile", TILE_LABELS[tt_index], &TILE_LABELS, 150.0) { set(app, &name, json!({"tileType": TileType::IDS[i]})); } }); let offset = match def.tile_type { TileType::BrickByRow { offset } | TileType::BrickByColumn { offset } => Some(offset), _ => None, }; row(ui, tl!("Brick Offset:"), |ui| { ui.add_enabled_ui(offset.is_some(), |ui| { let cur = BRICK.iter().find(|(_, v)| offset.is_some_and(|o| (o - v).abs() < 1e-6)).map(|(l, _)| *l).unwrap_or("1/2"); let labels: Vec<&str> = BRICK.iter().map(|(l, _)| *l).collect(); if let Some(i) = widgets::dropdown(ui, "po-brick", cur, &labels, 70.0) { set(app, &name, json!({"brickOffset": BRICK[i].1})); } }); }); let sized = def.size_tile_to_art; row(ui, tl!("Width:"), |ui| { ui.add_enabled_ui(!sized, |ui| { if let Some(v) = widgets::num_field(ui, "po-w", Some(def.tile.width()), unit, 90.0) { set(app, &name, json!({"width": v})); } }); }); row(ui, tl!("Height:"), |ui| { ui.add_enabled_ui(!sized, |ui| { if let Some(v) = widgets::num_field(ui, "po-h", Some(def.tile.height()), unit, 90.0) { set(app, &name, json!({"height": v})); } }); }); if widgets::check(ui, tl!("Size Tile to Art"), sized, true) { set(app, &name, json!({"sizeTileToArt": !sized})); } row(ui, tl!("H Spacing:"), |ui| { ui.add_enabled_ui(sized, |ui| { if let Some(v) = widgets::num_field(ui, "po-hs", Some(def.h_spacing), unit, 90.0) { set(app, &name, json!({"hSpacing": v})); } }); }); row(ui, tl!("V Spacing:"), |ui| { ui.add_enabled_ui(sized, |ui| { if let Some(v) = widgets::num_field(ui, "po-vs", Some(def.v_spacing), unit, 90.0) { set(app, &name, json!({"vSpacing": v})); } }); }); row(ui, tl!("Overlap:"), |ui| { let h = if def.overlap.right_in_front { "Right in Front" } else { "Left in Front" }; if let Some(i) = widgets::dropdown(ui, "po-oh", h, &["Left in Front", "Right in Front"], 110.0) { set(app, &name, json!({"overlap": {"h": if i == 1 { "right" } else { "left" }}})); } }); row(ui, "", |ui| { let v = if def.overlap.bottom_in_front { "Bottom in Front" } else { "Top in Front" }; if let Some(i) = widgets::dropdown(ui, "po-ov", v, &["Top in Front", "Bottom in Front"], 110.0) { set(app, &name, json!({"overlap": {"v": if i == 1 { "bottom" } else { "top" }}})); } }); widgets::divider(ui); row(ui, tl!("Copies:"), |ui| { let cur = COPIES.iter().find(|(_, n)| *n == def.copies).map(|(l, _)| *l).unwrap_or("5 x 5"); let labels: Vec<&str> = COPIES.iter().map(|(l, _)| *l).collect(); if let Some(i) = widgets::dropdown(ui, "po-copies", cur, &labels, 80.0) { set(app, &name, json!({"copies": COPIES[i].1})); } }); row(ui, tl!("Dim Copies to:"), |ui| { if let Some(v) = widgets::plain_field(ui, "po-dim", def.dim_copies as f64, "%", 0, 60.0) { set(app, &name, json!({"dimCopies": v})); } }); if widgets::check(ui, tl!("Show Tile Edge"), def.show_tile_edge, true) { set(app, &name, json!({"showTileEdge": !def.show_tile_edge})); } if widgets::check(ui, tl!("Show Swatch Bounds"), def.show_swatch_bounds, true) { set(app, &name, json!({"showSwatchBounds": !def.show_swatch_bounds})); } widgets::divider(ui); ui.horizontal(|ui| { if widgets::flat_button(ui, tl!("Save a Copy"), 90.0).clicked() && let Err(e) = app.run("object.pattern.saveCopy", json!({})) { app.status(e); } if widgets::flat_button(ui, tl!("Done"), 60.0).clicked() { app.run("object.pattern.done", json!({})).ok(); } if widgets::flat_button(ui, tl!("Cancel"), 60.0).clicked() { app.run("object.pattern.cancel", json!({})).ok(); } }); } /// Not editing: the document's patterns, one to pick and edit. fn idle(app: &mut VectorcraftApp, ui: &mut Ui) { let names: Vec = app.session.active().map(|st| st.doc.patterns.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); widgets::dim_label(ui, tl!("Pattern options are available in pattern editing mode.")); ui.add_space(4.0); if names.is_empty() { widgets::dim_label(ui, tl!("Select art and choose Object › Pattern › Make.")); return; } let mut chosen: String = pstate(ui.ctx(), "po-chosen"); if !names.contains(&chosen) { chosen = names[0].clone(); } ui.horizontal(|ui| { let labels: Vec<&str> = names.iter().map(String::as_str).collect(); if let Some(i) = widgets::dropdown_names(ui, "po-pick", &chosen, &labels, 150.0) { set_pstate(ui.ctx(), "po-chosen", names[i].clone()); } if widgets::flat_button(ui, tl!("Edit Pattern"), 90.0).clicked() && let Err(e) = app.run("object.pattern.edit", json!({"name": chosen})) { app.status(e); } }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let editing = editing(app).is_some(); if menu_item(ui, tl!("Save a Copy"), editing, false) { app.run("object.pattern.saveCopy", json!({})).ok(); } if menu_item(ui, tl!("Done"), editing, false) { app.run("object.pattern.done", json!({})).ok(); } if menu_item(ui, tl!("Cancel"), editing, false) { app.run("object.pattern.cancel", json!({})).ok(); } } /// Current values of the selected repeat as Repeat Options dialog fields. pub fn repeat_fields(app: &VectorcraftApp) -> Option { let st = app.session.active()?; let spec = st.selection.objects.iter().find_map(|id| { st.doc.ancestry(*id)?.into_iter().rev().find_map(|a| match &st.doc.node(a)?.kind { NodeKind::Repeat(r) => Some(r.clone()), _ => None, }) })?; Some(match spec.kind { RepeatKind::Radial { instances, radius, reverse_overlap, start_angle, end_angle, .. } => { json!({"instances": instances, "radius": radius, "startAngle": start_angle, "endAngle": end_angle, "reverseOverlap": reverse_overlap}) } RepeatKind::Grid { h_spacing, v_spacing, rows, cols, grid_type, flip_rows, flip_cols } => { json!({"hSpacing": h_spacing, "vSpacing": v_spacing, "rows": rows, "cols": cols, "gridType": grid_type.id(), "flipRows": flip_rows, "flipCols": flip_cols}) } RepeatKind::Mirror { angle, .. } => json!({"angle": angle}), }) } #[cfg(test)] mod tests { use super::*; use vectorcraft_engine::Session; /// One headless frame of the panel with `events`: where each text was drawn. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, egui::Rect)> { let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); out.textures_delta.clear(); let mut v = vec![]; for c in &out.shapes { if let egui::Shape::Text(t) = &c.shape { v.push((t.galley.text().to_string(), t.visual_bounding_rect())); } } v } #[test] fn show_swatch_bounds_toggles_the_pattern_option() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); app.run("object.pattern.make", json!({"name": "Dots"})).unwrap(); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let texts = frame(&mut app, &ctx, vec![]); let at = texts.iter().find(|(t, _)| t == "Show Swatch Bounds").expect("the checkbox").1.center(); let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(true)]); frame(&mut app, &ctx, vec![click(false)]); assert!(app.session.active().unwrap().doc.pattern("Dots").unwrap().show_swatch_bounds); } }