feat(ui): material (solid/acrylic surfaces) and controls (checkbox, toggle, select)

Moved from PaperCraft so other Craft apps can share them. Controls take a
ControlStyle built from the app's tokens and paint through material.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-28 00:14:37 -03:00
parent 7fcee66b8e
commit 9c470aa935
6 changed files with 572 additions and 0 deletions
+15
View File
@@ -4,6 +4,8 @@
embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
`docking` supplies transactional panel moves and an optional docking surface.
`material` paints flat or acrylic surfaces (lit gradients, fine edges, a backdrop glow); `controls`
holds a checkbox, toggle and dropdown select painted through it.
`printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft.
The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
records their scope and acceptance limits. Assets, complete themes, localization and command handling
@@ -254,3 +256,16 @@ color, spacing and stroke changes fail those comparisons. To retain reviewable P
```sh
CRAFT_UI_VISUAL_DIR=/absolute/path/to/visuals cargo test -p craft-ui --test visual_regression
```
## Material and controls
`material::Material` is `Solid` or `Acrylic` (serde behind the `serde` feature). Every painter
takes the material, colours and `dark` explicitly and paints the plain fill under `Solid`, so callers
don't branch: `paint_control`, `paint_selected`, `paint_accent`, `paint_gloss`, `paint_panel`,
`backdrop_mesh`, `paint_backing` (call first inside a popup or modal) and `show_surface` (a
`Frame::show` that lights the fill).
`controls::{checkbox, toggle, select, select_with_icons}` take a `ControlStyle` built from the app's
tokens (fills, inks, accent, radii, fonts, menu size, animation time). The select menu fits its
widest option up to `menu_max`, scrolls past its height, and shortens `Group / Sub / Name` options
from the front (`… / Name`) with the full name in a tooltip.
+225
View File
@@ -0,0 +1,225 @@
//! Form controls with application-supplied colours: a rounded checkbox, a pill toggle and a
//! dropdown select whose menu fits its options. Each paints through [`crate::material`], so
//! they take the app's material (flat or acrylic) as part of [`ControlStyle`].
use crate::material::{self, Material};
use egui::{Align2, Color32, FontId, Galley, Painter, Rect, Response, Sense, Stroke, Ui, pos2, text::LayoutJob, vec2};
use std::sync::Arc;
#[cfg(test)]
mod tests;
/// Colours, sizes and fonts the controls are painted with: built from the app's own tokens.
#[derive(Clone, Debug)]
pub struct ControlStyle {
pub material: Material,
/// A field's or unchecked box's fill.
pub field: Color32,
/// Hover wash over a field or row (its alpha is the full-hover strength).
pub hover: Color32,
pub text: Color32,
/// Secondary ink: a chevron, a path before a name, an unchecked toggle's knob.
pub secondary: Color32,
pub accent: Color32,
/// Ink on the accent (a check mark, a toggle's knob).
pub on_accent: Color32,
/// Fill of a menu's selected row (under an accent wash with acrylic).
pub selected: Color32,
/// A field's and menu row's corner radius.
pub radius: u8,
/// A checkbox's corner radius.
pub box_radius: u8,
/// A select field's height.
pub field_height: f32,
pub font: FontId,
/// The selected option's font in a menu.
pub font_strong: FontId,
/// The menu's widest and tallest size; longer options are shortened, more options scroll.
pub menu_max: egui::Vec2,
/// Seconds a hover or check animates over.
pub animation_time: f32,
}
fn hover_t(ui: &Ui, resp: &Response, style: &ControlStyle, salt: &str) -> f32 {
if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
ui.ctx().animate_bool_with_time_and_easing(resp.id.with(salt), resp.hovered(), style.animation_time, egui::emath::easing::cubic_out)
}
fn check_mark(painter: &Painter, c: egui::Pos2, ink: Color32) {
let s = Stroke::new(2.0, ink);
painter.line_segment([c + vec2(-4.0, 0.5), c + vec2(-1.0, 3.5)], s);
painter.line_segment([c + vec2(-1.0, 3.5), c + vec2(4.5, -3.0)], s);
}
/// A rounded checkbox with a label. Toggles `on` on click and marks the response changed.
pub fn checkbox(ui: &mut Ui, style: &ControlStyle, on: &mut bool, label: &str) -> Response {
const BOX: f32 = 18.0;
const LABEL_X: f32 = 26.0;
let label_w = ui.painter().layout_no_wrap(label.to_owned(), style.font.clone(), style.text).size().x;
let (rect, mut resp) = ui.allocate_exact_size(vec2(LABEL_X + label_w + 4.0, 24.0), Sense::click());
if resp.clicked() {
*on = !*on;
resp.mark_changed();
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, label));
let t = ui.ctx().animate_bool_with_time_and_easing(resp.id.with("check"), *on, style.animation_time, egui::emath::easing::cubic_out);
let hover = hover_t(ui, &resp, style, "check-hover");
let bx = Rect::from_center_size(pos2(rect.left() + BOX / 2.0, rect.center().y), vec2(BOX, BOX));
let fill = material::blend(material::blend(style.field, style.hover.gamma_multiply(hover)), style.accent.gamma_multiply(t));
let dark = ui.visuals().dark_mode;
if t > 0.5 {
material::paint_accent(ui.painter(), style.material, bx, style.box_radius, fill);
} else {
material::paint_control(ui.painter(), style.material, bx, style.box_radius, fill, dark);
}
if t > 0.0 {
check_mark(ui.painter(), bx.center(), style.on_accent.gamma_multiply(t));
}
ui.painter().text(pos2(rect.left() + LABEL_X, rect.center().y), Align2::LEFT_CENTER, label, style.font.clone(), style.text);
resp
}
/// A pill switch. Toggles `on` on click and marks the response changed.
pub fn toggle(ui: &mut Ui, style: &ControlStyle, on: &mut bool) -> Response {
let (rect, mut resp) = ui.allocate_exact_size(vec2(42.0, 24.0), Sense::click());
if resp.clicked() {
*on = !*on;
resp.mark_changed();
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, ui.is_enabled(), *on, ""));
let t = ui.ctx().animate_bool_with_time_and_easing(resp.id.with("toggle"), *on, style.animation_time, egui::emath::easing::cubic_out);
let pill = (rect.height() / 2.0) as u8;
let fill = material::blend(style.field, style.accent.gamma_multiply(t));
if t > 0.5 {
material::paint_accent(ui.painter(), style.material, rect, pill, fill);
} else {
material::paint_control(ui.painter(), style.material, rect, pill, fill, ui.visuals().dark_mode);
}
let x = egui::lerp((rect.left() + 12.0)..=(rect.right() - 12.0), t);
ui.painter().circle_filled(pos2(x, rect.center().y), 8.0, if t > 0.5 { style.on_accent } else { style.secondary });
resp
}
/// Paints the icon of option `i` in `rect` with an ink, for [`select_with_icons`].
pub type PaintOptionIcon<'a> = &'a dyn Fn(&Painter, Rect, usize, Color32);
/// A rounded dropdown: the current value in a field, a menu of options below it. Returns the
/// newly chosen value, if any.
pub fn select<T: PartialEq + Copy>(ui: &mut Ui, style: &ControlStyle, current: T, options: &[(T, &str)], width: f32) -> Option<T> {
select_with_icons(ui, style, current, options, width, None)
}
/// [`select`] with an icon before each option (in the field and the menu), painted by the app.
/// `width` 0 takes the available width.
pub fn select_with_icons<T: PartialEq + Copy>(
ui: &mut Ui,
style: &ControlStyle,
current: T,
options: &[(T, &str)],
width: f32,
icon: Option<PaintOptionIcon<'_>>,
) -> Option<T> {
const PAD: f32 = 12.0;
const ICON: f32 = 14.0;
let indent = if icon.is_some() { 22.0 } else { 0.0 };
let cur = options.iter().position(|(o, _)| *o == current);
let label = cur.map_or("\u{2014}", |i| options[i].1);
let w = if width > 0.0 { width } else { ui.available_width() };
let (rect, resp) = ui.allocate_exact_size(vec2(w, style.field_height), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, ui.is_enabled(), label));
let hover = hover_t(ui, &resp, style, "select");
let dark = ui.visuals().dark_mode;
material::paint_control(ui.painter(), style.material, rect, style.radius, material::blend(style.field, style.hover.gamma_multiply(hover)), dark);
if let (Some(paint), Some(i)) = (icon, cur) {
paint(ui.painter(), Rect::from_center_size(pos2(rect.left() + 19.0, rect.center().y), vec2(ICON, ICON)), i, style.secondary);
}
let g = option_label(ui, label, style.font.clone(), style.text, style.secondary, rect.width() - PAD - indent - 28.0);
ui.painter().galley(pos2(rect.left() + PAD + indent, rect.center().y - g.size().y / 2.0), g, style.text);
chevron(ui.painter(), pos2(rect.right() - 14.0, rect.center().y), style.secondary);
let mut chosen = None;
let min_w = rect.width().max(150.0);
// The menu grows to fit its longest option, up to the cap; measured only while open.
let menu_w = if egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&resp)) {
let widest = options
.iter()
.map(|(_, l)| ui.painter().layout_no_wrap((*l).to_owned(), style.font_strong.clone(), style.text).size().x)
.fold(0.0, f32::max);
(widest + PAD + indent + 40.0).clamp(min_w, style.menu_max.x.max(min_w))
} else {
min_w
};
egui::Popup::menu(&resp).width(menu_w).show(|ui| {
let v = ui.visuals();
material::paint_backing(ui, style.material, v.window_fill, v.menu_corner_radius.nw);
ui.spacing_mut().item_spacing.y = 2.0;
egui::ScrollArea::vertical().max_height(style.menu_max.y).show(ui, |ui| {
for (k, (o, l)) in options.iter().enumerate() {
let (row, r) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
let on = *o == current;
let h = hover_t(ui, &r, style, "opt");
if on {
material::paint_selected(ui.painter(), style.material, row, style.radius, style.selected, style.accent);
} else if h > 0.0 {
ui.painter().rect_filled(row, style.radius, style.hover.gamma_multiply(h));
}
if let Some(paint) = icon {
paint(ui.painter(), Rect::from_center_size(pos2(row.left() + 19.0, row.center().y), vec2(ICON, ICON)), k, if on { style.text } else { style.secondary });
}
let font = if on { style.font_strong.clone() } else { style.font.clone() };
let g = option_label(ui, l, font, style.text, style.secondary, row.width() - PAD - indent - 32.0);
let cut = g.elided || g.text() != *l;
ui.painter().galley(pos2(row.left() + PAD + indent, row.center().y - g.size().y / 2.0), g, style.text);
let r = if cut { r.on_hover_text(*l) } else { r };
if on {
check_mark(ui.painter(), pos2(row.right() - 16.0, row.center().y), style.accent);
}
if r.clicked() {
chosen = Some(*o);
ui.close();
}
}
});
});
chosen.filter(|c| *c != current)
}
fn chevron(painter: &Painter, c: egui::Pos2, ink: Color32) {
let s = Stroke::new(1.5, ink);
painter.line_segment([c + vec2(-4.0, -2.0), c + vec2(0.0, 2.0)], s);
painter.line_segment([c + vec2(0.0, 2.0), c + vec2(4.0, -2.0)], s);
}
/// One line of `text` within `max_w`. A `Group / Sub / Name` path keeps its last parts and drops
/// leading ones (`… / Name`), since the end tells options apart; the path before the name is in
/// `dim`. Anything still too long ends in "…".
pub fn option_label(ui: &Ui, text: &str, font: FontId, ink: Color32, dim: Color32, max_w: f32) -> Arc<Galley> {
let parts: Vec<&str> = text.split(" / ").collect();
let job = |skip: usize, cut: bool| {
let mut job = LayoutJob::default();
let (prefix, name) = parts.split_at(parts.len() - 1);
let mut head = prefix[skip..].join(" / ");
if skip > 0 {
head = if head.is_empty() { "\u{2026}".into() } else { format!("\u{2026} / {head}") };
}
if !head.is_empty() {
job.append(&format!("{head} / "), 0.0, egui::TextFormat::simple(font.clone(), dim));
}
job.append(name[0], 0.0, egui::TextFormat::simple(font.clone(), ink));
if cut {
job.wrap.max_width = max_w;
job.wrap.max_rows = 1;
job.wrap.overflow_character = Some('\u{2026}');
}
job
};
for skip in 0..parts.len() {
let g = ui.painter().layout_job(job(skip, false));
if g.size().x <= max_w {
return g;
}
}
ui.painter().layout_job(job(parts.len() - 1, true))
}
+95
View File
@@ -0,0 +1,95 @@
use super::*;
use egui::{Context, Event, PointerButton, Pos2, RawInput, Rect, Vec2};
fn style() -> ControlStyle {
ControlStyle {
material: Material::Solid,
field: Color32::from_gray(40),
hover: Color32::from_white_alpha(20),
text: Color32::WHITE,
secondary: Color32::GRAY,
accent: Color32::from_rgb(120, 90, 250),
on_accent: Color32::WHITE,
selected: Color32::from_gray(50),
radius: 8,
box_radius: 6,
field_height: 32.0,
font: FontId::proportional(13.0),
font_strong: FontId::proportional(13.0),
menu_max: egui::vec2(320.0, 420.0),
animation_time: 0.0,
}
}
fn run(ctx: &Context, events: Vec<Event>, mut f: impl FnMut(&mut Ui)) {
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(600.0, 400.0))), events, ..Default::default() };
ctx.run_ui(input, |ui| f(ui)).textures_delta.clear();
}
fn click(at: Pos2) -> Vec<Event> {
vec![
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() },
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() },
]
}
#[test]
fn checkbox_and_toggle_flip_on_click() {
let ctx = Context::default();
let s = style();
let (mut on, mut sw, mut rects) = (false, false, (Rect::NOTHING, Rect::NOTHING));
run(&ctx, vec![], |ui| {
rects.0 = checkbox(ui, &s, &mut on, "Screen").rect;
rects.1 = toggle(ui, &s, &mut sw).rect;
});
let mut changed = false;
run(&ctx, click(rects.0.left_center() + egui::vec2(9.0, 0.0)), |ui| {
changed = checkbox(ui, &s, &mut on, "Screen").changed();
toggle(ui, &s, &mut sw);
});
assert!(on && changed);
run(&ctx, click(rects.1.center()), |ui| {
checkbox(ui, &s, &mut on, "Screen");
toggle(ui, &s, &mut sw);
});
assert!(sw && on);
}
#[test]
fn select_opens_and_picks_an_option() {
let ctx = Context::default();
let s = style();
let opts = [(1, "One"), (2, "Two"), (3, "Three")];
let field = Rect::from_min_size(Pos2::ZERO, egui::vec2(140.0, 32.0));
let mut chosen = None;
let mut frame = |events| {
run(&ctx, events, |ui| chosen = chosen.or(select(ui, &s, 1, &opts, 140.0)));
};
frame(vec![]);
frame(click(field.center()));
frame(vec![]);
let menu = ctx.memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Foreground).find_map(|l| m.area_rect(l.id)));
let menu = menu.expect("the menu is open");
// Rows are 32 tall with 2 between, under the menu's margin: aim at the second one.
let two = pos2(menu.center().x, menu.top() + menu.height() / 2.0);
frame(click(two));
assert_eq!(chosen, Some(2));
}
#[test]
fn option_label_keeps_the_name_of_a_path() {
let ctx = Context::default();
let mut out = (String::new(), String::new(), String::new());
run(&ctx, vec![], |ui| {
let font = FontId::proportional(13.0);
out.0 = option_label(ui, "Label / Entry Request / Waiting", font.clone(), Color32::WHITE, Color32::GRAY, 1000.0).text().to_owned();
out.1 = option_label(ui, "Label / Entry Request / Waiting", font.clone(), Color32::WHITE, Color32::GRAY, 90.0).text().to_owned();
let g = option_label(ui, "A very long name without a path at all", font, Color32::WHITE, Color32::GRAY, 60.0);
out.2 = if g.elided && g.size().x <= 61.0 { String::new() } else { format!("not elided: {}", g.size().x) };
});
assert_eq!(out.0, "Label / Entry Request / Waiting");
assert!(out.1.starts_with('\u{2026}') && out.1.ends_with("Waiting"), "{}", out.1);
assert!(out.2.is_empty(), "{}", out.2);
let _ = Vec2::ZERO;
}
+2
View File
@@ -6,9 +6,11 @@
//! accept focused style values and return egui responses; no application engine is required.
pub mod buttons;
pub mod controls;
pub mod docking;
pub mod icons;
pub mod layout;
pub mod material;
pub mod printing;
pub mod tabs;
pub mod theme;
+193
View File
@@ -0,0 +1,193 @@
//! Surface materials: flat fills, or acrylic (surfaces lit from above with a fine edge, a soft
//! accent glow behind the chrome, a glossy primary button).
//!
//! Every function takes the material, palette colours and `dark` explicitly; the application
//! stores the user's choice and its palette. Under [`Material::Solid`] each one paints the plain
//! fill it was given, so callers don't branch.
use egui::{Color32, CornerRadius, Frame, InnerResponse, Mesh, Painter, Pos2, Rect, Shadow, Stroke, StrokeKind, Ui, pos2, vec2};
#[cfg(test)]
mod tests;
/// How surfaces are painted.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Material {
#[default]
Solid,
Acrylic,
}
impl Material {
pub const ALL: [Material; 2] = [Material::Solid, Material::Acrylic];
pub fn is_acrylic(self) -> bool {
self == Material::Acrylic
}
}
/// `over` composited on `base` (the result is opaque).
pub fn blend(base: Color32, over: Color32) -> Color32 {
let a = f32::from(over.a()) / 255.0;
let [or, og, ob, _] = over.to_srgba_unmultiplied();
let mix = |b: u8, o: u8| (f32::from(b) * (1.0 - a) + f32::from(o) * a).round() as u8;
Color32::from_rgb(mix(base.r(), or), mix(base.g(), og), mix(base.b(), ob))
}
/// The fine edge of acrylic surfaces.
pub fn edge(dark: bool) -> Stroke {
Stroke::new(1.0, if dark { Color32::from_white_alpha(22) } else { Color32::from_black_alpha(22) })
}
/// The top and bottom of `fill` lit from above. A translucent fill (a tint over a panel) gets
/// brighter at the top and fainter at the bottom instead.
pub fn lit(fill: Color32, dark: bool) -> (Color32, Color32) {
if fill.a() < 255 {
let [r, g, b, a] = fill.to_srgba_unmultiplied();
let with = |k: f32| Color32::from_rgba_unmultiplied(r, g, b, (f32::from(a) * k).min(255.0) as u8);
return (with(1.6), with(0.8));
}
if dark {
(blend(fill, Color32::from_white_alpha(26)), blend(fill, Color32::from_black_alpha(22)))
} else {
(blend(fill, Color32::from_white_alpha(140)), blend(fill, Color32::from_black_alpha(4)))
}
}
/// A rounded rectangle filled with a vertical gradient. A fan from the centre interpolates a
/// colour that is linear in y exactly; an edge stroke covers the unsmoothed outline.
pub fn vertical_gradient(rect: Rect, radius: f32, top: Color32, bottom: Color32) -> Mesh {
let mut outline = Vec::new();
egui::epaint::tessellator::path::rounded_rectangle(&mut outline, rect, radius.into());
let at = |y: f32| top.lerp_to_gamma(bottom, ((y - rect.top()) / rect.height().max(f32::EPSILON)).clamp(0.0, 1.0));
let mut mesh = Mesh::default();
mesh.colored_vertex(rect.center(), at(rect.center().y));
for q in &outline {
mesh.colored_vertex(*q, at(q.y));
}
let n = outline.len() as u32;
for i in 0..n {
mesh.add_triangle(0, 1 + i, 1 + (i + 1) % n);
}
mesh
}
fn paint_lit(painter: &Painter, rect: Rect, radius: u8, top: Color32, bottom: Color32, stroke: Stroke) {
painter.add(vertical_gradient(rect, f32::from(radius), top, bottom));
painter.rect_stroke(rect, radius, stroke, StrokeKind::Inside);
}
/// A control's surface (field, chip, select, secondary button, segmented track).
pub fn paint_control(painter: &Painter, material: Material, rect: Rect, radius: u8, fill: Color32, dark: bool) {
if !material.is_acrylic() {
painter.rect_filled(rect, radius, fill);
return;
}
let (top, bottom) = lit(fill, dark);
paint_lit(painter, rect, radius, top, bottom, edge(dark));
}
/// A selected row or item (nav, menu option, layer): an accent wash with an accent edge.
pub fn paint_selected(painter: &Painter, material: Material, rect: Rect, radius: u8, fill: Color32, accent: Color32) {
if !material.is_acrylic() {
painter.rect_filled(rect, radius, fill);
return;
}
let wash = |alpha| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), alpha);
paint_lit(painter, rect, radius, blend(fill, wash(56)), blend(fill, wash(20)), Stroke::new(1.0, wash(80)));
}
/// An accent surface that is not the primary button (toggle on, active segment, checked box):
/// glossy with a light edge, no glow.
pub fn paint_accent(painter: &Painter, material: Material, rect: Rect, radius: u8, fill: Color32) {
if !material.is_acrylic() {
painter.rect_filled(rect, radius, fill);
return;
}
paint_lit(
painter,
rect,
radius,
blend(fill, Color32::from_white_alpha(70)),
blend(fill, Color32::from_black_alpha(40)),
Stroke::new(1.0, Color32::from_white_alpha(90)),
);
}
/// The acrylic primary button: a soft glow in its own colour, a glossy gradient, a light edge.
pub fn paint_gloss(painter: &Painter, rect: Rect, radius: u8, fill: Color32) {
let glow = Shadow { offset: [0, 0], blur: 16, spread: 0, color: Color32::from_rgba_unmultiplied(fill.r(), fill.g(), fill.b(), 70) };
painter.add(glow.as_shape(rect, CornerRadius::same(radius)));
paint_accent(painter, Material::Acrylic, rect, radius, fill);
}
/// An acrylic panel under its content: a soft shadow, the fill lit from above, a fine edge.
pub fn paint_panel(painter: &Painter, rect: Rect, radius: u8, fill: Color32, dark: bool) {
let shadow = Shadow { offset: [0, 4], blur: 12, spread: 0, color: Color32::from_black_alpha(if dark { 80 } else { 24 }) };
painter.add(shadow.as_shape(rect, CornerRadius::same(radius)));
let (top, bottom) = lit(fill, dark);
paint_lit(painter, rect, radius, top, bottom, edge(dark));
}
/// The acrylic backdrop at `pos`: `backdrop` lit by a soft `accent` glow that falls off from
/// the top of `screen`, a third of the way across.
pub fn backdrop_at(backdrop: Color32, accent: Color32, screen: Rect, pos: Pos2) -> Color32 {
const GLOW_ALPHA: f32 = 46.0;
const GLOW_REACH: f32 = 0.8;
let centre = pos2(screen.left() + screen.width() * 0.3, screen.top());
let reach = (screen.width().max(screen.height()) * GLOW_REACH).max(1.0);
let d = ((pos - centre).length() / reach).min(1.0);
let glow = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (GLOW_ALPHA * (1.0 - d) * (1.0 - d)) as u8);
blend(backdrop, glow)
}
/// `rect` filled with the acrylic backdrop: a grid of vertex colours, so the glow is smooth.
pub fn backdrop_mesh(backdrop: Color32, accent: Color32, screen: Rect, rect: Rect) -> Mesh {
const CELLS: u32 = 12;
let mut mesh = Mesh::default();
for j in 0..=CELLS {
for i in 0..=CELLS {
let pos = rect.min + rect.size() * vec2(i as f32, j as f32) / CELLS as f32;
mesh.colored_vertex(pos, backdrop_at(backdrop, accent, screen, pos));
}
}
let row = CELLS + 1;
for j in 0..CELLS {
for i in 0..CELLS {
let k = j * row + i;
mesh.add_triangle(k, k + 1, k + row);
mesh.add_triangle(k + 1, k + row + 1, k + row);
}
}
mesh
}
/// Lights a window, modal, popup or menu frame from inside its content: call first in the
/// content closure. Paints over the frame's flat `fill` at the area's rect from the last frame,
/// so nothing has to run after the content. No-op unless acrylic.
pub fn paint_backing(ui: &Ui, material: Material, fill: Color32, radius: u8) {
if !material.is_acrylic() {
return;
}
let Some(rect) = ui.ctx().memory(|m| m.area_rect(ui.layer_id().id)) else { return };
let dark = ui.visuals().dark_mode;
let (top, bottom) = lit(fill, dark);
paint_lit(&ui.painter().with_clip_rect(rect), rect, radius, top, bottom, edge(dark));
}
/// `Frame::show` that, under acrylic, paints the frame's fill lit from above with a fine edge
/// (the frame's own stroke when it has one). For cards, fields, popovers and docks.
pub fn show_surface<R>(frame: Frame, ui: &mut Ui, material: Material, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {
if !material.is_acrylic() || frame.fill.a() == 0 {
return frame.show(ui, add);
}
let dark = ui.visuals().dark_mode;
let (fill, radius) = (frame.fill, f32::from(frame.corner_radius.nw));
let slot = ui.painter().add(egui::Shape::Noop);
let stroke = if frame.stroke.width > 0.0 { frame.stroke } else { edge(dark) };
let r = frame.fill(Color32::TRANSPARENT).stroke(stroke).show(ui, add);
let (top, bottom) = lit(fill, dark);
ui.painter().set(slot, vertical_gradient(r.response.rect, radius, top, bottom));
r
}
+42
View File
@@ -0,0 +1,42 @@
use super::*;
#[test]
fn blend_composites_alpha() {
let c = blend(Color32::from_rgb(0, 0, 0), Color32::from_rgba_unmultiplied(255, 255, 255, 128));
assert!((127..=129).contains(&c.r()) && c.a() == 255);
assert_eq!(blend(Color32::RED, Color32::TRANSPARENT), Color32::RED);
}
#[test]
fn lit_is_lighter_on_top() {
let fill = Color32::from_rgb(60, 60, 70);
for dark in [true, false] {
let (top, bottom) = lit(fill, dark);
assert!(top.r() > bottom.r(), "{dark}: {top:?} {bottom:?}");
}
let (top, bottom) = lit(Color32::from_rgba_unmultiplied(255, 255, 255, 40), true);
assert!(top.a() > bottom.a(), "a translucent tint fades downwards");
}
#[test]
fn gradient_is_linear_in_y() {
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 40.0));
let m = vertical_gradient(rect, 8.0, Color32::WHITE, Color32::BLACK);
assert!(m.indices.len() >= 3 * 4);
let mid = m.vertices[0];
assert_eq!(mid.pos, rect.center());
let grey = mid.color.r();
assert!((100..=156).contains(&grey), "{grey}");
}
#[test]
fn backdrop_glows_at_the_top_only() {
let screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0));
let bg = Color32::from_rgb(20, 20, 30);
let accent = Color32::from_rgb(200, 100, 255);
let near = backdrop_at(bg, accent, screen, pos2(300.0, 0.0));
let far = backdrop_at(bg, accent, screen, pos2(1000.0, 800.0));
assert_ne!(near, bg);
assert_eq!(far, bg);
assert_eq!(backdrop_mesh(bg, accent, screen, screen).vertices.len(), 13 * 13);
}