3 Commits

Author SHA1 Message Date
Marcos Paulo 9c470aa935 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>
2026-10-28 00:14:37 -03:00
Marcos Paulo 7fcee66b8e feat(ui): Catppuccin palettes (Latte, Frappé, Macchiato, Mocha)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:53:00 -03:00
Marcos Paulo 3e6869e88c chore: debrand ArtCraft references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:52:59 -03:00
14 changed files with 763 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
# Strip upstream (ArtCraft / Discord) branding. Run: tools/craft-skills/skills/debrand/debrand.pl (--check in CI)
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
sub ; ArtCraft trademarks remain outside such a library\. => ; trademarks remain outside such a library.
forbid (?i)artcraft(?! Team and the [\w-]+ contributors)
forbid (?i)discord
+3
View File
@@ -0,0 +1,3 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
+3 -1
View File
@@ -4,4 +4,6 @@ Third-party assets bundled in this repository (fonts, icons, images, test data),
author, source and license. Library code and original assets are under the repository license
(MIT OR Apache-2.0, see [NOTICE](NOTICE)).
None yet.
| Path | Title | Author | Source | License |
| --- | --- | --- | --- | --- |
| `crates/ui/src/theme/catppuccin.rs` | Catppuccin Latte, Frappé, Macchiato and Mocha palettes | Catppuccin | https://catppuccin.com/palette | MIT (`crates/ui/LICENSE-catppuccin.txt`) |
+28
View File
@@ -0,0 +1,28 @@
Catppuccin
https://catppuccin.com/palette
https://github.com/catppuccin/catppuccin
The Catppuccin Latte, Frappé, Macchiato and Mocha themes' colour palettes in `catppuccin.rs`
are the Catppuccin project's palettes, used under the MIT License below.
MIT License
Copyright (c) 2021 Catppuccin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+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);
}
+148
View File
@@ -0,0 +1,148 @@
//! The Catppuccin colour palettes (Latte, Frappé, Macchiato, Mocha).
//!
//! Colours only: how they map onto an application's tokens stays with the application.
//! Palette: <https://catppuccin.com/palette> (MIT, see `LICENSE-catppuccin.txt`).
use egui::Color32;
/// The 26 named colours of one Catppuccin flavour.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub rosewater: Color32,
pub flamingo: Color32,
pub pink: Color32,
pub mauve: Color32,
pub red: Color32,
pub maroon: Color32,
pub peach: Color32,
pub yellow: Color32,
pub green: Color32,
pub teal: Color32,
pub sky: Color32,
pub sapphire: Color32,
pub blue: Color32,
pub lavender: Color32,
pub text: Color32,
pub subtext1: Color32,
pub subtext0: Color32,
pub overlay2: Color32,
pub overlay1: Color32,
pub overlay0: Color32,
pub surface2: Color32,
pub surface1: Color32,
pub surface0: Color32,
pub base: Color32,
pub mantle: Color32,
pub crust: Color32,
}
const fn rgb(hex: u32) -> Color32 {
Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
}
impl Palette {
/// `h` in the field order above, as 0xRRGGBB.
const fn new(h: [u32; 26]) -> Self {
Self {
rosewater: rgb(h[0]),
flamingo: rgb(h[1]),
pink: rgb(h[2]),
mauve: rgb(h[3]),
red: rgb(h[4]),
maroon: rgb(h[5]),
peach: rgb(h[6]),
yellow: rgb(h[7]),
green: rgb(h[8]),
teal: rgb(h[9]),
sky: rgb(h[10]),
sapphire: rgb(h[11]),
blue: rgb(h[12]),
lavender: rgb(h[13]),
text: rgb(h[14]),
subtext1: rgb(h[15]),
subtext0: rgb(h[16]),
overlay2: rgb(h[17]),
overlay1: rgb(h[18]),
overlay0: rgb(h[19]),
surface2: rgb(h[20]),
surface1: rgb(h[21]),
surface0: rgb(h[22]),
base: rgb(h[23]),
mantle: rgb(h[24]),
crust: rgb(h[25]),
}
}
}
pub const LATTE: Palette = Palette::new([
0xdc8a78, 0xdd7878, 0xea76cb, 0x8839ef, 0xd20f39, 0xe64553, 0xfe640b, 0xdf8e1d, 0x40a02b, 0x179299, 0x04a5e5, 0x209fb5, 0x1e66f5, 0x7287fd,
0x4c4f69, 0x5c5f77, 0x6c6f85, 0x7c7f93, 0x8c8fa1, 0x9ca0b0, 0xacb0be, 0xbcc0cc, 0xccd0da, 0xeff1f5, 0xe6e9ef, 0xdce0e8,
]);
pub const FRAPPE: Palette = Palette::new([
0xf2d5cf, 0xeebebe, 0xf4b8e4, 0xca9ee6, 0xe78284, 0xea999c, 0xef9f76, 0xe5c890, 0xa6d189, 0x81c8be, 0x99d1db, 0x85c1dc, 0x8caaee, 0xbabbf1,
0xc6d0f5, 0xb5bfe2, 0xa5adce, 0x949cbb, 0x838ba7, 0x737994, 0x626880, 0x51576d, 0x414559, 0x303446, 0x292c3c, 0x232634,
]);
pub const MACCHIATO: Palette = Palette::new([
0xf4dbd6, 0xf0c6c6, 0xf5bde6, 0xc6a0f6, 0xed8796, 0xee99a0, 0xf5a97f, 0xeed49f, 0xa6da95, 0x8bd5ca, 0x91d7e3, 0x7dc4e4, 0x8aadf4, 0xb7bdf8,
0xcad3f5, 0xb8c0e0, 0xa5adcb, 0x939ab7, 0x8087a2, 0x6e738d, 0x5b6078, 0x494d64, 0x363a4f, 0x24273a, 0x1e2030, 0x181926,
]);
pub const MOCHA: Palette = Palette::new([
0xf5e0dc, 0xf2cdcd, 0xf5c2e7, 0xcba6f7, 0xf38ba8, 0xeba0ac, 0xfab387, 0xf9e2af, 0xa6e3a1, 0x94e2d5, 0x89dceb, 0x74c7ec, 0x89b4fa, 0xb4befe,
0xcdd6f4, 0xbac2de, 0xa6adc8, 0x9399b2, 0x7f849c, 0x6c7086, 0x585b70, 0x45475a, 0x313244, 0x1e1e2e, 0x181825, 0x11111b,
]);
/// The four Catppuccin flavours.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Flavor {
Latte,
Frappe,
Macchiato,
Mocha,
}
impl Flavor {
pub const ALL: [Flavor; 4] = [Flavor::Latte, Flavor::Frappe, Flavor::Macchiato, Flavor::Mocha];
pub const fn palette(self) -> &'static Palette {
match self {
Flavor::Latte => &LATTE,
Flavor::Frappe => &FRAPPE,
Flavor::Macchiato => &MACCHIATO,
Flavor::Mocha => &MOCHA,
}
}
/// Latte is the only light flavour.
pub const fn is_dark(self) -> bool {
!matches!(self, Flavor::Latte)
}
pub const fn name(self) -> &'static str {
match self {
Flavor::Latte => "Latte",
Flavor::Frappe => "Frappé",
Flavor::Macchiato => "Macchiato",
Flavor::Mocha => "Mocha",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_values_and_brightness() {
assert_eq!(Flavor::Mocha.palette().base, Color32::from_rgb(0x1e, 0x1e, 0x2e));
assert_eq!(Flavor::Latte.palette().mauve, Color32::from_rgb(0x88, 0x39, 0xef));
assert_eq!(Flavor::Frappe.palette().crust, Color32::from_rgb(0x23, 0x26, 0x34));
assert_eq!(Flavor::Macchiato.palette().text, Color32::from_rgb(0xca, 0xd3, 0xf5));
assert_eq!(Flavor::ALL.iter().filter(|f| !f.is_dark()).count(), 1);
for f in Flavor::ALL {
let p = f.palette();
// Text must contrast with the base surface in the right direction.
assert_eq!(p.text.r() > p.base.r(), f.is_dark(), "{}", f.name());
}
}
}
+2
View File
@@ -3,6 +3,8 @@
//! This module does not install fonts, store a theme, or change global visuals. Applications
//! decide their colors, borders, typography, spacing and saved preferences independently.
pub mod catppuccin;
use egui::{Color32, CornerRadius, Stroke, style::Widgets};
/// Background colors for egui's five widget interaction states.
+1 -1
View File
@@ -66,7 +66,7 @@ The SVG substitution is deliberately narrow: it preserves the existing literal r
PdfCraft resolves the fallback name before requesting a source, preserving its existing URI policy. This replaces its indexed fallback lookup with the shared non-panicking lookup; the shared absent-fallback test covers a catalog without that entry. No icon assets move.
Most other Rust apps draw icons with egui's painter. Converting them all to SVG would be a separate visual change with different cost and fidelity questions. Common icon assets are a future candidate after checking file hashes, ownership and each app's asset manifest; ArtCraft trademarks remain outside such a library.
Most other Rust apps draw icons with egui's painter. Converting them all to SVG would be a separate visual change with different cost and fidelity questions. Common icon assets are a future candidate after checking file hashes, ownership and each app's asset manifest; trademarks remain outside such a library.
## Themes and common controls
Submodule tools/craft-skills added at 26c1c201a7