Files
designcraft/crates/ui-egui/src/widgets.rs
aiongg 378fe81011 Merge PR #1059: Properties: Edit Page takes the Page tool to the page and sets that page's size, as InDesign's
Conflicts:
- crates/ui-egui/src/i18n.rs, i18n/{ar,de,it,ja,ka,pt_br,uk}.rs: kept both sides.
2026-10-16 03:55:59 +00:00

804 lines
36 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Small InDesign-style widgets (measured from InDesign 2026, `plan/indesign/11-observed-ui.md` §4):
//! 21 pt fields (`#454545` fill, 1 pt `#747474` border) with an optional 18 pt spinner segment on
//! the **left** of the value and an optional preset chevron segment on the right, 20 pt outline
//! buttons, underlined link labels, `•••` more-options, 21 pt icon toggles in a `#303030` well,
//! section headers and 1.5 pt section rules.
use designcraft_geom::Unit;
use designcraft_geom::units::{format_measure, parse_measure};
use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use crate::theme::Tokens;
/// Field height including the 1 pt border.
pub const FIELD_H: f32 = 21.0;
/// Width of the spinner (⌃⌄) and preset (⌄) segments.
pub const SEG_W: f32 = 18.0;
/// Outline button height.
pub const BUTTON_H: f32 = 20.0;
/// UI label size (InDesign: about 10.5–11 pt).
pub const LABEL_SIZE: f32 = 11.0;
fn label_font() -> egui::FontId {
egui::FontId::proportional(LABEL_SIZE)
}
/// What a numeric field holds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum NumKind<'a> {
/// A length in points shown in `Unit`; accepts any unit and arithmetic.
Measure(Unit),
/// A plain number with a display suffix (`%`, `°`, ` pt`).
Number { suffix: &'a str, decimals: usize },
}
impl NumKind<'_> {
pub fn format(&self, v: f64) -> String {
match *self {
NumKind::Measure(u) => format_measure(v, u),
NumKind::Number { suffix, decimals } => {
let s = format!("{v:.decimals$}");
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
let s = if s == "-0" { "0".to_string() } else { s };
format!("{s}{suffix}")
}
}
}
pub fn parse(&self, s: &str) -> Option<f64> {
match *self {
NumKind::Measure(u) => parse_measure(s, u).ok(),
NumKind::Number { .. } => {
// Numbers accept the same arithmetic as measures (`12+3`, `50%*2`): evaluate as points.
let cleaned: String =
s.chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '*' | '/' | '(' | ')' | ' ')).collect();
parse_measure(cleaned.trim(), Unit::Points).ok().or_else(|| cleaned.trim().parse().ok())
}
}
}
/// One spinner click (points for measures).
pub fn step(&self) -> f64 {
match *self {
NumKind::Measure(u) => match u {
Unit::Points | Unit::Picas | Unit::Ciceros | Unit::Pixels | Unit::Agates => 1.0,
Unit::Inches | Unit::InchesDecimal => 72.0 / 16.0,
Unit::Millimeters | Unit::Centimeters | Unit::Q | Unit::Ha => Unit::Millimeters.to_pt(1.0),
},
NumKind::Number { .. } => 1.0,
}
}
}
/// A numeric field. Build with [`NumField::measure`] / [`NumField::number`], then [`NumField::show`].
pub struct NumField<'a> {
id: &'a str,
value: Option<f64>,
kind: NumKind<'a>,
width: f32,
spinner: bool,
presets: &'a [f64],
step: Option<f64>,
range: (f64, f64),
enabled: bool,
parens: bool,
}
impl<'a> NumField<'a> {
pub fn measure(id: &'a str, value: Option<f64>, unit: Unit) -> Self {
Self::new(id, value, NumKind::Measure(unit))
}
pub fn number(id: &'a str, value: Option<f64>, suffix: &'a str, decimals: usize) -> Self {
Self::new(id, value, NumKind::Number { suffix, decimals })
}
fn new(id: &'a str, value: Option<f64>, kind: NumKind<'a>) -> Self {
NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false }
}
/// Total width (spinner and preset segments included).
pub fn width(mut self, w: f32) -> Self {
self.width = w;
self
}
/// Show the ⌃⌄ spinner segment on the left.
pub fn spinner(mut self) -> Self {
self.spinner = true;
self
}
/// Show a ⌄ preset segment on the right listing `presets`.
pub fn presets(mut self, p: &'a [f64]) -> Self {
self.presets = p;
self
}
pub fn step(mut self, s: f64) -> Self {
self.step = Some(s);
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.range = (min, max);
self
}
/// Show the value in parentheses (an automatic value, like auto leading "(14.4 pt)").
pub fn parens(mut self, on: bool) -> Self {
self.parens = on;
self
}
fn fmt(&self, v: f64) -> String {
if self.parens { format!("({})", self.kind.format(v)) } else { self.kind.format(v) }
}
fn parse(&self, s: &str) -> Option<f64> {
self.kind.parse(s.trim().trim_start_matches('(').trim_end_matches(')'))
}
pub fn enabled(mut self, on: bool) -> Self {
self.enabled = on;
self
}
/// Draw the field; returns the new value when the user commits an edit (Enter, focus loss,
/// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset).
pub fn show(self, ui: &mut Ui) -> Option<f64> {
self.show_or_clear(ui).flatten()
}
/// Like [`NumField::show`]; a field emptied while it had a value commits `Some(None)` (for
/// values that may be unset, like a character style's attributes).
pub fn show_or_clear(self, ui: &mut Ui) -> Option<Option<f64>> {
let t = Tokens::get(ui.ctx());
let key = ui.id().with(self.id);
let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover());
let step = self.step.unwrap_or_else(|| self.kind.step());
let clamp = |v: f64| v.clamp(self.range.0, self.range.1);
let mut out: Option<f64> = None;
let mut cleared = false;
let mut text_rect = rect;
let focused_id = key.with("te");
let focused = ui.memory(|m| m.has_focus(focused_id));
let (bg, fg, border) =
if focused { (Color32::WHITE, Color32::from_gray(20), Color32::from_rgb(0x45, 0xa0, 0xf5)) } else { (t.input, t.text, t.field_border) };
let fg = if self.enabled { fg } else { t.text_disabled };
// Spinner segment.
if self.spinner {
let seg = Rect::from_min_size(rect.min, vec2(SEG_W, FIELD_H));
text_rect.min.x = seg.max.x;
ui.painter().rect_filled(seg, 0.0, t.input);
let up = Rect::from_min_max(seg.min, pos2(seg.max.x, seg.center().y));
let down = Rect::from_min_max(pos2(seg.min.x, seg.center().y), seg.max);
for (r, dir) in [(up, 1.0), (down, -1.0)] {
let resp = ui.interact(r, key.with(("spin", dir as i32)), if self.enabled { Sense::click() } else { Sense::hover() });
if resp.hovered() && self.enabled {
ui.painter().rect_filled(r.shrink(1.0), 0.0, t.hover);
}
let c = r.center();
let (a, b) = if dir > 0.0 { (2.5, -1.5) } else { (-2.5, 1.5) };
ui.painter().line_segment(
[pos2(c.x - 4.0, c.y + a * 0.6), pos2(c.x, c.y + b)],
Stroke::new(1.0, if self.enabled { t.icon } else { t.text_disabled }),
);
ui.painter().line_segment(
[pos2(c.x, c.y + b), pos2(c.x + 4.0, c.y + a * 0.6)],
Stroke::new(1.0, if self.enabled { t.icon } else { t.text_disabled }),
);
if resp.clicked() {
let mul = if ui.input(|i| i.modifiers.shift) { 10.0 } else { 1.0 };
out = Some(clamp(self.value.unwrap_or(0.0) + dir * step * mul));
}
}
}
// Preset segment.
let mut preset_resp = None;
if !self.presets.is_empty() {
let seg = Rect::from_min_max(pos2(rect.max.x - SEG_W, rect.min.y), rect.max);
text_rect.max.x = seg.min.x;
let resp = ui.interact(seg, key.with("presets"), Sense::click());
ui.painter().rect_filled(seg, 0.0, if resp.hovered() { t.hover } else { t.input });
chevron_down(ui.painter(), seg.center(), t.icon);
preset_resp = Some(resp);
}
// Value.
ui.painter().rect_filled(text_rect, 0.0, bg);
let mut buf: String = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| self.value.map(|v| self.fmt(v)).unwrap_or_default());
let te = egui::TextEdit::singleline(&mut buf)
.id(focused_id)
.frame(egui::Frame::NONE)
.font(label_font())
.text_color(fg)
.margin(egui::Margin::ZERO)
.vertical_align(egui::Align::Center);
// The field already allocated its slot. A scoped allocation here would consume another
// grid cell and enlarge toolbar rows; the value editor must stay inside that same slot.
let value_rect = Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0));
let mut editor_ui = ui.new_child(egui::UiBuilder::new().max_rect(value_rect));
editor_ui.set_clip_rect(editor_ui.clip_rect().intersect(text_rect));
if !self.enabled {
editor_ui.disable();
}
let r = editor_ui.put(value_rect, te);
if r.has_focus() {
// ↑/↓ step the value while typing.
let (up, down, shift) = ui.input_mut(|i| {
let s = i.modifiers.shift;
let up = i.consume_key(egui::Modifiers::SHIFT, egui::Key::ArrowUp) || i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp);
let down = i.consume_key(egui::Modifiers::SHIFT, egui::Key::ArrowDown) || i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown);
(up, down, s)
});
if up || down {
let base = self.parse(&buf).or(self.value).unwrap_or(0.0);
let v = clamp(base + if up { step } else { -step } * if shift { 10.0 } else { 1.0 });
buf = self.fmt(v);
out = Some(v);
}
ui.data_mut(|d| d.insert_temp(key, buf.clone()));
}
if r.lost_focus() {
if let Some(v) = self.parse(&buf).map(clamp)
&& self.value.is_none_or(|old| (old - v).abs() > 1e-9)
{
out = Some(v);
} else if buf.trim().is_empty() && self.value.is_some() {
cleared = true;
}
ui.data_mut(|d| d.remove::<String>(key));
} else if !r.has_focus() {
ui.data_mut(|d| d.remove::<String>(key));
}
if out.is_some() && !r.has_focus() {
ui.data_mut(|d| d.remove::<String>(key));
}
// Frame: outer border plus segment rules.
let p = ui.painter();
p.rect_stroke(rect, 0.0, Stroke::new(1.0, border), StrokeKind::Inside);
if self.spinner {
p.line_segment([pos2(rect.min.x + SEG_W, rect.min.y), pos2(rect.min.x + SEG_W, rect.max.y)], Stroke::new(1.0, t.field_border));
}
if !self.presets.is_empty() {
p.line_segment([pos2(rect.max.x - SEG_W, rect.min.y), pos2(rect.max.x - SEG_W, rect.max.y)], Stroke::new(1.0, t.field_border));
}
if let Some(resp) = preset_resp {
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(self.width);
for v in self.presets {
if ui.selectable_label(self.value.is_some_and(|c| (c - v).abs() < 1e-9), self.kind.format(*v)).clicked() {
out = Some(*v);
ui.close();
}
}
});
}
if cleared { Some(None) } else { out.map(Some) }
}
}
/// A plain measurement field (no spinner), e.g. Transform X/Y/W/H.
pub fn measure(ui: &mut Ui, id: &str, value: Option<f64>, unit: Unit, width: f32) -> Option<f64> {
NumField::measure(id, value, unit).width(width).show(ui)
}
/// A measurement field with the spinner segment.
pub fn spin_measure(ui: &mut Ui, id: &str, value: Option<f64>, unit: Unit, width: f32) -> Option<f64> {
NumField::measure(id, value, unit).width(width).spinner().show(ui)
}
/// A plain number field (percent, degrees, counts).
pub fn number(ui: &mut Ui, id: &str, value: Option<f64>, suffix: &str, width: f32, decimals: usize) -> Option<f64> {
NumField::number(id, value, suffix, decimals).width(width).show(ui)
}
/// A number field with the spinner segment.
pub fn spin_number(ui: &mut Ui, id: &str, value: Option<f64>, suffix: &str, width: f32, decimals: usize) -> Option<f64> {
NumField::number(id, value, suffix, decimals).width(width).spinner().show(ui)
}
pub fn chevron_down(p: &egui::Painter, c: egui::Pos2, col: Color32) {
p.line_segment([pos2(c.x - 4.0, c.y - 2.0), pos2(c.x, c.y + 2.0)], Stroke::new(1.0, col));
p.line_segment([pos2(c.x, c.y + 2.0), pos2(c.x + 4.0, c.y - 2.0)], Stroke::new(1.0, col));
}
/// A dropdown box (field style, ⌄ segment on the right). Returns the response; open a popup on it
/// with [`egui::Popup::menu`].
pub fn dropdown(ui: &mut Ui, text: &str, width: f32) -> Response {
dropdown_h(ui, text, width, FIELD_H)
}
pub fn dropdown_h(ui: &mut Ui, text: &str, width: f32, h: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(width, h), Sense::click());
let p = ui.painter();
p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input });
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
let seg = Rect::from_min_max(pos2(r.max.x - SEG_W, r.min.y), r.max);
p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border));
chevron_down(p, seg.center(), t.icon);
let clip = Rect::from_min_max(r.min, pos2(seg.min.x - 2.0, r.max.y));
p.with_clip_rect(clip).text(pos2(r.min.x + 7.0, r.center().y), egui::Align2::LEFT_CENTER, text, label_font(), t.text);
resp
}
/// A dropdown that lists `items` and returns the picked index.
pub fn dropdown_list(ui: &mut Ui, text: &str, width: f32, items: &[String], current: Option<usize>) -> Option<usize> {
let resp = dropdown(ui, text, width);
let mut out = None;
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(width);
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (i, it) in items.iter().enumerate() {
if ui.selectable_label(current == Some(i), it).clicked() {
out = Some(i);
ui.close();
}
}
});
});
out
}
/// A 20 pt outline button (1 pt `#747474`, transparent fill, centred label).
pub fn outline_button(ui: &mut Ui, label: &str, width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(width, BUTTON_H), Sense::click());
let p = ui.painter();
if resp.is_pointer_button_down_on() {
p.rect_filled(r, 2.0, t.well);
} else if resp.hovered() {
p.rect_filled(r, 2.0, t.hover);
}
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
crate::rtl::paint(p, r.center(), egui::Align2::CENTER_CENTER, label, label_font(), t.text);
// Screen readers (and tests) find it by its label.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
resp
}
/// Two half-width buttons side by side (10 pt gutter); returns which was clicked.
pub fn button_pair(ui: &mut Ui, a: &str, b: &str) -> (bool, bool) {
let w = (full_width(ui) - 10.0) / 2.0;
let mut out = (false, false);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
out.0 = outline_button(ui, a, w).clicked();
out.1 = outline_button(ui, b, w).clicked();
});
out
}
/// Width of a full-width row in the Properties panel (214 pt in a 253 pt panel).
pub fn full_width(ui: &Ui) -> f32 {
(ui.available_width() - 8.0).max(60.0)
}
/// An underlined link label (`Stroke`, `Corner`, `Opacity`) that opens the full panel.
pub fn link_label(ui: &mut Ui, text: &str, width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click());
let g = crate::rtl::plain(ui.ctx(), text, label_font(), t.text);
let pos = pos2(r.min.x, r.center().y - g.size().y / 2.0);
let w = g.size().x;
ui.painter().galley(pos, g, t.text);
let y = r.center().y + 6.5;
ui.painter().line_segment([pos2(r.min.x, y), pos2(r.min.x + w, y)], Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.text_dim }));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text));
resp.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// `•••` more-options, right-aligned on its own row at the bottom of a section.
pub fn more_options(ui: &mut Ui, language: &str) -> Response {
let t = Tokens::get(ui.ctx());
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover());
let r = Rect::from_min_max(pos2(row.max.x - 26.0, row.min.y), pos2(row.max.x - 4.0, row.max.y));
let resp = ui.interact(r, ui.id().with(("more", row.min.y as i32)), Sense::click());
for i in 0..3 {
ui.painter().circle_filled(pos2(r.min.x + 4.0 + i as f32 * 7.0, r.center().y), 1.9, if resp.hovered() { t.text_strong } else { t.icon });
}
resp.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(language, "More Options"));
})
}
/// A small dim label (field captions like `X:` or `W:`).
pub fn caption(ui: &mut Ui, s: &str) {
let t = Tokens::get(ui.ctx());
crate::rtl::label(ui, egui::RichText::new(s).size(LABEL_SIZE).color(t.text));
}
/// A right-aligned caption of a fixed width (`W:` before a spinner).
pub fn caption_w(ui: &mut Ui, s: &str, w: f32) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
// Painted, so tell screen readers (and UI tests) what it says.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, s));
crate::rtl::paint(ui.painter(), pos2(r.max.x - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, s, egui::FontId::proportional(11.5), t.text);
}
/// The selection-type line at the top of the Properties panel ("No Selection", "Rectangle", …):
/// a 33 pt band followed by a section rule.
pub fn selection_band(ui: &mut Ui, title: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
crate::rtl::paint(ui.painter(), pos2(r.min.x, r.center().y + 1.0), egui::Align2::LEFT_CENTER, title, crate::theme::semibold(11.5), t.text_strong);
rule(ui);
}
/// Panel section header (InDesign 2026: regular-weight white title, no disclosure chevron; a
/// 1.5 pt rule separates sections). Returns whether to show the section body (always true).
pub fn section(ui: &mut Ui, title: &str, _default_open: bool) -> bool {
let t = Tokens::get(ui.ctx());
ui.add_space(7.0);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover());
crate::rtl::paint(ui.painter(), r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong);
// Screen readers hear the section's title.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, title));
ui.add_space(3.0);
true
}
/// A section header whose controls sit on the same row (Rulers & Grids, Guides, Flex Layout):
/// returns the remaining rect to the right of the title.
pub fn section_row(ui: &mut Ui, title: &str, add: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.set_min_height(23.0);
let (r, _) = ui.allocate_exact_size(vec2(100.0, 23.0), Sense::hover());
crate::rtl::paint(ui.painter(), r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(4.0);
add(ui)
});
});
ui.add_space(6.0);
}
/// The 1.5 pt section rule, full panel width.
pub fn rule(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.5), Sense::hover());
let clip = ui.clip_rect();
ui.painter().with_clip_rect(clip.expand2(vec2(12.0, 0.0))).rect_filled(
Rect::from_min_max(pos2(r.min.x - 12.0, r.min.y), pos2(r.max.x + 12.0, r.max.y)),
0.0,
t.section_divider,
);
}
pub fn divider(ui: &mut Ui) {
ui.add_space(8.0);
rule(ui);
}
/// A swatch chip (colour square, [None] slash, gradient ramp).
pub fn swatch_chip(ui: &mut Ui, size: f32, color: Option<Color32>, gradient: Option<(Color32, Color32)>) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click());
paint_chip(ui.painter(), r, color, gradient);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
resp
}
pub fn paint_chip(p: &egui::Painter, r: egui::Rect, color: Option<Color32>, gradient: Option<(Color32, Color32)>) {
match (color, gradient) {
(_, Some((a, b))) => {
let n = 12;
for i in 0..n {
let f = i as f32 / (n - 1) as f32;
let c = Color32::from_rgb(
(a.r() as f32 * (1.0 - f) + b.r() as f32 * f) as u8,
(a.g() as f32 * (1.0 - f) + b.g() as f32 * f) as u8,
(a.b() as f32 * (1.0 - f) + b.b() as f32 * f) as u8,
);
let x0 = r.min.x + r.width() * i as f32 / n as f32;
p.rect_filled(egui::Rect::from_min_max(egui::pos2(x0, r.min.y), egui::pos2(x0 + r.width() / n as f32 + 0.5, r.max.y)), 0.0, c);
}
}
(Some(c), None) => {
p.rect_filled(r, 0.0, c);
}
(None, None) => {
p.rect_filled(r, 0.0, Color32::WHITE);
p.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(230, 30, 30)));
}
}
}
/// A stroke chip: a ring of the colour around a dark centre (None = white with a red slash).
pub fn paint_stroke_chip(p: &egui::Painter, r: egui::Rect, color: Option<Color32>, inner: Color32) {
match color {
Some(c) => {
p.rect_filled(r, 0.0, c);
p.rect_filled(r.shrink(r.width() * 0.3), 0.0, inner);
}
None => {
paint_chip(p, r, None, None);
p.rect_stroke(r.shrink(r.width() * 0.3), 0.0, Stroke::new(1.0, Color32::from_gray(40)), StrokeKind::Middle);
}
}
}
/// Resolve a swatch to a display colour (and gradient end colours).
pub fn swatch_colors(doc: &designcraft_doc::Document, name: &str, tint: f32) -> (Option<Color32>, Option<(Color32, Color32)>) {
let conv = |c: designcraft_color::Color| {
let [r, g, b, _] = c.to_rgba8(1.0);
Color32::from_rgb(r, g, b)
};
if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, name)
&& let (Some(a), Some(b)) = (g.stops.first(), g.stops.last())
{
return (None, Some((conv(a.color), conv(b.color))));
}
(doc.resolve_color(name, tint).map(conv), None)
}
/// A 21 pt icon toggle (active = `#303030` well with a 1 pt rim).
pub fn icon_toggle(ui: &mut Ui, icon: &str, on: bool, tip: &str) -> Response {
icon_toggle_sized(ui, icon, on, tip, vec2(23.0, 21.0))
}
pub fn icon_toggle_sized(ui: &mut Ui, icon: &str, on: bool, tip: &str, size: egui::Vec2) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
if on {
ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let s = (size.y - 4.0).min(17.0);
crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, if on { t.text_strong } else { t.icon });
// Named by its tooltip, for screen readers.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip));
if tip.is_empty() {
resp
} else {
resp.on_hover_ui(|ui| {
crate::rtl::label(ui, tip);
})
}
}
/// An InDesign checkbox: 12 pt rounded box, white check, label.
pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response {
let t = Tokens::get(ui.ctx());
let g = ui.painter().layout_no_wrap(label.to_string(), label_font(), t.text);
let (r, mut resp) = ui.allocate_exact_size(vec2(18.0 + g.size().x, FIELD_H), Sense::click());
let b = Rect::from_center_size(pos2(r.min.x + 6.5, r.center().y), vec2(12.0, 12.0));
if *on {
ui.painter().rect_filled(b, 2.0, t.icon);
ui.painter().line_segment([b.min + vec2(2.5, 6.0), b.min + vec2(5.0, 8.5)], Stroke::new(1.5, t.panel));
ui.painter().line_segment([b.min + vec2(5.0, 8.5), b.min + vec2(9.5, 3.5)], Stroke::new(1.5, t.panel));
} else {
ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
}
ui.painter().galley(pos2(r.min.x + 18.0, r.center().y - g.size().y / 2.0), g, t.text);
if resp.clicked() {
*on = !*on;
resp.mark_changed();
}
resp
}
/// A two-segment control ([Paragraph Styles | Character Styles]); returns the clicked index.
pub fn segmented(ui: &mut Ui, labels: &[&str], active: usize, width: f32) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(width, BUTTON_H), Sense::hover());
let w = width / labels.len() as f32;
let mut out = None;
for (i, l) in labels.iter().enumerate() {
let sr = Rect::from_min_size(pos2(r.min.x + w * i as f32, r.min.y), vec2(w, BUTTON_H));
let resp = ui.interact(sr, ui.id().with(("seg", l)), Sense::click());
if i == active {
ui.painter().rect_filled(sr, 0.0, t.well);
} else if resp.hovered() {
ui.painter().rect_filled(sr, 0.0, t.hover);
}
crate::rtl::paint(ui.painter(), sr.center(), egui::Align2::CENTER_CENTER, l, label_font(), if i == active { t.accent } else { t.text });
if i > 0 {
ui.painter().line_segment([sr.left_top(), sr.left_bottom()], Stroke::new(1.0, t.field_border));
}
if resp.clicked() {
out = Some(i);
}
}
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
out
}
/// Scrolling for a bar that a small window cuts off (Tools panel, Control panel, application
/// bar): the mouse wheel scrolls the bar's only direction, and a thin scroll bar floats over the
/// contents, so nothing moves and nothing shows while everything fits.
pub fn overflow_scrolling(ui: &mut Ui) {
let style = ui.style_mut();
style.always_scroll_the_only_direction = true;
// A caption at the end of a scrolled row gets only what is left of it: extend, don't wrap.
style.wrap_mode = Some(egui::TextWrapMode::Extend);
let s = &mut style.spacing.scroll;
*s = egui::style::ScrollStyle::floating();
s.bar_width = 6.0;
// A faint handle while the pointer is elsewhere: the bar shows there is more.
s.dormant_handle_opacity = 0.5;
}
#[cfg(test)]
mod tests {
use super::*;
fn run(ctx: &egui::Context, events: Vec<egui::Event>, mods: egui::Modifiers, f: &mut dyn FnMut(&mut Ui)) {
let raw = egui::RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))),
events: std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(),
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| f(ui));
out.textures_delta.clear();
}
fn click(p: egui::Pos2, mods: egui::Modifiers) -> Vec<Vec<egui::Event>> {
vec![
vec![egui::Event::PointerMoved(p)],
vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }],
vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }],
]
}
/// Drive a spinner field through frames; returns every committed value and the field rect.
fn drive(frames: Vec<(Vec<egui::Event>, egui::Modifiers)>, value: f64) -> (Vec<f64>, Rect) {
let ctx = egui::Context::default();
let mut got = vec![];
let mut rect = Rect::NOTHING;
let mut all = vec![(vec![], egui::Modifiers::NONE)];
all.extend(frames);
for (ev, m) in all {
run(&ctx, ev, m, &mut |ui| {
rect = ui.available_rect_before_wrap();
if let Some(v) = NumField::measure("w", Some(value), Unit::Points).width(80.0).spinner().show(ui) {
got.push(v);
}
});
}
(got, rect)
}
#[test]
fn spinner_up_down_and_shift() {
let (_, r) = drive(vec![], 10.0);
let up = r.min + vec2(9.0, 5.0);
let down = r.min + vec2(9.0, 16.0);
let (got, _) = drive(click(up, egui::Modifiers::NONE).into_iter().map(|e| (e, egui::Modifiers::NONE)).collect(), 10.0);
assert_eq!(got, vec![11.0]);
let (got, _) = drive(click(down, egui::Modifiers::NONE).into_iter().map(|e| (e, egui::Modifiers::NONE)).collect(), 10.0);
assert_eq!(got, vec![9.0]);
let (got, _) = drive(click(up, egui::Modifiers::SHIFT).into_iter().map(|e| (e, egui::Modifiers::SHIFT)).collect(), 10.0);
assert_eq!(got, vec![20.0]);
}
#[test]
fn arrow_keys_step_and_typing_parses_arithmetic() {
let (_, r) = drive(vec![], 10.0);
let text = r.min + vec2(50.0, 10.0);
let mut frames: Vec<(Vec<egui::Event>, egui::Modifiers)> =
click(text, egui::Modifiers::NONE).into_iter().map(|e| (e, egui::Modifiers::NONE)).collect();
let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
frames.push((vec![key(egui::Key::ArrowUp)], egui::Modifiers::NONE));
let (got, _) = drive(frames.clone(), 10.0);
assert_eq!(got, vec![11.0]);
// Select all, type an expression, press Enter.
frames.truncate(3);
frames.push((
vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }],
egui::Modifiers::COMMAND,
));
frames.push((vec![egui::Event::Text("6+6".into())], egui::Modifiers::NONE));
frames.push((vec![key(egui::Key::Enter)], egui::Modifiers::NONE));
let (got, _) = drive(frames, 10.0);
assert_eq!(got, vec![12.0]);
}
#[test]
fn emptying_a_clearable_field_clears_it() {
let ctx = egui::Context::default();
let field = |ui: &mut Ui| NumField::number("c", Some(12.0), " pt", 2).width(80.0).show_or_clear(ui);
let mut rect = Rect::NOTHING;
run(&ctx, vec![], egui::Modifiers::NONE, &mut |ui| {
rect = ui.available_rect_before_wrap();
field(ui);
});
let key = |k, m| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m };
let mut frames: Vec<Vec<egui::Event>> = click(rect.min + vec2(40.0, 10.0), egui::Modifiers::NONE);
frames.push(vec![key(egui::Key::A, egui::Modifiers::COMMAND)]);
frames.push(vec![key(egui::Key::Backspace, egui::Modifiers::NONE)]);
frames.push(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
let mut got = vec![];
for ev in frames {
let m = if ev.iter().any(|e| matches!(e, egui::Event::Key { modifiers, .. } if modifiers.command)) {
egui::Modifiers::COMMAND
} else {
egui::Modifiers::NONE
};
run(&ctx, ev, m, &mut |ui| {
if let Some(v) = field(ui) {
got.push(v);
}
});
}
assert_eq!(got, vec![None]);
}
#[test]
fn numeric_fields_use_one_cell_in_a_two_row_grid() {
let ctx = egui::Context::default();
let mut bounds = Rect::NOTHING;
let mut draw = |ui: &mut Ui| {
bounds = egui::Grid::new("numeric_field_grid")
.num_columns(4)
.min_col_width(0.0)
.spacing(vec2(4.0, 4.0))
.show(ui, |ui| {
for row in 0..2 {
for column in 0..2 {
caption_w(ui, if column == 0 { "X:" } else { "W:" }, 14.0);
let id = [["grid_x", "grid_w"], ["grid_y", "grid_h"]][row][column];
NumField::number(id, Some(100.0), "%", 0).width(72.0).show(ui);
}
ui.end_row();
}
})
.response
.rect;
};
run(&ctx, vec![], egui::Modifiers::NONE, &mut draw);
run(&ctx, vec![], egui::Modifiers::NONE, &mut draw);
assert!(bounds.height() <= 2.0 * FIELD_H + 4.0, "both rows fit their allocated height: {bounds:?}");
assert!(bounds.width() <= 190.0, "editors do not add extra grid columns: {bounds:?}");
}
#[test]
fn number_kind_formats_and_parses() {
let k = NumKind::Number { suffix: "%", decimals: 0 };
assert_eq!(k.format(100.0), "100%");
assert_eq!(k.parse("50%"), Some(50.0));
assert_eq!(k.parse("10*3"), Some(30.0));
let m = NumKind::Measure(Unit::Picas);
assert_eq!(m.parse("1p0+0p6"), Some(18.0));
assert_eq!(m.step(), 1.0);
}
}
/// `img` made to fit the GPU's largest texture (`max_side` pixels a side), sampled down
/// (nearest pixel) when it is larger. A texture past the limit panics in the renderer, so every
/// UI texture goes through this: an oversized image loses resolution, not the document.
pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage {
let [w, h] = img.size;
let max_side = max_side.max(1);
if w <= max_side && h <= max_side {
return img;
}
let k = (max_side as f64 / w.max(h) as f64).min(1.0);
let (nw, nh) = (((w as f64 * k).floor() as usize).clamp(1, max_side), ((h as f64 * k).floor() as usize).clamp(1, max_side));
let mut pixels = Vec::with_capacity(nw * nh);
for y in 0..nh {
let sy = (y * h / nh).min(h.saturating_sub(1));
for x in 0..nw {
let sx = (x * w / nw).min(w.saturating_sub(1));
pixels.push(img.pixels.get(sy * w + sx).copied().unwrap_or(Color32::TRANSPARENT));
}
}
egui::ColorImage::new([nw, nh], pixels)
}
/// [`egui::Context::load_texture`] for an image of any size (see [`fit_texture`]).
pub fn load_texture(ctx: &egui::Context, name: impl Into<String>, img: egui::ColorImage, options: egui::TextureOptions) -> egui::TextureHandle {
let max = ctx.input(|i| i.max_texture_side);
ctx.load_texture(name, fit_texture(img, max), options)
}
#[cfg(test)]
mod texture_tests {
/// The glyph grid that crashed the renderer: 7 columns of 1200 characters at 60 px, 10320 px
/// tall against an 8192 px limit.
#[test]
fn oversized_images_fit_the_texture_limit() {
let img = egui::ColorImage::new([420, 10320], vec![egui::Color32::WHITE; 420 * 10320]);
let out = super::fit_texture(img, 8192);
assert!(out.size[0] <= 8192 && out.size[1] == 8192, "{:?}", out.size);
assert_eq!(out.pixels.len(), out.size[0] * out.size[1]);
let small = egui::ColorImage::new([10, 10], vec![egui::Color32::RED; 100]);
assert_eq!(super::fit_texture(small, 8192).size, [10, 10]);
let wide = egui::ColorImage::new([20000, 3], vec![egui::Color32::RED; 60000]);
let out = super::fit_texture(wide, 8192);
assert!(out.size[0] == 8192 && out.size[1] >= 1);
}
}