diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 04e58d452e..4ac6da2fbe 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -158,9 +158,10 @@ pub use export_as::{export_selection, open as open_export_as}; pub(crate) use export_for_screens::drop_unread_screen_settings; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ - KIND as EXPORT_FOR_SCREENS, PresetPick as ScreenPresetPick, export_preset as screen_export_preset, formats as screen_formats, - import_presets as screen_import_presets, open_assets as open_export_for_screens_assets, preset_list as screen_preset_list, - saved_options as screen_saved_options, saved_rows as screen_saved_rows, settings_format as screen_settings_format, + KIND as EXPORT_FOR_SCREENS, PresetPick as ScreenPresetPick, artboard_name_editing as export_artboard_name_editing, + export_preset as screen_export_preset, formats as screen_formats, import_presets as screen_import_presets, + open_assets as open_export_for_screens_assets, preset_list as screen_preset_list, saved_options as screen_saved_options, + saved_rows as screen_saved_rows, settings_format as screen_settings_format, }; pub(crate) use form::{humanize, number_list_text, plugin_color_button, read_number_list, unit_suffix}; pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card}; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index ecd9ca6996..5b28affa2f 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -392,6 +392,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.vertical(|ui| { ui.set_width(LEFT_W); if let Some(i) = widgets::tab_bar(ui, &[tl!("Artboards"), tl!("Assets")], tab) { + if i != tab + && let Some((board, text, _)) = take_artboard_edit(app, d) + { + rename_artboard(app, board, &text); + } d.fields.insert("tab".into(), json!(TABS.get(i).copied().unwrap_or("artboards"))); } ui.add_space(6.0); @@ -521,23 +526,33 @@ fn select_options(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { fn artboard_tiles(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { let mut boards = boards(app, d); let before = boards.clone(); + let mut editing = take_artboard_edit(app, d); + let mut rename = None; let names: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default(); + let ids: Vec = app.session.active().map(|s| s.doc.artboards.iter().map(|a| a.id).collect()).unwrap_or_default(); ui.add_enabled_ui(d.str("select") != "full", |ui| { - tiles(ui, "efs-artboards", names.len(), list, |ui, i, r| { - match artboard_thumb(app, ui.ctx(), i) { - Some(tex) => { - let sz = tex.size_vec2(); - let s = (r.width() / sz.x).min(r.height() / sz.y).min(1.0); - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE); + tiles( + ui, + "efs-artboards", + names.len(), + list, + |ui, i, r| { + match artboard_thumb(app, ui.ctx(), i) { + Some(tex) => { + let sz = tex.size_vec2(); + let s = (r.width() / sz.x).min(r.height() / sz.y).min(1.0); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().image(tex.id(), egui::Rect::from_center_size(r.center(), sz * s), uv, egui::Color32::WHITE); + } + None => { + ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); + } } - None => { - ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE); - } - } - let name = names.get(i).cloned().unwrap_or_default(); - (format!("{} {name}", i + 1), name, boards.get(i).copied().unwrap_or(false)) - }) + let name = names.get(i).cloned().unwrap_or_default(); + (format!("{} {name}", i + 1), name, boards.get(i).copied().unwrap_or(false)) + }, + Some(&mut |ui, i, rect, id, name| edit_artboard_name(ui, ids.get(i).copied(), rect, id, name, &mut editing, &mut rename)), + ) .into_iter() .for_each(|i| { if let Some(b) = boards.get_mut(i) { @@ -545,6 +560,16 @@ fn artboard_tiles(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, l } }); }); + // A field whose artboard was deleted while it was typed in is closed, so Escape reaches the dialog again. + if d.str("select") != "full" + && let Some(uid) = app.session.active().map(|s| s.uid) + && let Some((board, text, focus)) = editing.filter(|(board, _, _)| ids.contains(board)) + { + d.fields.insert(RENAME_ARTBOARD.into(), json!((uid, board, text, focus))); + } + if let Some((board, name)) = rename { + rename_artboard(app, board, &name); + } if boards != before { set_select(d, if boards.iter().all(|b| *b) { "all" } else { "range" }); d.fields.insert("range".into(), json!(range_text(&boards))); @@ -552,6 +577,100 @@ fn artboard_tiles(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, l } } +/// Inline editor state belongs to this dialog, not to the document or its export settings. +const RENAME_ARTBOARD: &str = "__renameArtboard"; + +/// Whether an artboard name is being typed in Export for Screens: a field is open for an artboard +/// of the active document. +pub(crate) fn artboard_name_editing(app: &VectorcraftApp, d: &Dialog) -> bool { + d.kind == KIND + && d.fields + .get(RENAME_ARTBOARD) + .and_then(|value| serde_json::from_value::<(u64, u32, String, bool)>(value.clone()).ok()) + .zip(app.session.active()) + .is_some_and(|((uid, board, ..), s)| s.uid == uid && s.doc.artboards.iter().any(|a| a.id == board)) +} + +/// The artboard being renamed (its id, so the name goes to the same artboard after the artboards +/// change order), the name as typed, and whether the field still has to take the focus. A field +/// opened in another document than the active one is closed. +fn take_artboard_edit(app: &VectorcraftApp, d: &mut Dialog) -> Option<(u32, String, bool)> { + let doc = app.session.active().map(|s| s.uid); + d.fields + .remove(RENAME_ARTBOARD) + .and_then(|value| serde_json::from_value::<(u64, u32, String, bool)>(value).ok()) + .filter(|(uid, ..)| Some(*uid) == doc) + .map(|(_, board, text, focus)| (board, text, focus)) +} + +/// The field of the artboard with id `board`, the same wherever its tile is drawn. +fn artboard_name_field(board: u32) -> egui::Id { + egui::Id::unique(("efs-artboard-name", board)) +} + +fn rename_artboard(app: &mut VectorcraftApp, board: u32, text: &str) { + let name = text.trim(); + let Some(index) = app.session.active().and_then(|s| s.doc.artboards.iter().position(|a| a.id == board && a.name != name)) else { return }; + if !name.is_empty() + && let Err(err) = app.run("artboard.rename", json!({"indices": [index], "name": name})) + { + app.status(err); + } +} + +/// The caption of the artboard with id `board`: its name field while it is renamed, else a target +/// for the double-click that opens it → whether the click toggles the artboard's checkbox, and +/// whether the field was drawn over the caption. +fn edit_artboard_name( + ui: &mut egui::Ui, + board: Option, + rect: egui::Rect, + id: egui::Id, + name: &str, + editing: &mut Option<(u32, String, bool)>, + rename: &mut Option<(u32, String)>, +) -> (bool, bool) { + if let Some((b, text, focus)) = editing.as_mut().filter(|(b, _, _)| Some(*b) == board) { + let field = artboard_name_field(*b); + let cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let enter = crate::shortcuts::pressed_anew(ui.ctx(), egui::Key::Enter); + if enter { + widgets::reject_dialog_commit(ui.ctx()); + } + let response = ui.put(rect, egui::TextEdit::singleline(text).id(field).font(egui::FontId::proportional(11.0)).desired_width(rect.width())); + if *focus { + widgets::focus_field(ui, field, text); + *focus = false; + } else if cancel || enter || response.lost_focus() { + if !cancel { + *rename = Some((*b, std::mem::take(text))); + } + response.surrender_focus(); + *editing = None; + } + (false, true) + } else { + let response = ui.interact(rect, id, egui::Sense::click()); + if let Some(board) = board + && response.double_clicked() + { + *editing = Some((board, name.into(), true)); + ui.ctx().request_repaint(); + // If both releases arrived this frame, the first click did not toggle the row. + // Otherwise, undo the toggle from the first click on this caption. + let toggle = !ui.input(|input| { + input.events.iter().filter(|event| { + matches!(event, egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, .. } if rect.contains(*pos)) + }).nth(1).is_some() + }); + return (toggle, false); + } + (response.clicked(), false) + } +} + +type CaptionEdit<'a> = dyn FnMut(&mut egui::Ui, usize, egui::Rect, egui::Id, &str) -> (bool, bool) + 'a; + /// `count` thumbnails in a grid (or, with `list`, a list) that scrolls: `paint` draws thumbnail `i` /// into its rect and gives its caption, its checkbox's name and whether it is checked → the /// thumbnails whose checkbox (or picture) was clicked. @@ -561,6 +680,7 @@ fn tiles( count: usize, list: bool, mut paint: impl FnMut(&mut egui::Ui, usize, egui::Rect) -> (String, String, bool), + mut caption_edit: Option<&mut CaptionEdit<'_>>, ) -> Vec { let t = Tokens::get(ui.ctx()); let mut toggled = vec![]; @@ -581,11 +701,22 @@ fn tiles( let label_at = if list { egui::pos2(pic.right() + 8.0, pic.center().y) } else { egui::pos2(pic.left(), pic.bottom() + 9.0) }; let galley = ui.painter().layout(caption, egui::FontId::proportional(11.0), t.text, if list { cell.x - side - 12.0 } else { side }); - ui.painter().galley(egui::pos2(label_at.x, label_at.y - galley.size().y / 2.0), galley, t.text); + let label_pos = egui::pos2(label_at.x, label_at.y - galley.size().y / 2.0); + let caption_clicked = if let Some(edit) = &mut caption_edit { + let rect = egui::Rect::from_min_max(egui::pos2(label_at.x, r.top().max(label_at.y - 9.0)), r.right_bottom()); + let (clicked, field) = edit(ui, i, rect, resp.id.with("name"), &name); + if !field { + ui.painter().galley(label_pos, galley, t.text); + } + clicked + } else { + ui.painter().galley(label_pos, galley, t.text); + false + }; // The checkbox sits on the thumbnail's lower left corner, as Illustrator's. let bx = egui::Rect::from_min_size(egui::pos2(pic.left() + 4.0, pic.bottom() - 18.0), egui::vec2(14.0, 14.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bx)); - if widgets::check_box(&mut child, &name, checked, true) || resp.clicked() { + if widgets::check_box(&mut child, &name, checked, true) || resp.clicked() || caption_clicked { toggled.push(i); } } @@ -622,18 +753,25 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { // Asked for until its row is in view: the dialog's first frame only measures it. let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| shown.iter().any(|a| a.id == *id)); let mut in_view = scroll_to.is_none(); - let clicked = tiles(ui, "efs-assets", shown.len(), list, |ui, i, r| { - let Some(a) = shown.get(i) else { return (String::new(), String::new(), false) }; - crate::panels::asset_export::paint_thumb(ui, st, a, r); - if scroll_to == Some(a.id) { - if ui.clip_rect().contains_rect(r) { - in_view = true; - } else { - ui.scroll_to_rect(r, Some(egui::Align::Center)); + let clicked = tiles( + ui, + "efs-assets", + shown.len(), + list, + |ui, i, r| { + let Some(a) = shown.get(i) else { return (String::new(), String::new(), false) }; + crate::panels::asset_export::paint_thumb(ui, st, a, r); + if scroll_to == Some(a.id) { + if ui.clip_rect().contains_rect(r) { + in_view = true; + } else { + ui.scroll_to_rect(r, Some(egui::Align::Center)); + } } - } - (a.name.clone(), a.name.clone(), on.contains(&a.id)) - }); + (a.name.clone(), a.name.clone(), on.contains(&a.id)) + }, + None, + ); for i in clicked { if let Some(a) = shown.get(i) { if on.contains(&a.id) { diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 1bd4f10afc..e26c82de60 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -1319,3 +1319,231 @@ fn the_dialog_builds_the_preset_list_again_only_when_the_presets_change() { let mine = list.as_array().unwrap().iter().find(|p| p["name"] == "Mine").unwrap(); assert_eq!(mine["settings"]["formats"][0]["format"], "pdf", "{list}"); } + +/// #7275: a double-click on an artboard name enters rename without leaving Export for Screens. +#[test] +fn same_frame_artboard_double_click_keeps_export_selection() { + for view in ["grid", "list"] { + for checked in [true, false] { + let (mut app, _) = app(2, true); + open(&mut app); + set(&mut app, "view", json!(view)); + let mut h = ScreensFrame::new(); + let out = h.settle(&mut app); + let name = app.session.active().unwrap().doc.artboards[0].name.clone(); + let at = screen_text_at(&out, &format!("1 {name}")); + if !checked { + h.click(&mut app, at); + h.time += 1.0; + h.settle(&mut app); + } + let before = field(&app, "boards"); + h.frame(&mut app, vec![egui::Event::PointerMoved(at)]); + let events = [true, false, true, false] + .into_iter() + .map(|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }) + .collect(); + h.frame(&mut app, events); + h.settle(&mut app); + assert!(h.ctx.egui_wants_keyboard_input(), "{view}, checked={checked}"); + assert_eq!(field(&app, "boards"), before, "{view}, checked={checked}"); + } + } +} + +/// #7275: a double-click on an artboard name enters rename without leaving Export for Screens. +#[test] +fn switching_export_tabs_commits_an_artboard_name() { + for view in ["grid", "list"] { + let (mut app, log) = app(2, true); + open(&mut app); + set(&mut app, "view", json!(view)); + let mut h = ScreensFrame::new(); + let before = field(&app, "boards"); + let out = h.settle(&mut app); + let old = app.session.active().unwrap().doc.artboards[0].name.clone(); + let at = screen_text_at(&out, &format!("1 {old}")); + h.frame(&mut app, vec![egui::Event::PointerMoved(at)]); + for pressed in [true, false, true, false] { + h.frame( + &mut app, + vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }], + ); + } + h.settle(&mut app); + assert!(h.ctx.egui_wants_keyboard_input()); + h.frame(&mut app, vec![egui::Event::Text("Committed on tab".into())]); + h.click_label(&mut app, "Assets"); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Committed on tab"); + assert_eq!(field(&app, "tab"), "assets"); + assert_eq!(field(&app, "boards"), before); + assert!(log.borrow().is_empty()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, old); + } +} + +/// #7275: a double-click on an artboard name enters rename without leaving Export for Screens. +#[test] +fn double_clicking_an_artboard_name_in_export_for_screens_renames_it() { + for view in ["grid", "list"] { + let (mut app, log) = app(2, true); + open(&mut app); + set(&mut app, "view", json!(view)); + let mut h = ScreensFrame::new(); + let before = field(&app, "boards"); + let out = h.settle(&mut app); + let old = app.session.active().unwrap().doc.artboards[0].name.clone(); + let at = screen_text_at(&out, &format!("1 {old}")); + h.frame(&mut app, vec![egui::Event::PointerMoved(at)]); + for pressed in [true, false, true, false] { + h.frame( + &mut app, + vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }], + ); + } + h.settle(&mut app); + assert!(h.ctx.egui_wants_keyboard_input(), "double-clicking the artboard name must open an editable field"); + h.frame(&mut app, vec![egui::Event::Text("Renamed".into())]); + h.frame( + &mut app, + vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + h.settle(&mut app); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Renamed"); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + assert_eq!(field(&app, "boards"), before, "renaming must not change the checked artboards"); + assert!(log.borrow().is_empty(), "renaming must not export"); + // The real shortcut handler runs before the dialog body. Escape cancels only the name. + let out = h.settle(&mut app); + let at = screen_text_at(&out, "1 Renamed"); + h.frame(&mut app, vec![egui::Event::PointerMoved(at)]); + for pressed in [true, false, true, false] { + h.frame( + &mut app, + vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }], + ); + } + h.settle(&mut app); + h.frame(&mut app, vec![egui::Event::Text("Discard".into())]); + let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + h.time += 0.1; + let input = egui::RawInput { + events: vec![key], + time: Some(h.time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1100.0, 900.0))), + ..Default::default() + }; + let mut out = h.ctx.run_ui(input, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + super::show(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + h.settle(&mut app); + assert_eq!(kind(&app), Some(export_for_screens::KIND)); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Renamed"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, old); + } +} + +/// #7275: the typed name goes to the artboard whose name was double-clicked, even when the +/// artboards change order (a command from the control channel or MCP) while it is typed. When that +/// artboard is deleted, the field closes and Escape closes the dialog. +#[test] +fn an_artboard_name_follows_its_artboard_while_it_is_typed() { + for view in ["grid", "list"] { + let (mut app, log) = app(3, true); + open(&mut app); + set(&mut app, "view", json!(view)); + let mut h = ScreensFrame::new(); + let names = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::>(); + let rename = |h: &mut ScreensFrame, app: &mut VectorcraftApp, label: &str| { + let out = h.settle(app); + let at = screen_text_at(&out, label); + h.frame(app, vec![egui::Event::PointerMoved(at)]); + for pressed in [true, false, true, false] { + h.frame( + app, + vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }], + ); + } + h.settle(app); + assert!(h.ctx.egui_wants_keyboard_input(), "{view}: the double-click opens the name"); + }; + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + rename(&mut h, &mut app, "2 Artboard 2"); + app.run("artboard.reorder", json!({"index": 1, "to": 0})).unwrap(); + h.frame(&mut app, vec![egui::Event::Text("Hero".into())]); + h.frame(&mut app, vec![enter]); + h.settle(&mut app); + assert_eq!(names(&app), ["Hero", "Artboard 1", "Artboard 3"], "{view}"); + assert_eq!(kind(&app), Some(export_for_screens::KIND), "{view}"); + assert!(log.borrow().is_empty(), "{view}: renaming must not export"); + // The artboard is deleted while its name is typed: the field closes, no other artboard + // takes the name, and Escape closes the dialog again. + rename(&mut h, &mut app, "2 Artboard 1"); + h.frame(&mut app, vec![egui::Event::Text("Gone".into())]); + app.run("artboard.delete", json!({"indices": [1]})).unwrap(); + h.settle(&mut app); + assert!(!h.ctx.egui_wants_keyboard_input(), "{view}: the field of a deleted artboard closes"); + assert_eq!(names(&app), ["Hero", "Artboard 3"], "{view}"); + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + h.time += 0.1; + let input = egui::RawInput { + events: vec![escape], + time: Some(h.time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1100.0, 900.0))), + ..Default::default() + }; + let mut out = h.ctx.run_ui(input, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + super::show(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + h.settle(&mut app); + assert_eq!(kind(&app), None, "{view}: Escape closes the dialog"); + assert_eq!(names(&app), ["Hero", "Artboard 3"], "{view}"); + assert!(log.borrow().is_empty(), "{view}: renaming must not export"); + } +} + +/// #7275: a name typed while another document becomes active (a command from the control channel +/// or MCP) renames no artboard in either document, and Escape closes the dialog. +#[test] +fn an_artboard_name_stays_with_its_document() { + let (mut app, log) = app(2, true); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": 2})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + open(&mut app); + let mut h = ScreensFrame::new(); + let names = |app: &VectorcraftApp, i: usize| app.session.documents()[i].doc.artboards.iter().map(|a| a.name.clone()).collect::>(); + let out = h.settle(&mut app); + let at = screen_text_at(&out, "2 Artboard 2"); + h.frame(&mut app, vec![egui::Event::PointerMoved(at)]); + for pressed in [true, false, true, false] { + h.frame(&mut app, vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }]); + } + h.settle(&mut app); + assert!(h.ctx.egui_wants_keyboard_input(), "the double-click opens the name"); + h.frame(&mut app, vec![egui::Event::Text("Elsewhere".into())]); + app.run("document.activate", json!({"index": 1})).unwrap(); + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + h.time += 0.1; + let input = egui::RawInput { + events: vec![escape], + time: Some(h.time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1100.0, 900.0))), + ..Default::default() + }; + let mut out = h.ctx.run_ui(input, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + super::show(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + h.settle(&mut app); + assert_eq!(kind(&app), None, "Escape closes the dialog"); + assert_eq!(names(&app, 0), ["Artboard 1", "Artboard 2"]); + assert_eq!(names(&app, 1), ["Artboard 1", "Artboard 2"]); + assert!(log.borrow().is_empty(), "renaming must not export"); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index ea32f6c078..d102c40d7e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -226,7 +226,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and // leaves the dialog open. let popup = app.ui.dialog.as_ref().is_some_and(|d| { - egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d) || crate::dialogs::new_document_popup_open(d) + egui::Popup::is_any_open(ctx) + || crate::dialogs::brush_options::tips_open(d) + || crate::dialogs::new_document_popup_open(d) + || crate::dialogs::export_artboard_name_editing(app, d) }); // Escape in Preferences' search field with text clears the field first. if !popup && pressed_anew(ctx, Key::Escape) && !crate::prefs_dialog::clear_search_on_escape(app, ctx) { diff --git a/docs/roadmap.md b/docs/roadmap.md index 1cf18c3ce3..1f89391864 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -159,7 +159,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Rename artboards in Export for Screens (#7275); Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c6972468cc..9f3d8b94b8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -806,6 +806,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Batch… over data sets offers None, Save and Close and Folder as the destination, as for a folder source. Save and Close saves the open document over its own file after each data set, so the file keeps only the last data set, unless a recorded Save step that Override Save points into the destination folder first makes each data set's file the document's own. A recorded Save, Save a Copy, Save as Template or export step that writes a file and that the batch does not point into the destination folder (destination None, Override Save or Override Export off, or a copy, template, PDF, EPS, DXF, Save for Web or Save for Office Documents step) also writes the same file for every data set. In either case the dialog shows a warning and `action.batch` returns a `warning`. Unless a recorded Save or Save As step writes a file and makes it the document's own first, `action.batch` refuses Save and Close over the data sets of a document that was never saved, was converted from an older version or was restored by Data Recovery. - Export for Screens: the dialog's Example line shows the path of the file the export writes first, sub-folder included, named as the export names it, for the first chosen artboard, asset, layer or object (`document.exportForScreens`). - Export presets: the Asset Export panel menu lists the export presets of Export for Screens, with Import Preset… and Export Preset…, which read and write `.vcexportpresets` files (`document.exportPresets.import`, `document.exportPresets.export`). An imported preset whose name is taken gets a number. In the panel, a preset applies to the shared export settings (`assets.settings.set {exportPreset}`), and Save Preset… saves them. With Create Sub-folders off, the panel's Export writes every file into the folder itself, mobile and density rows included. + - Export for Screens artboard names can be double-clicked to rename them in grid or list view (#7275). Enter, a click outside the field or switching to Assets commits the name; Escape cancels the edit. The dialog stays open, checked artboards stay selected and the rename is undoable. The typed name goes to the artboard that was double-clicked even when the artboards change order while it is typed; the field closes when its artboard is deleted or another document becomes active. Evidence: `dialogs::tests_screens::double_clicking_an_artboard_name_in_export_for_screens_renames_it`, `dialogs::tests_screens::switching_export_tabs_commits_an_artboard_name`, `dialogs::tests_screens::an_artboard_name_follows_its_artboard_while_it_is_typed` and `dialogs::tests_screens::an_artboard_name_stays_with_its_document`. - Asset Export panel menu: Create Sub-folders (on by default) and Open Location after Export are check items that set the dialog's two checkboxes (`assets.settings.set {subfolders, openLocation}`), and the panel's Export follows them, including a folder per format or item that Export for Screens remembers (3D assets' mesh files go in the folder itself); Format Settings… opens Export for Screens on the first row's format settings ([Asset Export panel](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/collect-and-export-assets-from-asset-export-panel.html)). Tests: `panels::tests_asset_export`. - Export for Screens is laid out as the reference app's dialog: on the left the Artboards and Assets tabs (thumbnails with their checkboxes, number and name, in a grid or a list), Clear Selection and Prefix; on the right Select (All, Range, Include Bleed, Include Artboard Fill, Full Document), Export to, Open Location after Export, Create Sub-folders by Scale or by Format (on by default), Export PDFs As (Single File writes the chosen artboards as the pages of one PDF named after the document, `pdfs: "single"`), and the format rows with the iOS and Android buttons, the format presets (☰) and Format Settings; under them, how many items are selected and how many files the export writes. File name patterns, the Layers and Selection sources and per-item folders stay available through `document.exportForScreens` (scripts and MCP). Tests: `dialogs::tests_screens` (layout, iOS/Android, Clear Selection, sub-folders, Single File) and `fileio::tests_screens::single_file_pdfs_hold_every_chosen_artboard`. - Save for Web: Preview in Browser writes each preview's page and images to a folder of its own inside `VectorCraft Save for Web` in the system's temporary folder. A later preview, from the same app or another one running, does not overwrite an earlier one. Each preview first removes the preview folders there that are an hour old or older and, on Linux, macOS and FreeBSD, belong to the current user; it leaves other files and folders alone. The folders a preview makes there are open to their owner only on those systems. When `VectorCraft Save for Web` is a link or is not a folder, or on those systems belongs to another user or can be written by other users, the preview goes to a new folder of its own in the temporary folder, which a later preview removes once it is an hour old (`file.saveForWeb.browser`).