From 055d01760322d2fd6f8e1ae7888d97effd84465a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:46:51 +0000 Subject: [PATCH] refactor(ui-egui): move widgets.rs items to widgets/{blend,buttons,chips,color,drag,dropdowns,labels,numeric,panel_chrome,text,toggles}.rs --- crates/ui-egui/src/widgets.rs | 3041 +------------------- crates/ui-egui/src/widgets/blend.rs | 161 ++ crates/ui-egui/src/widgets/buttons.rs | 202 ++ crates/ui-egui/src/widgets/chips.rs | 250 ++ crates/ui-egui/src/widgets/color.rs | 556 ++++ crates/ui-egui/src/widgets/drag.rs | 125 + crates/ui-egui/src/widgets/dropdowns.rs | 309 ++ crates/ui-egui/src/widgets/labels.rs | 181 ++ crates/ui-egui/src/widgets/numeric.rs | 661 +++++ crates/ui-egui/src/widgets/panel_chrome.rs | 136 + crates/ui-egui/src/widgets/text.rs | 328 +++ crates/ui-egui/src/widgets/toggles.rs | 105 + 12 files changed, 3068 insertions(+), 2987 deletions(-) create mode 100644 crates/ui-egui/src/widgets/blend.rs create mode 100644 crates/ui-egui/src/widgets/buttons.rs create mode 100644 crates/ui-egui/src/widgets/chips.rs create mode 100644 crates/ui-egui/src/widgets/color.rs create mode 100644 crates/ui-egui/src/widgets/drag.rs create mode 100644 crates/ui-egui/src/widgets/dropdowns.rs create mode 100644 crates/ui-egui/src/widgets/labels.rs create mode 100644 crates/ui-egui/src/widgets/numeric.rs create mode 100644 crates/ui-egui/src/widgets/panel_chrome.rs create mode 100644 crates/ui-egui/src/widgets/text.rs create mode 100644 crates/ui-egui/src/widgets/toggles.rs diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 147e0f673..03467a8ef 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -9,6 +9,60 @@ use vectorcraft_doc::Unit; use crate::theme::{self, Tokens}; use crate::{icons, scrub}; +mod blend; +mod buttons; +mod chips; +mod color; +mod drag; +mod dropdowns; +mod labels; +mod numeric; +mod panel_chrome; +mod text; +mod toggles; + +pub use blend::{ + TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, +}; +pub use buttons::{ + SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button, + primary_button_enabled, secondary_button, split_button, split_button_named, toggle_icon, +}; +pub(crate) use buttons::{icon_button_info, primary_button_width, secondary_button_width}; +#[cfg(test)] +use chips::gradient_chip_colors; +pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; +pub(crate) use color::pointer_phase; +pub use color::{ + Live, SliderValue, WheelResponse, channel_slider, channel_slider_named, channel_slider_valued, color_field, color_mesh, color_slider, + color_slider_named, color_slider_stepped, color_slider_valued, harmony_wheel, hex_field, hex_field_named, region_texture, +}; +pub use drag::{PanelDrag, SwatchRows, art_drop, doc_preview, drag_source}; +pub(crate) use dropdowns::combo; +#[cfg(test)] +use dropdowns::combo_button_id; +pub use dropdowns::{ + dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, + panel_scroll, popover, set_menu_floor, strip_scroll, +}; +pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader}; +pub(crate) use labels::{label_field, take_previous_label}; +pub use numeric::{ + SpinPick, mixed_field, mixed_field_named, num_field, num_field_named, opt_field, opt_field_named, range_field, range_field_named, + range_field_stepped, size_field, size_field_named, slider, spin_field, spin_field_auto, spin_field_auto_named, spin_field_named, spin_plain, + spin_plain_named, +}; +#[cfg(test)] +use numeric::{decimal_point, share_of, spinner_step, step_to_multiple}; +pub(crate) use numeric::{round_to, set_bare_numbers_are_points, typed_unit}; +pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar}; +pub(crate) use text::{dialog_focus_flag, enter_modifiers, focus_field, own_field_shift_enter, set_dialog_open, take_dialog_focus}; +pub use text::{ + exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, + text_presets_named, +}; +pub use toggles::{check, check_box, check_name, check_tip, check3, radio, radio_button}; + /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do /// not need a `Debug` bound merely to salt a widget id, and never make an accessible name from it. @@ -35,2993 +89,6 @@ fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id { parent.with(HashOnly(salt)) } -/// Square icon button; `selected` draws the pressed well. -pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response { - let (_, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - paint_icon_button(ui, resp, icon, tip, selected) -} - -/// [`icon_button`] drawn for a response the caller made (its own id or sense). -pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, selected: bool) -> Response { - let t = Tokens::get(ui.ctx()); - let rect = resp.rect; - let size = rect.width(); - let bg = if selected { - t.tool_active - } else if resp.hovered() { - t.hover - } else { - Color32::TRANSPARENT - }; - ui.painter().rect_filled(rect, CornerRadius::same(5), bg); - let pad = (size * 0.2).round(); - icons::paint(ui, icon, rect.shrink(pad), if selected { t.text_strong } else { t.icon }); - resp.widget_info(|| icon_button_info(ui.is_enabled(), selected, tl!(tip))); - if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp } -} - -/// Small flat text button (Quick Actions style). -pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); - let rect = rect.shrink2(vec2(0.0, 0.5)); - ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); - flat_fill(ui, rect, &resp); - ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// A [`flat_button`] that expands to fit localized labels wider than `min_width`. -pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); - let width = (galley.size().x + 24.0).max(min_width); - let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); - let rect = rect.shrink2(vec2(0.0, 0.5)); - ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); - flat_fill(ui, rect, &resp); - ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// Width of a [`split_button`]'s arrow. -pub const SPLIT_ARROW: f32 = 18.0; - -/// A [`flat_button`] `width` wide whose right end is a menu arrow (Image Trace ▾) → the -/// responses of the button and of its arrow. -pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) { - split_button_named(ui, text, "", width) -} - -/// [`split_button`] whose arrow has the tool tip `arrow_tip`, which screen readers also call it -/// (none when empty). -pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) -> (Response, Response) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); - let rect = rect.shrink2(vec2(0.0, 0.5)); - let split = rect.right() - SPLIT_ARROW; - let main_rect = Rect::from_min_max(rect.min, pos2(split, rect.bottom())); - let arrow_rect = Rect::from_min_max(pos2(split, rect.top()), rect.max); - let main = ui.interact(main_rect, ui.scope_id().with((text, "split")), Sense::click()); - let arrow = ui.interact(arrow_rect, ui.scope_id().with((text, "split-arrow")), Sense::click()); - ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); - flat_fill(ui, main_rect, &main); - flat_fill(ui, arrow_rect, &arrow); - ui.painter().vline(split, rect.y_range().shrink(5.0), Stroke::new(1.0, t.panel)); - ui.painter().with_clip_rect(main_rect).text( - main_rect.center(), - egui::Align2::CENTER_CENTER, - tl!(text), - egui::FontId::proportional(12.5), - t.text_strong, - ); - icons::paint(ui, "chevron-down", Rect::from_center_size(arrow_rect.center(), vec2(12.0, 12.0)), t.text_dim); - main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - if arrow_tip.is_empty() { - return (main, arrow); - } - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(arrow_tip))); - (main, arrow.on_hover_text(tl!(arrow_tip))) -} - -/// The pressed or hovered fill of a flat button part. -fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) { - let t = Tokens::get(ui.ctx()); - if resp.is_pointer_button_down_on() { - ui.painter().rect_filled(rect, CornerRadius::same(5), t.tool_active); - } else if resp.hovered() { - ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover); - } -} - -/// The accent call-to-action button (dialog OK/Create). -pub fn primary_button(ui: &mut Ui, text: &str) -> Response { - primary_button_enabled(ui, text, true) -} - -/// [`primary_button`], greyed out and not clickable unless `enabled`. -pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Response { - primary_button_sized(ui, text, enabled, 72.0) -} - -/// Primary dialog button with a minimum width, for aligned action columns. -pub(crate) fn primary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { - primary_button_sized(ui, text, true, min_width) -} - -/// Draws a primary button at least `min_width` wide, growing to fit the translated label. -/// Returns its response with the enabled state and screen-reader name preserved. -fn primary_button_sized(ui: &mut Ui, text: &str, enabled: bool, min_width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let fg = if enabled { Color32::WHITE } else { t.text_disabled }; - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg); - let w = galley.size().x + 32.0; - let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), if enabled { Sense::click() } else { Sense::hover() }); - let bg = match (enabled, resp.hovered()) { - (false, _) => t.input, - (true, true) => t.accent, - (true, false) => t.accent_strong, - }; - ui.painter().rect_filled(rect, CornerRadius::same(6), bg); - if !enabled { - ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); - } - ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!(text))); - resp -} - -/// The quiet secondary button (dialog Cancel). -pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { - secondary_button_width(ui, text, 72.0) -} - -/// Secondary dialog button with a minimum width, for aligned action columns. -pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), t.text); - let w = galley.size().x + 32.0; - let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), Sense::click()); - ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button }); - ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// Bold section header (Properties panel). -pub fn section_header(ui: &mut Ui, text: &str) { - let t = Tokens::get(ui.ctx()); - ui.add_space(2.0); - ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text_strong)); - ui.add_space(2.0); -} - -/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`; -/// a click anywhere on it toggles the section. Whether it was clicked. -pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool { - section_toggle_name(ui, tl!(text), open).clicked() -} - -/// [`section_toggle`] for a name that is user data (a library group), shown as it is → its -/// response (clicked: toggle; it takes a context menu). -pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(rect, 3.0, t.hover); - } - let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0)); - icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon }); - ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, true, open, text)); - resp.on_hover_cursor(egui::CursorIcon::PointingHand) -} - -/// Full-width 1 px divider with vertical margins. -pub fn divider(ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - ui.add_space(6.0); - let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); - ui.painter().rect_filled(rect, 0.0, t.divider); - ui.add_space(6.0); -} - -/// The `Id` under which [`dim_label`]/[`field_label`] leave the label a following field should be -/// named by. egui 0.37's kittest fails an input with no accessible name, and VectorCraft paints -/// most labels beside, not inside, their fields. -fn next_label_id() -> egui::Id { - egui::Id::unique("vectorcraft-next-input-label") -} - -/// The label a field may be named by, and the widget id the field must be drawn under for the -/// label to belong to it. egui allocates a fresh auto id for every widget, but not for moving -/// between grid cells: the next field in a grid still has the id the label recorded, while a -/// checkbox or another widget between the label and a field has taken it. A label therefore names -/// the very next field, also in a grid, but not a later one after an unrelated widget. -#[derive(Clone, Copy)] -struct NextLabel { - id: egui::Id, - viewport: egui::ViewportId, - pass: u64, - at: Pos2, - next_auto_id: egui::Id, -} - -/// Record a label's widget id, viewport, pass, position and next auto id so only the immediately -/// following field can take the label. -fn remember_next_label(ui: &Ui, id: egui::Id) { - let label = NextLabel { - id, - viewport: ui.ctx().viewport_id(), - pass: ui.ctx().cumulative_pass_nr(), - at: ui.next_widget_position(), - next_auto_id: ui.next_auto_id(), - }; - ui.ctx().data_mut(|d| d.insert_temp(next_label_id(), label)); -} - -/// A label taken for the next field, and whether no widget came between the two. -pub(crate) struct PreviousLabel { - id: egui::Id, - at: Pos2, - immediate: bool, -} - -/// The label drawn immediately before a field in this pass, if no other widget came between them -/// (an app-local adapter around [`egui::Response::labelled_by`], not a general compatibility -/// layer). Takes the record, so one label names one field. -pub(crate) fn take_previous_label(ui: &Ui) -> Option { - let label = ui.ctx().data_mut(|d| { - let label = d.get_temp::(next_label_id()); - d.remove::(next_label_id()); - label - })?; - // Frame and pass numbers are per viewport; a label left in one viewport must never name a - // field in another. The shipped hosts draw one root viewport, so this scoping is defensive. - (label.viewport == ui.ctx().viewport_id() && label.pass == ui.ctx().cumulative_pass_nr()).then(|| PreviousLabel { - id: label.id, - at: label.at, - immediate: ui.next_auto_id() == label.next_auto_id, - }) -} - -/// Name `resp` by the field's real adjacent label (see [`take_previous_label`]). -pub(crate) fn label_field(resp: Response, label: Option) -> Response { - let Some(label) = label else { return resp }; - let adjacent = label.immediate || resp.rect.min.distance(label.at) <= 16.0 || resp.rect.contains(label.at); - if adjacent { resp.labelled_by(label.id) } else { resp } -} - -/// Give `resp` the explicit semantic name of a field whose visible label is not a widget beside -/// it (a label below a grid of dash cells, a heading over a row of fields). The name is already -/// translated at the call site. An empty name leaves the field unnamed rather than inventing one. -fn name_field(resp: Response, name: Option<&str>) -> Response { - match name { - Some(name) if !name.trim().is_empty() => resp.accessible_name(name), - _ => resp, - } -} - -/// A label in the panels' text colour; before a numeric field it scrubs the field -/// ([`scrub`]). -pub fn dim_label(ui: &mut Ui, text: &str) -> Response { - let resp = dim_name(ui, tl!(text)); - scrub::note_label(ui, resp.rect); - remember_next_label(ui, resp.id); - resp -} - -/// A field's label in other styles (`text` as given): before a numeric field it scrubs the field -/// ([`scrub`]). -pub fn field_label(ui: &mut Ui, text: impl Into) -> Response { - let resp = ui.label(text); - scrub::note_label(ui, resp.rect); - remember_next_label(ui, resp.id); - resp -} - -/// [`dim_label`] for a name that is user or file data (a swatch, style, artboard or font name): -/// shown as it is, never translated. -pub fn dim_name(ui: &mut Ui, text: &str) -> Response { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(text).color(t.text).size(12.5)) -} - -/// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it -/// when the preference changes (`prefs_dialog::apply_runtime`), the length fields read it. -pub(crate) fn set_bare_numbers_are_points(ctx: &egui::Context, on: bool) { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("bare_numbers_are_points"), on)); -} - -/// The unit a number typed with no unit into a field in `unit` is read in: `unit`, or for a field -/// in picas points with Numbers Without Units Are Points on (where `12` could be either). A typed -/// unit (`12 mm`, `2p6`, `1in`) always wins. -pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit { - let bare_points = ctx.data(|d| d.get_temp::(egui::Id::unique("bare_numbers_are_points"))).unwrap_or(true); - if bare_points && unit == Unit::Picas { Unit::Points } else { unit } -} - -/// `text` typed in a numeric field, with a decimal comma read as a point: `0,1 mm` is `0.1 mm` -/// (#3837). Text with a point keeps its commas, so `1,000.5` is still refused rather than read -/// with two decimal separators, and so does a number that reads as thousands ([`thousands`]). -fn decimal_point(text: &str) -> std::borrow::Cow<'_, str> { - if text.contains(',') && !text.contains('.') && !thousands(text) { text.replace(',', ".").into() } else { text.into() } -} - -/// Is `text` a number written with a thousands comma, as `1,000` or `12,500 mm`: one to three -/// digits (not a lone 0), a comma, exactly three digits, then at most a unit? Read as a decimal it -/// would be a thousand times too small, so the field refuses it instead (`0,125` and `1,25` are -/// decimals). -fn thousands(text: &str) -> bool { - let number = text.trim().trim_start_matches('-'); - let Some((whole, rest)) = number.split_once(',') else { return false }; - let (part, unit) = rest.split_at(rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())); - (1..=3).contains(&whole.len()) - && whole.bytes().all(|b| b.is_ascii_digit()) - && !whole.starts_with('0') - && part.len() == 3 - && unit.trim().chars().all(char::is_alphabetic) -} - -/// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) -/// when the user commits (Enter / focus loss / Shift+Enter, which keeps the focus outside dialogs). -/// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in -/// points in a picas field with Numbers Without Units Are Points on). -pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { - num_field_named(ui, id, None, value, unit, width) -} - -/// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with -/// its label painted separately): screen readers name the field `name` (already translated). -pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { - let label = take_previous_label(ui); - unit_field(ui, id, label, name, value, unit, width, false, ANY) -} - -/// Any number: the bounds of a field that has none. -const ANY: RangeInclusive = f64::NEG_INFINITY..=f64::INFINITY; - -/// `v` within `bounds` (bounds the wrong way round or not numbers don't panic, as `f64::clamp` would). -fn within(v: f64, bounds: &RangeInclusive) -> f64 { - v.max(*bounds.start()).min(*bounds.end()) -} - -/// [`num_field`]; with `share_of_value` a percentage typed alone is that share of `value` -/// ([`share_of`]). ↑/↓, the wheel and a scrub stop at `bounds` (points), and the field shows where -/// they stopped. -#[allow(clippy::too_many_arguments)] -fn unit_field( - ui: &mut Ui, - id: impl std::hash::Hash, - label: Option, - name: Option<&str>, - value: Option, - unit: Unit, - width: f32, - share_of_value: bool, - bounds: RangeInclusive, -) -> Option { - let id = id_with(ui.scope_id(), id); - let shown = value.map(|v| unit.format(v)).unwrap_or_default(); - take_dialog_focus(ui, id, &shown); - let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); - select_all_on_focus(ui, &resp, &buf); - // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. - let typed = typed_unit(ui.ctx(), unit); - let read = |b: &str| typed.parse(&decimal_point(b)); - let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); - let stepped = step_value(ui, &resp, &mut buf, &bounds, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None); - let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds); - let commit = commit && buf != shown; - ui.data_mut(|d| d.insert_temp(id, buf.clone())); - if commit { - read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) - } else { - stepped.or(scrubbed).map(|v| unit.to_pt(v)) - } -} - -/// A percentage typed alone (`50%`, `150 %`, `100/3%`) as that share of `value`; `None` for any -/// other text. `Unit::parse` reads a bare percentage as nothing, having no value to take it of. -fn share_of(text: &str, value: f64) -> Option { - let percent = vectorcraft_doc::parse_number(text.trim().strip_suffix('%')?)?; - Some(value * percent / 100.0) -} - -/// The places a stepped value in a unit field keeps (no 12.300000001). -const STEP_DECIMALS: i32 = 3; - -/// `v` rounded to `decimals` places. -pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { - let scale = 10f64.powi(decimals.clamp(0, 6)); - (v * scale).round() / scale -} - -/// One step of a numeric field with each modifier, most specific first (a plain pattern would also -/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to -/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. -const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; - -/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next -/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 -/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a -/// step of a multiple counts as that multiple. -fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 { - if n == 0.0 { - return v; - } - let q = v / size; - let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() }; - (from + n) * size -} - -/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` -/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step -/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to -/// the next tenth, and a Shift step adds or takes ten. Steps stop at the field's `bounds` (#3370: -/// a weight stops at 0, a miter limit at 1). The new value is applied at once, as in -/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused -/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the -/// wheel to the panel and the canvas. -fn step_value( - ui: &Ui, - resp: &Response, - buf: &mut String, - bounds: &RangeInclusive, - (read, show): (impl Fn(&str) -> Option, impl Fn(f64) -> String), - step: Option f64>, -) -> Option { - // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the - // window to report keyboard focus. - if !ui.memory(|m| m.has_focus(resp.id)) { - return None; - } - use egui::{Event, Key, MouseWheelUnit}; - let wheel = resp.contains_pointer(); - // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is - // left of a notch carries over to the next turn. - let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); - let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; - // Each press and each turn of whole notches in the order they came: its modifiers and size from - // [`STEPS`], and how many steps up (down when negative). - let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); - ui.input_mut(|i| { - i.events.retain(|e| { - let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; - let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; - let n = match e { - Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, - Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, - Event::MouseWheel { unit, delta, .. } if wheel => { - // Shift may turn the wheel sideways (the Mac does). - let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; - notches += match unit { - MouseWheelUnit::Point => d / line.max(1.0), - MouseWheelUnit::Line | MouseWheelUnit::Page => d, - }; - let whole = notches.trunc(); - notches -= whole; - f64::from(whole) - } - _ => return true, - }; - if n != 0.0 { - steps.push((mods, size, n)); - } - false - }); - if wheel { - // The rest of a turn egui spreads over the next frames scrolls nothing either. - i.smooth_scroll_delta = egui::Vec2::ZERO; - } - }); - if wheel { - ui.data_mut(|d| d.insert_temp(notch_id, notches)); - } - if steps.is_empty() { - return None; - } - let mut v = read(buf)?; - for (mods, size, n) in steps { - v = match step { - // The field's own steps: ten at once with Shift, one with or without Ctrl/Cmd. - Some(step) => step(v, if mods == egui::Modifiers::SHIFT { 10.0 * n } else { n }), - None if mods == egui::Modifiers::SHIFT => v + size * n, - None => step_to_multiple(v, size, n), - }; - // Each step stops at the bounds, so a step back in the same frame starts from there. - v = within(v, bounds); - } - // Rounded as fields show it, within the field's bounds. - let v = within(round_to(v, STEP_DECIMALS), bounds); - *buf = show(v); - ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); - select_all(ui, resp.id, buf); - Some(v) -} - -/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the -/// first enabled field drawn takes it ([`take_dialog_focus`]). -pub(crate) fn dialog_focus_flag() -> egui::Id { - egui::Id::unique("dialog-focus-first-field") -} - -/// If field `id` (showing `text`) is the first enabled field of a dialog that has just opened, give -/// it the keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. -pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { - // A new window's first frame only measures its contents: nothing can take focus yet. Nor can a - // greyed-out field (egui drops its focus): the flag stays for the first field that is enabled. - if ui.is_sizing_pass() || !ui.is_enabled() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { - return; - } - focus_field(ui, id, text); -} - -/// Give text field `id` the keyboard focus with all of `text` selected. -pub(crate) fn focus_field(ui: &Ui, id: egui::Id, text: &str) { - select_all(ui, id, text); - ui.memory_mut(|m| m.request_focus(id)); -} - -/// The modifiers held with an Enter press among this frame's events, `None` without one, as when a -/// field's editing ends with a click elsewhere. -pub(crate) fn enter_modifiers(ui: &Ui) -> Option { - ui.input(|i| { - i.events.iter().find_map(|e| match e { - egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } => Some(*modifiers), - _ => None, - }) - }) -} - -/// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter -/// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A -/// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). -pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { - size_field_named(ui, id, None, value, unit, width) -} - -/// [`size_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn size_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { - use egui::{Event, Key}; - let label = take_previous_label(ui); - // The text box does not end its editing on an Enter with Ctrl/Cmd: a plain Enter goes in right - // after it, so the box first takes the text typed before it in this frame. - let field = id_with(ui.scope_id(), &id); - if ui.memory(|m| m.has_focus(field)) { - ui.input_mut(|i| { - let cmd_enter = |e: &Event| matches!(e, Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); - if let Some(at) = i.events.iter().position(cmd_enter) { - let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; - i.events.insert(at + 1, enter); - } - }); - } - unit_field(ui, id, label, name, value, unit, width, true, ANY) -} - -/// Select all of `text` in text field `id`. -fn select_all(ui: &Ui, id: egui::Id, text: &str) { - let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); - st.cursor.set_char_range(Some(all)); - st.store(ui.ctx(), id); -} - -/// The flag that tells the fields a dialog is open in this frame ([`set_dialog_open`]). -fn dialog_open_flag() -> egui::Id { - egui::Id::unique("dialog-open") -} - -/// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field -/// applies its value and confirms the dialog as Enter does ([`shift_enter_keeps_focus`]). -pub(crate) fn set_dialog_open(ctx: &egui::Context, open: bool) { - ctx.data_mut(|d| d.insert_temp(dialog_open_flag(), open)); -} - -/// Shift+Enter (Shift+Return) in single-line field `id` while it has the keyboard focus and no -/// dialog is open: the press is taken from this frame's events before the field's text box reads -/// them, so the field keeps the focus. Returns true when it was pressed; the field then applies its -/// text as Enter does, and in the next frame shows its value with all of it selected -/// ([`after_shift_enter`]). -fn shift_enter_keeps_focus(ui: &Ui, id: egui::Id) -> bool { - if !ui.memory(|m| m.has_focus(id)) || ui.data(|d| d.get_temp::(dialog_open_flag())).unwrap_or(false) { - return false; - } - let pressed = ui.input_mut(|i| { - let before = i.events.len(); - i.events.retain(|e| { - !matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::SHIFT)) - }); - i.events.len() < before - }); - if pressed { - ui.data_mut(|d| d.insert_temp(id.with("shift-enter"), true)); - } - pressed -} - -/// Whether field `id`, still focused, took Shift+Enter in the last frame -/// ([`shift_enter_keeps_focus`]). Clears that record. -fn after_shift_enter(ui: &Ui, id: egui::Id) -> bool { - ui.data_mut(|d| d.remove_temp::(id.with("shift-enter"))).unwrap_or(false) && ui.memory(|m| m.has_focus(id)) -} - -/// Handles Shift+Enter in a single-line panel field that draws its own text box `id` (Pattern -/// Options' Name, the inline renames in the Layers, Artboards and Asset Export panels). Call it -/// before the box is drawn, with `buf` the text the box will show and `shown` the value as it is -/// now. When the field took Shift+Enter in the last frame, `buf` becomes `shown`, all of it -/// selected. Returns whether Shift+Enter was pressed in this frame ([`shift_enter_keeps_focus`]): -/// the field then applies `buf` as Enter does and keeps the focus. -pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, shown: &str) -> bool { - if after_shift_enter(ui, id) { - *buf = shown.to_string(); - select_all(ui, id, buf); - } - shift_enter_keeps_focus(ui, id) -} - -/// Numeric fields select the whole text on keyboard focus, a completed focus click or a -/// double-click. A pointer press keeps its caret so dragging selects from the pressed position, -/// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap -/// releases into select-all. -fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { - let pending = resp.id.with("select-all-on-focus-click"); - let press_pos = resp.id.with("select-all-on-focus-press"); - let (pressed, released, at_press) = ui.input(|i| { - (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.pointer.interact_pos()) - }); - if resp.gained_focus() { - ui.data_mut(|d| { - if pressed { - d.insert_temp(pending, true); - if let Some(p) = at_press { - d.insert_temp(press_pos, p); - } - } else { - d.remove_temp::(pending); - d.remove_temp::(press_pos); - } - }); - } - let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); - // A press released a few points away is a short drag, not a tap. egui still reports a - // click, and a release inside one character leaves no text selection, so select-all on - // release would replace the drag the user just made (#1326). - let dragged = clicked_focus - && ui - .data_mut(|d| d.remove_temp::(press_pos)) - .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); - if resp.lost_focus() { - ui.data_mut(|d| { - d.remove_temp::(pending); - d.remove_temp::(press_pos); - }); - } - if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus && !dragged) { - return; - } - if let Some(st) = egui::TextEdit::load_state(ui.ctx(), resp.id) { - // Preserve a text selection if the focus click already produced one. - if clicked_focus && !resp.double_clicked() && st.cursor.char_range().is_some_and(|r| !r.as_sorted_char_range().is_empty()) { - return; - } - select_all(ui, resp.id, text); - ui.ctx().request_repaint(); - } -} - -/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line), -/// its text kept under `id` while it has focus. Returns the text (as edited), whether the field -/// applies it in this frame (it lost the focus, or a single-line field took Shift+Enter and kept -/// the focus: [`shift_enter_keeps_focus`]), the text edit's response and the box. -fn recessed_text( - ui: &mut Ui, - id: egui::Id, - shown: &str, - width: f32, - rows: usize, - label: Option, - name: Option<&str>, -) -> (String, bool, Response, Rect) { - let t = Tokens::get(ui.ctx()); - let editing = ui.memory(|m| m.has_focus(id)); - // A press outside the field ends its editing in this frame, so the text typed is committed - // before the press acts: the canvas, drawn after the panels, may select another object, and a - // frame later the text would be committed to that one. - if editing - && let Some(at) = ui.input(|i| i.pointer.primary_pressed().then(|| i.pointer.interact_pos()).flatten()) - && ui.ctx().read_response(id).is_some_and(|r| !r.rect.contains(at)) - { - ui.memory_mut(|m| m.surrender_focus(id)); - } - // When the value changes under a focused field (the slider beside it, a preview, another - // field's edit), its text follows: the text typed for the old value would otherwise go back in - // on the next Enter, Tab or arrow key (#1457). - let shown_id = id.with("shown"); - let followed = ui.data_mut(|d| d.get_temp::(shown_id)).is_none_or(|before| before == shown); - ui.data_mut(|d| d.insert_temp(shown_id, shown.to_string())); - // A press elsewhere can end the focus outside this field's own drawing (a field drawn before it - // takes it, the canvas ends text input), so `editing` is already false on the frame it reports - // `lost_focus`: the text typed is still taken then, to be committed (#1637). Escape drops it. - let focus_id = id.with("had-focus"); - let had_focus = ui.data_mut(|d| d.get_temp::(focus_id)).unwrap_or(false) && !ui.input(|i| i.key_pressed(egui::Key::Escape)); - // After Shift+Enter applied the text, the field shows the value as it is now, all selected. - let reshown = after_shift_enter(ui, id); - let typed = if (editing || had_focus) && followed && !reshown { ui.data_mut(|d| d.get_temp::(id)) } else { None }; - let mut buf: String = typed.unwrap_or_else(|| shown.to_string()); - if reshown { - select_all(ui, id, &buf); - } - let kept = rows == 1 && shift_enter_keeps_focus(ui, id); - // The box is exactly as tall as the other controls of a row (26 points a line), its text - // centred in it: a frame around the text came out a point taller and stood out next to - // spinners and dropdowns (#900). - let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32; - let layout = if rows > 1 { egui::Layout::top_down(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Center) }; - let (rect, resp) = ui - .allocate_ui_with_layout(vec2(width, height), layout, |ui| { - ui.set_min_size(vec2(width, height)); - let rect = ui.max_rect(); - ui.painter().rect(rect, 5.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside); - let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 }; - let resp = egui::Frame::NONE - .inner_margin(margin) - .show(ui, |ui| { - crate::theme::strong_selected_text(ui); - let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) }; - ui.add( - edit.id(id) - .frame(egui::Frame::NONE) - .margin(egui::Margin::ZERO) - .desired_width(width - 14.0) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - (rect, resp) - }) - .inner; - let resp = name_field(label_field(resp, label), name); - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(focus_id, focused); - }); - let commit = resp.lost_focus() || kept; - (buf, commit, resp, rect) -} - -/// A recessed text field showing `value` (blank when `None`: the selection's values differ), -/// `rows` lines tall (1: single line, which Enter and Shift+Enter commit). Returns the new text, -/// trimmed, when a change is committed (Enter, Shift+Enter or focus loss). -pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option<&str>, width: f32, rows: usize) -> Option { - text_field_named(ui, id, None, value, width, rows) -} - -/// [`text_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option<&str>, width: f32, rows: usize) -> Option { - let label = take_previous_label(ui); - let id = id_with(ui.scope_id(), id); - let shown = value.unwrap_or_default(); - let (buf, commit, _, _) = recessed_text(ui, id, shown, width, rows, label, name); - (commit && buf.trim() != shown).then(|| buf.trim().to_string()) -} - -/// A one-line [`text_field`] that keeps the text exactly as typed, spaces included (a tab -/// leader such as ". "). Returns it when a change is committed (Enter, Shift+Enter or focus loss). -pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash, value: &str, width: f32) -> Option { - exact_text_field_named(ui, id, None, value, width) -} - -/// [`exact_text_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn exact_text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, width: f32) -> Option { - let label = take_previous_label(ui); - let id = id_with(ui.scope_id(), id); - let (buf, commit, _, _) = recessed_text(ui, id, value, width, 1, label, name); - (commit && buf != value).then_some(buf) -} - -/// A form row: `label` in a column `label_width` wide, then what `add` draws. -pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) { - ui.horizontal(|ui| { - let (r, resp) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); - let t = Tokens::get(ui.ctx()); - let shown = tl!(label); - ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, shown, egui::FontId::proportional(12.5), t.text); - scrub::note_label(ui, r); - // The label is painted, not a widget of its own: give it a real semantic node so the - // input beside it can be named by `label_inputs_by` (egui 0.37's strict a11y gate). - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), shown)); - if label.trim().is_empty() { - add(ui); - } else { - ui.label_inputs_by(resp.id, add); - } - }); -} - -/// A plain number field (percent, degrees, counts) with optional suffix. -pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { - plain_field_named(ui, id, None, value, suffix, decimals, width) -} - -/// [`plain_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn plain_field_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - value: f64, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - mixed_field_named(ui, id, name, Some(value), suffix, decimals, width) -} - -/// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels -/// such as the Stroke panel's Limit); `None` shows a blank field, as [`mixed_field`] does. What is -/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count), and -/// ↑/↓, the wheel and a scrub stop at its ends in the field too (#3370). -pub fn range_field( - ui: &mut Ui, - id: impl std::hash::Hash, - value: impl Into>, - range: RangeInclusive, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - range_field_named(ui, id, None, value, range, suffix, decimals, width) -} - -/// [`range_field`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn range_field_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - value: impl Into>, - range: RangeInclusive, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - let places = decimals.min(6) as i32; - let value = value.into().map(|v| within(v, &range)); - let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None).map(|v| within(round_to(v, places), &range)) -} - -/// [`range_field`] whose ↑/↓ and wheel take `step`'s steps instead of whole numbers (see -/// [`SliderValue::step`]), for a number whose useful values are fractions, such as the Pencil's -/// Fidelity. -#[allow(clippy::too_many_arguments)] -pub fn range_field_stepped( - ui: &mut Ui, - id: impl std::hash::Hash, - value: impl Into>, - range: RangeInclusive, - suffix: &str, - decimals: usize, - width: f32, - step: fn(f64, f64) -> f64, -) -> Option { - let places = decimals.min(6) as i32; - let value = value.into().map(|v| within(v, &range)); - let label = take_previous_label(ui); - bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step)).map(|v| within(round_to(v, places), &range)) -} - -/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. -pub fn mixed_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, suffix: &str, decimals: usize, width: f32) -> Option { - mixed_field_named(ui, id, None, value, suffix, decimals, width) -} - -/// [`mixed_field`] for a row named explicitly instead of by an adjacent label widget. -pub fn mixed_field_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - value: Option, - suffix: &str, - decimals: usize, - width: f32, -) -> Option { - let label = take_previous_label(ui); - bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None) -} - -/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`; ↑/↓ and the wheel take `step`'s steps -/// when it is given (see [`SliderValue::step`]). -#[allow(clippy::too_many_arguments)] -fn bounded_field( - ui: &mut Ui, - id: impl std::hash::Hash, - label: Option, - name: Option<&str>, - value: Option, - suffix: &str, - decimals: usize, - width: f32, - bounds: &RangeInclusive, - step: Option f64>, -) -> Option { - let id = id_with(ui.scope_id(), id); - let show = |value: f64| field_text(value, suffix, decimals); - // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. - let read = |buf: &str| { - let suffix = suffix.trim(); - let buf = decimal_point(buf); - let bare = if suffix.is_empty() { buf.to_string() } else { buf.replace(suffix, "") }; - vectorcraft_doc::parse_number(&bare.replace(['%', '°'], "")) - }; - let shown = value.map(show).unwrap_or_default(); - take_dialog_focus(ui, id, &shown); - let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); - select_all_on_focus(ui, &resp, &buf); - // ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on - // its label scrubs it. - let decimals = decimals.min(6) as i32; - let stepped = step_value(ui, &resp, &mut buf, bounds, (read, show), step).map(|v| round_to(v, decimals)); - let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals, bounds); - if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } -} - -/// The text [`plain_field`] shows for `value`: at most `decimals` places, without trailing zeros, -/// then `suffix`. -fn field_text(value: f64, suffix: &str, decimals: usize) -> String { - let s = format!("{:.*}", decimals, value); - let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; - format!("{s}{suffix}") -} - -/// Draw a paint preview (swatch chip) into `rect`. -pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { - let p = ui.painter(); - match paint { - Paint::None => { - p.rect_filled(rect, 0.0, Color32::WHITE); - p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x20, 0x20))); - } - Paint::Solid { color, .. } => { - let [r, g, b, _] = color.to_rgba8(1.0); - p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); - } - Paint::Gradient(gp) => gradient_chip(ui, rect, gp), - Paint::Pattern { .. } => { - p.rect_filled(rect, 0.0, Color32::from_gray(200)); - for i in 0..4 { - let x = rect.left() + rect.width() * i as f32 / 4.0; - p.line_segment([pos2(x, rect.bottom()), pos2(x + rect.width() / 4.0, rect.top())], Stroke::new(1.0, Color32::from_gray(90))); - } - } - } -} - -/// Draw a gradient preview into `rect`, laid out as the gradient would be on an object of that -/// shape: linear along its angle, radial from the centre, freeform from its colour points. -pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::GradientPaint) { - if !(rect.width() > 0.0 && rect.height() > 0.0) { - return; - } - // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). - let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; - let max = if freeform { 16 } else { 64 }; - let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); - let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); - let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); - let mut mesh = egui::Mesh::default(); - for (k, color) in colors.into_iter().enumerate() { - let (i, j) = (k % (cols + 1), k / (cols + 1)); - let at = pos2(rect.left() + rect.width() * i as f32 / cols as f32, rect.top() + rect.height() * j as f32 / rows as f32); - mesh.colored_vertex(at, color); - } - for j in 0..rows as u32 { - for i in 0..cols as u32 { - let v = j * (cols as u32 + 1) + i; - mesh.add_triangle(v, v + 1, v + cols as u32 + 1); - mesh.add_triangle(v + 1, v + cols as u32 + 2, v + cols as u32 + 1); - } - } - ui.painter().add(egui::Shape::mesh(mesh)); -} - -/// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` -/// chip showing `gp` as it would look on an object of that size. -fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec { - use vectorcraft_color::{GradientGeom, GradientKind}; - let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); - let rgb = |c: vectorcraft_color::Color| { - let [r, g, b, _] = c.to_rgba8(1.0); - Color32::from_rgb(r, g, b) - }; - let at = |k: usize| { - let (i, j) = (k % (cols + 1), k / (cols + 1)); - vectorcraft_geom::kurbo::Point::new(b.width() * i as f64 / cols as f64, b.height() * j as f64 / rows as f64) - }; - let n = (cols + 1) * (rows + 1); - if gp.gradient.kind == GradientKind::Freeform { - let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] }; - let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted)); - return (0..n) - .map(|k| { - let ([r, g, b], _) = field.sample(at(k)); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - Color32::from_rgb(q(r), q(g), q(b)) - }) - .collect(); - } - // A placed gradient keeps its direction; a fresh one is fitted to the chip at its angle. - let angle = gp.geom.map_or(gp.angle, |g| g.angle_deg()); - let geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 }); - (0..n) - .map(|k| { - let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; - rgb(gp.gradient.sample(t).0) - }) - .collect() -} - -/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a -/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ -/// (`mixed`) shows a white chip with a "?" (in the hole for strokes). -pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option) -> Rect { - let t = Tokens::get(ui.ctx()); - if mixed { - ui.painter().rect_filled(rect, 0.0, Color32::WHITE); - } else { - paint_chip(ui, rect, paint); - } - let (hole, mark) = match ring { - Some(w) => { - let inner = rect.shrink(w); - ui.painter().rect_filled(inner, 0.0, t.panel); - (inner, t.text_strong) - } - None => (rect, Color32::BLACK), - }; - if mixed { - ui.painter().text(hole.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(hole.height() * 0.8), mark); - } - hole -} - -/// A chip that opens something (the Control bar's and Properties' Fill/Stroke chips, the Control -/// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with -/// `chevron` a chevron follows it. -pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response { - chip_button_named(ui, size, chevron, "", draw) -} - -/// [`chip_button`] with the tool tip `tip`, which screen readers also call it (none when empty). -pub fn chip_button_named(ui: &mut Ui, size: f32, chevron: bool, tip: &str, draw: impl FnOnce(&Ui, Rect)) -> Response { - let t = Tokens::get(ui.ctx()); - let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click()); - let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0); - draw(ui, chip); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); - if chevron { - icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim); - } - if tip.is_empty() { - return resp; - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(tip))); - resp.on_hover_text(tl!(tip)) -} - -/// What the user did on a [`fill_stroke_proxy`]. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ProxyClicks { - pub fill: bool, - pub stroke: bool, - pub swap: bool, - pub default: bool, - /// Double-clicked a square: open the Color Picker for it (`true` = the stroke). - pub pick: Option, - /// A paint dragged from the other proxy (or a swatch) dropped on the fill square. - pub dropped_fill: Option, - /// A paint dropped on the stroke square. - pub dropped_stroke: Option, -} - -/// The Fill/Stroke proxy pair (two overlapping squares). `mixed` (fill, stroke) draws a "?" square -/// for a selection whose objects differ. -pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool, bool), fill_active: bool, size: f32) -> ProxyClicks { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); - let s = size * 0.62; - let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); - let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); - let stroke_resp = ui.interact(stroke_r, ui.scope_id().with("stroke-proxy"), Sense::click_and_drag()); - let fill_resp = ui.interact(fill_r, ui.scope_id().with("fill-proxy"), Sense::click_and_drag()); - let dropped_fill = if mixed.0 { None } else { proxy_paint_drop(ui, &fill_resp) }; - let dropped_stroke = if mixed.1 { None } else { proxy_paint_drop(ui, &stroke_resp) }; - // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". - for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] { - if !mixed { - drag_source(ui, resp, || PanelDrag::paint(paint.clone())); - } - } - let draw_fill = |ui: &Ui| { - proxy_chip(ui, fill_r, fill, mixed.0, None); - ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); - ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); - }; - let draw_stroke = |ui: &Ui| { - let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28)); - ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); - ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside); - ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); - }; - if fill_active { - draw_stroke(ui); - draw_fill(ui); - } else { - draw_fill(ui); - draw_stroke(ui); - } - // Swap arrow (top-right) and default (bottom-left) mini buttons. - let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3)); - let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3)); - let swap = ui.interact(swap_r, ui.scope_id().with("swap-proxy"), Sense::click()); - let def = ui.interact(def_r, ui.scope_id().with("default-proxy"), Sense::click()); - icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon }); - let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14)); - let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14)); - ui.painter().rect_filled(b, 0.0, Color32::BLACK); - ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); - ui.painter().rect_filled(a, 0.0, Color32::WHITE); - ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); - let pick = if fill_resp.double_clicked() { - Some(false) - } else if stroke_resp.double_clicked() { - Some(true) - } else { - None - }; - let on = ui.is_enabled(); - fill_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); - stroke_resp - .widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); - swap.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); - def.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Default Fill and Stroke (D)"))); - ProxyClicks { - fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(), - stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(), - swap: swap.on_hover_text(tl!("Swap Fill and Stroke (Shift+X)")).clicked(), - default: def.on_hover_text(tl!("Default Fill and Stroke (D)")).clicked(), - pick, - dropped_fill, - dropped_stroke, - } -} - -/// A [`PanelDrag::Paint`] released on a Fill/Stroke proxy square: highlight while held over it. -fn proxy_paint_drop(ui: &Ui, resp: &Response) -> Option { - if let Some(drag) = resp.dnd_hover_payload::() - && let PanelDrag::Paint { params, .. } = &*drag - && !params.is_null() - { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); - } - resp.dnd_release_payload::().and_then(|d| { - let PanelDrag::Paint { params, .. } = &*d else { return None }; - (!params.is_null()).then_some(params.clone()) - }) -} - -/// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index. -pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { - dropdown_with_named(ui, id, None, current, options, width, |_| true) -} - -/// [`dropdown`] for a row whose visible label is not an adjacent label widget. -pub fn dropdown_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { - dropdown_with_named(ui, id, name, current, options, width, |_| true) -} - -/// [`dropdown`] of names that are user, file or system data (font styles, artboards, presets the -/// user saved, printers, profiles), shown as they are, never translated. For a list that mixes -/// built-in labels with such names, translate the built-in ones (`tl!`) before passing them. -pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { - dropdown_names_named(ui, id, None, current, options, width) -} - -/// [`dropdown_names`] for a row whose visible label is not an adjacent label widget. -pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { - combo_named(ui, id, name, current, width, false, |ui| { - let mut chosen = None; - for (i, o) in options.iter().enumerate() { - if ui.add(egui::Button::selectable(*o == current, *o)).clicked() { - chosen = Some(i); - } - } - chosen - }) -} - -/// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). -pub fn dropdown_with( - ui: &mut Ui, - id: impl std::hash::Hash, - current: &str, - options: &[&str], - width: f32, - enabled: impl Fn(usize) -> bool, -) -> Option { - dropdown_with_named(ui, id, None, current, options, width, enabled) -} - -/// [`dropdown_with`] for a row whose visible label is not an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn dropdown_with_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - current: &str, - options: &[&str], - width: f32, - enabled: impl Fn(usize) -> bool, -) -> Option { - combo_named(ui, id, name, tl!(current), width, false, |ui| { - let mut chosen = None; - for (i, o) in options.iter().enumerate() { - if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, tl!(o))).clicked() { - chosen = Some(i); - } - } - chosen - }) -} - -/// The id egui's [`egui::ComboBox::from_id_salt`] gives its button in this `Ui`: `from_id_salt` -/// stores `IdSalt::new(salt)` and hashes it once more with the scope, so the app must hash `id` -/// the same way to read the button's focus and open state before it is drawn. -fn combo_button_id(ui: &Ui, id: &impl std::hash::Hash) -> egui::Id { - ui.make_persistent_id(egui::IdSalt::new(HashOnly(id))) -} - -/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns -/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open -/// while it is clicked: it closes itself when an option is chosen. -pub(crate) fn combo( - ui: &mut Ui, - id: impl std::hash::Hash, - current: &str, - width: f32, - searchable: bool, - list: impl FnOnce(&mut Ui) -> Option, -) -> Option { - combo_named(ui, id, None, current, width, searchable, list) -} - -/// [`combo`] for a row whose visible label is not an adjacent label widget. -pub(crate) fn combo_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: Option<&str>, - current: &str, - width: f32, - searchable: bool, - list: impl FnOnce(&mut Ui) -> Option, -) -> Option { - let t = Tokens::get(ui.ctx()); - let label = take_previous_label(ui); - egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, t.input_border)) - .corner_radius(CornerRadius::same(3)) - .show(ui, |ui| { - if searchable { - return searchable_combo(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0, list); - } - let button_id = combo_button_id(ui, &id); - let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || egui::ComboBox::is_open(ui.ctx(), button_id); - let response = egui::ComboBox::from_id_salt(HashOnly(&id)) - .selected_text(egui::RichText::new(current).size(12.0)) - .width(width - 4.0) - .height(ui.spacing().combo_height) - .show_ui(ui, list); - // The button or open list handles Enter before the modal can accept it. - if enter_for_combo { - ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); - } - let _ = name_field(label_field(response.response.clone(), label), name); - let chosen = response.inner.flatten(); - // egui closes the list on a click of the pointer only: an option picked with Space or - // Enter closes it here, and the combo gives the keyboard back, so that Enter reaches the - // dialog's OK (#2789). - if chosen.is_some() { - egui::Popup::close_all(ui.ctx()); - ui.memory_mut(|m| m.surrender_focus(button_id)); - } - chosen - }) - .inner -} - -/// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as -/// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so -/// the field stays at the top and only the rows scroll (#555). A click outside closes it. -fn searchable_combo( - ui: &mut Ui, - id: egui::Id, - label: Option, - name: Option<&str>, - current: &str, - width: f32, - list: impl FnOnce(&mut Ui) -> Option, -) -> Option { - let popup = id.with("popup"); - let open = egui::Popup::is_id_open(ui.ctx(), popup); - let pad = ui.spacing().button_padding; - let icon = Vec2::splat(ui.spacing().icon_width); - let galley = egui::WidgetText::from(egui::RichText::new(current).size(12.0)).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - width - 2.0 * pad.x - ui.spacing().icon_spacing - icon.x, - egui::TextStyle::Button, - ); - let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y); - let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); - // Screen readers get what egui's combo box gives them: its role and the text it shows. - resp.widget_info(|| egui::WidgetInfo { - enabled: ui.is_enabled(), - current_text_value: Some(current.to_owned()), - ..egui::WidgetInfo::new(egui::Role::ComboBox) - }); - let resp = name_field(label_field(resp, label), name); - if ui.is_rect_visible(rect) { - let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; - ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); - let inner = rect.shrink2(pad); - ui.painter().galley(egui::Align2::LEFT_CENTER.align_size_within_rect(galley.size(), inner).min, galley, v.text_color()); - // egui's combo arrow: a downward triangle. - let arrow = - Rect::from_center_size(egui::Align2::RIGHT_CENTER.align_size_within_rect(icon, inner).center(), vec2(icon.x * 0.7, icon.y * 0.45)); - ui.painter().add(egui::Shape::convex_polygon( - vec![arrow.left_top(), arrow.right_top(), arrow.center_bottom()], - v.fg_stroke.color, - Stroke::NONE, - )); - } - // It opens below the button, or above it when there is more room there, and the list gets - // the room left (the popup's frame aside). - const GAP: f32 = 12.0; - let screen = ui.ctx().content_rect(); - let frame = ui.spacing().menu_margin.sum().y + GAP; - let (below, above) = (screen.bottom() - rect.bottom() - frame, rect.top() - screen.top() - frame); - let (align, room) = if below >= above { (egui::RectAlign::BOTTOM_START, below) } else { (egui::RectAlign::TOP_START, above) }; - egui::Popup::menu(&resp) - .id(popup) - .width(rect.width()) - .align(align) - .align_alternatives(&[]) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| { - ui.set_min_width(ui.available_width()); - ui.set_max_height(room); - ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); - list(ui) - }) - .and_then(|r| r.inner) -} - -/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and -/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its -/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or -/// popups of its own: egui remembers one open popup at a time, so opening a dropdown inside a -/// remembered popup would close the popup (and the dropdown with it). This one keeps its open -/// state itself: the last frame it was open in. -pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -> R) -> Option { - let ctx = &resp.ctx; - let id = resp.id.with("popover"); - let frame = ctx.cumulative_frame_nr(); - let was_open = ctx.data(|d| d.get_temp::(id)).is_some_and(|f| f + 1 >= frame); - let mut open = if toggle { - !was_open - } else { - // Popups (this one and those it opened) are on foreground layers; a click anywhere - // else closes it. While the pointer is down or a panel paint drag is active, keep the - // popover open so a Fill/Stroke proxy drag can finish (#3440). - let pointer_down = ctx.input(|i| i.pointer.any_down()); - let panel_drag = egui::DragAndDrop::payload::(ctx).is_some(); - let click_pos = ctx.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten()); - let clicked_anchor = click_pos.is_some_and(|pos| resp.rect.contains(pos)); - let clicked_elsewhere = !pointer_down - && !panel_drag - && !clicked_anchor - && click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); - was_open && !clicked_elsewhere - }; - let inner = - egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); - ctx.data_mut(|d| { - if open { - d.insert_temp(id, frame); - } else { - d.remove::(id); - } - }); - inner -} - -/// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above -/// them (#2086). Without bars it is the bottom of the window. -pub fn set_menu_floor(ctx: &egui::Context, y: f32) { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("menu_floor"), y)); -} - -/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt. -pub fn menu_room(floor: f32, top: f32) -> f32 { - const BOTTOM_GAP: f32 = 12.0; - const MIN_HEIGHT: f32 = 120.0; - (floor - top - BOTTOM_GAP).max(MIN_HEIGHT) -} - -/// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never -/// covers what sits at their right end (shortcuts, submenu arrows, a font's favourite star). -pub fn list_scroll_style() -> egui::style::ScrollStyle { - egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } -} - -/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window, and -/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on -/// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the -/// submenu arrows (#2086). -pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); - let room = menu_room(floor, ui.next_widget_position().y); - ui.scope(|ui| { - ui.spacing_mut().scroll = list_scroll_style(); - // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to - // its items when they need less. - egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner - }) - .inner -} - -/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the -/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is -/// over it. -/// Its scroll bar stays a slim line at the edge, even while scrolled or dragged, so it never covers -/// the buttons (#808). -pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - ui.scope(|ui| { - ui.spacing_mut().scroll = egui::style::ScrollStyle { - bar_width: 4.0, - floating_width: 2.0, - bar_inner_margin: 0.0, - bar_outer_margin: 1.0, - ..egui::style::ScrollStyle::floating() - }; - egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner - }) - .inner -} - -/// A panel's scrolling body (Properties, a dock column, a panel in the shared dock): `area` with -/// the scroll bar of a menu's list ([`list_scroll_style`]), in a column of its own at the right -/// edge, so it never covers the controls there, such as a dropdown's arrow (#3093). The scroll -/// areas inside the body keep their own style. -pub fn panel_scroll(ui: &mut Ui, area: egui::ScrollArea, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - ui.scope(|ui| { - let inner = std::mem::replace(&mut ui.spacing_mut().scroll, list_scroll_style()); - area.show(ui, |ui| { - ui.spacing_mut().scroll = inner; - add_contents(ui) - }) - .inner - }) - .inner -} - -/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a -/// [`BlendMode::group`] starts. -pub fn blend_separator_before(i: usize) -> bool { - i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group() -} - -/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion -/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode. -pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: Option, width: f32) -> Option { - combo(ui, id, current.map_or("", |m| tl!(m.label())), width, false, |ui| { - let mut chosen = None; - for (i, m) in BlendMode::ALL.into_iter().enumerate() { - if blend_separator_before(i) { - ui.separator(); - } - if ui.selectable_label(Some(m) == current, tl!(m.label())).clicked() { - chosen = Some(m); - } - } - chosen - }) -} - -/// The blend mode an effect parameter names (`"mode": "multiply"`), which editors show as a -/// [`blend_param_dropdown`]. -pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { - value.as_str().filter(|_| key == "mode").and_then(BlendMode::parse) -} - -/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. -pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: BlendMode) -> Option { - blend_dropdown(ui, id, Some(current), 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) -} - -/// An edit made with [`opacity_blend`]. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum TransparencyEdit { - Blend(BlendMode), - /// Opacity in percent, with the slider's drag phase (`Released` for a typed value). - Opacity(f64, Live), -} - -/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity -/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup. -/// `opacity` (0..1) and `blend` are `None` where the objects differ (shown blank). -pub fn opacity_blend( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - opacity: Option, - blend: Option, - enabled: bool, -) -> Option { - let t = Tokens::get(ui.ctx()); - let mut edit = None; - ui.horizontal(|ui| { - ui.add_enabled_ui(enabled, |ui| { - if let Some(m) = blend_dropdown(ui, (id, "blend"), blend, 104.0) { - edit = Some(TransparencyEdit::Blend(m)); - } - }); - dim_label(ui, "Opacity:"); - ui.spacing_mut().item_spacing.x = 0.0; - ui.add_enabled_ui(enabled, |ui| { - if let Some(o) = range_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), 0.0..=100.0, "%", 0, 50.0) { - edit = Some(TransparencyEdit::Opacity(o, Live::Released)); - } - }); - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); - ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity"))); - egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - let mut o = opacity.unwrap_or(1.0) * 100.0; - let r = slider(ui, egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); - let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { - Live::Released - } else if r.changed() { - Live::Dragging - } else { - return; - }; - edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase)); - }); - }); - edit -} - -/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around -/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. -pub fn reference_point(ui: &mut Ui, current: usize) -> Option { - let t = Tokens::get(ui.ctx()); - // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through - // the squares' centres stay crisp. - let (sq, step) = (5.0, 9.0); - let size = 2.0 * step + sq; - let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let o = rect.min.round(); - let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); - let line = Stroke::new(1.0, t.text_dim); - ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle); - let mut out = None; - // Screen readers name the squares left to right and top to bottom. - let names = [ - tl!("Top Left"), - tl!("Top"), - tl!("Top Right"), - tl!("Left"), - tl!("Center"), - tl!("Right"), - tl!("Bottom Left"), - tl!("Bottom"), - tl!("Bottom Right"), - ]; - for (i, name) in names.into_iter().enumerate() { - let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); - let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); - if resp.clicked() { - out = Some(i); - } - if i == current { - ui.painter().rect_filled(r, 0.0, t.text); - } else { - ui.painter().rect_filled(r, 0.0, t.panel); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); - } - } - out -} - -/// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, -/// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole -/// degrees (-179..180), Shift in 45° steps. Returns the new angle. -pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash, angle: f64, size: f32) -> Option { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); - let (c, r) = (rect.center(), size / 2.0 - 1.0); - let ring = if resp.hovered() || resp.dragged() { t.text } else { t.input_border }; - ui.painter().circle(c, r, t.input, Stroke::new(1.0, ring)); - let a = (angle as f32).to_radians(); - ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * (r - 3.0)], Stroke::new(1.5, t.text)); - ui.painter().circle_filled(c, 2.0, t.text); - resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), angle, tl!("Angle"))); - let (p, _) = pointer_phase(&resp)?; - let v = p - c; - if v.length() < 2.0 { - return None; - } - let step = if ui.input(|i| i.modifiers.shift) { 45.0 } else { 1.0 }; - let deg = ((-v.y).atan2(v.x).to_degrees() as f64 / step).round() * step; - // atan2 gives -180..180: -180 shows as 180. - let deg = if deg <= -180.0 { deg + 360.0 } else { deg }; - (deg != angle).then_some(deg) -} - -/// A toggle icon (e.g. eye / lock columns) — returns clicked. -pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); - if on { - icons::paint(ui, on_icon, rect.shrink(size * 0.2), if resp.hovered() { t.text } else { t.icon }); - } else if resp.hovered() { - icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tl!(tip))); - resp.on_hover_text(tl!(tip)).clicked() -} - -// ---------- panel widgets (Swatches, Color, Stroke, Gradient, Appearance, …) ---------- - -/// Icon button that can be disabled (greyed, no hover, never clicked). -pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, enabled: bool, size: f32) -> Response { - if enabled { - return icon_button(ui, icon, tip, selected, size); - } - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let pad = (size * 0.2).round(); - icons::paint(ui, icon, rect.shrink(pad), t.text_disabled); - resp.widget_info(|| icon_button_info(false, selected, tl!(tip))); - if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) } -} - -/// What screen readers get for an icon button: a button called `name` (already translated), -/// reported as pressed while `selected` and with no pressed state otherwise. -pub(crate) fn icon_button_info(enabled: bool, selected: bool, name: &str) -> egui::WidgetInfo { - egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::Role::Button, enabled, name) } -} - -/// A slider whose rail shows, filled up to the handle: the theme's widget fill matches the panel -/// and dialog background, which leaves only the handle visible (#1483). -pub fn slider(ui: &mut Ui, slider: egui::Slider<'_>) -> Response { - let t = Tokens::get(ui.ctx()); - ui.scope(|ui| { - let v = ui.visuals_mut(); - // The buttons' border shows against the panel in every brightness; the fields' border - // all but vanished on Medium Dark's panel (#1662). - v.widgets.inactive.bg_fill = t.button_border; - v.selection.bg_fill = t.accent; - ui.add(slider.trailing_fill(true)) - }) - .inner -} - -/// Regular-weight panel sub-header ("Shape Modes:", "Align Objects:"). -pub fn subheader(ui: &mut Ui, text: &str) { - let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text)); -} - -/// A label drawn with a dotted underline (Illustrator's link labels: "Stroke:", "Opacity:"). -pub fn link_label(ui: &mut Ui, text: &str) -> Response { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); - let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(0.0, 3.0), Sense::click()); - let y = rect.top() + galley.size().y + 1.0; - ui.painter().galley(rect.min, galley, t.text_strong); - let mut x = rect.left(); - while x < rect.right() - 1.0 { - ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); - x += 2.5; - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); - resp -} - -/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated), -/// draws the text and returns the box, the response and the box's border colour. -fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled }); - let (rect, resp) = - ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() }); - let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0)); - ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text); - let border = if !enabled { - t.divider - } else if resp.hovered() { - t.text - } else { - t.button_border - }; - (bx, resp, border) -} - -/// Panel-style checkbox with a disabled state. Returns true when toggled. -pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { - check3(ui, label, Some(value), enabled) -} - -/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. -pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { - let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled); - resp.on_hover_text(tip); - clicked -} - -/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked. -pub fn check3(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> bool { - check_row(ui, tl!(label), value, enabled).0 -} - -/// [`check`] for a name of the document's (an artboard's, an asset's), shown as it is, never -/// translated. Returns true when toggled. -pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool { - check_row(ui, name, Some(value), true).0 -} - -/// A [`check`] box without text, for a row whose label is drawn beside it in another column: -/// screen readers call it `name` (already translated). Returns true when toggled. -pub fn check_box(ui: &mut Ui, name: &str, value: bool, enabled: bool) -> bool { - check_row_named(ui, "", name, Some(value), enabled).0 -} - -/// [`check3`] with its `text` already translated, also returning the row's response (what a -/// tooltip goes on: a widget around it never counts as hovered under the box). -fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bool, Response) { - check_row_named(ui, text, text, value, enabled) -} - -/// [`check_row`] that screen readers call `name` instead of its `text`. -fn check_row_named(ui: &mut Ui, text: &str, name: &str, value: Option, enabled: bool) -> (bool, Response) { - let t = Tokens::get(ui.ctx()); - let (bx, resp, border) = choice_row(ui, text, enabled); - let checked = value == Some(true); - ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input }); - ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); - match value { - Some(true) => { - let c = if enabled { Color32::WHITE } else { t.text_disabled }; - ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); - ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); - } - None => { - let c = if enabled { t.text } else { t.text_disabled }; - ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, c)); - } - Some(false) => {} - } - resp.widget_info(|| match value { - Some(on) => egui::WidgetInfo::selected(egui::Role::CheckBox, enabled && ui.is_enabled(), on, name), - None => egui::WidgetInfo::labeled(egui::Role::CheckBox, enabled && ui.is_enabled(), name), - }); - (enabled && resp.clicked(), resp) -} - -/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. -pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { - radio_row(ui, tl!(label), tl!(label), selected, enabled) -} - -/// A [`radio`] button without text, for one followed by a field and the text that names it: -/// screen readers call it `name` (already translated). Returns true when clicked. -pub fn radio_button(ui: &mut Ui, name: &str, selected: bool, enabled: bool) -> bool { - radio_row(ui, "", name, selected, enabled) -} - -/// [`radio`] with its `text` already translated, which screen readers call `name`. -fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) -> bool { - let t = Tokens::get(ui.ctx()); - let (bx, resp, border) = choice_row(ui, text, enabled); - let c = bx.center(); - ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border)); - if selected { - ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, enabled && ui.is_enabled(), selected, name)); - enabled && resp.clicked() -} - -/// Numeric field with an up/down spinner on the left and a preset dropdown on the right -/// (Stroke weight, font size…): an editable combo box whose field keeps typing, ↑/↓ stepping and -/// label scrubbing. `value` (points) shows in `unit`, blank when `None` (the selection's values -/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. The spinner, ↑/↓, -/// the wheel and a scrub stop at `min` (#3370), and a typed value below it applies `min`. Returns -/// the committed, stepped or picked value. -#[allow(clippy::too_many_arguments)] -pub fn spin_field( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - value: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_field_named(ui, id, None, value, unit, width, step, min, presets) -} - -/// [`spin_field`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn spin_field_named( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - name: Option<&str>, - value: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_field_auto_named(ui, id, name, value, None, unit, width, step, min, presets).and_then(SpinPick::value) -} - -/// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum SpinPick { - Value(f64), - Auto, -} - -impl SpinPick { - /// The value, `None` for Auto. - pub fn value(self) -> Option { - match self { - SpinPick::Value(v) => Some(v), - SpinPick::Auto => None, - } - } -} - -/// [`spin_field`] whose dropdown starts with an Auto entry (Leading): `auto` is `Some(on)`, with -/// `on` checking it (no preset is then checked), or `None` for no Auto entry. -#[allow(clippy::too_many_arguments)] -pub fn spin_field_auto( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - value: Option, - auto: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_field_auto_named(ui, id, None, value, auto, unit, width, step, min, presets) -} - -/// [`spin_field_auto`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn spin_field_auto_named( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - name: Option<&str>, - value: Option, - auto: Option, - unit: Unit, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - let label = take_previous_label(ui); - spin_generic(ui, label, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw, label| { - unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY) - }) -} - -/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix; a -/// `None` value (the selection's values differ) shows blank. -#[allow(clippy::too_many_arguments)] -pub fn spin_plain( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - value: impl Into>, - suffix: &str, - decimals: usize, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - spin_plain_named(ui, id, None, value, suffix, decimals, width, step, min, presets) -} - -/// [`spin_plain`] for a row named explicitly instead of by an adjacent label widget. -#[allow(clippy::too_many_arguments)] -pub fn spin_plain_named( - ui: &mut Ui, - id: impl std::hash::Hash + Copy, - name: Option<&str>, - value: impl Into>, - suffix: &str, - decimals: usize, - width: f32, - step: f64, - min: f64, - presets: &[f64], -) -> Option { - let label = take_previous_label(ui); - let value = value.into(); - let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, label, value, None, width, step, min, presets, &fmt, &mut |ui, fw, label| { - bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None) - }) - .and_then(SpinPick::value) -} - -type SpinField<'a> = dyn FnMut(&mut Ui, f32, Option) -> Option + 'a; - -#[allow(clippy::too_many_arguments)] -fn spin_generic( - ui: &mut Ui, - label: Option, - value: Option, - auto: Option, - width: f32, - step: f64, - min: f64, - presets: &[f64], - fmt: &dyn Fn(f64) -> String, - field: &mut SpinField<'_>, -) -> Option { - let t = Tokens::get(ui.ctx()); - let mut out = None; - let h = 26.0; - let enabled = ui.is_enabled(); - ui.scope(|ui| { - // The spinner, the field and the chevron share their borders: one box in three parts. - ui.spacing_mut().item_spacing.x = -1.0; - let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click()); - ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input); - ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y)); - let down = Rect::from_min_max(pos2(sr.left(), sr.center().y), sr.max); - let hover = sresp.hover_pos(); - for (r, is_up) in [(up, true), (down, false)] { - let c = if !enabled { - t.text_disabled - } else if hover.is_some_and(|p| r.contains(p)) { - t.text_strong - } else { - t.icon - }; - let m = r.center(); - let d = if is_up { -1.5 } else { 1.5 }; - ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); - ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); - } - sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); - // A blank field (values that differ) has nothing to step from. - if sresp.clicked() - && let Some(p) = sresp.interact_pointer_pos() - && let Some(v) = value - { - let modifiers = ui.input(|i| i.modifiers); - out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); - } - let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; - if let Some(v) = field(ui, fw, label) { - out = Some(SpinPick::Value(v.max(min))); - } - if !presets.is_empty() { - let dresp = chevron_cell(ui, h); - egui::Popup::menu(&dresp).show(|ui| { - ui.set_min_width(width - 10.0); - if let Some(on) = auto - && ui.selectable_label(on, tl!("Auto")).clicked() - { - out = Some(SpinPick::Auto); - } - let current = value.filter(|_| auto != Some(true)); - for p in presets { - if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { - out = Some(SpinPick::Value(*p)); - } - } - }); - } - }); - out -} - -/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: -/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step. -fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 { - let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor); - let delta = step * multiplier; - (value + if up { delta } else { -delta }).max(min) -} - -/// Drag phase of a live slider/handle: previews while dragging, commits on release. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Live { - Idle, - Dragging, - Released, -} - -/// A colour slider with a gradient track (`track(t)` gives the colour at 0..1) and a triangular -/// thumb below it, like Illustrator's Color panel. Returns the new normalized value and the phase. -pub fn color_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, width: f32, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { - color_slider_named(ui, id, "", value, width, track) -} - -/// [`color_slider`] that screen readers get as a slider named `name` (already translated) with no -/// value (none when `name` is empty). -pub fn color_slider_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - color_slider_valued(ui, id, name, None, value, width, track) -} - -/// The value a slider gives screen readers: the number the field beside it shows, the range of the -/// slider's rail and the field's text. -#[derive(Clone, Debug)] -pub struct SliderValue { - pub value: f64, - pub range: std::ops::RangeInclusive, - /// The field's text, such as "50%"; `None` gives the number alone. - pub text: Option, - /// How the keys and screen readers step the number ([`color_slider_stepped`]): the number `n` - /// steps from `value` (±1 for an arrow, ±10 with Shift or Page Up/Down). `None` steps to whole - /// numbers. - pub step: Option f64>, -} - -/// Two values are the same when they show the same number, range and text, whatever their keys' steps -/// (function pointers don't compare reliably). -impl PartialEq for SliderValue { - fn eq(&self, other: &Self) -> bool { - (self.value, &self.range, &self.text) == (other.value, &other.range, &other.text) - } -} - -impl SliderValue { - /// `value` on a rail over `range`, without a text. - pub fn number(value: f64, range: std::ops::RangeInclusive) -> Self { - Self { value, range, text: None, step: None } - } - - /// `value` on a rail over `range` as [`plain_field`] shows it with `suffix` and at most - /// `decimals` places: the number it shows (`value` rounded) and its text. - pub fn field(value: f64, range: std::ops::RangeInclusive, suffix: &str, decimals: usize) -> Self { - let number = field_text(value, "", decimals); - Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")), step: None } - } - - /// `value` on a rail over `range`, with `text`, for a field that writes its own text (a length - /// in the document's unit, an egui `DragValue`). - pub fn text(value: f64, range: std::ops::RangeInclusive, text: String) -> Self { - Self { value, range, text: Some(text), step: None } - } - - /// This value, stepped by the keys with `step` instead of to whole numbers (see - /// [`SliderValue::step`]), for a rail whose useful numbers are fractions. - pub fn with_step(self, step: fn(f64, f64) -> f64) -> Self { - Self { step: Some(step), ..self } - } -} - -/// Give `resp` a slider's screen-reader node named `name` (nothing when `name` is empty), with -/// `shown`'s number, text and range: the rail's range, extended to the number when the field holds -/// a number past the rail's end. -fn slider_info(ui: &Ui, resp: &Response, name: &str, shown: Option) { - if name.is_empty() { - return; - } - let enabled = ui.is_enabled(); - let Some(s) = shown else { - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, name)); - return; - }; - resp.widget_info(|| egui::WidgetInfo { current_text_value: s.text.clone(), ..egui::WidgetInfo::slider(enabled, s.value, name) }); - ui.ctx().accesskit_node_builder(resp.id, |node| { - node.set_min_numeric_value(s.range.start().min(s.value)); - node.set_max_numeric_value(s.range.end().max(s.value)); - }); -} - -/// [`color_slider_named`] that screen readers also get `shown` from (see [`SliderValue`]). With -/// `None`, as for a field left blank for a mixed value, it reports no value. -pub fn color_slider_valued( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - shown: Option, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - let id = id_with(ui.scope_id(), id); - let (_, picked, phase) = color_rail(ui, id, name, shown, value, width, track); - (picked, phase) -} - -/// [`color_slider_valued`] for a dialog's rail beside a number field, which also responds to the -/// keyboard. A click or a drag gives the rail the keyboard focus; an outline around it shows the -/// focus once a key has stepped it, or when the focus came from the keyboard (Tab). -/// While it has the focus, → and ↑ raise the number `shown` holds to the next whole number and ← -/// and ↓ lower it to the next whole number, Shift with an arrow, Page Up and Page Down add or take -/// ten, and Home and End set the start and the end of `shown`'s range. Screen readers' increment -/// and decrement actions step the number as → and ← do. A number past the range steps from the -/// range's nearer end, and no step goes past either end. With `shown` `None` (a blank field), or -/// in a disabled `ui`, the keys and actions leave the rail as it is. Returns the new value from 0 -/// to 1 that a click or a drag gives, its phase, and the number in `shown`'s units that a key or -/// an action gives. -pub fn color_slider_stepped( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - shown: Option, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live, Option) { - let id = id_with(ui.scope_id(), id); - let (resp, picked, phase) = color_rail(ui, id, name, shown.clone(), value, width, track); - // Focus a click or a drag gave shows no outline until a key steps the rail: the outline tells - // a keyboard user where the focus is, and after a drag it only framed the rail. - let by_pointer = id.with("focused-by-pointer"); - if resp.drag_started() || resp.clicked() { - resp.request_focus(); - ui.data_mut(|d| d.insert_temp(by_pointer, true)); - } - let focused = ui.memory(|m| m.has_focus(id)); - if !focused { - ui.data_mut(|d| d.remove_temp::(by_pointer)); - } - let stepped = shown.filter(|_| ui.is_enabled()).and_then(|s| { - ui.ctx().accesskit_node_builder(id, |node| { - node.set_numeric_value_step(1.0); - node.add_action(egui::accesskit::Action::Increment); - node.add_action(egui::accesskit::Action::Decrement); - }); - rail_step(ui, id, focused, &s) - }); - if stepped.is_some() { - ui.data_mut(|d| d.remove_temp::(by_pointer)); - } - if focused && !ui.data(|d| d.get_temp::(by_pointer)).unwrap_or(false) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect, 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); - } - (picked, phase, stepped.filter(|_| picked.is_none())) -} - -/// The number of slider `id` (`s`) after this frame's keys while it is `focused`, and after screen -/// readers' actions on it ([`color_slider_stepped`]); `None` when they leave it as it was. -fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option { - use egui::accesskit::Action; - use egui::{Event, Key}; - let (lo, hi) = (*s.range.start(), *s.range.end()); - if !lo.is_finite() || !hi.is_finite() || hi <= lo { - return None; - } - let from = s.value.clamp(lo, hi); - let mut v = from; - // `n` steps from `v`: the rail's own steps, or to whole numbers (ten at a time with Shift). - let by = |v: f64, n: f64| match s.step { - Some(step) => step(v, n), - None if n.abs() >= 10.0 => v + n, - None => step_to_multiple(v, 1.0, n), - }; - // Home or End: an end of the range, which also replaces a number past it. - let mut end = false; - if focused { - // The arrows step the rail and do not move the focus. egui keeps the filter only when the - // rail also had the focus in the frame before, and applies it from the next frame; until - // then, an arrow the rail takes cancels the focus move that egui set at the start of the frame. - let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; - ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); - let mut arrows = false; - ui.input_mut(|i| { - i.events.retain(|e| { - let Event::Key { key, pressed: true, modifiers, .. } = e else { return true }; - let up = matches!(key, Key::ArrowRight | Key::ArrowUp | Key::PageUp); - let n = if up { 1.0 } else { -1.0 }; - match key { - Key::Home | Key::End if modifiers.is_none() => { - v = if *key == Key::Home { lo } else { hi }; - end = true; - } - Key::PageUp | Key::PageDown if modifiers.is_none() => v = by(v, 10.0 * n).clamp(lo, hi), - Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.shift_only() => { - v = by(v, 10.0 * n).clamp(lo, hi); - } - Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.is_none() => { - v = by(v, n).clamp(lo, hi); - arrows = true; - } - _ => return true, - } - false - }); - }); - if arrows { - ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - } - } - ui.input(|i| { - let n = i.num_accesskit_action_requests(id, Action::Increment) as f64 - i.num_accesskit_action_requests(id, Action::Decrement) as f64; - v = by(v, n).clamp(lo, hi); - }); - (v != from || (end && v != s.value)).then_some(round_to(v, STEP_DECIMALS).clamp(lo, hi)) -} - -/// The rail of [`color_slider_valued`] with the id `id`: its response, the new value from 0 to 1 -/// that a click or drag gives and the phase. -fn color_rail( - ui: &mut Ui, - id: egui::Id, - name: &str, - shown: Option, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Response, Option, Live) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); - let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0)); - let resp = ui.interact(rect, id, Sense::click_and_drag()); - gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track); - ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let v = value.clamp(0.0, 1.0); - let x = bar.left() + v * bar.width(); - let tip = pos2(x, bar.bottom() - 1.0); - let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; - let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; - ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); - slider_info(ui, &resp, name, shown); - let (picked, phase) = match pointer_phase(&resp) { - Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), - None => (None, Live::Idle), - }; - (resp, picked, phase) -} - -/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop). -pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { - if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) { - return None; - } - let p = resp.interact_pointer_pos()?; - Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released })) -} - -/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with -/// `track(t)` at its centre. -fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) { - let len = if vertical { rect.height() } else { rect.width() }; - let seg = len / n as f32; - for i in 0..n { - let r = if vertical { - Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6)) - } else { - Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height())) - }; - ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); - } -} - -/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y -/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. -pub fn color_field( - ui: &mut Ui, - id: impl std::hash::Hash, - size: Vec2, - pos: (f32, f32), - color_at: &dyn Fn(f32, f32) -> Color32, -) -> (Option<(f32, f32)>, Live) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); - let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); - let painter = ui.painter_at(rect.expand(6.0)); - painter.add(color_mesh(rect, (32, 32), color_at)); - painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); - painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); - painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); - match pointer_phase(&resp) { - Some((p, phase)) => { - let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); - (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) - } - None => (None, Live::Idle), - } -} - -/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 -/// each, y down) at its vertices. -pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f32) -> Color32) -> egui::Shape { - let mut mesh = egui::Mesh::default(); - let row = cols + 1; - mesh.reserve_vertices((row * (rows + 1)) as usize); - mesh.reserve_triangles((cols * rows * 2) as usize); - for j in 0..=rows { - for i in 0..=cols { - let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); - mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); - } - } - for j in 0..rows { - for i in 0..cols { - let a = j * row + i; - mesh.add_triangle(a, a + 1, a + row); - mesh.add_triangle(a + 1, a + row + 1, a + row); - } - } - egui::Shape::mesh(mesh) -} - -/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) -/// and arrows on both sides mark `value`. Returns the new value and the phase. -pub fn channel_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, size: Vec2, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { - channel_slider_named(ui, id, "", value, size, track) -} - -/// [`channel_slider`] that screen readers get as a slider named `name` (already translated) with -/// no value, as for [`color_slider_named`] (none when `name` is empty). -pub fn channel_slider_named( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - value: f32, - size: Vec2, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - channel_slider_valued(ui, id, name, None, value, size, track) -} - -/// [`channel_slider_named`] that screen readers also get `shown` from, as for -/// [`color_slider_valued`]. -pub fn channel_slider_valued( - ui: &mut Ui, - id: impl std::hash::Hash, - name: &str, - shown: Option, - value: f32, - size: Vec2, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); - let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); - let bar = rect.shrink2(vec2(6.0, 0.0)); - gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track); - ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); - let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height(); - let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon }; - for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] { - let tip = pos2(x + d * 5.0, y); - ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); - } - slider_info(ui, &resp, name, shown); - match pointer_phase(&resp) { - Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), - None => (None, Live::Idle), - } -} - -/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it -/// was typed into the field and differs from `hex`. Enter, Shift+Enter (which keeps the focus -/// outside dialogs) and focus loss commit it. -pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash, hex: &str) -> Option { - hex_field_named(ui, id, None, hex) -} - -/// [`hex_field`] for a colour field named explicitly instead of by an adjacent label widget. -pub fn hex_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, hex: &str) -> Option { - let t = Tokens::get(ui.ctx()); - let label = take_previous_label(ui); - ui.label(egui::RichText::new("#").size(15.0).color(t.text)); - let id = id_with(ui.scope_id(), id); - let editing = ui.memory(|m| m.has_focus(id)); - // Until the user types into it, the field shows `hex` as it is now, also while it has the - // focus. Tab can give it the focus in the frame another field commits an edit, before the edit - // changes `hex`; the text from before that edit is not committed when the focus moves on - // (#1692). - let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); - let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; - if after_shift_enter(ui, id) { - select_all(ui, id, &buf); - } - let kept = shift_enter_keeps_focus(ui, id); - let resp = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(5) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - // As tall as the other fields (26 points with the margin), whatever the font's line - // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. - ui.spacing_mut().interact_size.y = 18.0; - ui.set_height(18.0); - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(62.0) - .char_limit(7) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner; - let resp = name_field(label_field(resp, label), name); - let commit = (resp.lost_focus() || kept) && buf != hex; - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); - }); - commit.then_some(buf) -} - -/// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection). -pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: bool) { - let t = Tokens::get(ui.ctx()); - paint_chip(ui, rect, paint); - ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); - if selected || hovered { - ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); - ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, if selected { t.accent } else { t.text }), StrokeKind::Outside); - } -} - -/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its -/// text in egui memory under `id`. Returns the text. -pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String { - let t = Tokens::get(ui.ctx()); - let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); - egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, t.input_border)) - .corner_radius(egui::CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(8, 5)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut query) - .id(id.with("edit")) - .frame(egui::Frame::NONE) - .hint_text(egui::RichText::new(tl!(hint)).italics()) - .desired_width(ui.available_width()), - ); - }); - ui.data_mut(|d| d.insert_temp(id, query.clone())); - query -} - -/// A bordered list box (Swatches tiles, Appearance rows, Artboards list). -pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { - let t = Tokens::get(ui.ctx()); - egui::Frame::NONE.stroke(Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(1)).show(ui, add).inner -} - -/// A bottom button bar separated from the content by a divider (panel footers). -pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { - let t = Tokens::get(ui.ctx()); - ui.add_space(4.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); - ui.painter().rect_filled(r, 0.0, t.divider); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - add(ui) - }); -} - -/// A menu row for panel (≡) menus: label, optional check mark, disabled when not implemented. -pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { - menu_item_name(ui, tl!(label), enabled, checked) -} - -/// [`menu_item`] for a name that is user or file data (a library, a recent URL), never translated. -/// Like the menu bar's items, the row lights up under the pointer. -pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { - let text = if checked { format!("✓ {label}") } else { format!(" {label}") }; - ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame_when_inactive(false)).clicked() -} - -/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without -/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit (Enter, Shift+Enter or -/// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units -/// and arithmetic work as in [`num_field`]. The value is a length: ↑/↓, the wheel and a scrub stop -/// at 0. -pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option> { - opt_field_named(ui, id, None, value, unit, width) -} - -/// [`opt_field`] for a field named explicitly instead of by an adjacent label widget (the Stroke -/// panel's dash and gap cells have their labels in a row below them). -pub fn opt_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option> { - let t = Tokens::get(ui.ctx()); - let label = take_previous_label(ui); - let id = id_with(ui.scope_id(), id); - let shown = value.map(|v| unit.number(v)).unwrap_or_default(); - let editing = ui.memory(|m| m.has_focus(id)); - // As in `hex_field`: until the user types into it, the field shows `shown` as it is now - // (#1692). When `shown` changes while the field has the focus and nothing has been typed into - // it, all of the new text is selected, as on keyboard focus. - let edited_id = id.with("edited"); - let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); - let before = ui.data_mut(|d| d.get_temp::(id)); - let mut buf: String = if edited { before.clone().unwrap_or_else(|| shown.clone()) } else { shown.clone() }; - let reshown = after_shift_enter(ui, id); - if reshown || editing && !edited && before.is_some_and(|b| b != buf) { - select_all(ui, id, &buf); - } - let kept = shift_enter_keeps_focus(ui, id); - let enabled = ui.is_enabled(); - let framed = egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(4, 4)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(width - 10.0) - .font(egui::FontId::proportional(12.5)) - .text_color(if enabled { t.text_strong } else { t.text_disabled }), - ) - }); - let resp = framed.inner; - let resp = name_field(label_field(resp, label), name); - select_all_on_focus(ui, &resp, &buf); - // ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it. - let lengths = 0.0..=f64::INFINITY; - let stepped = step_value( - ui, - &resp, - &mut buf, - &lengths, - (|b: &str| Some(unit.from_pt(unit.parse(&decimal_point(b))?)), |v| unit.number(unit.to_pt(v))), - None, - ); - let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &lengths); - let focused = ui.memory(|m| m.has_focus(id)); - ui.data_mut(|d| { - d.insert_temp(id, buf.clone()); - d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); - }); - if (resp.lost_focus() || kept) && buf != shown { - let s = buf.trim(); - if s.is_empty() { Some(None) } else { typed_unit(ui.ctx(), unit).parse(&decimal_point(s)).map(Some) } - } else { - stepped.or(scrubbed).map(|v| Some(unit.to_pt(v))) - } -} - -/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop -/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. -#[derive(Clone, Debug, PartialEq)] -pub enum PanelDrag { - /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it - /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, - /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the - /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes - /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is - /// (library id, colour name), which that panel moves to where it is dropped. - Paint { paint: Paint, params: serde_json::Value, rows: Option, library: Option<(String, String)> }, - /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance - /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. - Appearance(vectorcraft_doc::NodeId), - /// A Graphic Styles panel style: art it is dropped on takes it (`graphicStyle.apply` with its - /// `ids`); the panel moves it to where it is dropped. - GraphicStyle(String), - /// The selected art, dragged off the canvas with the Selection tool: the Graphic Styles panel - /// makes a style of the first object (`graphicStyle.new`), the Symbols panel a symbol of it all - /// (`symbol.new`), the Swatches panel a pattern swatch of a copy (`object.pattern.make`) and the - /// Brushes panel an Art brush (`brush.new`). - Art(Vec), - /// A Symbols panel symbol: the canvas places an instance of it centred where it is dropped - /// (`symbol.place`). - Symbol(String), - /// A symbol of a symbol library panel (`name`: its name in `library`): the canvas adds it to the - /// document's Symbols and places an instance where it is dropped (`symbol.addFromLibrary` with - /// `place`). - LibrarySymbol { library: String, name: String }, - /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is - /// dropped on takes it (`brush.apply`). - Brush { name: String, def: serde_json::Value }, - /// A brush of a brush library panel (`name`: its name in `library`): the path it is dropped on - /// takes it (`brush.addFromLibrary` with `apply`), and the Brushes panel adds it. - LibraryBrush { library: String, name: String }, - /// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred - /// where it is dropped (`library.use`); the panel moves it to where it is dropped. - LibraryGraphic { library: String, item: String }, - /// A Libraries panel text style (`item`: its name in `library`), which the panel moves to where - /// it is dropped. - LibraryItem { library: String, kind: vectorcraft_engine::cmd::library::ItemKind, item: String }, - /// A Libraries panel group, by its header, which the panel moves to where it is dropped. - LibraryGroup { library: String, group: String }, -} - -/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it; -/// → the dragged ids when it is released there. -pub fn art_drop(ui: &Ui, zone: &Response) -> Option> { - let drag = zone.dnd_hover_payload::()?; - let PanelDrag::Art(ids) = &*drag else { return None }; - ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); - zone.dnd_release_payload::().map(|_| ids.iter().map(|id| id.0).collect()) -} - -/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour -/// group under the pointer when the drag started (`grabbed`) and `names`, the panel selection when -/// the grabbed one is part of it (only colour groups, or only swatches, like the grabbed one), in -/// panel order. -#[derive(Clone, Debug, PartialEq)] -pub struct SwatchRows { - pub grabbed: String, - pub names: Vec, - /// The names are colour groups. - pub groups: bool, -} - -impl PanelDrag { - /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient - /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, - /// coloured like the dragged ones). - pub fn paint(mut paint: Paint) -> Self { - if let Paint::Gradient(g) = &mut paint { - g.geom = None; - g.freeform = None; - } - Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None, library: None } - } - /// The dragged colour (a solid paint's), which the Gradient panel's ramp takes. - pub fn color(&self) -> Option { - match self { - Self::Paint { paint, .. } => paint.color(), - _ => None, - } - } -} - -/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts -/// on it. -pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) { - if resp.drag_started() { - egui::DragAndDrop::set_payload(ui.ctx(), drag()); - } -} - -/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes, -/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when -/// the texture for `key` at this size isn't cached yet. -pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option) -> Option { - use std::cell::RefCell; - use std::collections::HashMap; - thread_local! { - static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); - static CACHE: crate::graphics::TexCache> = crate::graphics::TexCache::default(); - } - let ppp = ui.ctx().pixels_per_point() as f64; - let (w, h) = (size.x as f64, size.y as f64); - let key = format!("{w}x{h}@{ppp}:{key}"); - if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { - return Some(t); - } - let doc = build(w, h)?; - let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false)); - let color = crate::canvas::color_image(&img); - let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR); - CACHE.with(|c| { - let mut c = c.borrow_mut(); - if c.len() > 256 { - c.clear(); - } - c.insert(key, tex.clone()); - }); - Some(tex) -} - -/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`. -fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 { - let a = h.to_radians(); - c + vec2(a.cos(), -a.sin()) * r * s -} - -/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards. -fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { - const SEG: u32 = 72; - const RINGS: u32 = 8; - let mut mesh = egui::Mesh::default(); - mesh.colored_vertex(c, crate::panels::c32(&Color::from_hsb(0.0, 0.0, v))); - for ring in 1..=RINGS { - let s = ring as f32 / RINGS as f32; - for k in 0..SEG { - let h = k as f32 * 360.0 / SEG as f32; - mesh.colored_vertex(wheel_pos(c, r, h, s), crate::panels::c32(&Color::from_hsb(h, s, v))); - } - } - let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG }; - for ring in 0..RINGS { - for k in 0..SEG { - if ring == 0 { - mesh.add_triangle(0, at(1, k), at(1, k + 1)); - } else { - mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1)); - mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1)); - } - } - } - egui::Shape::mesh(mesh) -} - -/// What a [`harmony_wheel`] did this frame. -#[derive(Default)] -pub struct WheelResponse { - /// The colour whose marker was pressed (it becomes the selected one). - pub pressed: Option, - /// A colour moved by dragging its marker (a new hue and saturation) or, the selected colour, - /// the brightness slider (a new brightness): `[h°, s, v]`, `None` where the control kept it. - pub moved: Option<(usize, [Option; 3])>, -} - -/// The harmony wheel of Recolor Artwork's Edit tab and the Color Themes panel: a hue and saturation -/// wheel `size` across (hue around, saturation outwards) at the selected colour's brightness, a -/// marker per colour joined to the centre (the selected colour, `base`, larger) and a Brightness -/// slider for the selected colour below it. -pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: Option) -> WheelResponse { - let t = Tokens::get(ui.ctx()); - let mut out = WheelResponse::default(); - let base_hsb = base.and_then(|b| colors.get(b)).map(Color::to_hsb); - let v = base_hsb.map_or(1.0, |c| c[2]); - ui.horizontal(|ui| { - ui.add_space(((ui.available_width() - size) / 2.0).max(0.0)); - let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); - let (c, r) = (rect.center(), size / 2.0 - 10.0); - let painter = ui.painter_at(rect); - painter.add(wheel_shape(c, r, v.max(0.15))); - painter.circle_stroke(c, r, Stroke::new(1.0, t.border)); - let at = |col: &Color| { - let [h, s, _] = col.to_hsb(); - wheel_pos(c, r, h, s) - }; - for (i, col) in colors.iter().enumerate() { - let (p, big) = (at(col), Some(i) == base); - painter.line_segment([c, p], Stroke::new(1.0, Color32::from_white_alpha(160))); - painter.circle(p, if big { 8.0 } else { 5.5 }, crate::panels::c32(col), Stroke::new(1.5, Color32::WHITE)); - painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, Stroke::new(1.0, Color32::BLACK)); - } - // Drag a marker: the nearest one within reach of the press. - let drag = ui.scope_id().with((id, "drag")); - if (resp.drag_started() || resp.clicked()) - && let Some(p) = resp.interact_pointer_pos() - { - let near = colors.iter().map(at).enumerate().map(|(i, m)| (i, m.distance(p))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1)); - out.pressed = near.map(|x| x.0); - ui.data_mut(|m| m.insert_temp(drag, out.pressed)); - } - if resp.dragged() - && let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::>(drag)).flatten(), resp.interact_pointer_pos()) - && i < colors.len() - { - let dv = p - c; - let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); - out.moved = Some((i, [Some(h), Some((dv.length() / r).clamp(0.0, 1.0)), None])); - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Color wheel"))); - }); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0)); - ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim))); - // Screen readers get the brightness in percent, as the Color panel's HSB B field shows it. - let shown = SliderValue::field((f64::from(v) * 100.0).round(), 0.0..=100.0, "%", 0); - if let (Some(b), Some([h, s, _])) = (base, base_hsb) - && let (Some(nv), _) = color_slider_valued(ui, (id, "brightness"), tl!("Brightness"), Some(shown), v, size, &|x| { - crate::panels::c32(&Color::from_hsb(h, s, x)) - }) - { - out.moved = Some((b, [None, None, Some(nv)])); - } - }); - out -} - -/// A row of text tabs, the current one underlined in the accent colour (New Document's -/// categories, Document Setup's sections). Returns the clicked tab's index. -pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { - let t = Tokens::get(ui.ctx()); - let mut clicked = None; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 18.0; - for (i, tab) in tabs.iter().enumerate() { - let sel = i == current; - let font = if sel { theme::semibold(13.0) } else { egui::FontId::proportional(13.0) }; - let galley = ui.painter().layout_no_wrap(tl!(tab).to_string(), font, t.text); - let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x, 30.0), Sense::click()); - let color = if sel || resp.hovered() { t.text_strong } else { t.text_dim }; - ui.painter().galley(pos2(rect.left(), rect.center().y - galley.size().y / 2.0 - 2.0), galley, color); - if sel { - ui.painter().rect_filled( - Rect::from_min_max(pos2(rect.left(), rect.bottom() - 2.0), rect.right_bottom()), - CornerRadius::same(1), - t.accent, - ); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), sel, tl!(tab))); - if resp.clicked() { - clicked = Some(i); - } - } - }); - clicked -} - -/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner, -/// accent-filled when `selected`. Returns clicked. -pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); - if resp.hovered() && !selected { - ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); - } - let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) }; - let page = Rect::from_center_size(rect.center(), size); - let color = if selected { t.accent } else { t.icon }; - let fold = 4.0; - let outline = - vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()]; - let fill = if selected { t.accent } else { Color32::TRANSPARENT }; - ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); - let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; - ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tl!(tip))); - resp.on_hover_text(tl!(tip)).clicked() -} - -/// An artboard-layout toggle drawn in code (Rearrange All Artboards): small artboards in the grid -/// cells `tiles` (column, row), tinted, and over them their order as a line from a dot on the first -/// to an arrowhead on the last; accent-drawn in a pressed well when `selected`, greyed in a disabled -/// `ui`. `tip` is shown as it is (the caller translates it). Returns clicked. -pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); - let enabled = ui.is_enabled(); - let bg = if selected { - t.tool_active - } else if resp.hovered() && enabled { - t.hover - } else { - Color32::TRANSPARENT - }; - ui.painter().rect_filled(rect, CornerRadius::same(3), bg); - let color = if !enabled { - t.text_disabled - } else if selected { - t.accent - } else { - t.icon - }; - // Cells at most 10 points a side in a 20-point square: a row's artboards stand tall, a - // column's lie flat. - let (cols, rows) = tiles.iter().fold((1u8, 1u8), |(c, r), &(x, y)| (c.max(x.saturating_add(1)), r.max(y.saturating_add(1)))); - let cell = vec2((20.0 / f32::from(cols)).min(10.0), (20.0 / f32::from(rows)).min(10.0)); - let origin = rect.center() - vec2(f32::from(cols) * cell.x, f32::from(rows) * cell.y) / 2.0; - let centre = |&(x, y): &(u8, u8)| origin + vec2((f32::from(x) + 0.5) * cell.x, (f32::from(y) + 0.5) * cell.y); - for tile in tiles { - ui.painter().rect_filled(Rect::from_center_size(centre(tile), cell - vec2(2.0, 2.0)), 1.0, color.gamma_multiply(0.35)); - } - let path: Vec = tiles.iter().map(centre).collect(); - if let ([first, ..], [.., from, to]) = (path.as_slice(), path.as_slice()) { - let (first, from, to) = (*first, *from, *to); - let dir = (to - from).normalized(); - let side = dir.rot90() * 2.5; - let head = vec![to + dir * 1.5, to - dir * 2.5 + side, to - dir * 2.5 - side]; - ui.painter().circle_filled(first, 1.6, color); - ui.painter().add(egui::Shape::line(path, Stroke::new(1.2, color))); - ui.painter().add(egui::Shape::convex_polygon(head, color, Stroke::NONE)); - } - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tip)); - resp.on_hover_text(tip).clicked() -} - -/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name` -/// (artboard and page thumbnails). -pub fn region_texture( - ctx: &egui::Context, - renderer: &mut vectorcraft_render::Renderer, - name: &str, - doc: &vectorcraft_doc::Document, - region: vectorcraft_geom::Rect, - px: f64, -) -> egui::TextureHandle { - let scale = px / region.width().max(region.height()).max(1.0); - let img = renderer.render_region(doc, region, scale, true); - let color = crate::canvas::color_image(&img); - ctx.load_texture(name, color, egui::TextureOptions::LINEAR) -} - -/// The chevron cell on the right of a field that opens its preset list (rounded on the right). -fn chevron_cell(ui: &mut Ui, h: f32) -> Response { - let t = Tokens::get(ui.ctx()); - let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); - ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); - ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let c = if !ui.is_enabled() { - t.text_disabled - } else if dresp.hovered() { - t.text_strong - } else { - t.icon - }; - icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); - dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); - dresp -} - -/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…). -/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked. -pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash, value: &str, presets: &[&str], width: f32) -> Option { - text_presets_named(ui, id, None, value, presets, width) -} - -/// [`text_presets`] for a row whose heading names it instead of an adjacent label widget. -pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, presets: &[&str], width: f32) -> Option { - let mut out = None; - ui.scope(|ui| { - // The field and the chevron share a border. - ui.spacing_mut().item_spacing.x = -1.0; - out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1); - let resp = chevron_cell(ui, 26.0); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(width - 10.0); - for p in presets { - if ui.selectable_label(*p == value, *p).clicked() { - out = Some((*p).to_string()); - } - } - }); - }); - out -} - #[cfg(test)] pub(crate) mod accessibility_tests { use egui::accesskit::{Node, Role, Toggled}; diff --git a/crates/ui-egui/src/widgets/blend.rs b/crates/ui-egui/src/widgets/blend.rs new file mode 100644 index 000000000..267bec339 --- /dev/null +++ b/crates/ui-egui/src/widgets/blend.rs @@ -0,0 +1,161 @@ +use super::color::{Live, pointer_phase}; +use super::dropdowns::combo; +use super::labels::dim_label; +use super::numeric::{range_field, slider}; +use super::*; + +/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a +/// [`BlendMode::group`] starts. +pub fn blend_separator_before(i: usize) -> bool { + i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group() +} + +/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion +/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode. +pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: Option, width: f32) -> Option { + combo(ui, id, current.map_or("", |m| tl!(m.label())), width, false, |ui| { + let mut chosen = None; + for (i, m) in BlendMode::ALL.into_iter().enumerate() { + if blend_separator_before(i) { + ui.separator(); + } + if ui.selectable_label(Some(m) == current, tl!(m.label())).clicked() { + chosen = Some(m); + } + } + chosen + }) +} + +/// The blend mode an effect parameter names (`"mode": "multiply"`), which editors show as a +/// [`blend_param_dropdown`]. +pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { + value.as_str().filter(|_| key == "mode").and_then(BlendMode::parse) +} + +/// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. +pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: BlendMode) -> Option { + blend_dropdown(ui, id, Some(current), 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) +} + +/// An edit made with [`opacity_blend`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TransparencyEdit { + Blend(BlendMode), + /// Opacity in percent, with the slider's drag phase (`Released` for a typed value). + Opacity(f64, Live), +} + +/// The transparency controls shared by the Transparency panel and the Appearance panel's Opacity +/// popups: the grouped blend-mode dropdown, the opacity percent field and its slider popup. +/// `opacity` (0..1) and `blend` are `None` where the objects differ (shown blank). +pub fn opacity_blend( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + opacity: Option, + blend: Option, + enabled: bool, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut edit = None; + ui.horizontal(|ui| { + ui.add_enabled_ui(enabled, |ui| { + if let Some(m) = blend_dropdown(ui, (id, "blend"), blend, 104.0) { + edit = Some(TransparencyEdit::Blend(m)); + } + }); + dim_label(ui, "Opacity:"); + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_enabled_ui(enabled, |ui| { + if let Some(o) = range_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), 0.0..=100.0, "%", 0, 50.0) { + edit = Some(TransparencyEdit::Opacity(o, Live::Released)); + } + }); + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); + ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity"))); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let mut o = opacity.unwrap_or(1.0) * 100.0; + let r = slider(ui, egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); + let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) { + Live::Released + } else if r.changed() { + Live::Dragging + } else { + return; + }; + edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase)); + }); + }); + edit +} + +/// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around +/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. +pub fn reference_point(ui: &mut Ui, current: usize) -> Option { + let t = Tokens::get(ui.ctx()); + // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through + // the squares' centres stay crisp. + let (sq, step) = (5.0, 9.0); + let size = 2.0 * step + sq; + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let o = rect.min.round(); + let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); + let line = Stroke::new(1.0, t.text_dim); + ui.painter().rect_stroke(Rect::from_two_pos(centre(0), centre(8)), 0.0, line, StrokeKind::Middle); + let mut out = None; + // Screen readers name the squares left to right and top to bottom. + let names = [ + tl!("Top Left"), + tl!("Top"), + tl!("Top Right"), + tl!("Left"), + tl!("Center"), + tl!("Right"), + tl!("Bottom Left"), + tl!("Bottom"), + tl!("Bottom Right"), + ]; + for (i, name) in names.into_iter().enumerate() { + let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); + let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); + if resp.clicked() { + out = Some(i); + } + if i == current { + ui.painter().rect_filled(r, 0.0, t.text); + } else { + ui.painter().rect_filled(r, 0.0, t.panel); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); + } + } + out +} + +/// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, +/// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole +/// degrees (-179..180), Shift in 45° steps. Returns the new angle. +pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash, angle: f64, size: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let (c, r) = (rect.center(), size / 2.0 - 1.0); + let ring = if resp.hovered() || resp.dragged() { t.text } else { t.input_border }; + ui.painter().circle(c, r, t.input, Stroke::new(1.0, ring)); + let a = (angle as f32).to_radians(); + ui.painter().line_segment([c, c + vec2(a.cos(), -a.sin()) * (r - 3.0)], Stroke::new(1.5, t.text)); + ui.painter().circle_filled(c, 2.0, t.text); + resp.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), angle, tl!("Angle"))); + let (p, _) = pointer_phase(&resp)?; + let v = p - c; + if v.length() < 2.0 { + return None; + } + let step = if ui.input(|i| i.modifiers.shift) { 45.0 } else { 1.0 }; + let deg = ((-v.y).atan2(v.x).to_degrees() as f64 / step).round() * step; + // atan2 gives -180..180: -180 shows as 180. + let deg = if deg <= -180.0 { deg + 360.0 } else { deg }; + (deg != angle).then_some(deg) +} diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs new file mode 100644 index 000000000..d759dcd29 --- /dev/null +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -0,0 +1,202 @@ +use super::*; + +/// Square icon button; `selected` draws the pressed well. +pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response { + let (_, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + paint_icon_button(ui, resp, icon, tip, selected) +} + +/// [`icon_button`] drawn for a response the caller made (its own id or sense). +pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, selected: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let rect = resp.rect; + let size = rect.width(); + let bg = if selected { + t.tool_active + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(5), bg); + let pad = (size * 0.2).round(); + icons::paint(ui, icon, rect.shrink(pad), if selected { t.text_strong } else { t.icon }); + resp.widget_info(|| icon_button_info(ui.is_enabled(), selected, tl!(tip))); + if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp } +} + +/// Small flat text button (Quick Actions style). +pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); + let rect = rect.shrink2(vec2(0.0, 0.5)); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); + flat_fill(ui, rect, &resp); + ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} + +/// A [`flat_button`] that expands to fit localized labels wider than `min_width`. +pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); + let width = (galley.size().x + 24.0).max(min_width); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); + let rect = rect.shrink2(vec2(0.0, 0.5)); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); + flat_fill(ui, rect, &resp); + ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} + +/// Width of a [`split_button`]'s arrow. +pub const SPLIT_ARROW: f32 = 18.0; + +/// A [`flat_button`] `width` wide whose right end is a menu arrow (Image Trace ▾) → the +/// responses of the button and of its arrow. +pub fn split_button(ui: &mut Ui, text: &str, width: f32) -> (Response, Response) { + split_button_named(ui, text, "", width) +} + +/// [`split_button`] whose arrow has the tool tip `arrow_tip`, which screen readers also call it +/// (none when empty). +pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) -> (Response, Response) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); + let rect = rect.shrink2(vec2(0.0, 0.5)); + let split = rect.right() - SPLIT_ARROW; + let main_rect = Rect::from_min_max(rect.min, pos2(split, rect.bottom())); + let arrow_rect = Rect::from_min_max(pos2(split, rect.top()), rect.max); + let main = ui.interact(main_rect, ui.scope_id().with((text, "split")), Sense::click()); + let arrow = ui.interact(arrow_rect, ui.scope_id().with((text, "split-arrow")), Sense::click()); + ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); + flat_fill(ui, main_rect, &main); + flat_fill(ui, arrow_rect, &arrow); + ui.painter().vline(split, rect.y_range().shrink(5.0), Stroke::new(1.0, t.panel)); + ui.painter().with_clip_rect(main_rect).text( + main_rect.center(), + egui::Align2::CENTER_CENTER, + tl!(text), + egui::FontId::proportional(12.5), + t.text_strong, + ); + icons::paint(ui, "chevron-down", Rect::from_center_size(arrow_rect.center(), vec2(12.0, 12.0)), t.text_dim); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + if arrow_tip.is_empty() { + return (main, arrow); + } + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(arrow_tip))); + (main, arrow.on_hover_text(tl!(arrow_tip))) +} + +/// The pressed or hovered fill of a flat button part. +fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) { + let t = Tokens::get(ui.ctx()); + if resp.is_pointer_button_down_on() { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover); + } +} + +/// The accent call-to-action button (dialog OK/Create). +pub fn primary_button(ui: &mut Ui, text: &str) -> Response { + primary_button_enabled(ui, text, true) +} + +/// [`primary_button`], greyed out and not clickable unless `enabled`. +pub fn primary_button_enabled(ui: &mut Ui, text: &str, enabled: bool) -> Response { + primary_button_sized(ui, text, enabled, 72.0) +} + +/// Primary dialog button with a minimum width, for aligned action columns. +pub(crate) fn primary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { + primary_button_sized(ui, text, true, min_width) +} + +/// Draws a primary button at least `min_width` wide, growing to fit the translated label. +/// Returns its response with the enabled state and screen-reader name preserved. +fn primary_button_sized(ui: &mut Ui, text: &str, enabled: bool, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let fg = if enabled { Color32::WHITE } else { t.text_disabled }; + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg); + let w = galley.size().x + 32.0; + let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), if enabled { Sense::click() } else { Sense::hover() }); + let bg = match (enabled, resp.hovered()) { + (false, _) => t.input, + (true, true) => t.accent, + (true, false) => t.accent_strong, + }; + ui.painter().rect_filled(rect, CornerRadius::same(6), bg); + if !enabled { + ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); + } + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!(text))); + resp +} + +/// The quiet secondary button (dialog Cancel). +pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { + secondary_button_width(ui, text, 72.0) +} + +/// Secondary dialog button with a minimum width, for aligned action columns. +pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), t.text); + let w = galley.size().x + 32.0; + let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), Sense::click()); + ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button }); + ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} + +/// A toggle icon (e.g. eye / lock columns) — returns clicked. +pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + if on { + icons::paint(ui, on_icon, rect.shrink(size * 0.2), if resp.hovered() { t.text } else { t.icon }); + } else if resp.hovered() { + icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tl!(tip))); + resp.on_hover_text(tl!(tip)).clicked() +} + +// ---------- panel widgets (Swatches, Color, Stroke, Gradient, Appearance, …) ---------- + +/// Icon button that can be disabled (greyed, no hover, never clicked). +pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, enabled: bool, size: f32) -> Response { + if enabled { + return icon_button(ui, icon, tip, selected, size); + } + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let pad = (size * 0.2).round(); + icons::paint(ui, icon, rect.shrink(pad), t.text_disabled); + resp.widget_info(|| icon_button_info(false, selected, tl!(tip))); + if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) } +} + +/// What screen readers get for an icon button: a button called `name` (already translated), +/// reported as pressed while `selected` and with no pressed state otherwise. +pub(crate) fn icon_button_info(enabled: bool, selected: bool, name: &str) -> egui::WidgetInfo { + egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::Role::Button, enabled, name) } +} + +/// A menu row for panel (≡) menus: label, optional check mark, disabled when not implemented. +pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { + menu_item_name(ui, tl!(label), enabled, checked) +} + +/// [`menu_item`] for a name that is user or file data (a library, a recent URL), never translated. +/// Like the menu bar's items, the row lights up under the pointer. +pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool { + let text = if checked { format!("✓ {label}") } else { format!(" {label}") }; + ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame_when_inactive(false)).clicked() +} diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs new file mode 100644 index 000000000..068bd4e7d --- /dev/null +++ b/crates/ui-egui/src/widgets/chips.rs @@ -0,0 +1,250 @@ +use super::drag::{PanelDrag, drag_source}; +use super::*; + +/// Draw a paint preview (swatch chip) into `rect`. +pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) { + let p = ui.painter(); + match paint { + Paint::None => { + p.rect_filled(rect, 0.0, Color32::WHITE); + p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x20, 0x20))); + } + Paint::Solid { color, .. } => { + let [r, g, b, _] = color.to_rgba8(1.0); + p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); + } + Paint::Gradient(gp) => gradient_chip(ui, rect, gp), + Paint::Pattern { .. } => { + p.rect_filled(rect, 0.0, Color32::from_gray(200)); + for i in 0..4 { + let x = rect.left() + rect.width() * i as f32 / 4.0; + p.line_segment([pos2(x, rect.bottom()), pos2(x + rect.width() / 4.0, rect.top())], Stroke::new(1.0, Color32::from_gray(90))); + } + } + } +} + +/// Draw a gradient preview into `rect`, laid out as the gradient would be on an object of that +/// shape: linear along its angle, radial from the centre, freeform from its colour points. +pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::GradientPaint) { + if !(rect.width() > 0.0 && rect.height() > 0.0) { + return; + } + // Freeform sampling is costly per spot, so it gets a coarse grid (the mesh interpolates). + let freeform = gradient.gradient.kind == vectorcraft_color::GradientKind::Freeform; + let max = if freeform { 16 } else { 64 }; + let cols = ((rect.width() / 3.0).ceil() as usize).clamp(1, max); + let rows = ((rect.height() / 3.0).ceil() as usize).clamp(1, max); + let colors = gradient_chip_colors(gradient, rect.width(), rect.height(), cols, rows); + let mut mesh = egui::Mesh::default(); + for (k, color) in colors.into_iter().enumerate() { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + let at = pos2(rect.left() + rect.width() * i as f32 / cols as f32, rect.top() + rect.height() * j as f32 / rows as f32); + mesh.colored_vertex(at, color); + } + for j in 0..rows as u32 { + for i in 0..cols as u32 { + let v = j * (cols as u32 + 1) + i; + mesh.add_triangle(v, v + 1, v + cols as u32 + 1); + mesh.add_triangle(v + 1, v + cols as u32 + 2, v + cols as u32 + 1); + } + } + ui.painter().add(egui::Shape::mesh(mesh)); +} + +/// The colours at the `(cols + 1) x (rows + 1)` grid points (row by row) of a `width` x `height` +/// chip showing `gp` as it would look on an object of that size. +pub(super) fn gradient_chip_colors(gp: &vectorcraft_color::GradientPaint, width: f32, height: f32, cols: usize, rows: usize) -> Vec { + use vectorcraft_color::{GradientGeom, GradientKind}; + let b = vectorcraft_geom::kurbo::Rect::new(0.0, 0.0, width as f64, height as f64); + let rgb = |c: vectorcraft_color::Color| { + let [r, g, b, _] = c.to_rgba8(1.0); + Color32::from_rgb(r, g, b) + }; + let at = |k: usize| { + let (i, j) = (k % (cols + 1), k / (cols + 1)); + vectorcraft_geom::kurbo::Point::new(b.width() * i as f64 / cols as f64, b.height() * j as f64 / rows as f64) + }; + let n = (cols + 1) * (rows + 1); + if gp.gradient.kind == GradientKind::Freeform { + let Some(painted) = vectorcraft_color::freeform::painted_box(b) else { return vec![Color32::TRANSPARENT; n] }; + let field = gp.freeform_on(painted).field(vectorcraft_color::freeform::spread_scale(painted)); + return (0..n) + .map(|k| { + let ([r, g, b], _) = field.sample(at(k)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(q(r), q(g), q(b)) + }) + .collect(); + } + // A placed gradient keeps its direction; a fresh one is fitted to the chip at its angle. + let angle = gp.geom.map_or(gp.angle, |g| g.angle_deg()); + let geom = GradientGeom::fit(gp.gradient.kind, b, if angle.is_finite() { angle } else { 0.0 }); + (0..n) + .map(|k| { + let t = geom.param_at(gp.gradient.kind, at(k)).clamp(0.0, 1.0) as f32; + rgb(gp.gradient.sample(t).0) + }) + .collect() +} + +/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a +/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ +/// (`mixed`) shows a white chip with a "?" (in the hole for strokes). +pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option) -> Rect { + let t = Tokens::get(ui.ctx()); + if mixed { + ui.painter().rect_filled(rect, 0.0, Color32::WHITE); + } else { + paint_chip(ui, rect, paint); + } + let (hole, mark) = match ring { + Some(w) => { + let inner = rect.shrink(w); + ui.painter().rect_filled(inner, 0.0, t.panel); + (inner, t.text_strong) + } + None => (rect, Color32::BLACK), + }; + if mixed { + ui.painter().text(hole.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(hole.height() * 0.8), mark); + } + hole +} + +/// A chip that opens something (the Control bar's and Properties' Fill/Stroke chips, the Control +/// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with +/// `chevron` a chevron follows it. +pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response { + chip_button_named(ui, size, chevron, "", draw) +} + +/// [`chip_button`] with the tool tip `tip`, which screen readers also call it (none when empty). +pub fn chip_button_named(ui: &mut Ui, size: f32, chevron: bool, tip: &str, draw: impl FnOnce(&Ui, Rect)) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click()); + let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0); + draw(ui, chip); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside); + if chevron { + icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim); + } + if tip.is_empty() { + return resp; + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(tip))); + resp.on_hover_text(tl!(tip)) +} + +/// What the user did on a [`fill_stroke_proxy`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProxyClicks { + pub fill: bool, + pub stroke: bool, + pub swap: bool, + pub default: bool, + /// Double-clicked a square: open the Color Picker for it (`true` = the stroke). + pub pick: Option, + /// A paint dragged from the other proxy (or a swatch) dropped on the fill square. + pub dropped_fill: Option, + /// A paint dropped on the stroke square. + pub dropped_stroke: Option, +} + +/// The Fill/Stroke proxy pair (two overlapping squares). `mixed` (fill, stroke) draws a "?" square +/// for a selection whose objects differ. +pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool, bool), fill_active: bool, size: f32) -> ProxyClicks { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover()); + let s = size * 0.62; + let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); + let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); + let stroke_resp = ui.interact(stroke_r, ui.scope_id().with("stroke-proxy"), Sense::click_and_drag()); + let fill_resp = ui.interact(fill_r, ui.scope_id().with("fill-proxy"), Sense::click_and_drag()); + let dropped_fill = if mixed.0 { None } else { proxy_paint_drop(ui, &fill_resp) }; + let dropped_stroke = if mixed.1 { None } else { proxy_paint_drop(ui, &stroke_resp) }; + // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". + for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] { + if !mixed { + drag_source(ui, resp, || PanelDrag::paint(paint.clone())); + } + } + let draw_fill = |ui: &Ui| { + proxy_chip(ui, fill_r, fill, mixed.0, None); + ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); + ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + }; + let draw_stroke = |ui: &Ui| { + let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28)); + ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside); + ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside); + ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside); + }; + if fill_active { + draw_stroke(ui); + draw_fill(ui); + } else { + draw_fill(ui); + draw_stroke(ui); + } + // Swap arrow (top-right) and default (bottom-left) mini buttons. + let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3)); + let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3)); + let swap = ui.interact(swap_r, ui.scope_id().with("swap-proxy"), Sense::click()); + let def = ui.interact(def_r, ui.scope_id().with("default-proxy"), Sense::click()); + icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon }); + let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14)); + let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14)); + ui.painter().rect_filled(b, 0.0, Color32::BLACK); + ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); + ui.painter().rect_filled(a, 0.0, Color32::WHITE); + ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); + let pick = if fill_resp.double_clicked() { + Some(false) + } else if stroke_resp.double_clicked() { + Some(true) + } else { + None + }; + let on = ui.is_enabled(); + fill_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); + stroke_resp + .widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); + swap.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); + def.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Default Fill and Stroke (D)"))); + ProxyClicks { + fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(), + stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(), + swap: swap.on_hover_text(tl!("Swap Fill and Stroke (Shift+X)")).clicked(), + default: def.on_hover_text(tl!("Default Fill and Stroke (D)")).clicked(), + pick, + dropped_fill, + dropped_stroke, + } +} + +/// A [`PanelDrag::Paint`] released on a Fill/Stroke proxy square: highlight while held over it. +fn proxy_paint_drop(ui: &Ui, resp: &Response) -> Option { + if let Some(drag) = resp.dnd_hover_payload::() + && let PanelDrag::Paint { params, .. } = &*drag + && !params.is_null() + { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + resp.dnd_release_payload::().and_then(|d| { + let PanelDrag::Paint { params, .. } = &*d else { return None }; + (!params.is_null()).then_some(params.clone()) + }) +} + +/// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection). +pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: bool) { + let t = Tokens::get(ui.ctx()); + paint_chip(ui, rect, paint); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + if selected || hovered { + ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside); + ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, if selected { t.accent } else { t.text }), StrokeKind::Outside); + } +} diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs new file mode 100644 index 000000000..4ffdaf6a9 --- /dev/null +++ b/crates/ui-egui/src/widgets/color.rs @@ -0,0 +1,556 @@ +use super::labels::{label_field, name_field, take_previous_label}; +use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple}; +use super::text::{after_shift_enter, select_all, shift_enter_keeps_focus}; +use super::*; + +/// Drag phase of a live slider/handle: previews while dragging, commits on release. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Live { + Idle, + Dragging, + Released, +} + +/// A colour slider with a gradient track (`track(t)` gives the colour at 0..1) and a triangular +/// thumb below it, like Illustrator's Color panel. Returns the new normalized value and the phase. +pub fn color_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, width: f32, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { + color_slider_named(ui, id, "", value, width, track) +} + +/// [`color_slider`] that screen readers get as a slider named `name` (already translated) with no +/// value (none when `name` is empty). +pub fn color_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + color_slider_valued(ui, id, name, None, value, width, track) +} + +/// The value a slider gives screen readers: the number the field beside it shows, the range of the +/// slider's rail and the field's text. +#[derive(Clone, Debug)] +pub struct SliderValue { + pub value: f64, + pub range: std::ops::RangeInclusive, + /// The field's text, such as "50%"; `None` gives the number alone. + pub text: Option, + /// How the keys and screen readers step the number ([`color_slider_stepped`]): the number `n` + /// steps from `value` (±1 for an arrow, ±10 with Shift or Page Up/Down). `None` steps to whole + /// numbers. + pub step: Option f64>, +} + +/// Two values are the same when they show the same number, range and text, whatever their keys' steps +/// (function pointers don't compare reliably). +impl PartialEq for SliderValue { + fn eq(&self, other: &Self) -> bool { + (self.value, &self.range, &self.text) == (other.value, &other.range, &other.text) + } +} + +impl SliderValue { + /// `value` on a rail over `range`, without a text. + pub fn number(value: f64, range: std::ops::RangeInclusive) -> Self { + Self { value, range, text: None, step: None } + } + + /// `value` on a rail over `range` as [`plain_field`] shows it with `suffix` and at most + /// `decimals` places: the number it shows (`value` rounded) and its text. + pub fn field(value: f64, range: std::ops::RangeInclusive, suffix: &str, decimals: usize) -> Self { + let number = field_text(value, "", decimals); + Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")), step: None } + } + + /// `value` on a rail over `range`, with `text`, for a field that writes its own text (a length + /// in the document's unit, an egui `DragValue`). + pub fn text(value: f64, range: std::ops::RangeInclusive, text: String) -> Self { + Self { value, range, text: Some(text), step: None } + } + + /// This value, stepped by the keys with `step` instead of to whole numbers (see + /// [`SliderValue::step`]), for a rail whose useful numbers are fractions. + pub fn with_step(self, step: fn(f64, f64) -> f64) -> Self { + Self { step: Some(step), ..self } + } +} + +/// Give `resp` a slider's screen-reader node named `name` (nothing when `name` is empty), with +/// `shown`'s number, text and range: the rail's range, extended to the number when the field holds +/// a number past the rail's end. +fn slider_info(ui: &Ui, resp: &Response, name: &str, shown: Option) { + if name.is_empty() { + return; + } + let enabled = ui.is_enabled(); + let Some(s) = shown else { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, name)); + return; + }; + resp.widget_info(|| egui::WidgetInfo { current_text_value: s.text.clone(), ..egui::WidgetInfo::slider(enabled, s.value, name) }); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_min_numeric_value(s.range.start().min(s.value)); + node.set_max_numeric_value(s.range.end().max(s.value)); + }); +} + +/// [`color_slider_named`] that screen readers also get `shown` from (see [`SliderValue`]). With +/// `None`, as for a field left blank for a mixed value, it reports no value. +pub fn color_slider_valued( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + shown: Option, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + let id = id_with(ui.scope_id(), id); + let (_, picked, phase) = color_rail(ui, id, name, shown, value, width, track); + (picked, phase) +} + +/// [`color_slider_valued`] for a dialog's rail beside a number field, which also responds to the +/// keyboard. A click or a drag gives the rail the keyboard focus; an outline around it shows the +/// focus once a key has stepped it, or when the focus came from the keyboard (Tab). +/// While it has the focus, → and ↑ raise the number `shown` holds to the next whole number and ← +/// and ↓ lower it to the next whole number, Shift with an arrow, Page Up and Page Down add or take +/// ten, and Home and End set the start and the end of `shown`'s range. Screen readers' increment +/// and decrement actions step the number as → and ← do. A number past the range steps from the +/// range's nearer end, and no step goes past either end. With `shown` `None` (a blank field), or +/// in a disabled `ui`, the keys and actions leave the rail as it is. Returns the new value from 0 +/// to 1 that a click or a drag gives, its phase, and the number in `shown`'s units that a key or +/// an action gives. +pub fn color_slider_stepped( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + shown: Option, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live, Option) { + let id = id_with(ui.scope_id(), id); + let (resp, picked, phase) = color_rail(ui, id, name, shown.clone(), value, width, track); + // Focus a click or a drag gave shows no outline until a key steps the rail: the outline tells + // a keyboard user where the focus is, and after a drag it only framed the rail. + let by_pointer = id.with("focused-by-pointer"); + if resp.drag_started() || resp.clicked() { + resp.request_focus(); + ui.data_mut(|d| d.insert_temp(by_pointer, true)); + } + let focused = ui.memory(|m| m.has_focus(id)); + if !focused { + ui.data_mut(|d| d.remove_temp::(by_pointer)); + } + let stepped = shown.filter(|_| ui.is_enabled()).and_then(|s| { + ui.ctx().accesskit_node_builder(id, |node| { + node.set_numeric_value_step(1.0); + node.add_action(egui::accesskit::Action::Increment); + node.add_action(egui::accesskit::Action::Decrement); + }); + rail_step(ui, id, focused, &s) + }); + if stepped.is_some() { + ui.data_mut(|d| d.remove_temp::(by_pointer)); + } + if focused && !ui.data(|d| d.get_temp::(by_pointer)).unwrap_or(false) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect, 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + (picked, phase, stepped.filter(|_| picked.is_none())) +} + +/// The number of slider `id` (`s`) after this frame's keys while it is `focused`, and after screen +/// readers' actions on it ([`color_slider_stepped`]); `None` when they leave it as it was. +fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option { + use egui::accesskit::Action; + use egui::{Event, Key}; + let (lo, hi) = (*s.range.start(), *s.range.end()); + if !lo.is_finite() || !hi.is_finite() || hi <= lo { + return None; + } + let from = s.value.clamp(lo, hi); + let mut v = from; + // `n` steps from `v`: the rail's own steps, or to whole numbers (ten at a time with Shift). + let by = |v: f64, n: f64| match s.step { + Some(step) => step(v, n), + None if n.abs() >= 10.0 => v + n, + None => step_to_multiple(v, 1.0, n), + }; + // Home or End: an end of the range, which also replaces a number past it. + let mut end = false; + if focused { + // The arrows step the rail and do not move the focus. egui keeps the filter only when the + // rail also had the focus in the frame before, and applies it from the next frame; until + // then, an arrow the rail takes cancels the focus move that egui set at the start of the frame. + let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let mut arrows = false; + ui.input_mut(|i| { + i.events.retain(|e| { + let Event::Key { key, pressed: true, modifiers, .. } = e else { return true }; + let up = matches!(key, Key::ArrowRight | Key::ArrowUp | Key::PageUp); + let n = if up { 1.0 } else { -1.0 }; + match key { + Key::Home | Key::End if modifiers.is_none() => { + v = if *key == Key::Home { lo } else { hi }; + end = true; + } + Key::PageUp | Key::PageDown if modifiers.is_none() => v = by(v, 10.0 * n).clamp(lo, hi), + Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.shift_only() => { + v = by(v, 10.0 * n).clamp(lo, hi); + } + Key::ArrowRight | Key::ArrowUp | Key::ArrowLeft | Key::ArrowDown if modifiers.is_none() => { + v = by(v, n).clamp(lo, hi); + arrows = true; + } + _ => return true, + } + false + }); + }); + if arrows { + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + } + ui.input(|i| { + let n = i.num_accesskit_action_requests(id, Action::Increment) as f64 - i.num_accesskit_action_requests(id, Action::Decrement) as f64; + v = by(v, n).clamp(lo, hi); + }); + (v != from || (end && v != s.value)).then_some(round_to(v, STEP_DECIMALS).clamp(lo, hi)) +} + +/// The rail of [`color_slider_valued`] with the id `id`: its response, the new value from 0 to 1 +/// that a click or drag gives and the phase. +fn color_rail( + ui: &mut Ui, + id: egui::Id, + name: &str, + shown: Option, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, +) -> (Response, Option, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); + let bar = Rect::from_min_size(pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(width - 8.0, 7.0)); + let resp = ui.interact(rect, id, Sense::click_and_drag()); + gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let v = value.clamp(0.0, 1.0); + let x = bar.left() + v * bar.width(); + let tip = pos2(x, bar.bottom() - 1.0); + let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)]; + let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon }; + ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border))); + slider_info(ui, &resp, name, shown); + let (picked, phase) = match pointer_phase(&resp) { + Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), + }; + (resp, picked, phase) +} + +/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop). +pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> { + if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) { + return None; + } + let p = resp.interact_pointer_pos()?; + Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released })) +} + +/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with +/// `track(t)` at its centre. +fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) { + let len = if vertical { rect.height() } else { rect.width() }; + let seg = len / n as f32; + for i in 0..n { + let r = if vertical { + Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6)) + } else { + Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height())) + }; + ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32)); + } +} + +/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y +/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. +pub fn color_field( + ui: &mut Ui, + id: impl std::hash::Hash, + size: Vec2, + pos: (f32, f32), + color_at: &dyn Fn(f32, f32) -> Color32, +) -> (Option<(f32, f32)>, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let painter = ui.painter_at(rect.expand(6.0)); + painter.add(color_mesh(rect, (32, 32), color_at)); + painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height()); + painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK)); + painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE)); + match pointer_phase(&resp) { + Some((p, phase)) => { + let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0); + (Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase) + } + None => (None, Live::Idle), + } +} + +/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1 +/// each, y down) at its vertices. +pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f32) -> Color32) -> egui::Shape { + let mut mesh = egui::Mesh::default(); + let row = cols + 1; + mesh.reserve_vertices((row * (rows + 1)) as usize); + mesh.reserve_triangles((cols * rows * 2) as usize); + for j in 0..=rows { + for i in 0..=cols { + let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32); + mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y)); + } + } + for j in 0..rows { + for i in 0..cols { + let a = j * row + i; + mesh.add_triangle(a, a + 1, a + row); + mesh.add_triangle(a + 1, a + row + 1, a + row); + } + } + egui::Shape::mesh(mesh) +} + +/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) +/// and arrows on both sides mark `value`. Returns the new value and the phase. +pub fn channel_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, size: Vec2, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { + channel_slider_named(ui, id, "", value, size, track) +} + +/// [`channel_slider`] that screen readers get as a slider named `name` (already translated) with +/// no value, as for [`color_slider_named`] (none when `name` is empty). +pub fn channel_slider_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + channel_slider_valued(ui, id, name, None, value, size, track) +} + +/// [`channel_slider_named`] that screen readers also get `shown` from, as for +/// [`color_slider_valued`]. +pub fn channel_slider_valued( + ui: &mut Ui, + id: impl std::hash::Hash, + name: &str, + shown: Option, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, +) -> (Option, Live) { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); + let bar = rect.shrink2(vec2(6.0, 0.0)); + gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height(); + let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon }; + for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] { + let tip = pos2(x + d * 5.0, y); + ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE)); + } + slider_info(ui, &resp, name, shown); + match pointer_phase(&resp) { + Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase), + None => (None, Live::Idle), + } +} + +/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it +/// was typed into the field and differs from `hex`. Enter, Shift+Enter (which keeps the focus +/// outside dialogs) and focus loss commit it. +pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash, hex: &str) -> Option { + hex_field_named(ui, id, None, hex) +} + +/// [`hex_field`] for a colour field named explicitly instead of by an adjacent label widget. +pub fn hex_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, hex: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); + ui.label(egui::RichText::new("#").size(15.0).color(t.text)); + let id = id_with(ui.scope_id(), id); + let editing = ui.memory(|m| m.has_focus(id)); + // Until the user types into it, the field shows `hex` as it is now, also while it has the + // focus. Tab can give it the focus in the frame another field commits an edit, before the edit + // changes `hex`; the text from before that edit is not committed when the focus moves on + // (#1692). + let edited_id = id.with("edited"); + let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); + let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; + if after_shift_enter(ui, id) { + select_all(ui, id, &buf); + } + let kept = shift_enter_keeps_focus(ui, id); + let resp = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(5) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + // As tall as the other fields (26 points with the margin), whatever the font's line + // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. + ui.spacing_mut().interact_size.y = 18.0; + ui.set_height(18.0); + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(62.0) + .char_limit(7) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + let resp = name_field(label_field(resp, label), name); + let commit = (resp.lost_focus() || kept) && buf != hex; + let focused = ui.memory(|m| m.has_focus(id)); + ui.data_mut(|d| { + d.insert_temp(id, buf.clone()); + d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); + }); + commit.then_some(buf) +} + +/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`. +fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 { + let a = h.to_radians(); + c + vec2(a.cos(), -a.sin()) * r * s +} + +/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards. +fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape { + const SEG: u32 = 72; + const RINGS: u32 = 8; + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(c, crate::panels::c32(&Color::from_hsb(0.0, 0.0, v))); + for ring in 1..=RINGS { + let s = ring as f32 / RINGS as f32; + for k in 0..SEG { + let h = k as f32 * 360.0 / SEG as f32; + mesh.colored_vertex(wheel_pos(c, r, h, s), crate::panels::c32(&Color::from_hsb(h, s, v))); + } + } + let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG }; + for ring in 0..RINGS { + for k in 0..SEG { + if ring == 0 { + mesh.add_triangle(0, at(1, k), at(1, k + 1)); + } else { + mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1)); + mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1)); + } + } + } + egui::Shape::mesh(mesh) +} + +/// What a [`harmony_wheel`] did this frame. +#[derive(Default)] +pub struct WheelResponse { + /// The colour whose marker was pressed (it becomes the selected one). + pub pressed: Option, + /// A colour moved by dragging its marker (a new hue and saturation) or, the selected colour, + /// the brightness slider (a new brightness): `[h°, s, v]`, `None` where the control kept it. + pub moved: Option<(usize, [Option; 3])>, +} + +/// The harmony wheel of Recolor Artwork's Edit tab and the Color Themes panel: a hue and saturation +/// wheel `size` across (hue around, saturation outwards) at the selected colour's brightness, a +/// marker per colour joined to the centre (the selected colour, `base`, larger) and a Brightness +/// slider for the selected colour below it. +pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: Option) -> WheelResponse { + let t = Tokens::get(ui.ctx()); + let mut out = WheelResponse::default(); + let base_hsb = base.and_then(|b| colors.get(b)).map(Color::to_hsb); + let v = base_hsb.map_or(1.0, |c| c[2]); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - size) / 2.0).max(0.0)); + let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); + let (c, r) = (rect.center(), size / 2.0 - 10.0); + let painter = ui.painter_at(rect); + painter.add(wheel_shape(c, r, v.max(0.15))); + painter.circle_stroke(c, r, Stroke::new(1.0, t.border)); + let at = |col: &Color| { + let [h, s, _] = col.to_hsb(); + wheel_pos(c, r, h, s) + }; + for (i, col) in colors.iter().enumerate() { + let (p, big) = (at(col), Some(i) == base); + painter.line_segment([c, p], Stroke::new(1.0, Color32::from_white_alpha(160))); + painter.circle(p, if big { 8.0 } else { 5.5 }, crate::panels::c32(col), Stroke::new(1.5, Color32::WHITE)); + painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, Stroke::new(1.0, Color32::BLACK)); + } + // Drag a marker: the nearest one within reach of the press. + let drag = ui.scope_id().with((id, "drag")); + if (resp.drag_started() || resp.clicked()) + && let Some(p) = resp.interact_pointer_pos() + { + let near = colors.iter().map(at).enumerate().map(|(i, m)| (i, m.distance(p))).filter(|x| x.1 <= 14.0).min_by(|a, b| a.1.total_cmp(&b.1)); + out.pressed = near.map(|x| x.0); + ui.data_mut(|m| m.insert_temp(drag, out.pressed)); + } + if resp.dragged() + && let (Some(i), Some(p)) = (ui.data(|m| m.get_temp::>(drag)).flatten(), resp.interact_pointer_pos()) + && i < colors.len() + { + let dv = p - c; + let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); + out.moved = Some((i, [Some(h), Some((dv.length() / r).clamp(0.0, 1.0)), None])); + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Color wheel"))); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0)); + ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim))); + // Screen readers get the brightness in percent, as the Color panel's HSB B field shows it. + let shown = SliderValue::field((f64::from(v) * 100.0).round(), 0.0..=100.0, "%", 0); + if let (Some(b), Some([h, s, _])) = (base, base_hsb) + && let (Some(nv), _) = color_slider_valued(ui, (id, "brightness"), tl!("Brightness"), Some(shown), v, size, &|x| { + crate::panels::c32(&Color::from_hsb(h, s, x)) + }) + { + out.moved = Some((b, [None, None, Some(nv)])); + } + }); + out +} + +/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name` +/// (artboard and page thumbnails). +pub fn region_texture( + ctx: &egui::Context, + renderer: &mut vectorcraft_render::Renderer, + name: &str, + doc: &vectorcraft_doc::Document, + region: vectorcraft_geom::Rect, + px: f64, +) -> egui::TextureHandle { + let scale = px / region.width().max(region.height()).max(1.0); + let img = renderer.render_region(doc, region, scale, true); + let color = crate::canvas::color_image(&img); + ctx.load_texture(name, color, egui::TextureOptions::LINEAR) +} diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs new file mode 100644 index 000000000..4a73214b9 --- /dev/null +++ b/crates/ui-egui/src/widgets/drag.rs @@ -0,0 +1,125 @@ +use super::*; + +/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop +/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. +#[derive(Clone, Debug, PartialEq)] +pub enum PanelDrag { + /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it + /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, + /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the + /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes + /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is + /// (library id, colour name), which that panel moves to where it is dropped. + Paint { paint: Paint, params: serde_json::Value, rows: Option, library: Option<(String, String)> }, + /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance + /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. + Appearance(vectorcraft_doc::NodeId), + /// A Graphic Styles panel style: art it is dropped on takes it (`graphicStyle.apply` with its + /// `ids`); the panel moves it to where it is dropped. + GraphicStyle(String), + /// The selected art, dragged off the canvas with the Selection tool: the Graphic Styles panel + /// makes a style of the first object (`graphicStyle.new`), the Symbols panel a symbol of it all + /// (`symbol.new`), the Swatches panel a pattern swatch of a copy (`object.pattern.make`) and the + /// Brushes panel an Art brush (`brush.new`). + Art(Vec), + /// A Symbols panel symbol: the canvas places an instance of it centred where it is dropped + /// (`symbol.place`). + Symbol(String), + /// A symbol of a symbol library panel (`name`: its name in `library`): the canvas adds it to the + /// document's Symbols and places an instance where it is dropped (`symbol.addFromLibrary` with + /// `place`). + LibrarySymbol { library: String, name: String }, + /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is + /// dropped on takes it (`brush.apply`). + Brush { name: String, def: serde_json::Value }, + /// A brush of a brush library panel (`name`: its name in `library`): the path it is dropped on + /// takes it (`brush.addFromLibrary` with `apply`), and the Brushes panel adds it. + LibraryBrush { library: String, name: String }, + /// A Libraries panel graphic (`item`: its id in `library`): the canvas places a copy centred + /// where it is dropped (`library.use`); the panel moves it to where it is dropped. + LibraryGraphic { library: String, item: String }, + /// A Libraries panel text style (`item`: its name in `library`), which the panel moves to where + /// it is dropped. + LibraryItem { library: String, kind: vectorcraft_engine::cmd::library::ItemKind, item: String }, + /// A Libraries panel group, by its header, which the panel moves to where it is dropped. + LibraryGroup { library: String, group: String }, +} + +/// A panel list `zone` that takes art dragged off the canvas: outlined while art is held over it; +/// → the dragged ids when it is released there. +pub fn art_drop(ui: &Ui, zone: &Response) -> Option> { + let drag = zone.dnd_hover_payload::()?; + let PanelDrag::Art(ids) = &*drag else { return None }; + ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside); + zone.dnd_release_payload::().map(|_| ids.iter().map(|id| id.0).collect()) +} + +/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour +/// group under the pointer when the drag started (`grabbed`) and `names`, the panel selection when +/// the grabbed one is part of it (only colour groups, or only swatches, like the grabbed one), in +/// panel order. +#[derive(Clone, Debug, PartialEq)] +pub struct SwatchRows { + pub grabbed: String, + pub names: Vec, + /// The names are colour groups. + pub groups: bool, +} + +impl PanelDrag { + /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient + /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, + /// coloured like the dragged ones). + pub fn paint(mut paint: Paint) -> Self { + if let Paint::Gradient(g) = &mut paint { + g.geom = None; + g.freeform = None; + } + Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None, library: None } + } + /// The dragged colour (a solid paint's), which the Gradient panel's ramp takes. + pub fn color(&self) -> Option { + match self { + Self::Paint { paint, .. } => paint.color(), + _ => None, + } + } +} + +/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts +/// on it. +pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) { + if resp.drag_started() { + egui::DragAndDrop::set_payload(ui.ctx(), drag()); + } +} + +/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes, +/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when +/// the texture for `key` at this size isn't cached yet. +pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option) -> Option { + use std::cell::RefCell; + use std::collections::HashMap; + thread_local! { + static RENDERER: RefCell = RefCell::new(vectorcraft_render::Renderer::new()); + static CACHE: crate::graphics::TexCache> = crate::graphics::TexCache::default(); + } + let ppp = ui.ctx().pixels_per_point() as f64; + let (w, h) = (size.x as f64, size.y as f64); + let key = format!("{w}x{h}@{ppp}:{key}"); + if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) { + return Some(t); + } + let doc = build(w, h)?; + let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false)); + let color = crate::canvas::color_image(&img); + let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 256 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + Some(tex) +} diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs new file mode 100644 index 000000000..d1bca8067 --- /dev/null +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -0,0 +1,309 @@ +use super::drag::PanelDrag; +use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; +use super::*; + +/// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index. +pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { + dropdown_with_named(ui, id, None, current, options, width, |_| true) +} + +/// [`dropdown`] for a row whose visible label is not an adjacent label widget. +pub fn dropdown_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { + dropdown_with_named(ui, id, name, current, options, width, |_| true) +} + +/// [`dropdown`] of names that are user, file or system data (font styles, artboards, presets the +/// user saved, printers, profiles), shown as they are, never translated. For a list that mixes +/// built-in labels with such names, translate the built-in ones (`tl!`) before passing them. +pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { + dropdown_names_named(ui, id, None, current, options, width) +} + +/// [`dropdown_names`] for a row whose visible label is not an adjacent label widget. +pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { + combo_named(ui, id, name, current, width, false, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.add(egui::Button::selectable(*o == current, *o)).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + +/// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). +pub fn dropdown_with( + ui: &mut Ui, + id: impl std::hash::Hash, + current: &str, + options: &[&str], + width: f32, + enabled: impl Fn(usize) -> bool, +) -> Option { + dropdown_with_named(ui, id, None, current, options, width, enabled) +} + +/// [`dropdown_with`] for a row whose visible label is not an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn dropdown_with_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + options: &[&str], + width: f32, + enabled: impl Fn(usize) -> bool, +) -> Option { + combo_named(ui, id, name, tl!(current), width, false, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, tl!(o))).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + +/// The id egui's [`egui::ComboBox::from_id_salt`] gives its button in this `Ui`: `from_id_salt` +/// stores `IdSalt::new(salt)` and hashes it once more with the scope, so the app must hash `id` +/// the same way to read the button's focus and open state before it is drawn. +pub(super) fn combo_button_id(ui: &Ui, id: &impl std::hash::Hash) -> egui::Id { + ui.make_persistent_id(egui::IdSalt::new(HashOnly(id))) +} + +/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns +/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open +/// while it is clicked: it closes itself when an option is chosen. +pub(crate) fn combo( + ui: &mut Ui, + id: impl std::hash::Hash, + current: &str, + width: f32, + searchable: bool, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + combo_named(ui, id, None, current, width, searchable, list) +} + +/// [`combo`] for a row whose visible label is not an adjacent label widget. +pub(crate) fn combo_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + width: f32, + searchable: bool, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(CornerRadius::same(3)) + .show(ui, |ui| { + if searchable { + return searchable_combo(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0, list); + } + let button_id = combo_button_id(ui, &id); + let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || egui::ComboBox::is_open(ui.ctx(), button_id); + let response = egui::ComboBox::from_id_salt(HashOnly(&id)) + .selected_text(egui::RichText::new(current).size(12.0)) + .width(width - 4.0) + .height(ui.spacing().combo_height) + .show_ui(ui, list); + // The button or open list handles Enter before the modal can accept it. + if enter_for_combo { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } + let _ = name_field(label_field(response.response.clone(), label), name); + let chosen = response.inner.flatten(); + // egui closes the list on a click of the pointer only: an option picked with Space or + // Enter closes it here, and the combo gives the keyboard back, so that Enter reaches the + // dialog's OK (#2789). + if chosen.is_some() { + egui::Popup::close_all(ui.ctx()); + ui.memory_mut(|m| m.surrender_focus(button_id)); + } + chosen + }) + .inner +} + +/// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as +/// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so +/// the field stays at the top and only the rows scroll (#555). A click outside closes it. +fn searchable_combo( + ui: &mut Ui, + id: egui::Id, + label: Option, + name: Option<&str>, + current: &str, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + let popup = id.with("popup"); + let open = egui::Popup::is_id_open(ui.ctx(), popup); + let pad = ui.spacing().button_padding; + let icon = Vec2::splat(ui.spacing().icon_width); + let galley = egui::WidgetText::from(egui::RichText::new(current).size(12.0)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + width - 2.0 * pad.x - ui.spacing().icon_spacing - icon.x, + egui::TextStyle::Button, + ); + let height = (galley.size().y.max(icon.y) + 2.0 * pad.y).max(ui.spacing().interact_size.y); + let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + // Screen readers get what egui's combo box gives them: its role and the text it shows. + resp.widget_info(|| egui::WidgetInfo { + enabled: ui.is_enabled(), + current_text_value: Some(current.to_owned()), + ..egui::WidgetInfo::new(egui::Role::ComboBox) + }); + let resp = name_field(label_field(resp, label), name); + if ui.is_rect_visible(rect) { + let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; + ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); + let inner = rect.shrink2(pad); + ui.painter().galley(egui::Align2::LEFT_CENTER.align_size_within_rect(galley.size(), inner).min, galley, v.text_color()); + // egui's combo arrow: a downward triangle. + let arrow = + Rect::from_center_size(egui::Align2::RIGHT_CENTER.align_size_within_rect(icon, inner).center(), vec2(icon.x * 0.7, icon.y * 0.45)); + ui.painter().add(egui::Shape::convex_polygon( + vec![arrow.left_top(), arrow.right_top(), arrow.center_bottom()], + v.fg_stroke.color, + Stroke::NONE, + )); + } + // It opens below the button, or above it when there is more room there, and the list gets + // the room left (the popup's frame aside). + const GAP: f32 = 12.0; + let screen = ui.ctx().content_rect(); + let frame = ui.spacing().menu_margin.sum().y + GAP; + let (below, above) = (screen.bottom() - rect.bottom() - frame, rect.top() - screen.top() - frame); + let (align, room) = if below >= above { (egui::RectAlign::BOTTOM_START, below) } else { (egui::RectAlign::TOP_START, above) }; + egui::Popup::menu(&resp) + .id(popup) + .width(rect.width()) + .align(align) + .align_alternatives(&[]) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.set_min_width(ui.available_width()); + ui.set_max_height(room); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + list(ui) + }) + .and_then(|r| r.inner) +} + +/// A popover anchored to `resp`, opened and closed by `toggle` (usually `resp.clicked()`), and +/// closed by Escape, a click outside it and outside any popup it opened, or a frame in which its +/// anchor isn't shown. Use it instead of `egui::Popup::menu` when the popover holds dropdowns or +/// popups of its own: egui remembers one open popup at a time, so opening a dropdown inside a +/// remembered popup would close the popup (and the dropdown with it). This one keeps its open +/// state itself: the last frame it was open in. +pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -> R) -> Option { + let ctx = &resp.ctx; + let id = resp.id.with("popover"); + let frame = ctx.cumulative_frame_nr(); + let was_open = ctx.data(|d| d.get_temp::(id)).is_some_and(|f| f + 1 >= frame); + let mut open = if toggle { + !was_open + } else { + // Popups (this one and those it opened) are on foreground layers; a click anywhere + // else closes it. While the pointer is down or a panel paint drag is active, keep the + // popover open so a Fill/Stroke proxy drag can finish (#3440). + let pointer_down = ctx.input(|i| i.pointer.any_down()); + let panel_drag = egui::DragAndDrop::payload::(ctx).is_some(); + let click_pos = ctx.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten()); + let clicked_anchor = click_pos.is_some_and(|pos| resp.rect.contains(pos)); + let clicked_elsewhere = !pointer_down + && !panel_drag + && !clicked_anchor + && click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); + was_open && !clicked_elsewhere + }; + let inner = + egui::Popup::menu(resp).id(id).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(content).map(|r| r.inner); + ctx.data_mut(|d| { + if open { + d.insert_temp(id, frame); + } else { + d.remove::(id); + } + }); + inner +} + +/// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above +/// them (#2086). Without bars it is the bottom of the window. +pub fn set_menu_floor(ctx: &egui::Context, y: f32) { + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("menu_floor"), y)); +} + +/// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt. +pub fn menu_room(floor: f32, top: f32) -> f32 { + const BOTTOM_GAP: f32 = 12.0; + const MIN_HEIGHT: f32 = 120.0; + (floor - top - BOTTOM_GAP).max(MIN_HEIGHT) +} + +/// The scroll bar of a menu's list: slim, in a column of its own beside the items, so it never +/// covers what sits at their right end (shortcuts, submenu arrows, a font's favourite star). +pub fn list_scroll_style() -> egui::style::ScrollStyle { + egui::style::ScrollStyle { bar_width: 6.0, bar_inner_margin: 2.0, ..egui::style::ScrollStyle::solid() } +} + +/// The body of a menu or popup list: as tall as its items up to the bottom bars of the window, and +/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on +/// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the +/// submenu arrows (#2086). +pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); + let room = menu_room(floor, ui.next_widget_position().y); + ui.scope(|ui| { + ui.spacing_mut().scroll = list_scroll_style(); + // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to + // its items when they need less. + egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner + }) + .inner +} + +/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the +/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is +/// over it. +/// Its scroll bar stays a slim line at the edge, even while scrolled or dragged, so it never covers +/// the buttons (#808). +pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope(|ui| { + ui.spacing_mut().scroll = egui::style::ScrollStyle { + bar_width: 4.0, + floating_width: 2.0, + bar_inner_margin: 0.0, + bar_outer_margin: 1.0, + ..egui::style::ScrollStyle::floating() + }; + egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner + }) + .inner +} + +/// A panel's scrolling body (Properties, a dock column, a panel in the shared dock): `area` with +/// the scroll bar of a menu's list ([`list_scroll_style`]), in a column of its own at the right +/// edge, so it never covers the controls there, such as a dropdown's arrow (#3093). The scroll +/// areas inside the body keep their own style. +pub fn panel_scroll(ui: &mut Ui, area: egui::ScrollArea, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + ui.scope(|ui| { + let inner = std::mem::replace(&mut ui.spacing_mut().scroll, list_scroll_style()); + area.show(ui, |ui| { + ui.spacing_mut().scroll = inner; + add_contents(ui) + }) + .inner + }) + .inner +} diff --git a/crates/ui-egui/src/widgets/labels.rs b/crates/ui-egui/src/widgets/labels.rs new file mode 100644 index 000000000..76c69053e --- /dev/null +++ b/crates/ui-egui/src/widgets/labels.rs @@ -0,0 +1,181 @@ +use super::*; + +/// Bold section header (Properties panel). +pub fn section_header(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(2.0); + ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text_strong)); + ui.add_space(2.0); +} + +/// A collapsible section's header across the panel: a chevron (down while `open`) and `text`; +/// a click anywhere on it toggles the section. Whether it was clicked. +pub fn section_toggle(ui: &mut Ui, text: &str, open: bool) -> bool { + section_toggle_name(ui, tl!(text), open).clicked() +} + +/// [`section_toggle`] for a name that is user data (a library group), shown as it is → its +/// response (clicked: toggle; it takes a context menu). +pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0)); + icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon }); + ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, true, open, text)); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// Full-width 1 px divider with vertical margins. +pub fn divider(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().rect_filled(rect, 0.0, t.divider); + ui.add_space(6.0); +} + +/// The `Id` under which [`dim_label`]/[`field_label`] leave the label a following field should be +/// named by. egui 0.37's kittest fails an input with no accessible name, and VectorCraft paints +/// most labels beside, not inside, their fields. +fn next_label_id() -> egui::Id { + egui::Id::unique("vectorcraft-next-input-label") +} + +/// The label a field may be named by, and the widget id the field must be drawn under for the +/// label to belong to it. egui allocates a fresh auto id for every widget, but not for moving +/// between grid cells: the next field in a grid still has the id the label recorded, while a +/// checkbox or another widget between the label and a field has taken it. A label therefore names +/// the very next field, also in a grid, but not a later one after an unrelated widget. +#[derive(Clone, Copy)] +struct NextLabel { + id: egui::Id, + viewport: egui::ViewportId, + pass: u64, + at: Pos2, + next_auto_id: egui::Id, +} + +/// Record a label's widget id, viewport, pass, position and next auto id so only the immediately +/// following field can take the label. +fn remember_next_label(ui: &Ui, id: egui::Id) { + let label = NextLabel { + id, + viewport: ui.ctx().viewport_id(), + pass: ui.ctx().cumulative_pass_nr(), + at: ui.next_widget_position(), + next_auto_id: ui.next_auto_id(), + }; + ui.ctx().data_mut(|d| d.insert_temp(next_label_id(), label)); +} + +/// A label taken for the next field, and whether no widget came between the two. +pub(crate) struct PreviousLabel { + id: egui::Id, + at: Pos2, + immediate: bool, +} + +/// The label drawn immediately before a field in this pass, if no other widget came between them +/// (an app-local adapter around [`egui::Response::labelled_by`], not a general compatibility +/// layer). Takes the record, so one label names one field. +pub(crate) fn take_previous_label(ui: &Ui) -> Option { + let label = ui.ctx().data_mut(|d| { + let label = d.get_temp::(next_label_id()); + d.remove::(next_label_id()); + label + })?; + // Frame and pass numbers are per viewport; a label left in one viewport must never name a + // field in another. The shipped hosts draw one root viewport, so this scoping is defensive. + (label.viewport == ui.ctx().viewport_id() && label.pass == ui.ctx().cumulative_pass_nr()).then(|| PreviousLabel { + id: label.id, + at: label.at, + immediate: ui.next_auto_id() == label.next_auto_id, + }) +} + +/// Name `resp` by the field's real adjacent label (see [`take_previous_label`]). +pub(crate) fn label_field(resp: Response, label: Option) -> Response { + let Some(label) = label else { return resp }; + let adjacent = label.immediate || resp.rect.min.distance(label.at) <= 16.0 || resp.rect.contains(label.at); + if adjacent { resp.labelled_by(label.id) } else { resp } +} + +/// Give `resp` the explicit semantic name of a field whose visible label is not a widget beside +/// it (a label below a grid of dash cells, a heading over a row of fields). The name is already +/// translated at the call site. An empty name leaves the field unnamed rather than inventing one. +pub(super) fn name_field(resp: Response, name: Option<&str>) -> Response { + match name { + Some(name) if !name.trim().is_empty() => resp.accessible_name(name), + _ => resp, + } +} + +/// A label in the panels' text colour; before a numeric field it scrubs the field +/// ([`scrub`]). +pub fn dim_label(ui: &mut Ui, text: &str) -> Response { + let resp = dim_name(ui, tl!(text)); + scrub::note_label(ui, resp.rect); + remember_next_label(ui, resp.id); + resp +} + +/// A field's label in other styles (`text` as given): before a numeric field it scrubs the field +/// ([`scrub`]). +pub fn field_label(ui: &mut Ui, text: impl Into) -> Response { + let resp = ui.label(text); + scrub::note_label(ui, resp.rect); + remember_next_label(ui, resp.id); + resp +} + +/// [`dim_label`] for a name that is user or file data (a swatch, style, artboard or font name): +/// shown as it is, never translated. +pub fn dim_name(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).color(t.text).size(12.5)) +} + +/// A form row: `label` in a column `label_width` wide, then what `add` draws. +pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) { + ui.horizontal(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); + let t = Tokens::get(ui.ctx()); + let shown = tl!(label); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, shown, egui::FontId::proportional(12.5), t.text); + scrub::note_label(ui, r); + // The label is painted, not a widget of its own: give it a real semantic node so the + // input beside it can be named by `label_inputs_by` (egui 0.37's strict a11y gate). + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), shown)); + if label.trim().is_empty() { + add(ui); + } else { + ui.label_inputs_by(resp.id, add); + } + }); +} + +/// Regular-weight panel sub-header ("Shape Modes:", "Align Objects:"). +pub fn subheader(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text)); +} + +/// A label drawn with a dotted underline (Illustrator's link labels: "Stroke:", "Opacity:"). +pub fn link_label(ui: &mut Ui, text: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); + let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(0.0, 3.0), Sense::click()); + let y = rect.top() + galley.size().y + 1.0; + ui.painter().galley(rect.min, galley, t.text_strong); + let mut x = rect.left(); + while x < rect.right() - 1.0 { + ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); + x += 2.5; + } + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); + resp +} diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs new file mode 100644 index 000000000..655f3f978 --- /dev/null +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -0,0 +1,661 @@ +use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; +use super::panel_chrome::chevron_cell; +use super::text::{after_shift_enter, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_dialog_focus}; +use super::*; + +/// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it +/// when the preference changes (`prefs_dialog::apply_runtime`), the length fields read it. +pub(crate) fn set_bare_numbers_are_points(ctx: &egui::Context, on: bool) { + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("bare_numbers_are_points"), on)); +} + +/// The unit a number typed with no unit into a field in `unit` is read in: `unit`, or for a field +/// in picas points with Numbers Without Units Are Points on (where `12` could be either). A typed +/// unit (`12 mm`, `2p6`, `1in`) always wins. +pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit { + let bare_points = ctx.data(|d| d.get_temp::(egui::Id::unique("bare_numbers_are_points"))).unwrap_or(true); + if bare_points && unit == Unit::Picas { Unit::Points } else { unit } +} + +/// `text` typed in a numeric field, with a decimal comma read as a point: `0,1 mm` is `0.1 mm` +/// (#3837). Text with a point keeps its commas, so `1,000.5` is still refused rather than read +/// with two decimal separators, and so does a number that reads as thousands ([`thousands`]). +pub(super) fn decimal_point(text: &str) -> std::borrow::Cow<'_, str> { + if text.contains(',') && !text.contains('.') && !thousands(text) { text.replace(',', ".").into() } else { text.into() } +} + +/// Is `text` a number written with a thousands comma, as `1,000` or `12,500 mm`: one to three +/// digits (not a lone 0), a comma, exactly three digits, then at most a unit? Read as a decimal it +/// would be a thousand times too small, so the field refuses it instead (`0,125` and `1,25` are +/// decimals). +fn thousands(text: &str) -> bool { + let number = text.trim().trim_start_matches('-'); + let Some((whole, rest)) = number.split_once(',') else { return false }; + let (part, unit) = rest.split_at(rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())); + (1..=3).contains(&whole.len()) + && whole.bytes().all(|b| b.is_ascii_digit()) + && !whole.starts_with('0') + && part.len() == 3 + && unit.trim().chars().all(char::is_alphabetic) +} + +/// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) +/// when the user commits (Enter / focus loss / Shift+Enter, which keeps the focus outside dialogs). +/// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in +/// points in a picas field with Numbers Without Units Are Points on). +pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { + num_field_named(ui, id, None, value, unit, width) +} + +/// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with +/// its label painted separately): screen readers name the field `name` (already translated). +pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { + let label = take_previous_label(ui); + unit_field(ui, id, label, name, value, unit, width, false, ANY) +} + +/// Any number: the bounds of a field that has none. +const ANY: RangeInclusive = f64::NEG_INFINITY..=f64::INFINITY; + +/// `v` within `bounds` (bounds the wrong way round or not numbers don't panic, as `f64::clamp` would). +fn within(v: f64, bounds: &RangeInclusive) -> f64 { + v.max(*bounds.start()).min(*bounds.end()) +} + +/// [`num_field`]; with `share_of_value` a percentage typed alone is that share of `value` +/// ([`share_of`]). ↑/↓, the wheel and a scrub stop at `bounds` (points), and the field shows where +/// they stopped. +#[allow(clippy::too_many_arguments)] +fn unit_field( + ui: &mut Ui, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + share_of_value: bool, + bounds: RangeInclusive, +) -> Option { + let id = id_with(ui.scope_id(), id); + let shown = value.map(|v| unit.format(v)).unwrap_or_default(); + take_dialog_focus(ui, id, &shown); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); + select_all_on_focus(ui, &resp, &buf); + // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. + let typed = typed_unit(ui.ctx(), unit); + let read = |b: &str| typed.parse(&decimal_point(b)); + let bounds = unit.from_pt(*bounds.start())..=unit.from_pt(*bounds.end()); + let stepped = step_value(ui, &resp, &mut buf, &bounds, (|b: &str| Some(unit.from_pt(read(b)?)), |v| unit.format(unit.to_pt(v))), None); + let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &bounds); + let commit = commit && buf != shown; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if commit { + read(&buf).or_else(|| if share_of_value { share_of(&decimal_point(&buf), value?) } else { None }) + } else { + stepped.or(scrubbed).map(|v| unit.to_pt(v)) + } +} + +/// A percentage typed alone (`50%`, `150 %`, `100/3%`) as that share of `value`; `None` for any +/// other text. `Unit::parse` reads a bare percentage as nothing, having no value to take it of. +pub(super) fn share_of(text: &str, value: f64) -> Option { + let percent = vectorcraft_doc::parse_number(text.trim().strip_suffix('%')?)?; + Some(value * percent / 100.0) +} + +/// The places a stepped value in a unit field keeps (no 12.300000001). +pub(super) const STEP_DECIMALS: i32 = 3; + +/// `v` rounded to `decimals` places. +pub(crate) fn round_to(v: f64, decimals: i32) -> f64 { + let scale = 10f64.powi(decimals.clamp(0, 6)); + (v * scale).round() / scale +} + +/// One step of a numeric field with each modifier, most specific first (a plain pattern would also +/// match Shift): ten with Shift, a tenth with Ctrl/Cmd, else one. A Ctrl/Cmd or plain step goes to +/// the next multiple of its size ([`step_to_multiple`]); a Shift step adds or takes ten. +const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; + +/// `v` moved `n` steps of `size` (down when `n` is negative): the first step goes to the next +/// multiple of `size` in its direction and each further step adds or takes one `size`. By 1, 5.1 +/// goes up to 6 and down to 5, and 5 goes up to 6 and down to 4. A value within a millionth of a +/// step of a multiple counts as that multiple. +pub(super) fn step_to_multiple(v: f64, size: f64, n: f64) -> f64 { + if n == 0.0 { + return v; + } + let q = v / size; + let from = if n > 0.0 { (q + 1e-6).floor() } else { (q - 1e-6).ceil() }; + (from + n) * size +} + +/// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` +/// shows (`read` parses it, `show` formats it) per press or wheel notch ([`STEPS`]): a plain step +/// goes to the next whole number in its direction (5.1 up to 6, down to 5), a Ctrl/Cmd step goes to +/// the next tenth, and a Shift step adds or takes ten. Steps stop at the field's `bounds` (#3370: +/// a weight stops at 0, a miter limit at 1). The new value is applied at once, as in +/// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing +/// replaces it. Returns the new number when a step was taken. While the pointer is over the focused +/// field the wheel is the field's (the panel under it doesn't scroll); an unfocused field leaves the +/// wheel to the panel and the canvas. +fn step_value( + ui: &Ui, + resp: &Response, + buf: &mut String, + bounds: &RangeInclusive, + (read, show): (impl Fn(&str) -> Option, impl Fn(f64) -> String), + step: Option f64>, +) -> Option { + // Memory focus, as the field's highlight and its typing use: `Response::has_focus` also needs the + // window to report keyboard focus. + if !ui.memory(|m| m.has_focus(resp.id)) { + return None; + } + use egui::{Event, Key, MouseWheelUnit}; + let wheel = resp.contains_pointer(); + // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is + // left of a notch carries over to the next turn. + let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); + let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; + // Each press and each turn of whole notches in the order they came: its modifiers and size from + // [`STEPS`], and how many steps up (down when negative). + let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); + ui.input_mut(|i| { + i.events.retain(|e| { + let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; + let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; + let n = match e { + Event::Key { key: Key::ArrowUp, pressed: true, .. } => 1.0, + Event::Key { key: Key::ArrowDown, pressed: true, .. } => -1.0, + Event::MouseWheel { unit, delta, .. } if wheel => { + // Shift may turn the wheel sideways (the Mac does). + let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; + notches += match unit { + MouseWheelUnit::Point => d / line.max(1.0), + MouseWheelUnit::Line | MouseWheelUnit::Page => d, + }; + let whole = notches.trunc(); + notches -= whole; + f64::from(whole) + } + _ => return true, + }; + if n != 0.0 { + steps.push((mods, size, n)); + } + false + }); + if wheel { + // The rest of a turn egui spreads over the next frames scrolls nothing either. + i.smooth_scroll_delta = egui::Vec2::ZERO; + } + }); + if wheel { + ui.data_mut(|d| d.insert_temp(notch_id, notches)); + } + if steps.is_empty() { + return None; + } + let mut v = read(buf)?; + for (mods, size, n) in steps { + v = match step { + // The field's own steps: ten at once with Shift, one with or without Ctrl/Cmd. + Some(step) => step(v, if mods == egui::Modifiers::SHIFT { 10.0 * n } else { n }), + None if mods == egui::Modifiers::SHIFT => v + size * n, + None => step_to_multiple(v, size, n), + }; + // Each step stops at the bounds, so a step back in the same frame starts from there. + v = within(v, bounds); + } + // Rounded as fields show it, within the field's bounds. + let v = within(round_to(v, STEP_DECIMALS), bounds); + *buf = show(v); + ui.data_mut(|d| d.insert_temp(resp.id, buf.clone())); + select_all(ui, resp.id, buf); + Some(v) +} + +/// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter +/// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A +/// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). +pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { + size_field_named(ui, id, None, value, unit, width) +} + +/// [`size_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn size_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { + use egui::{Event, Key}; + let label = take_previous_label(ui); + // The text box does not end its editing on an Enter with Ctrl/Cmd: a plain Enter goes in right + // after it, so the box first takes the text typed before it in this frame. + let field = id_with(ui.scope_id(), &id); + if ui.memory(|m| m.has_focus(field)) { + ui.input_mut(|i| { + let cmd_enter = |e: &Event| matches!(e, Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); + if let Some(at) = i.events.iter().position(cmd_enter) { + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + i.events.insert(at + 1, enter); + } + }); + } + unit_field(ui, id, label, name, value, unit, width, true, ANY) +} + +/// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels +/// such as the Stroke panel's Limit); `None` shows a blank field, as [`mixed_field`] does. What is +/// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count), and +/// ↑/↓, the wheel and a scrub stop at its ends in the field too (#3370). +pub fn range_field( + ui: &mut Ui, + id: impl std::hash::Hash, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + range_field_named(ui, id, None, value, range, suffix, decimals, width) +} + +/// [`range_field`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn range_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + let places = decimals.min(6) as i32; + let value = value.into().map(|v| within(v, &range)); + let label = take_previous_label(ui); + bounded_field(ui, id, label, name, value, suffix, decimals, width, &range, None).map(|v| within(round_to(v, places), &range)) +} + +/// [`range_field`] whose ↑/↓ and wheel take `step`'s steps instead of whole numbers (see +/// [`SliderValue::step`]), for a number whose useful values are fractions, such as the Pencil's +/// Fidelity. +#[allow(clippy::too_many_arguments)] +pub fn range_field_stepped( + ui: &mut Ui, + id: impl std::hash::Hash, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, + step: fn(f64, f64) -> f64, +) -> Option { + let places = decimals.min(6) as i32; + let value = value.into().map(|v| within(v, &range)); + let label = take_previous_label(ui); + bounded_field(ui, id, label, None, value, suffix, decimals, width, &range, Some(step)).map(|v| within(round_to(v, places), &range)) +} + +/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. +pub fn mixed_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, suffix: &str, decimals: usize, width: f32) -> Option { + mixed_field_named(ui, id, None, value, suffix, decimals, width) +} + +/// [`mixed_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn mixed_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + value: Option, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + let label = take_previous_label(ui); + bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY, None) +} + +/// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`; ↑/↓ and the wheel take `step`'s steps +/// when it is given (see [`SliderValue::step`]). +#[allow(clippy::too_many_arguments)] +fn bounded_field( + ui: &mut Ui, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, + value: Option, + suffix: &str, + decimals: usize, + width: f32, + bounds: &RangeInclusive, + step: Option f64>, +) -> Option { + let id = id_with(ui.scope_id(), id); + let show = |value: f64| field_text(value, suffix, decimals); + // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. + let read = |buf: &str| { + let suffix = suffix.trim(); + let buf = decimal_point(buf); + let bare = if suffix.is_empty() { buf.to_string() } else { buf.replace(suffix, "") }; + vectorcraft_doc::parse_number(&bare.replace(['%', '°'], "")) + }; + let shown = value.map(show).unwrap_or_default(); + take_dialog_focus(ui, id, &shown); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); + select_all_on_focus(ui, &resp, &buf); + // ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on + // its label scrubs it. + let decimals = decimals.min(6) as i32; + let stepped = step_value(ui, &resp, &mut buf, bounds, (read, show), step).map(|v| round_to(v, decimals)); + let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals, bounds); + if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } +} + +/// The text [`plain_field`] shows for `value`: at most `decimals` places, without trailing zeros, +/// then `suffix`. +pub(super) fn field_text(value: f64, suffix: &str, decimals: usize) -> String { + let s = format!("{:.*}", decimals, value); + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; + format!("{s}{suffix}") +} + +/// A slider whose rail shows, filled up to the handle: the theme's widget fill matches the panel +/// and dialog background, which leaves only the handle visible (#1483). +pub fn slider(ui: &mut Ui, slider: egui::Slider<'_>) -> Response { + let t = Tokens::get(ui.ctx()); + ui.scope(|ui| { + let v = ui.visuals_mut(); + // The buttons' border shows against the panel in every brightness; the fields' border + // all but vanished on Medium Dark's panel (#1662). + v.widgets.inactive.bg_fill = t.button_border; + v.selection.bg_fill = t.accent; + ui.add(slider.trailing_fill(true)) + }) + .inner +} + +/// Numeric field with an up/down spinner on the left and a preset dropdown on the right +/// (Stroke weight, font size…): an editable combo box whose field keeps typing, ↑/↓ stepping and +/// label scrubbing. `value` (points) shows in `unit`, blank when `None` (the selection's values +/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. The spinner, ↑/↓, +/// the wheel and a scrub stop at `min` (#3370), and a typed value below it applies `min`. Returns +/// the committed, stepped or picked value. +#[allow(clippy::too_many_arguments)] +pub fn spin_field( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + value: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_field_named(ui, id, None, value, unit, width, step, min, presets) +} + +/// [`spin_field`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_field_auto_named(ui, id, name, value, None, unit, width, step, min, presets).and_then(SpinPick::value) +} + +/// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SpinPick { + Value(f64), + Auto, +} + +impl SpinPick { + /// The value, `None` for Auto. + pub fn value(self) -> Option { + match self { + SpinPick::Value(v) => Some(v), + SpinPick::Auto => None, + } + } +} + +/// [`spin_field`] whose dropdown starts with an Auto entry (Leading): `auto` is `Some(on)`, with +/// `on` checking it (no preset is then checked), or `None` for no Auto entry. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_auto( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + value: Option, + auto: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_field_auto_named(ui, id, None, value, auto, unit, width, step, min, presets) +} + +/// [`spin_field_auto`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_auto_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: Option, + auto: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + let label = take_previous_label(ui); + spin_generic(ui, label, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw, label| { + unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY) + }) +} + +/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix; a +/// `None` value (the selection's values differ) shows blank. +#[allow(clippy::too_many_arguments)] +pub fn spin_plain( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + value: impl Into>, + suffix: &str, + decimals: usize, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_plain_named(ui, id, None, value, suffix, decimals, width, step, min, presets) +} + +/// [`spin_plain`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_plain_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: impl Into>, + suffix: &str, + decimals: usize, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + let label = take_previous_label(ui); + let value = value.into(); + let fmt = |v: f64| format!("{v}{suffix}"); + spin_generic(ui, label, value, None, width, step, min, presets, &fmt, &mut |ui, fw, label| { + bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY), None) + }) + .and_then(SpinPick::value) +} + +type SpinField<'a> = dyn FnMut(&mut Ui, f32, Option) -> Option + 'a; + +#[allow(clippy::too_many_arguments)] +fn spin_generic( + ui: &mut Ui, + label: Option, + value: Option, + auto: Option, + width: f32, + step: f64, + min: f64, + presets: &[f64], + fmt: &dyn Fn(f64) -> String, + field: &mut SpinField<'_>, +) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out = None; + let h = 26.0; + let enabled = ui.is_enabled(); + ui.scope(|ui| { + // The spinner, the field and the chevron share their borders: one box in three parts. + ui.spacing_mut().item_spacing.x = -1.0; + let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click()); + ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input); + ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y)); + let down = Rect::from_min_max(pos2(sr.left(), sr.center().y), sr.max); + let hover = sresp.hover_pos(); + for (r, is_up) in [(up, true), (down, false)] { + let c = if !enabled { + t.text_disabled + } else if hover.is_some_and(|p| r.contains(p)) { + t.text_strong + } else { + t.icon + }; + let m = r.center(); + let d = if is_up { -1.5 } else { 1.5 }; + ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); + ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); + } + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); + // A blank field (values that differ) has nothing to step from. + if sresp.clicked() + && let Some(p) = sresp.interact_pointer_pos() + && let Some(v) = value + { + let modifiers = ui.input(|i| i.modifiers); + out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); + } + let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; + if let Some(v) = field(ui, fw, label) { + out = Some(SpinPick::Value(v.max(min))); + } + if !presets.is_empty() { + let dresp = chevron_cell(ui, h); + egui::Popup::menu(&dresp).show(|ui| { + ui.set_min_width(width - 10.0); + if let Some(on) = auto + && ui.selectable_label(on, tl!("Auto")).clicked() + { + out = Some(SpinPick::Auto); + } + let current = value.filter(|_| auto != Some(true)); + for p in presets { + if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { + out = Some(SpinPick::Value(*p)); + } + } + }); + } + }); + out +} + +/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: +/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step. +pub(super) fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 { + let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor); + let delta = step * multiplier; + (value + if up { delta } else { -delta }).max(min) +} + +/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without +/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit (Enter, Shift+Enter or +/// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units +/// and arithmetic work as in [`num_field`]. The value is a length: ↑/↓, the wheel and a scrub stop +/// at 0. +pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option> { + opt_field_named(ui, id, None, value, unit, width) +} + +/// [`opt_field`] for a field named explicitly instead of by an adjacent label widget (the Stroke +/// panel's dash and gap cells have their labels in a row below them). +pub fn opt_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option> { + let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); + let shown = value.map(|v| unit.number(v)).unwrap_or_default(); + let editing = ui.memory(|m| m.has_focus(id)); + // As in `hex_field`: until the user types into it, the field shows `shown` as it is now + // (#1692). When `shown` changes while the field has the focus and nothing has been typed into + // it, all of the new text is selected, as on keyboard focus. + let edited_id = id.with("edited"); + let edited = editing && ui.data_mut(|d| d.get_temp::(edited_id)).unwrap_or(false); + let before = ui.data_mut(|d| d.get_temp::(id)); + let mut buf: String = if edited { before.clone().unwrap_or_else(|| shown.clone()) } else { shown.clone() }; + let reshown = after_shift_enter(ui, id); + if reshown || editing && !edited && before.is_some_and(|b| b != buf) { + select_all(ui, id, &buf); + } + let kept = shift_enter_keeps_focus(ui, id); + let enabled = ui.is_enabled(); + let framed = egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(4, 4)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut buf) + .id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 10.0) + .font(egui::FontId::proportional(12.5)) + .text_color(if enabled { t.text_strong } else { t.text_disabled }), + ) + }); + let resp = framed.inner; + let resp = name_field(label_field(resp, label), name); + select_all_on_focus(ui, &resp, &buf); + // ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it. + let lengths = 0.0..=f64::INFINITY; + let stepped = step_value( + ui, + &resp, + &mut buf, + &lengths, + (|b: &str| Some(unit.from_pt(unit.parse(&decimal_point(b))?)), |v| unit.number(unit.to_pt(v))), + None, + ); + let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS, &lengths); + let focused = ui.memory(|m| m.has_focus(id)); + ui.data_mut(|d| { + d.insert_temp(id, buf.clone()); + d.insert_temp(edited_id, (edited || resp.changed()) && focused && !kept); + }); + if (resp.lost_focus() || kept) && buf != shown { + let s = buf.trim(); + if s.is_empty() { Some(None) } else { typed_unit(ui.ctx(), unit).parse(&decimal_point(s)).map(Some) } + } else { + stepped.or(scrubbed).map(|v| Some(unit.to_pt(v))) + } +} diff --git a/crates/ui-egui/src/widgets/panel_chrome.rs b/crates/ui-egui/src/widgets/panel_chrome.rs new file mode 100644 index 000000000..f92114448 --- /dev/null +++ b/crates/ui-egui/src/widgets/panel_chrome.rs @@ -0,0 +1,136 @@ +use super::*; + +/// A bordered list box (Swatches tiles, Appearance rows, Artboards list). +pub fn list_box(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.stroke(Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(1)).show(ui, add).inner +} + +/// A bottom button bar separated from the content by a divider (panel footers). +pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { + let t = Tokens::get(ui.ctx()); + ui.add_space(4.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, t.divider); + ui.add_space(2.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + add(ui) + }); +} + +/// A row of text tabs, the current one underlined in the accent colour (New Document's +/// categories, Document Setup's sections). Returns the clicked tab's index. +pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { + let t = Tokens::get(ui.ctx()); + let mut clicked = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 18.0; + for (i, tab) in tabs.iter().enumerate() { + let sel = i == current; + let font = if sel { theme::semibold(13.0) } else { egui::FontId::proportional(13.0) }; + let galley = ui.painter().layout_no_wrap(tl!(tab).to_string(), font, t.text); + let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x, 30.0), Sense::click()); + let color = if sel || resp.hovered() { t.text_strong } else { t.text_dim }; + ui.painter().galley(pos2(rect.left(), rect.center().y - galley.size().y / 2.0 - 2.0), galley, color); + if sel { + ui.painter().rect_filled( + Rect::from_min_max(pos2(rect.left(), rect.bottom() - 2.0), rect.right_bottom()), + CornerRadius::same(1), + t.accent, + ); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), sel, tl!(tab))); + if resp.clicked() { + clicked = Some(i); + } + } + }); + clicked +} + +/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner, +/// accent-filled when `selected`. Returns clicked. +pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); + if resp.hovered() && !selected { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); + } + let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) }; + let page = Rect::from_center_size(rect.center(), size); + let color = if selected { t.accent } else { t.icon }; + let fold = 4.0; + let outline = + vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()]; + let fill = if selected { t.accent } else { Color32::TRANSPARENT }; + ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); + let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; + ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tl!(tip))); + resp.on_hover_text(tl!(tip)).clicked() +} + +/// An artboard-layout toggle drawn in code (Rearrange All Artboards): small artboards in the grid +/// cells `tiles` (column, row), tinted, and over them their order as a line from a dot on the first +/// to an arrowhead on the last; accent-drawn in a pressed well when `selected`, greyed in a disabled +/// `ui`. `tip` is shown as it is (the caller translates it). Returns clicked. +pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click()); + let enabled = ui.is_enabled(); + let bg = if selected { + t.tool_active + } else if resp.hovered() && enabled { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(3), bg); + let color = if !enabled { + t.text_disabled + } else if selected { + t.accent + } else { + t.icon + }; + // Cells at most 10 points a side in a 20-point square: a row's artboards stand tall, a + // column's lie flat. + let (cols, rows) = tiles.iter().fold((1u8, 1u8), |(c, r), &(x, y)| (c.max(x.saturating_add(1)), r.max(y.saturating_add(1)))); + let cell = vec2((20.0 / f32::from(cols)).min(10.0), (20.0 / f32::from(rows)).min(10.0)); + let origin = rect.center() - vec2(f32::from(cols) * cell.x, f32::from(rows) * cell.y) / 2.0; + let centre = |&(x, y): &(u8, u8)| origin + vec2((f32::from(x) + 0.5) * cell.x, (f32::from(y) + 0.5) * cell.y); + for tile in tiles { + ui.painter().rect_filled(Rect::from_center_size(centre(tile), cell - vec2(2.0, 2.0)), 1.0, color.gamma_multiply(0.35)); + } + let path: Vec = tiles.iter().map(centre).collect(); + if let ([first, ..], [.., from, to]) = (path.as_slice(), path.as_slice()) { + let (first, from, to) = (*first, *from, *to); + let dir = (to - from).normalized(); + let side = dir.rot90() * 2.5; + let head = vec![to + dir * 1.5, to - dir * 2.5 + side, to - dir * 2.5 - side]; + ui.painter().circle_filled(first, 1.6, color); + ui.painter().add(egui::Shape::line(path, Stroke::new(1.2, color))); + ui.painter().add(egui::Shape::convex_polygon(head, color, Stroke::NONE)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tip)); + resp.on_hover_text(tip).clicked() +} + +/// The chevron cell on the right of a field that opens its preset list (rounded on the right). +pub(super) fn chevron_cell(ui: &mut Ui, h: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); + ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); + ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); + let c = if !ui.is_enabled() { + t.text_disabled + } else if dresp.hovered() { + t.text_strong + } else { + t.icon + }; + icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); + dresp +} diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs new file mode 100644 index 000000000..f3f922dfa --- /dev/null +++ b/crates/ui-egui/src/widgets/text.rs @@ -0,0 +1,328 @@ +use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; +use super::numeric::mixed_field_named; +use super::panel_chrome::chevron_cell; +use super::*; + +/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the +/// first enabled field drawn takes it ([`take_dialog_focus`]). +pub(crate) fn dialog_focus_flag() -> egui::Id { + egui::Id::unique("dialog-focus-first-field") +} + +/// If field `id` (showing `text`) is the first enabled field of a dialog that has just opened, give +/// it the keyboard focus with all of `text` selected: typing replaces the value and Enter applies it. +pub(crate) fn take_dialog_focus(ui: &Ui, id: egui::Id, text: &str) { + // A new window's first frame only measures its contents: nothing can take focus yet. Nor can a + // greyed-out field (egui drops its focus): the flag stays for the first field that is enabled. + if ui.is_sizing_pass() || !ui.is_enabled() || ui.data_mut(|d| d.remove_temp::(dialog_focus_flag())).is_none() { + return; + } + focus_field(ui, id, text); +} + +/// Give text field `id` the keyboard focus with all of `text` selected. +pub(crate) fn focus_field(ui: &Ui, id: egui::Id, text: &str) { + select_all(ui, id, text); + ui.memory_mut(|m| m.request_focus(id)); +} + +/// The modifiers held with an Enter press among this frame's events, `None` without one, as when a +/// field's editing ends with a click elsewhere. +pub(crate) fn enter_modifiers(ui: &Ui) -> Option { + ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) + }) +} + +/// Select all of `text` in text field `id`. +pub(super) fn select_all(ui: &Ui, id: egui::Id, text: &str) { + let mut st = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + st.cursor.set_char_range(Some(all)); + st.store(ui.ctx(), id); +} + +/// The flag that tells the fields a dialog is open in this frame ([`set_dialog_open`]). +fn dialog_open_flag() -> egui::Id { + egui::Id::unique("dialog-open") +} + +/// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field +/// applies its value and confirms the dialog as Enter does ([`shift_enter_keeps_focus`]). +pub(crate) fn set_dialog_open(ctx: &egui::Context, open: bool) { + ctx.data_mut(|d| d.insert_temp(dialog_open_flag(), open)); +} + +/// Shift+Enter (Shift+Return) in single-line field `id` while it has the keyboard focus and no +/// dialog is open: the press is taken from this frame's events before the field's text box reads +/// them, so the field keeps the focus. Returns true when it was pressed; the field then applies its +/// text as Enter does, and in the next frame shows its value with all of it selected +/// ([`after_shift_enter`]). +pub(super) fn shift_enter_keeps_focus(ui: &Ui, id: egui::Id) -> bool { + if !ui.memory(|m| m.has_focus(id)) || ui.data(|d| d.get_temp::(dialog_open_flag())).unwrap_or(false) { + return false; + } + let pressed = ui.input_mut(|i| { + let before = i.events.len(); + i.events.retain(|e| { + !matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::SHIFT)) + }); + i.events.len() < before + }); + if pressed { + ui.data_mut(|d| d.insert_temp(id.with("shift-enter"), true)); + } + pressed +} + +/// Whether field `id`, still focused, took Shift+Enter in the last frame +/// ([`shift_enter_keeps_focus`]). Clears that record. +pub(super) fn after_shift_enter(ui: &Ui, id: egui::Id) -> bool { + ui.data_mut(|d| d.remove_temp::(id.with("shift-enter"))).unwrap_or(false) && ui.memory(|m| m.has_focus(id)) +} + +/// Handles Shift+Enter in a single-line panel field that draws its own text box `id` (Pattern +/// Options' Name, the inline renames in the Layers, Artboards and Asset Export panels). Call it +/// before the box is drawn, with `buf` the text the box will show and `shown` the value as it is +/// now. When the field took Shift+Enter in the last frame, `buf` becomes `shown`, all of it +/// selected. Returns whether Shift+Enter was pressed in this frame ([`shift_enter_keeps_focus`]): +/// the field then applies `buf` as Enter does and keeps the focus. +pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, shown: &str) -> bool { + if after_shift_enter(ui, id) { + *buf = shown.to_string(); + select_all(ui, id, buf); + } + shift_enter_keeps_focus(ui, id) +} + +/// Numeric fields select the whole text on keyboard focus, a completed focus click or a +/// double-click. A pointer press keeps its caret so dragging selects from the pressed position, +/// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap +/// releases into select-all. +pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { + let pending = resp.id.with("select-all-on-focus-click"); + let press_pos = resp.id.with("select-all-on-focus-press"); + let (pressed, released, at_press) = ui.input(|i| { + (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.button_released(egui::PointerButton::Primary), i.pointer.interact_pos()) + }); + if resp.gained_focus() { + ui.data_mut(|d| { + if pressed { + d.insert_temp(pending, true); + if let Some(p) = at_press { + d.insert_temp(press_pos, p); + } + } else { + d.remove_temp::(pending); + d.remove_temp::(press_pos); + } + }); + } + let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); + // A press released a few points away is a short drag, not a tap. egui still reports a + // click, and a release inside one character leaves no text selection, so select-all on + // release would replace the drag the user just made (#1326). + let dragged = clicked_focus + && ui + .data_mut(|d| d.remove_temp::(press_pos)) + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); + if resp.lost_focus() { + ui.data_mut(|d| { + d.remove_temp::(pending); + d.remove_temp::(press_pos); + }); + } + if !(resp.gained_focus() && !pressed || resp.double_clicked() || clicked_focus && !dragged) { + return; + } + if let Some(st) = egui::TextEdit::load_state(ui.ctx(), resp.id) { + // Preserve a text selection if the focus click already produced one. + if clicked_focus && !resp.double_clicked() && st.cursor.char_range().is_some_and(|r| !r.as_sorted_char_range().is_empty()) { + return; + } + select_all(ui, resp.id, text); + ui.ctx().request_repaint(); + } +} + +/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line), +/// its text kept under `id` while it has focus. Returns the text (as edited), whether the field +/// applies it in this frame (it lost the focus, or a single-line field took Shift+Enter and kept +/// the focus: [`shift_enter_keeps_focus`]), the text edit's response and the box. +pub(super) fn recessed_text( + ui: &mut Ui, + id: egui::Id, + shown: &str, + width: f32, + rows: usize, + label: Option, + name: Option<&str>, +) -> (String, bool, Response, Rect) { + let t = Tokens::get(ui.ctx()); + let editing = ui.memory(|m| m.has_focus(id)); + // A press outside the field ends its editing in this frame, so the text typed is committed + // before the press acts: the canvas, drawn after the panels, may select another object, and a + // frame later the text would be committed to that one. + if editing + && let Some(at) = ui.input(|i| i.pointer.primary_pressed().then(|| i.pointer.interact_pos()).flatten()) + && ui.ctx().read_response(id).is_some_and(|r| !r.rect.contains(at)) + { + ui.memory_mut(|m| m.surrender_focus(id)); + } + // When the value changes under a focused field (the slider beside it, a preview, another + // field's edit), its text follows: the text typed for the old value would otherwise go back in + // on the next Enter, Tab or arrow key (#1457). + let shown_id = id.with("shown"); + let followed = ui.data_mut(|d| d.get_temp::(shown_id)).is_none_or(|before| before == shown); + ui.data_mut(|d| d.insert_temp(shown_id, shown.to_string())); + // A press elsewhere can end the focus outside this field's own drawing (a field drawn before it + // takes it, the canvas ends text input), so `editing` is already false on the frame it reports + // `lost_focus`: the text typed is still taken then, to be committed (#1637). Escape drops it. + let focus_id = id.with("had-focus"); + let had_focus = ui.data_mut(|d| d.get_temp::(focus_id)).unwrap_or(false) && !ui.input(|i| i.key_pressed(egui::Key::Escape)); + // After Shift+Enter applied the text, the field shows the value as it is now, all selected. + let reshown = after_shift_enter(ui, id); + let typed = if (editing || had_focus) && followed && !reshown { ui.data_mut(|d| d.get_temp::(id)) } else { None }; + let mut buf: String = typed.unwrap_or_else(|| shown.to_string()); + if reshown { + select_all(ui, id, &buf); + } + let kept = rows == 1 && shift_enter_keeps_focus(ui, id); + // The box is exactly as tall as the other controls of a row (26 points a line), its text + // centred in it: a frame around the text came out a point taller and stood out next to + // spinners and dropdowns (#900). + let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32; + let layout = if rows > 1 { egui::Layout::top_down(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Center) }; + let (rect, resp) = ui + .allocate_ui_with_layout(vec2(width, height), layout, |ui| { + ui.set_min_size(vec2(width, height)); + let rect = ui.max_rect(); + ui.painter().rect(rect, 5.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside); + let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 }; + let resp = egui::Frame::NONE + .inner_margin(margin) + .show(ui, |ui| { + crate::theme::strong_selected_text(ui); + let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) }; + ui.add( + edit.id(id) + .frame(egui::Frame::NONE) + .margin(egui::Margin::ZERO) + .desired_width(width - 14.0) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner; + (rect, resp) + }) + .inner; + let resp = name_field(label_field(resp, label), name); + let focused = ui.memory(|m| m.has_focus(id)); + ui.data_mut(|d| { + d.insert_temp(id, buf.clone()); + d.insert_temp(focus_id, focused); + }); + let commit = resp.lost_focus() || kept; + (buf, commit, resp, rect) +} + +/// A recessed text field showing `value` (blank when `None`: the selection's values differ), +/// `rows` lines tall (1: single line, which Enter and Shift+Enter commit). Returns the new text, +/// trimmed, when a change is committed (Enter, Shift+Enter or focus loss). +pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option<&str>, width: f32, rows: usize) -> Option { + text_field_named(ui, id, None, value, width, rows) +} + +/// [`text_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option<&str>, width: f32, rows: usize) -> Option { + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); + let shown = value.unwrap_or_default(); + let (buf, commit, _, _) = recessed_text(ui, id, shown, width, rows, label, name); + (commit && buf.trim() != shown).then(|| buf.trim().to_string()) +} + +/// A one-line [`text_field`] that keeps the text exactly as typed, spaces included (a tab +/// leader such as ". "). Returns it when a change is committed (Enter, Shift+Enter or focus loss). +pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash, value: &str, width: f32) -> Option { + exact_text_field_named(ui, id, None, value, width) +} + +/// [`exact_text_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn exact_text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, width: f32) -> Option { + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); + let (buf, commit, _, _) = recessed_text(ui, id, value, width, 1, label, name); + (commit && buf != value).then_some(buf) +} + +/// A plain number field (percent, degrees, counts) with optional suffix. +pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { + plain_field_named(ui, id, None, value, suffix, decimals, width) +} + +/// [`plain_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn plain_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + value: f64, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + mixed_field_named(ui, id, name, Some(value), suffix, decimals, width) +} + +/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its +/// text in egui memory under `id`. Returns the text. +pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String { + let t = Tokens::get(ui.ctx()); + let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default(); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, t.input_border)) + .corner_radius(egui::CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut query) + .id(id.with("edit")) + .frame(egui::Frame::NONE) + .hint_text(egui::RichText::new(tl!(hint)).italics()) + .desired_width(ui.available_width()), + ); + }); + ui.data_mut(|d| d.insert_temp(id, query.clone())); + query +} + +/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…). +/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked. +pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash, value: &str, presets: &[&str], width: f32) -> Option { + text_presets_named(ui, id, None, value, presets, width) +} + +/// [`text_presets`] for a row whose heading names it instead of an adjacent label widget. +pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, presets: &[&str], width: f32) -> Option { + let mut out = None; + ui.scope(|ui| { + // The field and the chevron share a border. + ui.spacing_mut().item_spacing.x = -1.0; + out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1); + let resp = chevron_cell(ui, 26.0); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(width - 10.0); + for p in presets { + if ui.selectable_label(*p == value, *p).clicked() { + out = Some((*p).to_string()); + } + } + }); + }); + out +} diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs new file mode 100644 index 000000000..26ee2e125 --- /dev/null +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -0,0 +1,105 @@ +use super::*; + +/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated), +/// draws the text and returns the box, the response and the box's border colour. +fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled }); + let (rect, resp) = + ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() }); + let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0)); + ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text); + let border = if !enabled { + t.divider + } else if resp.hovered() { + t.text + } else { + t.button_border + }; + (bx, resp, border) +} + +/// Panel-style checkbox with a disabled state. Returns true when toggled. +pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { + check3(ui, label, Some(value), enabled) +} + +/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. +pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { + let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled); + resp.on_hover_text(tip); + clicked +} + +/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked. +pub fn check3(ui: &mut Ui, label: &str, value: Option, enabled: bool) -> bool { + check_row(ui, tl!(label), value, enabled).0 +} + +/// [`check`] for a name of the document's (an artboard's, an asset's), shown as it is, never +/// translated. Returns true when toggled. +pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool { + check_row(ui, name, Some(value), true).0 +} + +/// A [`check`] box without text, for a row whose label is drawn beside it in another column: +/// screen readers call it `name` (already translated). Returns true when toggled. +pub fn check_box(ui: &mut Ui, name: &str, value: bool, enabled: bool) -> bool { + check_row_named(ui, "", name, Some(value), enabled).0 +} + +/// [`check3`] with its `text` already translated, also returning the row's response (what a +/// tooltip goes on: a widget around it never counts as hovered under the box). +fn check_row(ui: &mut Ui, text: &str, value: Option, enabled: bool) -> (bool, Response) { + check_row_named(ui, text, text, value, enabled) +} + +/// [`check_row`] that screen readers call `name` instead of its `text`. +fn check_row_named(ui: &mut Ui, text: &str, name: &str, value: Option, enabled: bool) -> (bool, Response) { + let t = Tokens::get(ui.ctx()); + let (bx, resp, border) = choice_row(ui, text, enabled); + let checked = value == Some(true); + ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input }); + ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); + match value { + Some(true) => { + let c = if enabled { Color32::WHITE } else { t.text_disabled }; + ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c)); + ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c)); + } + None => { + let c = if enabled { t.text } else { t.text_disabled }; + ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, c)); + } + Some(false) => {} + } + resp.widget_info(|| match value { + Some(on) => egui::WidgetInfo::selected(egui::Role::CheckBox, enabled && ui.is_enabled(), on, name), + None => egui::WidgetInfo::labeled(egui::Role::CheckBox, enabled && ui.is_enabled(), name), + }); + (enabled && resp.clicked(), resp) +} + +/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked. +pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, tl!(label), tl!(label), selected, enabled) +} + +/// A [`radio`] button without text, for one followed by a field and the text that names it: +/// screen readers call it `name` (already translated). Returns true when clicked. +pub fn radio_button(ui: &mut Ui, name: &str, selected: bool, enabled: bool) -> bool { + radio_row(ui, "", name, selected, enabled) +} + +/// [`radio`] with its `text` already translated, which screen readers call `name`. +fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (bx, resp, border) = choice_row(ui, text, enabled); + let c = bx.center(); + ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border)); + if selected { + ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, enabled && ui.is_enabled(), selected, name)); + enabled && resp.clicked() +}