From 3dc0a1563ad2a74618386124d2baf30770ca3b5b Mon Sep 17 00:00:00 2001 From: japbcoelho <319161831+japbcoelho@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:39:40 -0300 Subject: [PATCH 1/3] Place: PDF pages and SVGs keep their exact size (#511) file.place sized a placed PDF page (and SVG) from its size rounded to whole points, while the export draws the page into that size: a page of 91.774 x 613.276 pt became 92 x 613 and was drawn 0.25 % wider and 0.3 % shorter, so pieces laid edge to edge left gaps of up to 1.9 pt. A 100.4 x 50.4 pt page placed 100.4 pt wide came out 50.2 pt tall: `Rect { x0: 0.0, y0: 0.0, x1: 100.4, y1: 50.2 }`. The graphic's natural size is now the exact page (crop box) or SVG size; the rounded pixel size stays for display and preflight. place.load (the place cursor) gets the same exact size. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 26 +++++++++++++++++++++++--- crates/render/src/lib.rs | 9 +++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f871a43e8..3a2885ce3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -313,8 +313,9 @@ fn file_place(s: &mut Session, p: &Value) -> Result { .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, }; - // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. - let (nw, nh) = eps_size.unwrap_or((pw as f64, ph as f64)); + // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages + // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. + let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); // Image Import Options › Crop to: the frame shows that box of the PDF page. let crop_box = match str_param(p, "pdfCrop") { Some(k) if designcraft_render::is_pdf(&bytes) => { @@ -440,6 +441,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option) fn place_load(s: &mut Session, p: &Value) -> Result { let (bytes, name, link) = read_source(p)?; let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; + let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64)); let aid = s.edit(|d, _| { let aid = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); @@ -449,7 +451,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result { ); Ok(aid) })?; - s.loaded = Some((aid, (pw as f64, ph as f64))); + s.loaded = Some((aid, natural)); s.set_tool("placeGun"); Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph})) } @@ -689,6 +691,24 @@ mod pdf_crop_tests { assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); } + + #[test] + fn placed_pdf_keeps_its_exact_page_size() { + // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = &t.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let b = it.bounds(); + assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; + assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); + } } #[cfg(test)] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..b0f8df133 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1022,6 +1022,15 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { designcraft_images::pixel_size(bytes) } +/// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster +/// in pixels. Placed graphics are drawn at this size; `image_size` rounds it to whole pixels for display. +pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> { + if is_pdf(bytes) { + return pdf_page_size(bytes, page).map(|(w, h)| (w.max(1e-3), h.max(1e-3))); + } + designcraft_images::natural_size(bytes) +} + /// MIME type guess for encoded image bytes. /// Is this a PDF file (placed PDFs are graphics)? pub fn is_pdf(bytes: &[u8]) -> bool { From 2b46ca664cab8c22ffa396f20f6c51948f3ecd8d Mon Sep 17 00:00:00 2001 From: japbcoelho <319161831+japbcoelho@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:39:53 -0300 Subject: [PATCH 2/3] Hyphenation: Portuguese text is hyphenated with hyph-utf8's Portuguese patterns (#512) Text set in Portuguese ("Portuguese: Brazilian", "Portuguese", pt_BR) got no hyphenation, so justified Portuguese columns could only stretch their word spaces (up to 335 % of the normal space in a 56 mm column of 10 pt Times). Turn on hypher's "portuguese" feature, beside Spanish: hyph-pt.tex 1.4 (2024-07-13) from hyph-utf8, BSD-3-Clause, credited in NOTICE. Lang::for_language already maps the Portuguese names and locale codes. Co-authored-by: Claude Opus 5.5 --- NOTICE | 20 ++++++++++++++++++++ crates/compose/Cargo.toml | 2 +- crates/compose/src/hyphen/mod.rs | 22 +++++++++++++++++++++- crates/compose/src/tests.rs | 17 +++++++++++++++++ docs/target-app-parity.md | 2 +- 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/NOTICE b/NOTICE index 8b67a99e1..087bb6196 100644 --- a/NOTICE +++ b/NOTICE @@ -32,6 +32,26 @@ list (author, source, license) is in ASSETS.md. AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +- Portuguese hyphenation patterns, embedded through the hypher crate (feature "portuguese"): + hyph-pt.tex 1.4 from hyph-utf8, https://github.com/hyphenation/tex-hyphen. + Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, + 2024 Leonardo Araujo and Aline Benevides. BSD 3-clause licence: + Redistribution and use in source and binary forms, with or without modification, are permitted + provided that the following conditions are met: * Redistributions of source code must retain the + above copyright notice, this list of conditions and the following disclaimer. * Redistributions in + binary form must reproduce the above copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided with the distribution. * Neither + the name of the University of Campinas, of the University of Minho nor the names of its + contributors may be used to endorse or promote products derived from this software without + specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + EVENT SHALL PEDRO J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - Japanese fonts (craft-fonts): builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho, diff --git a/crates/compose/Cargo.toml b/crates/compose/Cargo.toml index 012868835..b8993bcfb 100644 --- a/crates/compose/Cargo.toml +++ b/crates/compose/Cargo.toml @@ -18,7 +18,7 @@ designcraft-fonts = { workspace = true } log = { workspace = true } regex = { workspace = true } miniz_oxide = "0.8" -hypher = { version = "0.1.8", default-features = false, features = ["alloc", "spanish"] } +hypher = { version = "0.1.8", default-features = false, features = ["alloc", "portuguese", "spanish"] } [dev-dependencies] designcraft-fonts = { workspace = true, features = ["testing"] } diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen/mod.rs index 22644eb70..e21ade031 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen/mod.rs @@ -1,6 +1,7 @@ //! Hyphenation. English uses the bundled dictionary and patterns below. Other languages use the //! Liang patterns compiled into `hypher`, one cargo feature per language: Spanish is Javier Bezos's -//! `hyph-es.tex` 5.0 from hyph-utf8, MIT (see NOTICE). +//! `hyph-es.tex` 5.0 from hyph-utf8, MIT; Portuguese is `hyph-pt.tex` 1.4 from hyph-utf8 (Pedro J. +//! de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides), BSD-3-Clause (see NOTICE). //! //! English lookup order for each word: //! 1. the **dictionary** ([`Dictionary::en_us`]) — the public-domain Moby Hyphenator II word list @@ -389,6 +390,25 @@ mod tests { assert!(Lang::for_language("French").is_none(), "only languages built in"); } + #[test] + fn portuguese_uses_the_hyph_pt_patterns() { + let pt = Lang::for_language("Portuguese: Brazilian").unwrap(); + assert_eq!(pt, Lang::for_language("Portuguese").unwrap()); + assert_eq!(pt, Lang::for_language("pt_BR").unwrap()); + assert_ne!(pt, Lang::for_language("Spanish").unwrap()); + // hyph-pt's own minimum: three letters after the last break. + let lim = Limits { before_last: 3, ..Limits::default() }; + let h = |w: &str| { + let pts = hyphen_points_in(w, &lim, pt); + w.chars().enumerate().map(|(i, c)| if pts.contains(&i) { format!("-{c}") } else { c.to_string() }).collect::() + }; + assert_eq!(h("matemática"), "ma-te-má-tica"); + assert_eq!(h("orientação"), "ori-en-ta-ção"); + assert_eq!(h("Universidade"), "Uni-ver-si-dade"); + assert_eq!(h("experiência"), "ex-pe-ri-ên-cia"); + assert_eq!(h("pós-graduação"), "pós-gra-du-a-ção", "each part of a compound"); + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d27..f5d0113c1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1371,6 +1371,23 @@ fn spanish_text_gets_spanish_hyphenation() { } } +#[test] +fn portuguese_text_gets_portuguese_hyphenation() { + let text = "matemática matemática matemática matemática matemática matemática"; + let (mut d, sid, _) = doc_with(text, Rect::new(36.0, 36.0, 76.0, 700.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + let n = st.len(); + st.format_chars(0..n, |f| f.over.language = Some("Portuguese: Brazilian".into())); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let breaks: Vec<&str> = all_lines(&cs).into_iter().filter(|l| l.hyphenated).map(|l| text[l.range.clone()].trim_end()).collect(); + assert!(!breaks.is_empty(), "Portuguese text is hyphenated"); + // Portuguese syllables: ma-te-má-tica. + for b in breaks { + let tail = b.rsplit(' ').next().unwrap_or(b); + assert!(["ma", "mate", "matemá"].contains(&tail), "{b:?}"); + } +} + #[test] fn cjk_aki_adds_space_without_scaling_outlines() { let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..ac553fe24 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -346,7 +346,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. - View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). - Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. -- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. +- Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) and Portuguese with hyph-utf8's `hyph-pt.tex` 1.4 (BSD-3-Clause), both through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. From 66860bd1de915971b1da9a84530ddeabdae92e80 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:40:06 -0400 Subject: [PATCH 3/3] UI: Return and Escape in the font menu stay in the menu, never reach the dialog under it (#447) Every font field's menu (Control bar, Contextual Task Bar, Properties > Character, Character and Glyphs panels, Paragraph/Character Style Options, Find/Replace Font) keeps its search focused and open on a click and filters as you type. In a dialog, Return picked the highlighted family and also pressed OK, and Escape closed the menu and cancelled the dialog: the menu now consumes the keys it uses. The drawn font fields report themselves as combo boxes to screen readers. --- crates/ui-egui/src/panels/mod.rs | 129 +++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 05a73395b..581f6747e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -251,8 +251,9 @@ struct FontMenuState { /// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard /// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites /// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move -/// through the matches and Return picks one. Returns the family picked and closes the menu; the -/// search, Favorites and the stars keep it open (the popup closes on a click outside only). +/// through the matches, Return picks one and Escape closes the menu; none of them reach a dialog +/// under the menu. Returns the family picked and closes the menu; the search, Favorites and the +/// stars keep it open (the popup closes on a click outside only). pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option { let (fonts, scope) = (fonts(app), font_scope(app)); let english = app.session.prefs.show_font_names_in_english; @@ -293,7 +294,12 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf if search.changed() { st.highlight = if q.is_empty() { None } else { Some(0) }; } - let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter))); + // The menu owns these keys while it is open: a dialog under it must not take Return as OK or + // Escape as Cancel. + let (down, up, enter, escape) = ui.input_mut(|i| { + let mut key = |k| i.consume_key(egui::Modifiers::NONE, k); + (key(egui::Key::ArrowDown), key(egui::Key::ArrowUp), key(egui::Key::Enter), key(egui::Key::Escape)) + }); let last = shown.len().checked_sub(1); st.highlight = match (st.highlight, last) { (_, None) => None, @@ -374,7 +380,7 @@ pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraf if favs_changed { let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); } - if pick.is_some() { + if pick.is_some() || escape { ui.close(); } pick @@ -404,6 +410,12 @@ pub fn font_combo( /// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family /// picked. pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { + // Screen readers (and tests) see the field as the combo box it looks like. + field.widget_info(|| { + let mut info = egui::WidgetInfo::new(egui::WidgetType::ComboBox); + info.current_text_value = Some(if current.is_empty() { "—".to_string() } else { font_label(app, menu, current) }); + info + }); egui::Popup::menu(field) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| font_menu_body(app, ui, menu, current, field.rect.width())) @@ -506,9 +518,22 @@ mod font_menu_ui_tests { /// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the /// story. fn typing() -> (Harness<'static, Window>, u64) { + typing_in("Advanced") + } + + /// [`typing`] in the workspace `name`. + fn typing_in(name: &str) -> (Harness<'static, Window>, u64) { + typing_on(name, vec2(1440.0, 900.0), false) + } + + /// [`typing_in`] a window of `size`, with the macOS window chrome (the native menu bar and the + /// title bar inside the window) when `mac`. + fn typing_on(name: &str, size: egui::Vec2, mac: bool) -> (Harness<'static, Window>, u64) { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.ui.control_bar = true; - let mut h = test_window::open(app, vec2(1440.0, 900.0)); + app.run("window.workspace", json!({ "name": name })).unwrap(); + app.native_menu = mac; + app.integrated_titlebar = mac; + let mut h = test_window::open(app, size); let app = &mut h.state_mut().app; let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap(); app.run("tool.select", json!({"tool": "type"})).unwrap(); @@ -523,7 +548,7 @@ mod font_menu_ui_tests { /// The Control bar's font field, showing `family`. fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect { - h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect() + fields(h, family)[0] } /// The text in the focused search field. @@ -582,4 +607,94 @@ mod font_menu_ui_tests { assert_eq!(story_text(&mut h, story), before); assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected"); } + + /// The font fields showing `family`, left to right and top to bottom. + fn fields(h: &Harness<'static, Window>, family: &str) -> Vec { + let mut r: Vec = + h.query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).map(|n| n.rect()).collect(); + r.sort_by(|a, b| a.min.x.total_cmp(&b.min.x).then(a.min.y.total_cmp(&b.min.y))); + r + } + + /// Opens the font menu of the field at `at`, clicks its search field, types, checks the menu + /// stays open and lists only the matches, then picks the first with ↓ and Return. + fn search_in_menu(h: &mut Harness<'static, Window>, at: egui::Rect, what: &str) { + click_at(h, at.center()); + let field = h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).map(|n| n.rect()); + let field = field.unwrap_or_else(|| panic!("{what}: the menu opens with the search field focused")); + click_at(h, field.center()); + assert!(h.ctx.text_edit_focused(), "{what}: a click on the search field keeps the menu open"); + h.event(egui::Event::Text("SOURCE SA".into())); + h.run_steps(4); + assert_eq!(search(h), "SOURCE SA", "{what}: the typing went to the search"); + assert!(h.query_by_label("Source Sans 3").is_some(), "{what}: a match is listed"); + assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "{what}: families that don't match aren't"); + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "{what}: the menu closed"); + } + + /// Every font field over selected text (Control bar, Contextual Task Bar, Properties › + /// Character, Character panel) searches, in the workspaces that show them and in a small + /// window with the macOS window chrome. + #[test] + fn every_font_field_searches_in_every_workspace() { + for (name, size, mac, panel) in [ + ("Advanced", vec2(1440.0, 900.0), false, None), + ("Printing and Proofing", vec2(1440.0, 900.0), false, None), + ("Essentials", vec2(1440.0, 900.0), false, Some("character")), + ("Printing and Proofing", vec2(900.0, 560.0), true, None), + ] { + let (mut h, story) = typing_on(name, size, mac); + if panel.is_some() { + h.state_mut().app.ui.open_panel = panel.map(str::to_string); + h.run_steps(4); + } + let name = &format!("{name} {size:?} {panel:?}"); + let before = story_text(&mut h, story); + let count = fields(&h, designcraft_fonts::DEFAULT_FAMILY).len(); + assert_eq!(count, 3, "{name}: the font fields are shown"); + for k in 0..count { + h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap(); + h.run_steps(4); + let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k]; + search_in_menu(&mut h, at, &format!("{name}, field {k}")); + assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}"); + assert_eq!(story_text(&mut h, story), before, "{name}, field {k}"); + } + } + } + + /// The font fields away from the text: the Glyphs panel, Paragraph Style Options › Basic + /// Character Formats and Find/Replace Font's Replace With. + #[test] + fn font_fields_in_panels_and_dialogs_search() { + let paragraph = json!({"name": designcraft_doc::BASIC_PARAGRAPH, "section": "chars"}); + for (what, panel, dialog, shown) in [ + ("Glyphs", Some("glyphs"), None, designcraft_fonts::DEFAULT_FAMILY), + ("Paragraph Style Options", None, Some(("paragraphStyleOptions", paragraph)), designcraft_fonts::DEFAULT_FAMILY), + ("Find/Replace Font", None, Some(("findFont", json!({}))), "—"), + ] { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = test_window::open(app, vec2(1440.0, 900.0)); + let dialog_id = dialog.as_ref().map(|(id, _)| id.to_string()); + h.state_mut().app.ui.open_panel = panel.map(str::to_string); + h.state_mut().app.ui.dialog = dialog.map(|(id, fields)| crate::dialogs::Dialog::new(id, fields)); + h.run_steps(4); + let at = *fields(&h, shown).first().unwrap_or_else(|| panic!("{what}: the font field is shown")); + search_in_menu(&mut h, at, what); + assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Return in the menu leaves the dialog open"); + assert!(!fields(&h, "Source Sans 3").is_empty(), "{what}: the field shows the family picked"); + // Escape closes the menu only. + let at = fields(&h, "Source Sans 3")[0]; + click_at(&mut h, at.center()); + assert!(h.ctx.text_edit_focused(), "{what}: the menu opened"); + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "{what}: Escape closed the menu"); + assert_eq!(h.state().app.ui.dialog.as_ref().map(|d| d.id.clone()), dialog_id, "{what}: Escape in the menu leaves the dialog open"); + } + } }