refactor(ui-egui): move widgets.rs items to widgets/{blend,buttons,chips,color,drag,dropdowns,labels,numeric,panel_chrome,text,toggles}.rs

This commit is contained in:
aiongg
2026-10-26 21:45:33 +00:00
parent b3789b280c
commit 055d017603
12 changed files with 3068 additions and 2987 deletions
File diff suppressed because it is too large Load Diff
+161
View File
@@ -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<BlendMode>, width: f32) -> Option<BlendMode> {
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<BlendMode> {
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<serde_json::Value> {
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<f32>,
blend: Option<BlendMode>,
enabled: bool,
) -> Option<TransparencyEdit> {
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<usize> {
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<f64> {
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)
}
+202
View File
@@ -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()
}
+250
View File
@@ -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<Color32> {
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<f32>) -> 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<bool>,
/// A paint dragged from the other proxy (or a swatch) dropped on the fill square.
pub dropped_fill: Option<serde_json::Value>,
/// A paint dropped on the stroke square.
pub dropped_stroke: Option<serde_json::Value>,
}
/// 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<serde_json::Value> {
if let Some(drag) = resp.dnd_hover_payload::<PanelDrag>()
&& 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::<PanelDrag>().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);
}
}
+556
View File
@@ -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<f32>, 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<f32>, 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<f64>,
/// The field's text, such as "50%"; `None` gives the number alone.
pub text: Option<String>,
/// 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<fn(f64, f64) -> 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<f64>) -> 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<f64>, 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<f64>, 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<SliderValue>) {
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<SliderValue>,
value: f32,
width: f32,
track: &dyn Fn(f32) -> Color32,
) -> (Option<f32>, 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<SliderValue>,
value: f32,
width: f32,
track: &dyn Fn(f32) -> Color32,
) -> (Option<f32>, Live, Option<f64>) {
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::<bool>(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::<bool>(by_pointer));
}
if focused && !ui.data(|d| d.get_temp::<bool>(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<f64> {
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<SliderValue>,
value: f32,
width: f32,
track: &dyn Fn(f32) -> Color32,
) -> (Response, Option<f32>, 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<f32>, 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<f32>, 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<SliderValue>,
value: f32,
size: Vec2,
track: &dyn Fn(f32) -> Color32,
) -> (Option<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 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<String> {
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<String> {
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::<bool>(edited_id)).unwrap_or(false);
let mut buf: String = if edited { ui.data_mut(|d| d.get_temp::<String>(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<usize>,
/// 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<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<usize>) -> 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::<Option<usize>>(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)
}
+125
View File
@@ -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<SwatchRows>, 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<vectorcraft_doc::NodeId>),
/// 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<Vec<u64>> {
let drag = zone.dnd_hover_payload::<PanelDrag>()?;
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::<PanelDrag>().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<String>,
/// 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<vectorcraft_color::Color> {
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<vectorcraft_doc::Document>) -> Option<egui::TextureHandle> {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: crate::graphics::TexCache<HashMap<String, egui::TextureHandle>> = 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)
}
+309
View File
@@ -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<usize> {
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<usize> {
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<usize> {
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<usize> {
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<usize> {
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<usize> {
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<R>(
ui: &mut Ui,
id: impl std::hash::Hash,
current: &str,
width: f32,
searchable: bool,
list: impl FnOnce(&mut Ui) -> Option<R>,
) -> Option<R> {
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<R>(
ui: &mut Ui,
id: impl std::hash::Hash,
name: Option<&str>,
current: &str,
width: f32,
searchable: bool,
list: impl FnOnce(&mut Ui) -> Option<R>,
) -> Option<R> {
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<R>(
ui: &mut Ui,
id: egui::Id,
label: Option<PreviousLabel>,
name: Option<&str>,
current: &str,
width: f32,
list: impl FnOnce(&mut Ui) -> Option<R>,
) -> Option<R> {
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<R>(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) -> R) -> Option<R> {
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::<u64>(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::<PanelDrag>(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::<u64>(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<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
let floor = ui.ctx().data(|d| d.get_temp::<f32>(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<R>(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<R>(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
}
+181
View File
@@ -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<PreviousLabel> {
let label = ui.ctx().data_mut(|d| {
let label = d.get_temp::<NextLabel>(next_label_id());
d.remove::<NextLabel>(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<PreviousLabel>) -> 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<egui::WidgetText>) -> 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
}
+661
View File
@@ -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::<bool>(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<f64>, unit: Unit, width: f32) -> Option<f64> {
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<f64>, unit: Unit, width: f32) -> Option<f64> {
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> = 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>) -> 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<PreviousLabel>,
name: Option<&str>,
value: Option<f64>,
unit: Unit,
width: f32,
share_of_value: bool,
bounds: RangeInclusive<f64>,
) -> Option<f64> {
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<f64> {
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<f64>,
(read, show): (impl Fn(&str) -> Option<f64>, impl Fn(f64) -> String),
step: Option<fn(f64, f64) -> f64>,
) -> Option<f64> {
// 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<f64>, unit: Unit, width: f32) -> Option<f64> {
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<f64>, unit: Unit, width: f32) -> Option<f64> {
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<Option<f64>>,
range: RangeInclusive<f64>,
suffix: &str,
decimals: usize,
width: f32,
) -> Option<f64> {
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<Option<f64>>,
range: RangeInclusive<f64>,
suffix: &str,
decimals: usize,
width: f32,
) -> Option<f64> {
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<Option<f64>>,
range: RangeInclusive<f64>,
suffix: &str,
decimals: usize,
width: f32,
step: fn(f64, f64) -> f64,
) -> Option<f64> {
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<f64>, suffix: &str, decimals: usize, width: f32) -> Option<f64> {
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<f64>,
suffix: &str,
decimals: usize,
width: f32,
) -> Option<f64> {
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<PreviousLabel>,
name: Option<&str>,
value: Option<f64>,
suffix: &str,
decimals: usize,
width: f32,
bounds: &RangeInclusive<f64>,
step: Option<fn(f64, f64) -> f64>,
) -> Option<f64> {
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<f64>,
unit: Unit,
width: f32,
step: f64,
min: f64,
presets: &[f64],
) -> Option<f64> {
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<f64>,
unit: Unit,
width: f32,
step: f64,
min: f64,
presets: &[f64],
) -> Option<f64> {
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<f64> {
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<f64>,
auto: Option<bool>,
unit: Unit,
width: f32,
step: f64,
min: f64,
presets: &[f64],
) -> Option<SpinPick> {
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<f64>,
auto: Option<bool>,
unit: Unit,
width: f32,
step: f64,
min: f64,
presets: &[f64],
) -> Option<SpinPick> {
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<Option<f64>>,
suffix: &str,
decimals: usize,
width: f32,
step: f64,
min: f64,
presets: &[f64],
) -> Option<f64> {
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<Option<f64>>,
suffix: &str,
decimals: usize,
width: f32,
step: f64,
min: f64,
presets: &[f64],
) -> Option<f64> {
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<PreviousLabel>) -> Option<f64> + 'a;
#[allow(clippy::too_many_arguments)]
fn spin_generic(
ui: &mut Ui,
label: Option<PreviousLabel>,
value: Option<f64>,
auto: Option<bool>,
width: f32,
step: f64,
min: f64,
presets: &[f64],
fmt: &dyn Fn(f64) -> String,
field: &mut SpinField<'_>,
) -> Option<SpinPick> {
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<f64>, unit: Unit, width: f32) -> Option<Option<f64>> {
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<f64>, unit: Unit, width: f32) -> Option<Option<f64>> {
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::<bool>(edited_id)).unwrap_or(false);
let before = ui.data_mut(|d| d.get_temp::<String>(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)))
}
}
+136
View File
@@ -0,0 +1,136 @@
use super::*;
/// A bordered list box (Swatches tiles, Appearance rows, Artboards list).
pub fn list_box<R>(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<usize> {
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<Pos2> = 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
}
+328
View File
@@ -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::<bool>(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<egui::Modifiers> {
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::<bool>(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::<bool>(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::<bool>(pending);
d.remove_temp::<egui::Pos2>(press_pos);
}
});
}
let clicked_focus = released && ui.data_mut(|d| d.remove_temp::<bool>(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::<egui::Pos2>(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::<bool>(pending);
d.remove_temp::<egui::Pos2>(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<PreviousLabel>,
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::<String>(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::<bool>(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::<String>(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<String> {
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<String> {
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<String> {
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<String> {
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<f64> {
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<f64> {
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<String> {
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<String> {
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
}
+105
View File
@@ -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<bool>, 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<bool>, 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<bool>, 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()
}