//! Small custom widgets built on the design tokens. use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, vec2}; use crate::PdfCraftApp; use crate::theme::{self, Tokens}; pub(super) mod icon_data; pub mod icons; /// Horizontal and vertical inset of a single-line field. const FIELD_MARGIN: egui::Margin = egui::Margin::symmetric(10, 6); /// Where text starts inside a [`line`] or [`block`] field, so plain values beside fields can line up with it. pub(crate) const FIELD_TEXT_INSET: f32 = FIELD_MARGIN.left as f32; /// Outer height of a single-line field, including its inset. const FIELD_MIN_HEIGHT: f32 = 30.0; /// Padding inside every modal. const DIALOG_PAD_X: i8 = 20; const DIALOG_PAD_Y: i8 = 16; fn dialog_frame(ctx: &egui::Context) -> egui::Frame { let t = Tokens::get(ctx); egui::Frame::new() .fill(t.card) .stroke(Stroke::new(1.0, t.border)) .corner_radius(CornerRadius::same(10)) .inner_margin(egui::Margin::symmetric(DIALOG_PAD_X, DIALOG_PAD_Y)) .shadow(ctx.global_style().visuals.window_shadow) } /// A modal with the card fill, a 10-point corner and room around its contents. pub fn modal(ctx: &egui::Context, id: impl std::hash::Hash + std::fmt::Debug) -> egui::Modal { egui::Modal::new(egui::Id::unique(id)).frame(dialog_frame(ctx)) } /// Show [`modal`], outline its controls, and remember the card rect for layout checks. pub fn show_modal(ctx: &egui::Context, id: &str, content: impl FnOnce(&mut egui::Ui) -> R) -> egui::ModalResponse { show_modal_sized(ctx, id, false, content) } /// [`show_modal`], growing from its contents' own height when `remeasure` is set rather than /// from the height it had last frame: one modal id that shows different contents would /// otherwise keep the height of the previous ones, since rows that fill the height offered to /// them take all of it (#5118). The width stays, and the contents stay visible (no hidden /// sizing frame), so the control channel can inspect a dialog the frame it opens. pub fn show_modal_sized(ctx: &egui::Context, id: &str, remeasure: bool, content: impl FnOnce(&mut egui::Ui) -> R) -> egui::ModalResponse { let stored = egui::Id::unique(id); if remeasure && let Some(mut state) = egui::AreaState::load(ctx, stored) { state.size = state.size.map(|s| egui::vec2(s.x, 0.0)); ctx.memory_mut(|m| m.areas_mut().set_state(egui::Modal::default_area(stored).layer(), state)); // The card is centred on the height it ends with: draw once more at that height. ctx.request_repaint(); } modal(ctx, id).show(ctx, |ui| { begin_dialog(ui); let inner = content(ui); // The frame's stroke sits outside this content rect; the stored card is the fill. let card = ui.min_rect().expand2(egui::vec2(f32::from(DIALOG_PAD_X), f32::from(DIALOG_PAD_Y))); ui.ctx().data_mut(|d| d.insert_temp(stored.with("card"), card)); inner }) } /// Outlined controls at a shared 30-point height, with a focus stroke and 4-point corners. /// /// Dialog fills match the theme's field colour, so radio buttons, check boxes, combo boxes /// and number fields need the border or they disappear into the card. pub fn begin_dialog(ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let w = &mut ui.visuals_mut().widgets; w.inactive.bg_stroke = Stroke::new(1.0, t.border); w.inactive.weak_bg_fill = t.field; // Slider rails and check-box interiors use the plain fill. w.inactive.bg_fill = t.hover; w.hovered.bg_stroke = Stroke::new(1.0, t.text_muted); for state in [&mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { state.corner_radius = CornerRadius::same(4); } ui.visuals_mut().selection.stroke = Stroke::new(1.5, t.accent); ui.spacing_mut().interact_size.y = FIELD_MIN_HEIGHT; } /// A single-line field tall enough to read and to click. pub fn line(text: &mut dyn egui::TextBuffer) -> egui::TextEdit<'_> { egui::TextEdit::singleline(text).margin(FIELD_MARGIN).min_size(vec2(0.0, FIELD_MIN_HEIGHT)) } /// A multiline field with the same horizontal inset as [`line()`]. pub fn block(text: &mut dyn egui::TextBuffer) -> egui::TextEdit<'_> { egui::TextEdit::multiline(text).margin(egui::Margin::symmetric(10, 6)) } /// One line that stays within `max_width`, cut on a character boundary with an ellipsis. /// Only the first 160 characters are measured, so a huge name cannot lay out thousands of candidates. pub(crate) fn fit_line(text: &str, max_width: f32, mut width_of: impl FnMut(&str) -> f32) -> String { if !max_width.is_finite() || max_width <= 0.0 { return String::new(); } let mut s: String = text.chars().take(160).collect(); if s.len() == text.len() && width_of(text) <= max_width { return text.to_owned(); } loop { s.pop(); if s.is_empty() { return if width_of("\u{2026}") <= max_width { "\u{2026}".into() } else { String::new() }; } let candidate = format!("{s}\u{2026}"); if width_of(&candidate) <= max_width { return candidate; } } } pub(crate) fn text_width(ui: &egui::Ui, text: &str, font: &egui::FontId) -> f32 { ui.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::WHITE).size().x) } /// Draw `text` from `pos` (left, vertically centered) so it never runs past `max_width`. pub(crate) fn paint_left(ui: &egui::Ui, pos: egui::Pos2, text: &str, font: egui::FontId, color: Color32, max_width: f32) { let shown = fit_line(text, max_width, |s| text_width(ui, s, &font)); let clip = Rect::from_min_size(egui::pos2(pos.x, pos.y - 14.0), vec2(max_width.max(0.0), 28.0)); ui.painter().with_clip_rect(clip).text(pos, Align2::LEFT_CENTER, shown, font, color); } /// A mode-bar tab: text with an underline when active. pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) }; let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); // Keep in step with `mode_tab_width`. let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, label)); let press = theme::Press::track(ui, &resp); let well = rect.shrink2(vec2(2.0, 9.0)); if active { if press.down > 0.01 { ui.painter().rect_filled(well, CornerRadius::same(6), t.pressed.gamma_multiply(press.down)); } } else { press.wash(ui, well, 6, false); } let body = rect.translate(press.offset()); ui.painter().text(body.center(), Align2::CENTER_CENTER, label, font, if active { t.text } else { t.text_muted }); if active { let r = Rect::from_min_max(rect.left_bottom() + vec2(10.0, -2.0), rect.right_bottom() - vec2(10.0, 0.0)); ui.painter().rect_filled(r, CornerRadius { nw: 1, ne: 1, sw: 0, se: 0 }, t.accent); } theme::hand(resp) } /// Width [`mode_tab`] takes for `label` (the semibold, active, form is the widest). pub fn mode_tab_width(ui: &egui::Ui, label: &str) -> f32 { let t = Tokens::get(ui.ctx()); ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), theme::semibold(13.5), t.text).size().x) + 22.0 } /// Rounded pill button; `primary` fills with the accent. pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { pill_button_marked(ui, label, primary, false) } /// [`pill_button`] that, when `marked`, is the keyboard's choice: it takes egui focus (so /// assistive tech follows it) and shows a ring in the accent colour around the pill. pub fn pill_button_marked(ui: &mut egui::Ui, label: &str, primary: bool, marked: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); let text_color = if primary { Color32::WHITE } else { t.text }; // Dialog buttons are translated UI text: an explicit RTL base keeps an Arabic label right // to left even when its first strong character is Latin or a placeholder. let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 26.0, 28.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); let (fill, stroke) = if primary { (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE) } else { (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border)) }; ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside); if marked { resp.request_focus(); ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(16), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); } let body = rect.translate(press.offset()); ui.painter().galley(body.center() - label_galley.size() / 2.0, label_galley, text_color); theme::hand(resp) } /// Icon + label, transparent until hovered. pub fn ghost_button(ui: &mut egui::Ui, icon: &str, label: &str) -> Response { ghost_button_sized(ui, icon, label, 30.0) } /// [`ghost_button`] at a given height (the macOS title bar is shorter than the tab strip elsewhere). pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f32) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(13.0); let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, t.text); let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, height), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, (height - 18.0) / 2.0), vec2(18.0, 18.0)), icon, 17.0, t.icon); ui.painter().galley(body.left_center() + vec2(30.0, -label_galley.size().y / 2.0), label_galley, t.text); theme::hand(resp) } /// A search-field lookalike that opens the command palette. pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), placeholder)); let press = theme::Press::track(ui, &resp); let fill = press.fill(t.field, t.hover, t.pressed); let edge = t.border.lerp_to_gamma(t.text_muted, press.hover * 0.35); ui.painter().rect(rect, CornerRadius::same(16), fill, Stroke::new(1.0, edge), egui::StrokeKind::Inside); icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 8.0), vec2(16.0, 16.0)), "search", 15.0, t.text_muted); let shortcut = crate::commands::command_shortcut_label(ui.ctx(), "view.palette"); let shortcut = ui.painter().layout_no_wrap(shortcut, theme::regular(11.5), t.text_faint); let shortcut_pos = rect.right_center() - vec2(12.0 + shortcut.size().x, shortcut.size().y * 0.5); // Reserve the measured shortcut width in every language; long translations are elided // within the remaining space rather than painted underneath the shortcut. let text_right = (shortcut_pos.x - 10.0).max(rect.left()); let text_left = (rect.left() + 34.0).min(text_right); let text_rect = Rect::from_min_max(egui::pos2(text_left, rect.top()), egui::pos2(text_right, rect.bottom())); let mut job = egui::text::LayoutJob::simple_singleline(placeholder.to_owned(), theme::regular(13.0), t.text_faint); job.direction = crate::bidi::ui_direction(); job.wrap = egui::text::TextWrapping { max_width: text_rect.width(), max_rows: 1, break_anywhere: true, ..Default::default() }; let text = ui.fonts_mut(|f| f.layout_job(job)); let elided = text.elided; ui.painter().with_clip_rect(text_rect).galley(egui::pos2(text_left, rect.center().y - text.size().y * 0.5), text, t.text_faint); ui.painter().with_clip_rect(rect).galley(shortcut_pos, shortcut, t.text_faint); let resp = resp.on_hover_cursor(egui::CursorIcon::Text); if elided { resp.on_hover_text(placeholder) } else { resp } } /// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px /// box is a circle, the shape of a radio button, and View ▸ Page display has both. const CHECKBOX_RADIUS: u8 = 3; /// A checkbox whose box is an outlined rounded square, never mistaken for a radio button. Every /// checkbox goes through here: clippy.toml disallows `ui.checkbox` and `egui::Checkbox`. pub fn checkbox<'a>(checked: &'a mut bool, atoms: impl egui::IntoAtoms<'a>) -> impl egui::Widget + 'a { #[expect(clippy::disallowed_methods, reason = "the one place that styles the box")] let widget = egui::Checkbox::new(checked, atoms); toggle(widget, Some(CHECKBOX_RADIUS)) } /// [`checkbox`] without a label: the selection box on Organize Pages thumbnails. pub fn checkbox_without_text(checked: &mut bool) -> impl egui::Widget + '_ { #[expect(clippy::disallowed_methods, reason = "the one place that styles the box")] let widget = egui::Checkbox::without_text(checked); toggle(widget, Some(CHECKBOX_RADIUS)) } /// A radio button with an outline and a fill, like [`checkbox`]. Every radio button goes through /// here or [`radio_value`]: clippy.toml disallows `ui.radio`, `ui.radio_value` and /// `egui::RadioButton`. pub fn radio<'a>(selected: bool, atoms: impl egui::IntoAtoms<'a>) -> impl egui::Widget + 'a { #[expect(clippy::disallowed_methods, reason = "the one place that styles the circle")] let widget = egui::RadioButton::new(selected, atoms); toggle(widget, None) } /// [`radio`] selected while `*current == alternative`; a click selects it (`Ui::radio_value`). pub fn radio_value<'a, V: PartialEq>(ui: &mut egui::Ui, current: &mut V, alternative: V, atoms: impl egui::IntoAtoms<'a>) -> Response { let mut response = ui.add(radio(*current == alternative, atoms)); if response.clicked() && *current != alternative { *current = alternative; response.mark_changed(); } response } /// Keeps the end of a "from … to …" page range at or after its start, but not while `typing` into the /// end's `DragValue` ([`typing_into`]): the half-typed "1" of "12" is below `from`, and raising it /// then replaced what the user was typing with `from` (#5574). A dialog's OK orders it whatever has /// the keyboard (`typing` false), so a range left reversed never reaches the edit. pub fn keep_range_ordered(from: usize, to: &mut usize, typing: bool) { if !typing { *to = (*to).max(from); } } /// Whether `field` is being typed into: it has the keyboard focus in egui's memory. Unlike /// `Response::has_focus` this stays true while the window is in the background, so switching to /// another app mid-number doesn't end the typing. pub fn typing_into(field: &Response) -> bool { field.ctx.memory(|m| m.has_focus(field.id)) } /// The box of a checkbox or the circle of a radio button. egui draws it with the widgets' visuals /// and has no style of its own for it, so it is set in a scope around the widget, keeping what /// the caller set (dialogs and the search panel already outline theirs): /// - an outline, darker on hover; /// - on the light theme, the plain fill (`Tokens::hover`), as dialogs have: the field colour is /// the panels' white, so the box would vanish; /// - for a checkbox, a corner radius small enough to keep the box square. fn toggle<'a>(widget: impl egui::Widget + 'a, radius: Option) -> impl egui::Widget + 'a { move |ui: &mut egui::Ui| { ui.scope(|ui| { let t = Tokens::get(ui.ctx()); let w = &mut ui.visuals_mut().widgets; if let Some(radius) = radius { for v in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { v.corner_radius = CornerRadius::same(radius); } } if !t.dark() && w.inactive.bg_fill == t.field { w.inactive.bg_fill = t.hover; } if w.inactive.bg_stroke.is_empty() { w.inactive.bg_stroke = Stroke::new(1.0, t.border); } if w.hovered.bg_stroke.is_empty() { w.hovered.bg_stroke = Stroke::new(1.0, t.text_muted); } ui.add(widget) }) .inner } } /// Draw a panel section heading with explicit paragraph direction and letter spacing. pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. // The paragraph direction is explicit: an Arabic title beginning with Latin or a placeholder // must not inherit a left-to-right base from its first strong character. let mut job = egui::text::LayoutJob::simple(crate::i18n::upper_label(text), theme::semibold(10.5), t.text_faint, ui.available_width().max(0.0)); job.direction = crate::bidi::ui_direction(); if let Some(section) = job.sections.first_mut() { section.format.extra_letter_spacing = 0.6; } let galley = ui.painter().layout_job(job); ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)); ui.add_space(2.0); } /// Paint one line of `text` left-aligned and vertically centred on `pos`, cut with "…" so it is /// at most `max_width` wide: a long file name or path stays inside its row (#425). pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into, font: egui::FontId, color: Color32, max_width: f32) { let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); // A file name or path: an Arabic name keeps its extension at the visual end, as in the tab. job.direction = egui::epaint::text::TextDirection::LeftToRight; job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.max(0.0)); let galley = ui.painter().layout_job(job); let rect = Align2::LEFT_CENTER.anchor_size(pos, galley.size()); ui.painter().galley(rect.min, galley, color); } /// A wrapping label for a file name or folder path, laid out explicitly left to right so an /// Arabic name keeps `.pdf` at the end. The accessibility name is the logical text. pub fn label_ltr(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { let mut job = egui::text::LayoutJob::simple(text.into(), font, color, ui.available_width().max(0.0)); job.direction = egui::epaint::text::TextDirection::LeftToRight; let galley = ui.painter().layout_job(job); ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) } /// [`label_ltr`] for one table row: the name is cut with an ellipsis instead of wrapping onto a /// second row. The logical text remains the accessibility name. pub fn label_ltr_truncated(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { let text = text.into(); let width = ui.available_width().max(0.0); let mut job = egui::text::LayoutJob::simple_singleline(text, font, color); job.direction = egui::epaint::text::TextDirection::LeftToRight; job.wrap = egui::text::TextWrapping::truncate_at_width(width); let galley = ui.painter().layout_job(job); ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) } /// Text wrapped to the available width, for text people write (comments, replies). A paragraph /// in a right-to-left script (Persian, Arabic, Hebrew) reads right to left and sits against the /// right edge ([`crate::bidi::wrap`]); text without one is an ordinary wrapping label. /// `truncate` keeps the first line only, with an ellipsis where the text goes on. pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32, truncate: bool) -> Response { if !crate::bidi::has_rtl(text) { let mut label = egui::Label::new(egui::RichText::new(text).font(font).color(color)).wrap(); if truncate { label = label.truncate(); } return ui.add(label); } let width = ui.available_width().max(1.0); let lines = wrapped_lines(ui, text, &font, width, truncate); let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, wrapped_line_galley(ui, line, &font, color))).collect(); let height = galleys.iter().map(|(_, g)| g.size().y).sum(); let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); // Painted text only: the accessibility name keeps the logical order. resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text)); let mut y = rect.top(); for (rtl, galley) in galleys { let x = if rtl { rect.right() - galley.size().x } else { rect.left() }; let h = galley.size().y; ui.painter().galley(egui::pos2(x, y), galley, color); y += h; } resp } /// One wrapped line laid out with its own paragraph direction. A line beginning with a Latin /// word inside a right-to-left comment is still part of that paragraph, so its base direction /// must stay right to left; `Auto` would follow the Latin word instead. fn wrapped_line_galley(ui: &egui::Ui, line: &crate::bidi::Line, font: &egui::FontId, color: Color32) -> std::sync::Arc { let mut job = egui::text::LayoutJob::simple_singleline(line.text.clone(), font.clone(), color); job.direction = if line.rtl { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::LeftToRight }; ui.painter().layout_job(job) } const ELLIPSIS: &str = "\u{2026}"; /// Wrapped right-to-left text kept between frames. Wrapping measures many candidate lines, so /// re-wrapping every comment on every frame would make a panel of long comments slow; the /// result depends only on the text, font, width, scale and collapsed state, so it is looked up. #[derive(Clone, Default)] struct WrapCache { lines: std::collections::HashMap>, } /// How many wrapped texts the cache holds before it starts over (each costs its text's size). const WRAP_CACHE_MAX: usize = 256; /// A collapsed comment shows one line, so no more than this many characters are wrapped for it: /// a long comment doesn't cost a full wrap just to show its start. const COLLAPSED_WRAP_CHARS: usize = 1024; /// The lines `wrapped_text` paints for a right-to-left `text`, from the cache when the same text /// was wrapped the same way before. fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, truncate: bool) -> std::sync::Arc<[crate::bidi::Line]> { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); (text, font, width.to_bits(), ui.ctx().pixels_per_point().to_bits(), truncate).hash(&mut hasher); let key = hasher.finish(); let cache_id = egui::Id::unique("pdfcraft-rtl-wrap-cache"); if let Some(hit) = ui.ctx().data_mut(|d| d.get_temp_mut_or_default::(cache_id).lines.get(&key).cloned()) { return hit; } // Collapsed: only the start is wrapped (cut at a char boundary, so the slice can't fail). let (measured, cut) = match text.char_indices().nth(COLLAPSED_WRAP_CHARS) { Some((at, _)) if truncate => (text.get(..at).unwrap_or(text), true), _ => (text, false), }; let measure = |f: &mut egui::epaint::text::FontsView<'_>, s: &str| f.layout_no_wrap(s.to_owned(), font.clone(), Color32::WHITE).size().x; let mut lines = ui.fonts_mut(|f| { let room = if truncate { width - measure(f, ELLIPSIS) } else { width }; crate::bidi::wrap(measured, |s| measure(f, s) <= room) }); if truncate && (lines.len() > 1 || cut) { lines.truncate(1); if let Some(line) = lines.first_mut() { // The ellipsis goes where the line's reading ends, in logical text: epaint places it // on the left for a right-to-left line and on the right for a left-to-right one. line.text.push_str(ELLIPSIS); } } let lines: std::sync::Arc<[crate::bidi::Line]> = lines.into(); ui.ctx().data_mut(|d| { let cache = &mut d.get_temp_mut_or_default::(cache_id).lines; if cache.len() >= WRAP_CACHE_MAX { cache.clear(); } cache.insert(key, lines.clone()); }); lines } /// Transient message at the bottom centre. pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { // A form script message waiting its turn takes the toast once it is free. if app.toast.is_none() { app.toast_script = None; // A left-out count whose toast was cleared before its time is said again. app.script_messages_dropped = app.script_messages_dropped.saturating_add(std::mem::take(&mut app.toast_dropped)); app.next_script_message(); } let Some((msg, start)) = app.toast.clone() else { return }; let now = ctx.input(|i| i.time); let start = if start == 0.0 { now } else { start }; app.toast = Some((msg.clone(), start)); if now - start > 3.5 { app.toast = None; app.toast_script = None; // Shown its time: a left-out count it said is done. app.toast_dropped = 0; if app.next_script_message() { ctx.request_repaint(); } return; } let script = app.toast_script.as_ref().map(|m| (m.title.clone(), crate::js_ui::message_icon(m.icon))); let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Above the progress notice while a job runs. let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::unique(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; let lift = if card > 0.0 { 28.0 + card + 10.0 } else { 28.0 }; // A running batch keeps its toast up (poll_ocr re-notifies), so it can carry the one // control a long job needs: Cancel — the file in flight finishes, the rest are not run. let batch_running = app.ocr_batch.is_some(); egui::Area::new(egui::Id::unique("toast")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, lift)) .show(ctx, |ui| { egui::Frame::NONE .fill(if t.dark() { Color32::from_rgb(0xEC, 0xEC, 0xEF) } else { Color32::from_rgb(0x2A, 0x2A, 0x2F) }) .corner_radius(CornerRadius::same(8)) .inner_margin(egui::Margin::symmetric(16, 10)) .show(ui, |ui| { // An Area remembers its previous content width. A short notice // must not force the next validation message into a narrow column. // Allow natural short labels; wrap longer ones within the viewport, // including the frame's 32 points and 16-point outside margins. ui.set_max_width((screen.width() - 64.0).clamp(1.0, 560.0)); let ink = if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE }; let text = |ui: &mut egui::Ui| { ui.horizontal_wrapped(|ui| { ui.add(egui::Label::new(egui::RichText::new(msg).color(ink)).wrap()); if batch_running && ui.small_button(crate::i18n::t("Cancel")).clicked() { app.cancel_ocr_batch(); } }); }; match script { // A form script's message: its icon, then its title (when it gave one) // over its text. Some((title, icon)) => { ui.horizontal_top(|ui| { ui.add(icons::image(icon, 16.0, ink)); ui.vertical(|ui| { if let Some(title) = title { ui.add(egui::Label::new(egui::RichText::new(title).strong().color(ink)).wrap()); } text(ui); }); }); } None => text(ui), } }); }); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } /// A background job's progress, shown at the bottom centre until the job is done. #[derive(Clone, Debug, PartialEq)] pub struct ProgressNotice { /// What is happening ("Optimizing… image 3 of 40"). pub label: String, /// From 0 to 1. pub fraction: f32, /// Show a Cancel button. pub cancellable: bool, } /// Where the progress notice keeps its height this frame, for placing the toast above it. const PROGRESS_HEIGHT: &str = "progress-notice-height"; /// Draw [`PdfCraftApp::progress_notice`]: the label, a bar that eases towards the fraction with /// a light sweeping across it, the percentage and Cancel. Returns true when Cancel was clicked. /// With animation effects off ([`crate::motion`]) the bar steps to the fraction and has no sweep. pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { let Some(p) = &app.progress_notice else { return false }; let t = Tokens::get(ctx); let screen = ctx.content_rect(); // The same inverted card as the toast. let (fill, text) = if t.dark() { (Color32::from_rgb(0xEC, 0xEC, 0xEF), Color32::from_rgb(0x22, 0x22, 0x26)) } else { (Color32::from_rgb(0x2A, 0x2A, 0x2F), Color32::WHITE) }; let fraction = if p.fraction.is_finite() { p.fraction.clamp(0.0, 1.0) } else { 0.0 }; let animated = crate::motion::animated(ctx); let shown = ctx.animate_value_with_time(egui::Id::unique("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); let now = ctx.input(|i| i.time); let mut cancel = false; let area = egui::Area::new(egui::Id::unique("progress-notice")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, 28.0)) .show(ctx, |ui| { egui::Frame::NONE.fill(fill).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::symmetric(16, 12)).show(ui, |ui| { let width = 340.0_f32.min(screen.width() - 64.0).max(160.0); ui.set_width(width); ui.horizontal(|ui| { ui.label(egui::RichText::new(&p.label).color(text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(format!("{:.0}%", shown * 100.0)).font(theme::medium(12.0)).color(text.gamma_multiply(0.7))); }); }); ui.add_space(6.0); let (track, resp) = ui.allocate_exact_size(vec2(width, 6.0), Sense::hover()); // The label above names the job; the bar carries the percentage. resp.widget_info(|| { let mut info = egui::WidgetInfo::new(egui::Role::ProgressIndicator); info.value = Some(f64::from(fraction) * 100.0); info }); let painter = ui.painter(); painter.rect_filled(track, CornerRadius::same(3), text.gamma_multiply(0.18)); let done = Rect::from_min_size(track.min, vec2(track.width() * shown, track.height())); if done.width() > 0.5 { painter.rect_filled(done, CornerRadius::same(3), t.accent); // A soft light sweeping along the filled part says the job is alive between // updates. if animated { let band = 48.0; let x = done.left() - band + ((now * 160.0) % f64::from(done.width() + band)) as f32; let clip = painter.with_clip_rect(done); for (i, a) in [0.10_f32, 0.22, 0.10].into_iter().enumerate() { let r = Rect::from_min_size(egui::pos2(x + i as f32 * band / 3.0, done.top()), vec2(band / 3.0, done.height())); clip.rect_filled(r, CornerRadius::ZERO, Color32::WHITE.gamma_multiply(a)); } } } if p.cancellable { ui.add_space(4.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let b = egui::Button::new(egui::RichText::new(tl!("Cancel")).font(theme::medium(12.5)).color(text)).frame(false); cancel = ui.add(b).clicked(); }); } }); }); ctx.data_mut(|d| d.insert_temp(egui::Id::unique(PROGRESS_HEIGHT), area.response.rect.height())); ctx.request_repaint_after(std::time::Duration::from_millis(33)); cancel } /// The ArtCraft wordmark (Storyteller's brand, docs/brand/; not open source), sized to `height`. pub fn artcraft_logo(ui: &mut egui::Ui, height: f32) -> Response { let dark = ui.visuals().dark_mode; let (uri, bytes): (&str, &'static [u8]) = if dark { ("bytes://artcraft-logo-white.svg", include_bytes!("../../../docs/brand/artcraft-logo-white.svg")) } else { ("bytes://artcraft-logo.svg", include_bytes!("../../../docs/brand/artcraft-logo.svg")) }; ui.add(egui::Image::from_bytes(uri, bytes).max_height(height).alt_text("ArtCraft")) } /// The ArtCraft mark (brand blue, works on light and dark), `size` points square. pub fn artcraft_mark(ui: &mut egui::Ui, size: f32) -> Response { ui.add( egui::Image::from_bytes("bytes://artcraft-mark.svg", include_bytes!("../../../docs/brand/artcraft-mark.svg")) .fit_to_exact_size(vec2(size, size)) .alt_text("ArtCraft"), ) } /// Buttons for every community link (`pdfcraft_engine::links`), Discord first and prominent. /// Returns the registry command of the one clicked. pub fn community_links(ui: &mut egui::Ui) -> Option<&'static str> { let mut clicked = None; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); for (i, l) in pdfcraft_engine::links::LINKS.iter().enumerate() { let resp = if i == 0 { icon_pill(ui, l.icon, tl!("Join our Discord"), true) } else { icon_pill(ui, l.icon, tl!(l.label), false) }; if resp.on_hover_text(l.url).clicked() { clicked = Some(l.command); } } }); clicked } /// [`icon_pill`] with a ▾ part at its end that opens a menu of related choices. Returns the /// main button's response and the ▾'s (give it to `egui::Popup::menu`). pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Response, Response) { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, _) = ui.allocate_exact_size(vec2(w + 46.0 + 28.0, 30.0), Sense::hover()); let (main_rect, more_rect) = rect.split_left_right_at_x(rect.right() - 28.0); let main = ui.interact(main_rect, ui.scope_id().with(("split-pill", label)), Sense::click()); let arrow = ui.interact(more_rect, ui.scope_id().with(("split-pill-more", label)), Sense::click()); main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), more)); let radius = CornerRadius::same(15); ui.painter().rect(rect, radius, t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 }; let right = CornerRadius { nw: 0, sw: 0, ne: 15, se: 15 }; let main_press = theme::Press::track(ui, &main); let arrow_press = theme::Press::track(ui, &arrow); for (r, press, corners) in [(main_rect, main_press, left), (more_rect, arrow_press, right)] { let fill = press.fill(Color32::TRANSPARENT, t.hover, t.pressed); if fill.a() > 0 { ui.painter().rect_filled(r.shrink(1.2), corners, fill); } } let body = rect.translate(main_press.offset()); let more_body = more_rect.translate(arrow_press.offset()); ui.painter().vline(more_rect.left(), rect.y_range().shrink(7.0), Stroke::new(1.0, t.text_muted)); crate::icons::paint(ui, Rect::from_min_size(body.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, t.text); ui.painter().text(body.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, t.text); crate::icons::paint(ui, Rect::from_center_size(more_body.center(), vec2(14.0, 14.0)), "chevron-down", 13.0, t.text); (theme::hand(main).on_hover_text(label), theme::hand(arrow).on_hover_text(more)) } /// A pill button with an icon (primary = filled accent). pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(w + 46.0, 30.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); let (fill, stroke, text) = if primary { (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE) } else { (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text) }; ui.painter().rect(rect, CornerRadius::same(15), fill, stroke, egui::StrokeKind::Inside); let body = rect.translate(press.offset()); crate::icons::paint(ui, Rect::from_min_size(body.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, text); ui.painter().text(body.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, text); theme::hand(resp) } #[cfg(test)] mod tests { use super::*; use egui::Pos2; #[test] fn search_placeholder_never_overlaps_the_shortcut() { if !crate::i18n::needs_catalogs() { return; } let previous = crate::i18n::current(); for (code, expected) in [("en", "Ctrl+K"), ("de", "Strg+K")] { crate::i18n::set_current(crate::i18n::Lang::from_code(code).unwrap()); for width in [260.0, 160.0, 90.0] { let ctx = egui::Context::default(); ctx.set_os(egui::os::OperatingSystem::Nix); ctx.set_fonts(theme::font_definitions()); let placeholder = "Werkzeuge und Befehle suchen"; let output = ctx.run_ui(egui::RawInput::default(), |ui| { search_box(ui, placeholder, width); }); let text_shapes: Vec<_> = output .shapes .iter() .filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some((shape.clip_rect, text)) } else { None }) .collect(); let (clip, hint) = text_shapes.iter().find(|(_, text)| text.galley.job.text == placeholder).unwrap(); let (_, shortcut) = text_shapes.iter().find(|(_, text)| text.galley.job.text.contains('K')).unwrap(); assert_eq!(shortcut.galley.job.text, expected); assert!(clip.right() <= shortcut.pos.x - 9.0, "hint must leave a gap before the shortcut at width {width}"); assert!(hint.galley.elided, "the long original German hint must be elided at width {width}"); assert_eq!(hint.galley.rows.len(), 1); output.drop_without_applying_deltas(); } } crate::i18n::set_current(previous); } /// View ▸ Page display mixes radio buttons and checkboxes. With the theme's 6 px widget radius /// egui's 14 px checkbox box drew as a circle, and on the light theme an unticked box or an /// unselected radio button was white on white with no outline, so it couldn't be seen. #[test] fn checkboxes_and_radio_buttons_are_outlined_filled_and_distinct() { for kind in [theme::ThemeKind::Light, theme::ThemeKind::Dark] { let ctx = egui::Context::default(); theme::apply(&ctx, kind); let t = Tokens::for_kind(kind); let side = ctx.global_style().spacing.icon_width; let output = ctx.run_ui(egui::RawInput::default(), |ui| { ui.add(checkbox(&mut false, "Show cover page in two-page view")); ui.add(radio(false, "Single page")); }); let boxes: Vec<_> = output .shapes .iter() .filter_map(|s| match &s.shape { egui::Shape::Rect(r) if (r.rect.width() - side).abs() < 0.5 && (r.rect.height() - side).abs() < 0.5 => Some(r), _ => None, }) .collect(); let circles: Vec<_> = output .shapes .iter() .filter_map(|s| match &s.shape { egui::Shape::Circle(c) if (c.radius * 2.0 - side).abs() < 1.0 => Some(c), _ => None, }) .collect(); assert_eq!((boxes.len(), circles.len()), (1, 1), "{kind:?}: one checkbox box and one radio circle"); let r = boxes[0].corner_radius; let radius = r.nw.max(r.ne).max(r.sw).max(r.se); assert!(f32::from(radius) <= side / 4.0, "{kind:?}: a {side} px box with corner radius {radius} looks like a radio button"); for (what, fill, edge) in [("checkbox", boxes[0].fill, boxes[0].stroke), ("radio button", circles[0].fill, circles[0].stroke)] { assert!(fill != t.panel && fill != t.card, "{kind:?}: an unselected {what} is filled like the panel behind it"); assert!( edge.width >= 1.0 && edge.color != t.panel && edge.color != t.card, "{kind:?}: an unselected {what} needs an outline, got {edge:?}" ); } output.drop_without_applying_deltas(); } // An outline the caller set (dialogs) is kept. let ctx = egui::Context::default(); let own = Stroke::new(1.5, Color32::RED); let output = ctx.run_ui(egui::RawInput::default(), |ui| { ui.visuals_mut().widgets.inactive.bg_stroke = own; ui.add(checkbox(&mut true, "Collate")); ui.add(radio(false, "All")); }); let kept = output .shapes .iter() .filter(|s| match &s.shape { egui::Shape::Rect(r) => r.stroke == own, egui::Shape::Circle(c) => c.stroke == own, _ => false, }) .count(); assert_eq!(kept, 2, "the checkbox and the radio button keep the caller's outline"); output.drop_without_applying_deltas(); } fn toast_rect(app: &mut PdfCraftApp, ctx: &egui::Context, width: f32, now: &mut f64) -> Rect { // Let the Area settle after a notice/viewport change, without advancing to // expiry. No native rendering or renderer worker is needed for this layout. for _ in 0..3 { *now += 0.016; let output = ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0))), time: Some(*now), ..Default::default() }, |ui| toast(app, ui.ctx()), ); output.drop_without_applying_deltas(); } ctx.memory(|memory| memory.area_rect(egui::Id::unique("toast")).unwrap()) } #[test] fn toast_width_adapts_to_long_notices_and_narrow_viewports() { let ctx = egui::Context::default(); let mut app = PdfCraftApp::new(); let mut now = 1.0; app.notify("Saved"); let short = toast_rect(&mut app, &ctx, 1280.0, &mut now); assert!(short.width() < 160.0, "short notices retain their natural width: {short:?}"); app.notify("Fill in name failed: The value entered is not valid for the field [ name ]"); let long = toast_rect(&mut app, &ctx, 1280.0, &mut now); assert!(long.width() > 300.0, "a previous short Area must not squeeze validation feedback: {long:?}"); assert!(long.height() < 85.0, "ordinary feedback must not become a seven-line column: {long:?}"); for width in [320.0, 180.0] { let rect = toast_rect(&mut app, &ctx, width, &mut now); assert!(rect.width() <= width - 32.0 + 1.0, "frame and viewport margins must fit: {rect:?}"); assert!(rect.left() >= 15.0 && rect.right() <= width - 15.0, "notice remains horizontally on screen: {rect:?}"); } app.notify("Saved"); let again = toast_rect(&mut app, &ctx, 1280.0, &mut now); assert!((again.width() - short.width()).abs() < 1.0, "long notices must not impose a permanent minimum width"); } /// The text shapes `wrapped_text` paints in a 120 pt wide column at x = 0. fn wrapped_shapes(text: &str, truncate: bool) -> Vec<(Pos2, Rect, String)> { let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); let output = ctx.run_ui(egui::RawInput::default(), |ui| { let column = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 400.0)); ui.scope_builder(egui::UiBuilder::new().max_rect(column), |ui| { wrapped_text(ui, text, theme::regular(13.0), Color32::WHITE, truncate); }); }); let shapes = output .shapes .iter() .filter_map(|shape| if let egui::Shape::Text(t) = &shape.shape { Some((t.pos, t.galley.rect, t.galley.job.text.clone())) } else { None }) .collect(); output.drop_without_applying_deltas(); shapes } /// Right-to-left text is wrapped once and looked up on later frames; the cache stays bounded /// however many comments go by, and a collapsed comment of any length still shows one line. #[test] fn rtl_wrapping_is_cached_and_bounded() { let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); let cache_len = |ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("pdfcraft-rtl-wrap-cache")).lines.len()); let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; let long = persian.repeat(2_000); let mut first = None; for _ in 0..3 { ctx.run_ui(egui::RawInput::default(), |ui| { let lines = wrapped_lines(ui, persian, &theme::regular(13.0), 120.0, false); assert!(lines.len() > 1); if let Some(prev) = &first { assert!(std::sync::Arc::ptr_eq(prev, &lines), "the second frame reuses the first frame's wrap"); } first = Some(lines); let collapsed = wrapped_lines(ui, &long, &theme::regular(13.0), 120.0, true); assert_eq!(collapsed.len(), 1); assert!(collapsed[0].text.ends_with(ELLIPSIS), "{:?}", collapsed[0].text); }) .drop_without_applying_deltas(); } assert_eq!(cache_len(&ctx), 2, "one entry per text and layout"); ctx.run_ui(egui::RawInput::default(), |ui| { for i in 0..WRAP_CACHE_MAX + 50 { wrapped_lines(ui, &format!("{persian} {i}"), &theme::regular(13.0), 120.0, false); } }) .drop_without_applying_deltas(); assert!(cache_len(&ctx) <= WRAP_CACHE_MAX, "bounded: {}", cache_len(&ctx)); } /// Comment lines reach epaint in logical order. A mixed Persian/Latin comment preordered by /// `bidi::wrap` would be reordered a second time by egui 0.37; the old display-order text is /// distinctly different, so this fails if the preordering comes back. #[test] fn rtl_comment_lines_stay_logical_for_epaint() { let mixed = "این نظر PDF درباره فایل است"; let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); ctx.run_ui(egui::RawInput::default(), |ui| { let lines = wrapped_lines(ui, mixed, &theme::regular(13.0), 1000.0, false); assert_eq!(lines.len(), 1); assert_eq!(lines[0].text, mixed, "epaint needs reading order, not display order"); assert!(lines[0].rtl); // Truncation appends the ellipsis logically; the line still starts with its first word. let cut = wrapped_lines(ui, mixed, &theme::regular(13.0), 60.0, true); assert_eq!(cut.len(), 1); assert!(cut[0].text.starts_with("این"), "{:?}", cut[0].text); assert!(cut[0].text.ends_with(ELLIPSIS), "{:?}", cut[0].text); // The galley keeps logical text, so inspect positioned glyphs to prove that the // visible ellipsis is at the left-hand reading end after epaint's bidi layout. let galley = ui.painter().layout_no_wrap(cut[0].text.clone(), theme::regular(13.0), Color32::WHITE); let row = &galley.rows[0]; let ellipsis_x = row.glyphs.iter().find(|glyph| glyph.chr == '\u{2026}').expect("ellipsis glyph").pos.x; assert!( row.glyphs.iter().filter(|glyph| glyph.chr != '\u{2026}' && !glyph.chr.is_whitespace()).all(|glyph| glyph.pos.x > ellipsis_x), "RTL truncation must place the ellipsis to the left: {:?}", row.glyphs ); }) .drop_without_applying_deltas(); } /// A wrapped line keeps its paragraph's direction. The second line here starts with a /// Latin word, but belongs to a right-to-left comment: `Auto` would shape it left to right /// and put `PDF` at the visual left. Assert the actual painted positions instead. #[test] fn wrapped_rtl_line_starting_with_latin_keeps_its_rtl_base() { if pdfcraft_fonts::ui_arabic_fonts().is_empty() { return; } let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); ctx.run_ui(egui::RawInput::default(), |ui| { let line = crate::bidi::Line { text: "PDF درباره فایل است".to_owned(), rtl: true }; let galley = wrapped_line_galley(ui, &line, &theme::regular(13.0), Color32::WHITE); assert_eq!(galley.job.direction, egui::epaint::text::TextDirection::RightToLeft); let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); let rightmost_arabic = positions.iter().filter(|(c, _)| crate::bidi::has_rtl(&c.to_string())).map(|(_, x)| *x).fold(f32::MIN, f32::max); assert!(p > rightmost_arabic, "the line stays right to left: {positions:?}"); }) .drop_without_applying_deltas(); } /// Check right-aligned wrapping and a single collapsed line whose logical text ends with an ellipsis. #[test] fn rtl_comment_wraps_against_the_right_edge() { let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; let lines = wrapped_shapes(persian, false); assert!(lines.len() > 1, "{lines:?}"); let right = lines.iter().map(|(pos, rect, _)| pos.x + rect.width()).fold(0.0, f32::max); for (pos, rect, text) in &lines { assert!(pos.x >= -0.5 && (pos.x + rect.width() - right).abs() < 0.5, "{text:?} at {pos:?} must be right-aligned"); } assert!(right <= 120.5, "lines stay inside the column: {right}"); // The first words read on the first line, at the top. let top = lines.iter().min_by(|a, b| a.0.y.total_cmp(&b.0.y)).unwrap(); assert!(top.2.contains("این"), "{top:?}"); // Collapsed: one line, its ellipsis on the left where right-to-left reading ends. let collapsed = wrapped_shapes(persian, true); assert_eq!(collapsed.len(), 1); assert!(collapsed[0].2.ends_with('\u{2026}'), "the galley retains logical text: {collapsed:?}"); // Text without right-to-left script is a plain label, as before. let latin = wrapped_shapes("A short note", false); assert_eq!(latin.len(), 1); assert_eq!(latin[0].2, "A short note"); assert!(latin[0].0.x.abs() < 0.5); } #[test] fn line_field_is_at_least_thirty_points_tall() { let ctx = egui::Context::default(); let mut text = String::new(); let mut height = 0.0; ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), ..Default::default() }, |ui| { begin_dialog(ui); height = ui.add(line(&mut text)).rect.height(); }) .drop_without_applying_deltas(); assert!(height >= FIELD_MIN_HEIGHT, "field height {height}"); } #[test] fn fit_line_keeps_short_text_and_ellipsizes_the_rest() { let width = |s: &str| s.chars().count() as f32; assert_eq!(fit_line("localhost", 20.0, width), "localhost"); let long = "Apple Worldwide Developer Relations Certification Authority"; let fitted = fit_line(long, 24.0, width); assert!(fitted.ends_with('\u{2026}'), "{fitted}"); assert!(fitted.chars().count() <= 24, "{fitted}"); assert!(long.starts_with(fitted.trim_end_matches('\u{2026}'))); assert_eq!(fit_line(long, 0.0, width), ""); assert_eq!(fit_line("ééé", 2.0, width), "é\u{2026}"); } }