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:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user