//! Shared widgets in Illustrator's panel style. Colours come from theme tokens. use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::Unit; use crate::theme::{self, Tokens}; use crate::{icons, scrub}; /// 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(3), bg); let pad = (size * 0.2).round(); icons::paint(ui, icon, rect.shrink(pad), if selected { t.text } else { t.icon }); 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)); flat_fill(ui, rect, &resp); ui.painter().rect_stroke( rect, CornerRadius::same(2), Stroke::new(1.0, if resp.hovered() { t.text } else { t.button_border }), StrokeKind::Inside, ); ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); 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) { 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.id().with((text, "split")), Sense::click()); let arrow = ui.interact(arrow_rect, ui.id().with((text, "split-arrow")), Sense::click()); flat_fill(ui, main_rect, &main); flat_fill(ui, arrow_rect, &arrow); let border = if main.hovered() || arrow.hovered() { t.text } else { t.button_border }; ui.painter().rect_stroke(rect, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside); ui.painter().vline(split, rect.y_range().shrink(4.0), Stroke::new(1.0, t.button_border)); 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, arrow) } /// 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(2), t.tool_active); } else if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(2), t.hover); } } /// Blue call-to-action pill (dialog OK/Create). pub fn primary_button(ui: &mut Ui, text: &str) -> Response { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), Color32::WHITE); let w = galley.size().x + 32.0; let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click()); let bg = if resp.hovered() { t.accent } else { t.accent_strong }; ui.painter().rect_filled(rect, CornerRadius::same(14), bg); ui.painter().galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE); resp } /// Outlined secondary pill (dialog Cancel). pub fn secondary_button(ui: &mut Ui, text: &str) -> 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(72.0), 28.0), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(14), t.hover); } ui.painter().rect_stroke(rect, CornerRadius::same(14), Stroke::new(1.5, t.text_dim), StrokeKind::Inside); ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.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(13.0).color(t.text)); ui.add_space(2.0); } /// 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); } /// 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); 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); 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::new("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::new("bare_numbers_are_points"))).unwrap_or(true); if bare_points && unit == Unit::Picas { Unit::Points } else { unit } } /// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points) /// when the user commits (Enter / focus loss). 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 + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option { let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); let shown = value.map(|v| unit.format(v)).unwrap_or_default(); let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); let editing = ui.memory(|m| m.has_focus(id)); if !editing { buf = shown.clone(); } take_dialog_focus(ui, id, &buf); let (rect, resp) = ui .allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(width); 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(6, 4)) .show(ui, |ui| { ui.add( egui::TextEdit::singleline(&mut buf) .id(id) .frame(egui::Frame::NONE) .desired_width(width - 14.0) .min_size(vec2(width - 14.0, 0.0)) .font(egui::FontId::proportional(12.5)) .text_color(t.text_strong), ) }); (framed.response.rect, framed.inner) }) .inner; select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. let read = typed_unit(ui.ctx(), unit); let stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(read.parse(b)?)), |v| unit.format(unit.to_pt(v))); let scrubbed = scrub::field(ui, id, rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS); let commit = resp.lost_focus() && buf != shown; ui.data_mut(|d| d.insert_temp(id, buf.clone())); if commit { read.parse(&buf) } else { stepped.or(scrubbed).map(|v| unit.to_pt(v)) } } /// 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. const STEPS: [(egui::Modifiers, f64); 3] = [(egui::Modifiers::SHIFT, 10.0), (egui::Modifiers::COMMAND, 0.1), (egui::Modifiers::NONE, 1.0)]; /// ↑/↓ in focused numeric field `resp`, or the mouse wheel turned over it, step the number `buf` /// shows (`read` parses it, `show` formats it) by one per press or wheel notch ([`STEPS`]: Shift /// ten, Ctrl/Cmd a tenth), applied at once as in Illustrator's panels and dialogs. 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, read: impl Fn(&str) -> Option, show: impl Fn(f64) -> String) -> 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 }; let mut steps = 0.0; ui.input_mut(|i| { for (mods, size) in STEPS { let up = i.count_and_consume_key(mods, Key::ArrowUp) as f64; let down = i.count_and_consume_key(mods, Key::ArrowDown) as f64; steps += size * (up - down); } if !wheel { return; } i.events.retain(|e| { let Event::MouseWheel { unit, delta, modifiers, .. } = e else { return true }; let Some((_, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; // 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; steps += size * f64::from(whole); false }); // 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 == 0.0 { return None; } // Rounded as fields show it. let v = round_to(read(buf)? + steps, STEP_DECIMALS); *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 field drawn takes it ([`take_dialog_focus`]). pub(crate) fn dialog_focus_flag() -> egui::Id { egui::Id::new("dialog-focus-first-field") } /// If field `id` (showing `text`) is the first 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. if ui.is_sizing_pass() || 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)); } /// 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); } /// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked /// (egui would select one word): typing a number or an expression replaces it. fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { if !(resp.gained_focus() || resp.double_clicked()) { return; } if egui::TextEdit::load_state(ui.ctx(), resp.id).is_some() { 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), the text edit's /// response and the box. fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response, Rect) { let t = Tokens::get(ui.ctx()); let editing = ui.memory(|m| m.has_focus(id)); let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.to_string()) } else { shown.to_string() }; let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32; let (rect, resp) = ui .allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(width); 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(6, 4)) .show(ui, |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) .desired_width(width - 14.0) .min_size(vec2(width - 14.0, 0.0)) .font(egui::FontId::proportional(12.5)) .text_color(t.text_strong), ) }); (framed.response.rect, framed.inner) }) .inner; ui.data_mut(|d| d.insert_temp(id, buf.clone())); (buf, resp, rect) } /// A recessed text field showing `value` (blank when `None`: the selection's values differ), /// `rows` lines tall (1: single line, which Enter commits). Returns the new text, trimmed, when a /// change is committed (Enter or focus loss). pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option { let id = ui.id().with(id); let shown = value.unwrap_or_default(); let (buf, resp, _) = recessed_text(ui, id, shown, width, rows); (resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string()) } /// 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, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); let t = Tokens::get(ui.ctx()); ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text); scrub::note_label(ui, r); add(ui); }); } /// A plain number field (percent, degrees, counts) with optional suffix. pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { mixed_field(ui, id, Some(value), suffix, decimals, width) } /// [`plain_field`] for a number kept within `range` (the values of dialogs and preferences): what is /// typed, stepped or scrubbed is clamped into it and rounded to `decimals` places (0: a count). pub fn range_field( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, range: std::ops::RangeInclusive, suffix: &str, decimals: usize, width: f32, ) -> Option { let (lo, hi) = (*range.start(), *range.end()); let places = decimals.min(6) as i32; plain_field(ui, id, value.clamp(lo, hi), suffix, decimals, width).map(|v| round_to(v, places).clamp(lo, hi)) } /// [`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 + std::fmt::Debug, value: Option, suffix: &str, decimals: usize, width: f32, ) -> Option { let id = ui.id().with(id); let show = |value: f64| { 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}") }; // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. let read = |buf: &str| { let suffix = suffix.trim(); 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(); let (mut buf, resp, rect) = recessed_text(ui, id, &shown, width, 1); 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, read, show).map(|v| round_to(v, decimals)); let scrubbed = scrub::field(ui, id, rect, &buf, value, decimals); if resp.lost_focus() && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) } } /// 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.gradient), 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` (linear left to right; radial from the centre). pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient) { let p = ui.painter(); let radial = gradient.kind == vectorcraft_color::GradientKind::Radial; if radial { // Outer colour fills the corners beyond the largest circle. let (c, _) = gradient.sample(1.0); let [r, g, b, _] = c.to_rgba8(1.0); p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b)); } let n = 24; let w = rect.width() / n as f32; for i in 0..n { let tt = (i as f32 + 0.5) / n as f32; if radial { let (c, _) = gradient.sample(1.0 - tt); let [r, g, b, _] = c.to_rgba8(1.0); let s = rect.width().min(rect.height()) * tt / 2.0; p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b)); } else { let (c, _) = gradient.sample(tt); let [r, g, b, _] = c.to_rgba8(1.0); let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height())); p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b)); } } } /// 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 { 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); } resp } /// What the user did on a [`fill_stroke_proxy`]. #[derive(Clone, Copy, 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, } /// 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.id().with("stroke-proxy"), Sense::click_and_drag()); let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag()); // 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.id().with("swap-proxy"), Sense::click()); let def = ui.interact(def_r, ui.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 }; ProxyClicks { fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(), stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(), swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(), default: def.on_hover_text("Default Fill and Stroke (D)").clicked(), pick, } } /// 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 + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { dropdown_with(ui, id, 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 + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { combo(ui, id, 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 + std::fmt::Debug, current: &str, options: &[&str], width: f32, enabled: impl Fn(usize) -> bool, ) -> Option { combo(ui, id, 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 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 + std::fmt::Debug, current: &str, width: f32, searchable: bool, list: impl FnOnce(&mut Ui) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); 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, ui.id().with(id), current, width - 4.0, list); } egui::ComboBox::from_id_salt(ui.id().with(id)) .selected_text(egui::RichText::new(current).size(12.0)) .width(width - 4.0) .height(ui.spacing().combo_height) .show_ui(ui, list) .inner .flatten() }) .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, 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()); 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. let clicked_elsewhere = ctx .input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten()) .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 } /// The body of a menu or popup list: as tall as its items up to the bottom of the window, and /// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on /// small windows. pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { // Room left below the popup's first item, less the popup frame and a small gap. const BOTTOM_GAP: f32 = 12.0; const MIN_HEIGHT: f32 = 120.0; let room = (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - BOTTOM_GAP).max(MIN_HEIGHT); // 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 } /// 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. pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R { egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).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 + std::fmt::Debug, 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 + std::fmt::Debug, 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 + std::fmt::Debug + 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) = mixed_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), "%", 0, 50.0) { edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), 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 }); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let mut o = opacity.unwrap_or(1.0) * 100.0; let r = ui.add(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; for i in 0..9 { let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); let resp = ui.interact(r.expand(2.0), ui.id().with(("refpt", i)), Sense::click()); 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 } /// 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.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); if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) } } /// 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 } /// The row of a checkbox or radio button: allocates a 13 pt box plus `label`, draws the label and /// returns the box, the response and the box's border colour. fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color32) { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(tl!(label).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, 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, label, value, enabled).0 } /// [`check3`], 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, label: &str, value: Option, enabled: bool) -> (bool, Response) { let t = Tokens::get(ui.ctx()); let (bx, resp, border) = choice_row(ui, label, 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) => {} } (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 { let t = Tokens::get(ui.ctx()); let (bx, resp, border) = choice_row(ui, label, 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 }); } 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. Returns the /// committed, stepped or picked value. #[allow(clippy::too_many_arguments)] pub fn spin_field( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug + Copy, value: Option, unit: Unit, width: f32, step: f64, min: f64, presets: &[f64], ) -> Option { spin_field_auto(ui, id, 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 + std::fmt::Debug + Copy, value: Option, auto: Option, unit: Unit, width: f32, step: f64, min: f64, presets: &[f64], ) -> Option { spin_generic(ui, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| num_field(ui, id, value, unit, fw)) } /// [`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 + std::fmt::Debug + Copy, value: impl Into>, suffix: &str, decimals: usize, width: f32, step: f64, min: f64, presets: &[f64], ) -> Option { let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); spin_generic(ui, value, None, width, step, min, presets, &fmt, &mut |ui, fw| mixed_field(ui, id, value, suffix, decimals, fw)) .and_then(SpinPick::value) } #[allow(clippy::too_many_arguments)] fn spin_generic( ui: &mut Ui, value: Option, auto: Option, width: f32, step: f64, min: f64, presets: &[f64], fmt: &dyn Fn(f64) -> String, field: &mut dyn FnMut(&mut Ui, f32) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; let h = 26.0; let enabled = ui.is_enabled(); ui.scope(|ui| { ui.spacing_mut().item_spacing.x = 0.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)); } // 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 nv = if up.contains(p) { v + step } else { v - step }; out = Some(SpinPick::Value(nv.max(min))); } let fw = if presets.is_empty() { width - 16.0 } else { width - 36.0 }; if let Some(v) = field(ui, fw) { 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 } /// 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 + std::fmt::Debug, value: f32, width: f32, track: &dyn Fn(f32) -> Color32, ) -> (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, ui.id().with(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))); match pointer_phase(&resp) { Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase), None => (None, Live::Idle), } } /// 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 + std::fmt::Debug, 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, ui.id().with(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 + std::fmt::Debug, 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, ui.id().with(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)); } 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 /// changed. pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: &str) -> Option { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new("#").size(15.0).color(t.text)); let id = ui.id().with(id); let editing = ui.memory(|m| m.has_focus(id)); let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() }; let resp = egui::Frame::NONE .fill(t.input) .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) .corner_radius(2) .inner_margin(egui::Margin::symmetric(6, 4)) .show(ui, |ui| { 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 commit = resp.lost_focus() && buf != hex; ui.data_mut(|d| d.insert_temp(id, buf.clone())); 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. 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(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, where `new` is `None` /// when the field was cleared; typed units and arithmetic work as in [`num_field`]. pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option> { let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); let shown = value.map(|v| unit.number(v)).unwrap_or_default(); let editing = ui.memory(|m| m.has_focus(id)); let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; 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; 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 stepped = step_value(ui, &resp, &mut buf, |b| Some(unit.from_pt(unit.parse(b)?)), |v| unit.number(unit.to_pt(v))); let scrubbed = scrub::field(ui, id, framed.response.rect, &buf, value.map(|v| unit.from_pt(v)), STEP_DECIMALS); ui.data_mut(|d| d.insert_temp(id, buf.clone())); if resp.lost_focus() && buf != shown { let s = buf.trim(); if s.is_empty() { Some(None) } else { typed_unit(ui.ctx(), unit).parse(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. Paint { paint: Paint, params: serde_json::Value, rows: Option }, /// 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 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 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 } } /// 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 = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); 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 to a new hue, saturation and brightness (`[h°, s, v]`), by dragging its /// marker or (the selected colour) the brightness slider. pub moved: Option<(usize, [f32; 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.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()) && let Some(old) = colors.get(i) { let dv = p - c; let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); out.moved = Some((i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]])); } }); 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("Brightness").color(t.text_dim))); if let (Some(b), Some([h, s, _])) = (base, base_hsb) && let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x))) { out.moved = Some((b, [h, s, 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, ); } 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.on_hover_text(tl!(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 = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); 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 } /// 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 + std::fmt::Debug, value: &str, presets: &[&str], width: f32) -> Option { let mut out = None; ui.scope(|ui| { ui.spacing_mut().item_spacing.x = 0.0; out = text_field(ui, &id, Some(value), width - 20.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 }