mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Art dragged off the canvas and released over the Symbols panel's list becomes a new symbol (symbol.new with the dragged ids), and a symbol dragged out of the panel (PanelDrag::Symbol) is placed on the canvas centred where it is dropped (symbol.place), showing its thumbnail at the pointer on the way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1423 lines
64 KiB
Rust
1423 lines
64 KiB
Rust
//! Shared widgets in Illustrator's panel style. Colours come from theme tokens.
|
||
|
||
use egui::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
|
||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||
use vectorcraft_doc::Unit;
|
||
|
||
use crate::icons;
|
||
use crate::theme::{self, Tokens};
|
||
|
||
/// 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 t = Tokens::get(ui.ctx());
|
||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||
let bg = if selected {
|
||
t.tool_active
|
||
} else if resp.hovered() {
|
||
t.hover
|
||
} else {
|
||
Color32::TRANSPARENT
|
||
};
|
||
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
|
||
let pad = (size * 0.2).round();
|
||
icons::paint(ui, icon, rect.shrink(pad), if selected { t.text } else { t.icon });
|
||
if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp }
|
||
}
|
||
|
||
/// Small flat text button (Quick Actions style).
|
||
pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click());
|
||
let rect = rect.shrink2(vec2(0.0, 0.5));
|
||
if resp.is_pointer_button_down_on() {
|
||
ui.painter().rect_filled(rect, CornerRadius::same(2), t.tool_active);
|
||
} else if resp.hovered() {
|
||
ui.painter().rect_filled(rect, CornerRadius::same(2), t.hover);
|
||
}
|
||
ui.painter().rect_stroke(
|
||
rect,
|
||
CornerRadius::same(2),
|
||
Stroke::new(1.0, if resp.hovered() { t.text } else { t.button_border }),
|
||
StrokeKind::Inside,
|
||
);
|
||
ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong);
|
||
resp
|
||
}
|
||
|
||
/// Blue call-to-action pill (dialog OK/Create).
|
||
pub fn primary_button(ui: &mut Ui, text: &str) -> Response {
|
||
let t = Tokens::get(ui.ctx());
|
||
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), Color32::WHITE);
|
||
let w = galley.size().x + 32.0;
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click());
|
||
let bg = if resp.hovered() { t.accent } else { t.accent_strong };
|
||
ui.painter().rect_filled(rect, CornerRadius::same(14), bg);
|
||
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE);
|
||
resp
|
||
}
|
||
|
||
/// Outlined secondary pill (dialog Cancel).
|
||
pub fn secondary_button(ui: &mut Ui, text: &str) -> Response {
|
||
let t = Tokens::get(ui.ctx());
|
||
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), t.text);
|
||
let w = galley.size().x + 32.0;
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(w.max(72.0), 28.0), Sense::click());
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(rect, CornerRadius::same(14), t.hover);
|
||
}
|
||
ui.painter().rect_stroke(rect, CornerRadius::same(14), Stroke::new(1.5, t.text_dim), StrokeKind::Inside);
|
||
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text);
|
||
resp
|
||
}
|
||
|
||
/// Bold section header (Properties panel).
|
||
pub fn section_header(ui: &mut Ui, text: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.add_space(2.0);
|
||
ui.label(egui::RichText::new(tl!(text)).size(13.0).color(t.text));
|
||
ui.add_space(2.0);
|
||
}
|
||
|
||
/// Full-width 1 px divider with vertical margins.
|
||
pub fn divider(ui: &mut Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.add_space(6.0);
|
||
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());
|
||
ui.painter().rect_filled(rect, 0.0, t.divider);
|
||
ui.add_space(6.0);
|
||
}
|
||
|
||
pub fn dim_label(ui: &mut Ui, text: &str) -> Response {
|
||
dim_name(ui, tl!(text))
|
||
}
|
||
|
||
/// [`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 recessed numeric field showing `value` (points) in `unit`. Returns the new value (points)
|
||
/// when the user commits (Enter / focus loss). Supports unit suffixes and arithmetic.
|
||
pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, unit: Unit, width: f32) -> Option<f64> {
|
||
let t = Tokens::get(ui.ctx());
|
||
let id = ui.id().with(id);
|
||
let shown = value.map(|v| unit.format(v)).unwrap_or_default();
|
||
let mut buf: String = ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
|
||
let editing = ui.memory(|m| m.has_focus(id));
|
||
if !editing {
|
||
buf = shown.clone();
|
||
}
|
||
let resp = ui
|
||
.allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||
ui.set_min_width(width);
|
||
egui::Frame::NONE
|
||
.fill(t.input)
|
||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||
.corner_radius(CornerRadius::same(2))
|
||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||
.show(ui, |ui| {
|
||
ui.add(
|
||
egui::TextEdit::singleline(&mut buf)
|
||
.id(id)
|
||
.frame(egui::Frame::NONE)
|
||
.desired_width(width - 14.0)
|
||
.min_size(vec2(width - 14.0, 0.0))
|
||
.font(egui::FontId::proportional(12.5))
|
||
.text_color(t.text_strong),
|
||
)
|
||
})
|
||
.inner
|
||
})
|
||
.inner;
|
||
select_all_on_focus(ui, &resp, &buf);
|
||
let commit = resp.lost_focus() && buf != shown;
|
||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||
if commit { unit.parse(&buf) } else { None }
|
||
}
|
||
|
||
/// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked
|
||
/// (egui would select one word): typing a number or an expression replaces it.
|
||
fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) {
|
||
if !(resp.gained_focus() || resp.double_clicked()) {
|
||
return;
|
||
}
|
||
if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), resp.id) {
|
||
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(), resp.id);
|
||
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) and the response.
|
||
fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let editing = ui.memory(|m| m.has_focus(id));
|
||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.to_string()) } else { shown.to_string() };
|
||
let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32;
|
||
let resp = ui
|
||
.allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||
ui.set_min_width(width);
|
||
egui::Frame::NONE
|
||
.fill(t.input)
|
||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||
.corner_radius(CornerRadius::same(2))
|
||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||
.show(ui, |ui| {
|
||
let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) };
|
||
ui.add(
|
||
edit.id(id)
|
||
.frame(egui::Frame::NONE)
|
||
.desired_width(width - 14.0)
|
||
.min_size(vec2(width - 14.0, 0.0))
|
||
.font(egui::FontId::proportional(12.5))
|
||
.text_color(t.text_strong),
|
||
)
|
||
})
|
||
.inner
|
||
})
|
||
.inner;
|
||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||
(buf, resp)
|
||
}
|
||
|
||
/// A recessed text field showing `value` (blank when `None`: the selection's values differ),
|
||
/// `rows` lines tall (1: single line, which Enter commits). Returns the new text, trimmed, when a
|
||
/// change is committed (Enter or focus loss).
|
||
pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option<String> {
|
||
let id = ui.id().with(id);
|
||
let shown = value.unwrap_or_default();
|
||
let (buf, resp) = recessed_text(ui, id, shown, width, rows);
|
||
(resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string())
|
||
}
|
||
|
||
/// A form row: `label` in a column `label_width` wide, then what `add` draws.
|
||
pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) {
|
||
ui.horizontal(|ui| {
|
||
let (r, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover());
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text);
|
||
add(ui);
|
||
});
|
||
}
|
||
|
||
/// A plain number field (percent, degrees, counts) with optional suffix.
|
||
pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option<f64> {
|
||
mixed_field(ui, id, Some(value), suffix, decimals, width)
|
||
}
|
||
|
||
/// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field.
|
||
pub fn mixed_field(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug,
|
||
value: Option<f64>,
|
||
suffix: &str,
|
||
decimals: usize,
|
||
width: f32,
|
||
) -> Option<f64> {
|
||
let id = ui.id().with(id);
|
||
let shown = value
|
||
.map(|value| {
|
||
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}")
|
||
})
|
||
.unwrap_or_default();
|
||
let (buf, resp) = recessed_text(ui, id, &shown, width, 1);
|
||
select_all_on_focus(ui, &resp, &buf);
|
||
if resp.lost_focus() && buf != shown {
|
||
// The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`.
|
||
let suffix = suffix.trim();
|
||
let bare = if suffix.is_empty() { buf.clone() } else { buf.replace(suffix, "") };
|
||
vectorcraft_doc::parse_number(&bare.replace(['%', '°'], ""))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Draw a paint preview (swatch chip) into `rect`.
|
||
pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
|
||
let p = ui.painter();
|
||
match paint {
|
||
Paint::None => {
|
||
p.rect_filled(rect, 0.0, Color32::WHITE);
|
||
p.line_segment([rect.left_bottom(), rect.right_top()], Stroke::new(1.6, Color32::from_rgb(0xe0, 0x20, 0x20)));
|
||
}
|
||
Paint::Solid { color, .. } => {
|
||
let [r, g, b, _] = color.to_rgba8(1.0);
|
||
p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b));
|
||
}
|
||
Paint::Gradient(gp) => gradient_chip(ui, rect, &gp.gradient),
|
||
Paint::Pattern { .. } => {
|
||
p.rect_filled(rect, 0.0, Color32::from_gray(200));
|
||
for i in 0..4 {
|
||
let x = rect.left() + rect.width() * i as f32 / 4.0;
|
||
p.line_segment([pos2(x, rect.bottom()), pos2(x + rect.width() / 4.0, rect.top())], Stroke::new(1.0, Color32::from_gray(90)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Draw a gradient preview into `rect` (linear left to right; radial from the centre).
|
||
pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient) {
|
||
let p = ui.painter();
|
||
let radial = gradient.kind == vectorcraft_color::GradientKind::Radial;
|
||
if radial {
|
||
// Outer colour fills the corners beyond the largest circle.
|
||
let (c, _) = gradient.sample(1.0);
|
||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||
p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b));
|
||
}
|
||
let n = 24;
|
||
let w = rect.width() / n as f32;
|
||
for i in 0..n {
|
||
let tt = (i as f32 + 0.5) / n as f32;
|
||
if radial {
|
||
let (c, _) = gradient.sample(1.0 - tt);
|
||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||
let s = rect.width().min(rect.height()) * tt / 2.0;
|
||
p.circle_filled(rect.center(), s.max(0.5), Color32::from_rgb(r, g, b));
|
||
} else {
|
||
let (c, _) = gradient.sample(tt);
|
||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||
let rr = Rect::from_min_size(pos2(rect.left() + i as f32 * w, rect.top()), vec2(w + 0.5, rect.height()));
|
||
p.rect_filled(rr, 0.0, Color32::from_rgb(r, g, b));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a
|
||
/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ
|
||
/// (`mixed`) shows a white chip with a "?" (in the hole for strokes).
|
||
pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option<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 {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click());
|
||
let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0);
|
||
draw(ui, chip);
|
||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||
if chevron {
|
||
icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim);
|
||
}
|
||
resp
|
||
}
|
||
|
||
/// What the user did on a [`fill_stroke_proxy`].
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||
pub struct ProxyClicks {
|
||
pub fill: bool,
|
||
pub stroke: bool,
|
||
pub swap: bool,
|
||
pub default: bool,
|
||
/// Double-clicked a square: open the Color Picker for it (`true` = the stroke).
|
||
pub pick: Option<bool>,
|
||
}
|
||
|
||
/// The Fill/Stroke proxy pair (two overlapping squares). `mixed` (fill, stroke) draws a "?" square
|
||
/// for a selection whose objects differ.
|
||
pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool, bool), fill_active: bool, size: f32) -> ProxyClicks {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
|
||
let s = size * 0.62;
|
||
let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s));
|
||
let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s));
|
||
let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click_and_drag());
|
||
let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag());
|
||
// A proxy drags its paint (onto the Swatches panel or art), unless it shows "?".
|
||
for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] {
|
||
if !mixed {
|
||
drag_source(ui, resp, || PanelDrag::paint(paint.clone()));
|
||
}
|
||
}
|
||
let draw_fill = |ui: &Ui| {
|
||
proxy_chip(ui, fill_r, fill, mixed.0, None);
|
||
ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||
ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||
};
|
||
let draw_stroke = |ui: &Ui| {
|
||
let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28));
|
||
ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside);
|
||
ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||
};
|
||
if fill_active {
|
||
draw_stroke(ui);
|
||
draw_fill(ui);
|
||
} else {
|
||
draw_fill(ui);
|
||
draw_stroke(ui);
|
||
}
|
||
// Swap arrow (top-right) and default (bottom-left) mini buttons.
|
||
let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3));
|
||
let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3));
|
||
let swap = ui.interact(swap_r, ui.id().with("swap-proxy"), Sense::click());
|
||
let def = ui.interact(def_r, ui.id().with("default-proxy"), Sense::click());
|
||
icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon });
|
||
let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14));
|
||
let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14));
|
||
ui.painter().rect_filled(b, 0.0, Color32::BLACK);
|
||
ui.painter().rect_stroke(b, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside);
|
||
ui.painter().rect_filled(a, 0.0, Color32::WHITE);
|
||
ui.painter().rect_stroke(a, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
|
||
let pick = if fill_resp.double_clicked() {
|
||
Some(false)
|
||
} else if stroke_resp.double_clicked() {
|
||
Some(true)
|
||
} else {
|
||
None
|
||
};
|
||
ProxyClicks {
|
||
fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(),
|
||
stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(),
|
||
swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(),
|
||
default: def.on_hover_text("Default Fill and Stroke (D)").clicked(),
|
||
pick,
|
||
}
|
||
}
|
||
|
||
/// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index.
|
||
pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option<usize> {
|
||
dropdown_with(ui, id, current, options, width, |_| true)
|
||
}
|
||
|
||
/// [`dropdown`] of names that are user, file or system data (font styles, artboards, presets the
|
||
/// user saved, printers, profiles), shown as they are, never translated. For a list that mixes
|
||
/// built-in labels with such names, translate the built-in ones (`tl!`) before passing them.
|
||
pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option<usize> {
|
||
combo(ui, id, current, width, false, |ui| {
|
||
let mut chosen = None;
|
||
for (i, o) in options.iter().enumerate() {
|
||
if ui.add(egui::Button::selectable(*o == current, *o)).clicked() {
|
||
chosen = Some(i);
|
||
}
|
||
}
|
||
chosen
|
||
})
|
||
}
|
||
|
||
/// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable).
|
||
pub fn dropdown_with(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug,
|
||
current: &str,
|
||
options: &[&str],
|
||
width: f32,
|
||
enabled: impl Fn(usize) -> bool,
|
||
) -> Option<usize> {
|
||
combo(ui, id, tl!(current), width, false, |ui| {
|
||
let mut chosen = None;
|
||
for (i, o) in options.iter().enumerate() {
|
||
if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, tl!(o))).clicked() {
|
||
chosen = Some(i);
|
||
}
|
||
}
|
||
chosen
|
||
})
|
||
}
|
||
|
||
/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns
|
||
/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open
|
||
/// while it is clicked: it closes itself when an option is chosen.
|
||
fn combo<R>(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug,
|
||
current: &str,
|
||
width: f32,
|
||
searchable: bool,
|
||
list: impl FnOnce(&mut Ui) -> Option<R>,
|
||
) -> Option<R> {
|
||
let t = Tokens::get(ui.ctx());
|
||
egui::Frame::NONE
|
||
.fill(t.input)
|
||
.stroke(Stroke::new(1.0, t.input_border))
|
||
.corner_radius(CornerRadius::same(3))
|
||
.show(ui, |ui| {
|
||
egui::ComboBox::from_id_salt(ui.id().with(id))
|
||
.selected_text(egui::RichText::new(current).size(12.0))
|
||
.width(width - 4.0)
|
||
.close_behavior(if searchable { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick })
|
||
.height(if searchable { f32::INFINITY } else { ui.spacing().combo_height })
|
||
.show_ui(ui, list)
|
||
.inner
|
||
.flatten()
|
||
})
|
||
.inner
|
||
}
|
||
|
||
/// The font family dropdown: every family available (the installed fonts included), with a search
|
||
/// field over the list that filters it as you type (Enter picks the first match). Returns the
|
||
/// chosen family.
|
||
pub fn font_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, width: f32) -> Option<String> {
|
||
let state = ui.id().with(&id).with("font-search");
|
||
combo(ui, id, current, width, true, |ui| {
|
||
// Each time the list opens: unfiltered, with the current font in view. Typing goes to the
|
||
// search field.
|
||
let pass = ui.ctx().cumulative_pass_nr();
|
||
// (the last pass the list was drawn in, the pass it opened in)
|
||
let (last, opened) = ui.data(|d| d.get_temp::<(u64, u64)>(state)).unwrap_or_default();
|
||
let opened = if last + 1 < pass || last == 0 { pass } else { opened };
|
||
let field = state.with("query");
|
||
ui.data_mut(|d| {
|
||
d.insert_temp(state, (pass, opened));
|
||
if opened == pass {
|
||
d.insert_temp(field, String::new());
|
||
}
|
||
});
|
||
// Its first two passes: a popup sizes itself in the first, invisibly.
|
||
let opening = pass <= opened + 1;
|
||
if ui.memory(|m| m.focused().is_none()) {
|
||
ui.memory_mut(|m| m.request_focus(field.with("edit")));
|
||
}
|
||
let query = search_field(ui, field, "Search").to_lowercase();
|
||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||
let families = vectorcraft_text::FontDb::global().menu_family_list();
|
||
let (mut chosen, mut first) = (None, None);
|
||
// The search field stays put over the list as it scrolls.
|
||
menu_scroll(ui, |ui| {
|
||
for f in families.iter().filter(|f| query.is_empty() || f.to_lowercase().contains(&query)) {
|
||
first.get_or_insert(f);
|
||
let r = ui.add(egui::Button::selectable(f == current, f.as_str()));
|
||
if opening && f == current {
|
||
r.scroll_to_me(Some(egui::Align::Center));
|
||
}
|
||
if r.clicked() {
|
||
chosen = Some(f.clone());
|
||
}
|
||
}
|
||
});
|
||
match first {
|
||
None => {
|
||
dim_label(ui, "No matching fonts");
|
||
}
|
||
Some(f) if enter && chosen.is_none() => chosen = Some(f.clone()),
|
||
_ => {}
|
||
}
|
||
if chosen.is_some() {
|
||
ui.close();
|
||
}
|
||
chosen
|
||
})
|
||
}
|
||
|
||
/// The body of a menu or popup list: as tall as its items up to the bottom of the window, and
|
||
/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on
|
||
/// small windows.
|
||
pub fn menu_scroll<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
|
||
// Room left below the popup's first item, less the popup frame and a small gap.
|
||
const BOTTOM_GAP: f32 = 12.0;
|
||
const MIN_HEIGHT: f32 = 120.0;
|
||
let room = (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - BOTTOM_GAP).max(MIN_HEIGHT);
|
||
// The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to
|
||
// its items when they need less.
|
||
egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner
|
||
}
|
||
|
||
/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the
|
||
/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is
|
||
/// over it.
|
||
pub fn strip_scroll<R>(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
|
||
egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner
|
||
}
|
||
|
||
/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a
|
||
/// [`BlendMode::group`] starts.
|
||
pub fn blend_separator_before(i: usize) -> bool {
|
||
i > 0 && i < BlendMode::ALL.len() && BlendMode::ALL[i].group() != BlendMode::ALL[i - 1].group()
|
||
}
|
||
|
||
/// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion
|
||
/// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode.
|
||
pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: Option<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 + std::fmt::Debug, 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 + std::fmt::Debug + 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) = mixed_field(ui, (id, "opacity"), opacity.map(|o| o as f64 * 100.0), "%", 0, 50.0) {
|
||
edit = Some(TransparencyEdit::Opacity(o.clamp(0.0, 100.0), Live::Released));
|
||
}
|
||
});
|
||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() });
|
||
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||
icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled });
|
||
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||
let mut o = opacity.unwrap_or(1.0) * 100.0;
|
||
let r = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).show_value(false));
|
||
let phase = if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||
Live::Released
|
||
} else if r.changed() {
|
||
Live::Dragging
|
||
} else {
|
||
return;
|
||
};
|
||
edit = Some(TransparencyEdit::Opacity(o.round() as f64, phase));
|
||
});
|
||
});
|
||
edit
|
||
}
|
||
|
||
/// The 3×3 reference point locator. Returns a new index when clicked.
|
||
pub fn reference_point(ui: &mut Ui, current: usize) -> Option<usize> {
|
||
let t = Tokens::get(ui.ctx());
|
||
let size = 22.0;
|
||
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
||
let mut out = None;
|
||
let step = size / 2.0 - 2.5;
|
||
ui.painter().rect_stroke(rect.shrink(4.5), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Middle);
|
||
for i in 0..9 {
|
||
let c = Pos2::new(rect.left() + 3.0 + (i % 3) as f32 * step + 1.5, rect.top() + 3.0 + (i / 3) as f32 * step + 1.5);
|
||
let r = Rect::from_center_size(c, Vec2::splat(5.0));
|
||
let resp = ui.interact(r.expand(1.5), ui.id().with(("refpt", i)), Sense::click());
|
||
if resp.clicked() {
|
||
out = Some(i);
|
||
}
|
||
if i == current {
|
||
ui.painter().rect_filled(r, 0.0, t.text);
|
||
} else {
|
||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A toggle icon (e.g. eye / lock columns) — returns clicked.
|
||
pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
|
||
if on {
|
||
icons::paint(ui, on_icon, rect.shrink(size * 0.2), if resp.hovered() { t.text } else { t.icon });
|
||
} else if resp.hovered() {
|
||
icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled);
|
||
}
|
||
resp.on_hover_text(tl!(tip)).clicked()
|
||
}
|
||
|
||
// ---------- panel widgets (Swatches, Color, Stroke, Gradient, Appearance, …) ----------
|
||
|
||
/// Icon button that can be disabled (greyed, no hover, never clicked).
|
||
pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, enabled: bool, size: f32) -> Response {
|
||
if enabled {
|
||
return icon_button(ui, icon, tip, selected, size);
|
||
}
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
|
||
let pad = (size * 0.2).round();
|
||
icons::paint(ui, icon, rect.shrink(pad), t.text_disabled);
|
||
if tip.is_empty() { resp } else { resp.on_hover_text(tl!(tip)) }
|
||
}
|
||
|
||
/// Regular-weight panel sub-header ("Shape Modes:", "Align Objects:").
|
||
pub fn subheader(ui: &mut Ui, text: &str) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text));
|
||
}
|
||
|
||
/// A label drawn with a dotted underline (Illustrator's link labels: "Stroke:", "Opacity:").
|
||
pub fn link_label(ui: &mut Ui, text: &str) -> Response {
|
||
let t = Tokens::get(ui.ctx());
|
||
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong);
|
||
let (rect, resp) = ui.allocate_exact_size(galley.size() + vec2(0.0, 3.0), Sense::click());
|
||
let y = rect.top() + galley.size().y + 1.0;
|
||
ui.painter().galley(rect.min, galley, t.text_strong);
|
||
let mut x = rect.left();
|
||
while x < rect.right() - 1.0 {
|
||
ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim));
|
||
x += 2.5;
|
||
}
|
||
resp
|
||
}
|
||
|
||
/// The row of a checkbox or radio button: allocates a 13 pt box plus `label`, draws the label and
|
||
/// returns the box, the response and the box's border colour.
|
||
fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color32) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let galley =
|
||
ui.painter().layout_no_wrap(tl!(label).to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled });
|
||
let (rect, resp) =
|
||
ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() });
|
||
let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0));
|
||
ui.painter().galley(pos2(bx.right() + 5.0, rect.center().y - galley.size().y / 2.0), galley, t.text);
|
||
let border = if !enabled {
|
||
t.divider
|
||
} else if resp.hovered() {
|
||
t.text
|
||
} else {
|
||
t.button_border
|
||
};
|
||
(bx, resp, border)
|
||
}
|
||
|
||
/// Panel-style checkbox with a disabled state. Returns true when toggled.
|
||
pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool {
|
||
check3(ui, label, Some(value), enabled)
|
||
}
|
||
|
||
/// 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 {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (bx, resp, border) = choice_row(ui, label, enabled);
|
||
let checked = value == Some(true);
|
||
ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input });
|
||
ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside);
|
||
match value {
|
||
Some(true) => {
|
||
let c = if enabled { Color32::WHITE } else { t.text_disabled };
|
||
ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.center_bottom() + vec2(-1.0, -3.5)], Stroke::new(1.6, c));
|
||
ui.painter().line_segment([bx.center_bottom() + vec2(-1.0, -3.5), bx.right_top() + vec2(-3.0, 3.0)], Stroke::new(1.6, c));
|
||
}
|
||
None => {
|
||
let c = if enabled { t.text } else { t.text_disabled };
|
||
ui.painter().line_segment([bx.left_center() + vec2(3.0, 0.0), bx.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, c));
|
||
}
|
||
Some(false) => {}
|
||
}
|
||
enabled && resp.clicked()
|
||
}
|
||
|
||
/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked.
|
||
pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (bx, resp, border) = choice_row(ui, label, enabled);
|
||
let c = bx.center();
|
||
ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border));
|
||
if selected {
|
||
ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled });
|
||
}
|
||
enabled && resp.clicked()
|
||
}
|
||
|
||
/// Numeric field with an up/down spinner on the left and a preset dropdown on the right
|
||
/// (Stroke weight, font size, leading…). `presets` empty = no dropdown. Returns the committed value.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn spin_field(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug + Copy,
|
||
value: Option<f64>,
|
||
unit: Unit,
|
||
width: f32,
|
||
step: f64,
|
||
min: f64,
|
||
presets: &[f64],
|
||
) -> Option<f64> {
|
||
spin_generic(ui, value, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| num_field(ui, id, value, unit, fw))
|
||
}
|
||
|
||
/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn spin_plain(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug + Copy,
|
||
value: f64,
|
||
suffix: &str,
|
||
decimals: usize,
|
||
width: f32,
|
||
step: f64,
|
||
min: f64,
|
||
presets: &[f64],
|
||
) -> Option<f64> {
|
||
let fmt = |v: f64| format!("{v}{suffix}");
|
||
spin_generic(ui, Some(value), width, step, min, presets, &fmt, &mut |ui, fw| plain_field(ui, id, value, suffix, decimals, fw))
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn spin_generic(
|
||
ui: &mut Ui,
|
||
value: Option<f64>,
|
||
width: f32,
|
||
step: f64,
|
||
min: f64,
|
||
presets: &[f64],
|
||
fmt: &dyn Fn(f64) -> String,
|
||
field: &mut dyn FnMut(&mut Ui, f32) -> Option<f64>,
|
||
) -> Option<f64> {
|
||
let t = Tokens::get(ui.ctx());
|
||
let mut out = None;
|
||
let h = 26.0;
|
||
let enabled = ui.is_enabled();
|
||
ui.scope(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 0.0;
|
||
let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click());
|
||
ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input);
|
||
ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||
let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y));
|
||
let down = Rect::from_min_max(pos2(sr.left(), sr.center().y), sr.max);
|
||
let hover = sresp.hover_pos();
|
||
for (r, is_up) in [(up, true), (down, false)] {
|
||
let c = if !enabled {
|
||
t.text_disabled
|
||
} else if hover.is_some_and(|p| r.contains(p)) {
|
||
t.text_strong
|
||
} else {
|
||
t.icon
|
||
};
|
||
let m = r.center();
|
||
let d = if is_up { -1.5 } else { 1.5 };
|
||
ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c));
|
||
ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c));
|
||
}
|
||
if sresp.clicked()
|
||
&& let Some(p) = sresp.interact_pointer_pos()
|
||
{
|
||
let v = value.unwrap_or(0.0);
|
||
let nv = if up.contains(p) { v + step } else { v - step };
|
||
out = Some(nv.max(min));
|
||
}
|
||
let fw = if presets.is_empty() { width - 16.0 } else { width - 36.0 };
|
||
if let Some(v) = field(ui, fw) {
|
||
out = Some(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);
|
||
for p in presets {
|
||
if ui.selectable_label(value.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() {
|
||
out = Some(*p);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// Drag phase of a live slider/handle: previews while dragging, commits on release.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Live {
|
||
Idle,
|
||
Dragging,
|
||
Released,
|
||
}
|
||
|
||
/// A colour slider with a gradient track (`track(t)` gives the colour at 0..1) and a triangular
|
||
/// thumb below it, like Illustrator's Color panel. Returns the new normalized value and the phase.
|
||
pub fn color_slider(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug,
|
||
value: f32,
|
||
width: f32,
|
||
track: &dyn Fn(f32) -> Color32,
|
||
) -> (Option<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, ui.id().with(id), Sense::click_and_drag());
|
||
gradient_strip(ui, bar, (bar.width() / 2.0).ceil().max(2.0) as usize, false, track);
|
||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||
let v = value.clamp(0.0, 1.0);
|
||
let x = bar.left() + v * bar.width();
|
||
let tip = pos2(x, bar.bottom() - 1.0);
|
||
let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)];
|
||
let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon };
|
||
ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border)));
|
||
match pointer_phase(&resp) {
|
||
Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase),
|
||
None => (None, Live::Idle),
|
||
}
|
||
}
|
||
|
||
/// The pointer of a click or drag on `resp` and its phase (dragging, or released on click/drop).
|
||
pub(crate) fn pointer_phase(resp: &Response) -> Option<(Pos2, Live)> {
|
||
if !(resp.dragged() || resp.clicked() || resp.drag_stopped()) {
|
||
return None;
|
||
}
|
||
let p = resp.interact_pointer_pos()?;
|
||
Some((p, if resp.dragged() && !resp.drag_stopped() { Live::Dragging } else { Live::Released }))
|
||
}
|
||
|
||
/// A gradient strip into `rect`: `n` bands along x (or up y when `vertical`), each filled with
|
||
/// `track(t)` at its centre.
|
||
fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn(f32) -> Color32) {
|
||
let len = if vertical { rect.height() } else { rect.width() };
|
||
let seg = len / n as f32;
|
||
for i in 0..n {
|
||
let r = if vertical {
|
||
Rect::from_min_size(pos2(rect.left(), rect.bottom() - (i + 1) as f32 * seg), vec2(rect.width(), seg + 0.6))
|
||
} else {
|
||
Rect::from_min_size(pos2(rect.left() + i as f32 * seg, rect.top()), vec2(seg + 0.6, rect.height()))
|
||
};
|
||
ui.painter().rect_filled(r, 0.0, track((i as f32 + 0.5) / n as f32));
|
||
}
|
||
}
|
||
|
||
/// The Color Picker's 2D colour field: `color_at(x, y)` gives the colour at a point (0..1 each, y
|
||
/// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase.
|
||
pub fn color_field(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug,
|
||
size: Vec2,
|
||
pos: (f32, f32),
|
||
color_at: &dyn Fn(f32, f32) -> Color32,
|
||
) -> (Option<(f32, f32)>, Live) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
|
||
let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag());
|
||
let painter = ui.painter_at(rect.expand(6.0));
|
||
painter.add(color_mesh(rect, (32, 32), color_at));
|
||
painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||
let c = pos2(rect.left() + pos.0.clamp(0.0, 1.0) * rect.width(), rect.top() + pos.1.clamp(0.0, 1.0) * rect.height());
|
||
painter.circle_stroke(c, 5.0, Stroke::new(1.0, Color32::BLACK));
|
||
painter.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::WHITE));
|
||
match pointer_phase(&resp) {
|
||
Some((p, phase)) => {
|
||
let f = |v: f32, lo: f32, len: f32| ((v - lo) / len).clamp(0.0, 1.0);
|
||
(Some((f(p.x, rect.left(), rect.width()), f(p.y, rect.top(), rect.height()))), phase)
|
||
}
|
||
None => (None, Live::Idle),
|
||
}
|
||
}
|
||
|
||
/// A smoothly shaded colour area: a `cols` × `rows` grid over `rect` with `color_at(x, y)` (0..1
|
||
/// each, y down) at its vertices.
|
||
pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f32) -> Color32) -> egui::Shape {
|
||
let mut mesh = egui::Mesh::default();
|
||
let row = cols + 1;
|
||
mesh.reserve_vertices((row * (rows + 1)) as usize);
|
||
mesh.reserve_triangles((cols * rows * 2) as usize);
|
||
for j in 0..=rows {
|
||
for i in 0..=cols {
|
||
let (x, y) = (i as f32 / cols as f32, j as f32 / rows as f32);
|
||
mesh.colored_vertex(pos2(rect.left() + x * rect.width(), rect.top() + y * rect.height()), color_at(x, y));
|
||
}
|
||
}
|
||
for j in 0..rows {
|
||
for i in 0..cols {
|
||
let a = j * row + i;
|
||
mesh.add_triangle(a, a + 1, a + row);
|
||
mesh.add_triangle(a + 1, a + row + 1, a + row);
|
||
}
|
||
}
|
||
egui::Shape::mesh(mesh)
|
||
}
|
||
|
||
/// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top)
|
||
/// and arrows on both sides mark `value`. Returns the new value and the phase.
|
||
pub fn channel_slider(
|
||
ui: &mut Ui,
|
||
id: impl std::hash::Hash + std::fmt::Debug,
|
||
value: f32,
|
||
size: Vec2,
|
||
track: &dyn Fn(f32) -> Color32,
|
||
) -> (Option<f32>, Live) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
|
||
let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag());
|
||
let bar = rect.shrink2(vec2(6.0, 0.0));
|
||
gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track);
|
||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
|
||
let y = bar.bottom() - value.clamp(0.0, 1.0) * bar.height();
|
||
let fill = if resp.dragged() || resp.hovered() { t.text_strong } else { t.icon };
|
||
for (x, d) in [(rect.left(), 1.0), (rect.right(), -1.0)] {
|
||
let tip = pos2(x + d * 5.0, y);
|
||
ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE));
|
||
}
|
||
match pointer_phase(&resp) {
|
||
Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase),
|
||
None => (None, Live::Idle),
|
||
}
|
||
}
|
||
|
||
/// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it
|
||
/// changed.
|
||
pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: &str) -> Option<String> {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.label(egui::RichText::new("#").size(15.0).color(t.text));
|
||
let id = ui.id().with(id);
|
||
let editing = ui.memory(|m| m.has_focus(id));
|
||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| hex.to_string()) } else { hex.to_string() };
|
||
let resp = egui::Frame::NONE
|
||
.fill(t.input)
|
||
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
|
||
.corner_radius(2)
|
||
.inner_margin(egui::Margin::symmetric(6, 4))
|
||
.show(ui, |ui| {
|
||
ui.add(
|
||
egui::TextEdit::singleline(&mut buf)
|
||
.id(id)
|
||
.frame(egui::Frame::NONE)
|
||
.desired_width(62.0)
|
||
.char_limit(7)
|
||
.font(egui::FontId::proportional(12.5))
|
||
.text_color(t.text_strong),
|
||
)
|
||
})
|
||
.inner;
|
||
let commit = resp.lost_focus() && buf != hex;
|
||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||
commit.then_some(buf)
|
||
}
|
||
|
||
/// One swatch tile (Illustrator: 1 px dark frame, white inset on hover/selection).
|
||
pub fn swatch_tile(ui: &Ui, rect: Rect, paint: &Paint, selected: bool, hovered: bool) {
|
||
let t = Tokens::get(ui.ctx());
|
||
paint_chip(ui, rect, paint);
|
||
ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||
if selected || hovered {
|
||
ui.painter().rect_stroke(rect.shrink(1.0), 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside);
|
||
ui.painter().rect_stroke(rect, 0.0, Stroke::new(1.0, if selected { t.accent } else { t.text }), StrokeKind::Outside);
|
||
}
|
||
}
|
||
|
||
/// A recessed search field with an italic `hint` (Layers' Search All, Swatches' Find), keeping its
|
||
/// text in egui memory under `id`. Returns the text.
|
||
pub fn search_field(ui: &mut Ui, id: egui::Id, hint: &str) -> String {
|
||
let t = Tokens::get(ui.ctx());
|
||
let mut query: String = ui.data(|d| d.get_temp(id)).unwrap_or_default();
|
||
egui::Frame::NONE
|
||
.fill(t.input)
|
||
.stroke(Stroke::new(1.0, t.input_border))
|
||
.corner_radius(egui::CornerRadius::same(2))
|
||
.inner_margin(egui::Margin::symmetric(8, 5))
|
||
.show(ui, |ui| {
|
||
ui.add(
|
||
egui::TextEdit::singleline(&mut query)
|
||
.id(id.with("edit"))
|
||
.frame(egui::Frame::NONE)
|
||
.hint_text(egui::RichText::new(tl!(hint)).italics())
|
||
.desired_width(ui.available_width()),
|
||
);
|
||
});
|
||
ui.data_mut(|d| d.insert_temp(id, query.clone()));
|
||
query
|
||
}
|
||
|
||
/// A bordered list box (Swatches tiles, Appearance rows, Artboards list).
|
||
pub fn list_box<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 menu row for panel (≡) menus: label, optional check mark, disabled when not implemented.
|
||
pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool {
|
||
menu_item_name(ui, tl!(label), enabled, checked)
|
||
}
|
||
|
||
/// [`menu_item`] for a name that is user or file data (a library, a recent URL), never translated.
|
||
pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool {
|
||
let text = if checked { format!("✓ {label}") } else { format!(" {label}") };
|
||
ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame(false)).clicked()
|
||
}
|
||
|
||
/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without
|
||
/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit, where `new` is `None`
|
||
/// when the field was cleared; typed units and arithmetic work as in [`num_field`].
|
||
pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<f64>, unit: Unit, width: f32) -> Option<Option<f64>> {
|
||
let t = Tokens::get(ui.ctx());
|
||
let id = ui.id().with(id);
|
||
let shown = value.map(|v| unit.number(v)).unwrap_or_default();
|
||
let editing = ui.memory(|m| m.has_focus(id));
|
||
let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() };
|
||
let enabled = ui.is_enabled();
|
||
let resp = 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 }),
|
||
)
|
||
})
|
||
.inner;
|
||
select_all_on_focus(ui, &resp, &buf);
|
||
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
|
||
if resp.lost_focus() && buf != shown {
|
||
let s = buf.trim();
|
||
if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) }
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop
|
||
/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum PanelDrag {
|
||
/// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it
|
||
/// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group,
|
||
/// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the
|
||
/// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes
|
||
/// a swatch of a paint dragged from elsewhere.
|
||
Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows> },
|
||
/// 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`).
|
||
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),
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
/// 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: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||
}
|
||
let ppp = ui.ctx().pixels_per_point() as f64;
|
||
let (w, h) = (size.x as f64, size.y as f64);
|
||
let key = format!("{w}x{h}@{ppp}:{key}");
|
||
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||
return Some(t);
|
||
}
|
||
let doc = build(w, h)?;
|
||
let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false));
|
||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||
let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR);
|
||
CACHE.with(|c| {
|
||
let mut c = c.borrow_mut();
|
||
if c.len() > 256 {
|
||
c.clear();
|
||
}
|
||
c.insert(key, tex.clone());
|
||
});
|
||
Some(tex)
|
||
}
|
||
|
||
/// Position of hue `h` (degrees) and saturation `s` on a wheel of radius `r` around `c`.
|
||
fn wheel_pos(c: Pos2, r: f32, h: f32, s: f32) -> Pos2 {
|
||
let a = h.to_radians();
|
||
c + vec2(a.cos(), -a.sin()) * r * s
|
||
}
|
||
|
||
/// The hue and saturation wheel at brightness `v`: hue around, saturation outwards.
|
||
fn wheel_shape(c: Pos2, r: f32, v: f32) -> egui::Shape {
|
||
const SEG: u32 = 72;
|
||
const RINGS: u32 = 8;
|
||
let mut mesh = egui::Mesh::default();
|
||
mesh.colored_vertex(c, crate::panels::c32(&Color::from_hsb(0.0, 0.0, v)));
|
||
for ring in 1..=RINGS {
|
||
let s = ring as f32 / RINGS as f32;
|
||
for k in 0..SEG {
|
||
let h = k as f32 * 360.0 / SEG as f32;
|
||
mesh.colored_vertex(wheel_pos(c, r, h, s), crate::panels::c32(&Color::from_hsb(h, s, v)));
|
||
}
|
||
}
|
||
let at = |ring: u32, k: u32| if ring == 0 { 0 } else { 1 + (ring - 1) * SEG + k % SEG };
|
||
for ring in 0..RINGS {
|
||
for k in 0..SEG {
|
||
if ring == 0 {
|
||
mesh.add_triangle(0, at(1, k), at(1, k + 1));
|
||
} else {
|
||
mesh.add_triangle(at(ring, k), at(ring + 1, k), at(ring + 1, k + 1));
|
||
mesh.add_triangle(at(ring, k), at(ring + 1, k + 1), at(ring, k + 1));
|
||
}
|
||
}
|
||
}
|
||
egui::Shape::mesh(mesh)
|
||
}
|
||
|
||
/// What a [`harmony_wheel`] did this frame.
|
||
#[derive(Default)]
|
||
pub struct WheelResponse {
|
||
/// The colour whose marker was pressed (it becomes the selected one).
|
||
pub pressed: Option<usize>,
|
||
/// A colour moved to a new hue, saturation and brightness (`[h°, s, v]`), by dragging its
|
||
/// marker or (the selected colour) the brightness slider.
|
||
pub moved: Option<(usize, [f32; 3])>,
|
||
}
|
||
|
||
/// The harmony wheel of Recolor Artwork's Edit tab and the Color Themes panel: a hue and saturation
|
||
/// wheel `size` across (hue around, saturation outwards) at the selected colour's brightness, a
|
||
/// marker per colour joined to the centre (the selected colour, `base`, larger) and a Brightness
|
||
/// slider for the selected colour below it.
|
||
pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: Option<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.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())
|
||
&& let Some(old) = colors.get(i)
|
||
{
|
||
let dv = p - c;
|
||
let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0);
|
||
out.moved = Some((i, [h, (dv.length() / r).clamp(0.0, 1.0), old.to_hsb()[2]]));
|
||
}
|
||
});
|
||
ui.add_space(6.0);
|
||
ui.horizontal(|ui| {
|
||
ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0));
|
||
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim)));
|
||
if let (Some(b), Some([h, s, _])) = (base, base_hsb)
|
||
&& let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x)))
|
||
{
|
||
out.moved = Some((b, [h, s, nv]));
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// A row of text tabs, the current one underlined in the accent colour (New Document's
|
||
/// categories, Document Setup's sections). Returns the clicked tab's index.
|
||
pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option<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,
|
||
);
|
||
}
|
||
if resp.clicked() {
|
||
clicked = Some(i);
|
||
}
|
||
}
|
||
});
|
||
clicked
|
||
}
|
||
|
||
/// A page-orientation toggle drawn in code: a portrait or landscape sheet with a folded corner,
|
||
/// accent-filled when `selected`. Returns clicked.
|
||
pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &str) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::click());
|
||
if resp.hovered() && !selected {
|
||
ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover);
|
||
}
|
||
let size = if landscape { vec2(16.0, 12.0) } else { vec2(12.0, 16.0) };
|
||
let page = Rect::from_center_size(rect.center(), size);
|
||
let color = if selected { t.accent } else { t.icon };
|
||
let fold = 4.0;
|
||
let outline =
|
||
vec![page.left_top(), page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(0.0, fold), page.right_bottom(), page.left_bottom()];
|
||
let fill = if selected { t.accent } else { Color32::TRANSPARENT };
|
||
ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color)));
|
||
let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)];
|
||
ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color })));
|
||
resp.on_hover_text(tl!(tip)).clicked()
|
||
}
|
||
|
||
/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name`
|
||
/// (artboard and page thumbnails).
|
||
pub fn region_texture(
|
||
ctx: &egui::Context,
|
||
renderer: &mut vectorcraft_render::Renderer,
|
||
name: &str,
|
||
doc: &vectorcraft_doc::Document,
|
||
region: vectorcraft_geom::Rect,
|
||
px: f64,
|
||
) -> egui::TextureHandle {
|
||
let scale = px / region.width().max(region.height()).max(1.0);
|
||
let img = renderer.render_region(doc, region, scale, true);
|
||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||
ctx.load_texture(name, color, egui::TextureOptions::LINEAR)
|
||
}
|
||
|
||
/// The chevron cell on the right of a field that opens its preset list (rounded on the right).
|
||
fn chevron_cell(ui: &mut Ui, h: f32) -> Response {
|
||
let t = Tokens::get(ui.ctx());
|
||
let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click());
|
||
ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input);
|
||
ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||
let c = if !ui.is_enabled() {
|
||
t.text_disabled
|
||
} else if dresp.hovered() {
|
||
t.text_strong
|
||
} else {
|
||
t.icon
|
||
};
|
||
icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c);
|
||
dresp
|
||
}
|
||
|
||
/// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…).
|
||
/// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked.
|
||
pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, presets: &[&str], width: f32) -> Option<String> {
|
||
let mut out = None;
|
||
ui.scope(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 0.0;
|
||
out = text_field(ui, &id, Some(value), width - 20.0, 1);
|
||
let resp = chevron_cell(ui, 26.0);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
ui.set_min_width(width - 10.0);
|
||
for p in presets {
|
||
if ui.selectable_label(*p == value, *p).clicked() {
|
||
out = Some((*p).to_string());
|
||
}
|
||
}
|
||
});
|
||
});
|
||
out
|
||
}
|