use std::collections::BTreeMap;
use std::fmt;
use bevy::input_focus::{InputFocus, InputFocusVisible};
use bevy::picking::hover::Hovered;
use bevy::prelude::*;
use bevy::text::{FontSize, FontSource, TextCursorStyle};
use bevy::ui::{Checked, ComputedUiRenderTargetInfo, InteractionDisabled, Pressed, Selected};
use bevy::ui_widgets::popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide};
use bevy::ui_widgets::{ScrollbarDragState, SliderDragState};
use crate::PopoverSlide;
use crate::binding::Capturing;
use crate::choice::{ChoiceCount, ChoiceValue};
use crate::drag::{DropCandidate, DropRefusing};
use crate::field::{FieldProblem, FieldProblemKind, ProblemRoom};
use crate::icon::{IconSize, IconTone};
use crate::list::ListView;
use crate::menu::{MenuMarked, MenuOpen};
use crate::model::{
COMFORT_TARGET_PX, ELEVATION_STEP, ENHANCED_CONTRAST, MIN_TARGET_PX, NON_TEXT_CONTRAST,
STATE_STEP, TEXT_CONTRAST, cie_lightness, composite, contrast_ratio, list_height,
snap_line_width, tree_indent,
};
use crate::popover::{PopoverPanel, placement_of};
use crate::prompt::PromptsAnchor;
use crate::status::StatusKind;
use crate::table::TableRow;
use crate::toast::{Toast, ToastKind};
use crate::tooltip::TooltipBox;
const HELD_FADE: f32 = 0.6;
const METER_MIN_TEXT_SIZES: f32 = 8.0;
const PROGRESS_MIN_TEXT_SIZES: f32 = 28.0;
const STATUS_PROGRESS_TEXT_SIZES: f32 = 8.0;
const SPINNER_TEXT_SIZES: f32 = 1.5;
const SLIDER_MIN_TEXT_SIZES: f32 = 10.0;
const CHOICE_MIN_TEXT_SIZES: f32 = 8.0;
const RADIAL_DIAMETER_TEXT_SIZES: f32 = 26.0;
const RADIAL_ICON_TEXT_SIZES: f32 = 3.0;
const SLOT_TEXT_SIZES: f32 = 4.0;
const SLOT_ICON_SHARE: f32 = 0.6;
const COOLDOWN_DIM_ALPHA: f32 = 0.66;
const COOLDOWN_EDGE_ALPHA: f32 = 0.7;
const COOLDOWN_CHIP_ALPHA: f32 = 0.92;
const RADIAL_WINDOW_SHARE: f32 = 0.92;
const SWITCH_TEXT_SIZES: f32 = 1.5;
const THUMB_TEXT_SIZES: f32 = 1.0;
const COLOR_AREA_HEIGHT_PX: f32 = 136.0;
const COLOR_MARKER_TEXT_SIZES: f32 = 1.3;
const LIST_LINE_TEXT_SIZES: f32 = 1.2;
const LIST_PADDING_FACTOR: f32 = 1.2;
const RAIL_LINES: f32 = 2.0;
const THUMB_RAIL_RATIO: f32 = 1.25;
const BINDING_CAP_TEXT_SIZES: f32 = 17.0;
const PALETTE_TEXT_SIZES: f32 = 40.0;
const PALETTE_TOP_VH: f32 = 5.0;
const MENU_MIN_TEXT_SIZES: f32 = 10.0;
const MENU_CHORD_TEXT_SIZES: f32 = 0.5;
const MENU_MARK_TEXT_SIZES: f32 = 1.1;
const TOAST_MEASURE_TEXT_SIZES: f32 = 24.0;
const TOAST_MARK_LINES: f32 = 4.0;
const CHEVRON_TEXT_SIZES: f32 = 0.6;
const CHEVRON_ARROW_FIT: f32 = 1.0 + std::f32::consts::FRAC_1_SQRT_2;
const DIALOG_MIN_TEXT_SIZES: f32 = 21.0;
const DIALOG_MAX_TEXT_SIZES: f32 = 36.0;
const DIALOG_ROOM_FACTOR: f32 = 1.5;
const DOCK_DROP_ALPHA: f32 = 0.22;
const DROP_MARK_ALPHA: f32 = 0.12;
const TABLE_BAND_ALPHA: f32 = 0.5;
const TABLE_SORT_MARK_SCALE: f32 = 0.8;
pub(crate) const SAFE_EDGE_PERCENT: f32 = 5.0;
const PROMPT_SPACING_PADDINGS: f32 = 2.0;
const PROMPTS_MAX_WIDTH_PERCENT: f32 = 100.0 - 2.0 * SAFE_EDGE_PERCENT;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Reflect)]
#[reflect(Default, Debug, PartialEq, Hash, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum Density {
#[default]
Comfortable,
Compact,
}
const COMPACT_PADDING: f32 = 0.5;
#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
#[reflect(Resource, Default, Debug, PartialEq, Clone)]
#[cfg_attr(
feature = "serde",
derive(serde::Serialize, serde::Deserialize),
serde(default)
)]
pub struct GooeySkin {
pub background: Color,
pub surface: Color,
pub surface_raised: Color,
pub text: Color,
pub text_muted: Color,
pub accent: Color,
pub accent_text: Color,
pub danger: Color,
pub focus_ring: Color,
pub outline: Color,
pub surface_edge: Option<Color>,
pub scrim: Color,
pub text_size: f32,
pub heading_size: f32,
pub gap: f32,
pub padding: f32,
pub radius: f32,
pub border: f32,
pub meter_height: f32,
pub min_target: f32,
pub density: Density,
pub text_scale: f32,
pub spacing: [f32; 6],
pub radii: [f32; 3],
pub type_scale: [f32; 5],
pub surfaces: Vec<Color>,
pub tones: BTreeMap<String, Color>,
pub hover: Option<Color>,
pub pressed: Option<Color>,
pub held: Option<Color>,
pub held_ground: Option<Color>,
pub held_fade: f32,
pub unchecked: Option<Color>,
pub card: Option<Color>,
pub preview_edge: Option<Color>,
}
impl GooeySkin {
pub fn dark() -> Self {
Self {
background: Color::srgb(0.07, 0.08, 0.10),
surface: Color::srgba(0.12, 0.13, 0.16, 0.92),
surface_raised: Color::srgb(0.20, 0.22, 0.27),
text: Color::srgb(0.93, 0.94, 0.96),
text_muted: Color::srgb(0.62, 0.65, 0.71),
accent: Color::srgb(0.95, 0.68, 0.20),
accent_text: Color::srgb(0.10, 0.08, 0.05),
danger: Color::srgb(0.86, 0.27, 0.24),
focus_ring: Color::srgb(0.45, 0.78, 1.0),
outline: Color::srgb(0.44, 0.47, 0.54),
scrim: Color::srgba(0.0, 0.0, 0.0, 0.6),
surfaces: vec![
Color::srgb(0.07, 0.08, 0.10),
Color::srgb(0.12, 0.13, 0.16),
Color::srgb(0.16, 0.175, 0.215),
Color::srgb(0.20, 0.22, 0.27),
Color::srgb(0.273, 0.292, 0.339),
],
..Self::sizes()
}
}
pub fn parchment() -> Self {
Self {
background: Color::srgb(0.18, 0.13, 0.09),
surface: Color::srgba(0.91, 0.85, 0.72, 0.95),
surface_raised: Color::srgb(0.83, 0.74, 0.58),
text: Color::srgb(0.20, 0.14, 0.09),
text_muted: Color::srgb(0.42, 0.33, 0.24),
accent: Color::srgb(0.55, 0.16, 0.12),
accent_text: Color::srgb(0.97, 0.93, 0.85),
danger: Color::srgb(0.70, 0.12, 0.10),
focus_ring: Color::srgb(0.12, 0.38, 0.55),
outline: Color::srgb(0.48, 0.38, 0.27),
scrim: Color::srgba(0.08, 0.05, 0.02, 0.7),
surfaces: vec![
Color::srgb(0.18, 0.13, 0.09),
Color::srgb(0.667, 0.588, 0.458),
Color::srgb(0.83, 0.74, 0.58),
Color::srgb(0.91, 0.85, 0.72),
Color::srgb(0.955, 0.925, 0.86),
],
..Self::sizes()
}
}
pub fn high_contrast() -> Self {
Self {
background: Color::BLACK,
surface: Color::srgb(0.02, 0.02, 0.02),
surface_raised: Color::srgb(0.133, 0.133, 0.133),
text: Color::WHITE,
text_muted: Color::srgb(0.72, 0.72, 0.72),
accent: Color::srgb(1.0, 0.66, 0.0),
accent_text: Color::BLACK,
danger: Color::srgb(1.0, 0.5, 0.5),
focus_ring: Color::srgb(0.2, 0.9, 1.0),
outline: Color::WHITE,
surface_edge: Some(Color::WHITE),
scrim: Color::srgba(0.0, 0.0, 0.0, 0.8),
surfaces: vec![
Color::srgb(0.0, 0.0, 0.0),
Color::srgb(0.092, 0.092, 0.092),
Color::srgb(0.156, 0.156, 0.156),
Color::srgb(0.241, 0.241, 0.241),
Color::srgb(0.351, 0.351, 0.351),
],
..Self::sizes()
}
}
pub fn tool() -> Self {
Self {
background: Color::srgb(0.07, 0.075, 0.085),
surface: Color::srgb(0.11, 0.115, 0.13),
surface_raised: Color::srgb(0.19, 0.20, 0.23),
text: Color::srgb(0.90, 0.91, 0.93),
text_muted: Color::srgb(0.62, 0.64, 0.68),
accent: Color::srgb(0.30, 0.60, 0.98),
accent_text: Color::srgb(0.04, 0.05, 0.08),
danger: Color::srgb(0.93, 0.36, 0.34),
focus_ring: Color::srgb(0.55, 0.80, 1.0),
outline: Color::srgb(0.45, 0.48, 0.54),
scrim: Color::srgba(0.0, 0.0, 0.0, 0.55),
text_size: 14.0,
heading_size: 20.0,
gap: 6.0,
padding: 10.0,
radius: 4.0,
border: 2.0,
meter_height: 10.0,
min_target: MIN_TARGET_PX,
density: Density::Compact,
surfaces: vec![
Color::srgb(0.07, 0.075, 0.085),
Color::srgb(0.11, 0.115, 0.13),
Color::srgb(0.15, 0.158, 0.18),
Color::srgb(0.19, 0.20, 0.23),
Color::srgb(0.261, 0.271, 0.30),
],
spacing: [2.0, 4.0, 6.0, 10.0, 16.0, 24.0],
radii: [2.0, 4.0, 8.0],
type_scale: [11.0, 12.0, 14.0, 16.0, 20.0],
..Self::sizes()
}
}
fn sizes() -> Self {
Self {
background: Color::BLACK,
surface: Color::BLACK,
surface_raised: Color::BLACK,
text: Color::WHITE,
text_muted: Color::WHITE,
accent: Color::WHITE,
accent_text: Color::BLACK,
danger: Color::WHITE,
focus_ring: Color::WHITE,
outline: Color::WHITE,
surface_edge: None,
scrim: Color::BLACK,
text_size: 20.0,
heading_size: 32.0,
gap: 10.0,
padding: 14.0,
radius: 8.0,
border: 3.0,
meter_height: 24.0,
min_target: COMFORT_TARGET_PX,
density: Density::Comfortable,
text_scale: 1.0,
spacing: [4.0, 8.0, 10.0, 14.0, 20.0, 28.0],
radii: [4.0, 8.0, 16.0],
type_scale: [14.0, 16.0, 20.0, 26.0, 32.0],
surfaces: vec![Color::BLACK],
tones: BTreeMap::new(),
hover: None,
pressed: None,
held: None,
held_ground: None,
held_fade: HELD_FADE,
unchecked: None,
card: None,
preview_edge: None,
}
}
pub fn tone(&self, name: &str) -> Option<Color> {
self.tones.get(name).copied()
}
fn colour_named(&self, name: &str) -> Option<Color> {
self.tone(name).or(match name {
"background" => Some(self.background),
"surface" => Some(self.solid_surface()),
"surface_raised" => Some(self.surface_raised),
"text" => Some(self.text),
"text_muted" => Some(self.text_muted),
"accent" => Some(self.accent),
"accent_text" => Some(self.accent_text),
"danger" => Some(self.danger),
"focus_ring" => Some(self.focus_ring),
"outline" => Some(self.outline),
_ => None,
})
}
pub fn tone_problems(&self, pairs: &[(&'static str, &str, &str, f32)]) -> Vec<SkinProblem> {
let mut out = Vec::new();
for &(pair, ink, ground, needed) in pairs {
let (Some(a), Some(b)) = (self.colour_named(ink), self.colour_named(ground)) else {
for name in [ink, ground] {
if self.colour_named(name).is_none() {
out.push(SkinProblem::UnknownTone {
name: name.to_owned(),
});
}
}
continue;
};
let ratio = contrast_ratio(rgb(a), rgb(b));
if ratio.is_nan() || ratio < needed {
out.push(SkinProblem::LowContrast {
pair,
ratio,
needed,
});
}
}
out
}
pub fn space(&self, step: usize) -> f32 {
self.spacing[step.min(self.spacing.len() - 1)]
}
pub fn corner(&self, step: usize) -> f32 {
self.radii[step.min(self.radii.len() - 1)]
}
pub fn type_size(&self, step: usize) -> f32 {
self.type_scale[step.min(self.type_scale.len() - 1)] * self.text_scale
}
pub fn surface_at(&self, level: u8) -> Color {
self.surfaces
.get(usize::from(level))
.or(self.surfaces.last())
.copied()
.unwrap_or(self.surface)
}
pub fn hovered(&self) -> Color {
self.hover
.unwrap_or_else(|| self.surface_raised.mix(&self.accent, 0.35))
}
pub fn pressed(&self) -> Color {
self.pressed.unwrap_or_else(|| self.solid_surface())
}
pub fn held(&self) -> Color {
self.held
.unwrap_or_else(|| self.text.mix(&self.held_ground(), self.fade()))
}
pub fn held_ground(&self) -> Color {
self.held_ground
.unwrap_or_else(|| self.solid_surface().mix(&self.background, self.fade()))
}
pub fn unchecked(&self) -> Color {
self.unchecked.unwrap_or(self.text_muted)
}
pub fn card(&self) -> Color {
self.card.unwrap_or_else(|| self.solid_surface())
}
pub fn preview_edge(&self) -> Color {
self.preview_edge.unwrap_or(self.outline)
}
fn fade(&self) -> f32 {
if self.held_fade.is_nan() {
0.0
} else {
self.held_fade.clamp(0.0, 1.0)
}
}
pub fn solid_surface(&self) -> Color {
let surface = Srgba::from(self.surface);
let [r, g, b] = composite(
[surface.red, surface.green, surface.blue],
surface.alpha,
rgb(self.background),
);
Color::srgb(r, g, b)
}
pub(crate) fn cooldown_dim(&self) -> Color {
let [r, g, b] = rgb(self.background);
Color::srgba(r, g, b, COOLDOWN_DIM_ALPHA)
}
pub(crate) fn cooldown_edge(&self) -> Color {
let dim = rgb(self.background);
let [text, surface] = [rgb(self.text), rgb(self.solid_surface())];
let [r, g, b] = if contrast_ratio(text, dim) >= contrast_ratio(surface, dim) {
text
} else {
surface
};
Color::srgba(r, g, b, COOLDOWN_EDGE_ALPHA)
}
fn cooldown_chip(&self) -> Color {
self.solid_surface().with_alpha(COOLDOWN_CHIP_ALPHA)
}
pub fn problems(&self) -> Vec<SkinProblem> {
self.check(0.0)
}
pub fn high_contrast_problems(&self) -> Vec<SkinProblem> {
self.check(ENHANCED_CONTRAST)
}
fn check(&self, floor: f32) -> Vec<SkinProblem> {
let mut out = Vec::new();
let surface = rgb(self.solid_surface());
let text = rgb(self.text);
let pairs = [
("text on surface", text, surface, TEXT_CONTRAST),
(
"text on surface_raised",
text,
rgb(self.surface_raised),
TEXT_CONTRAST,
),
(
"text on hovered button",
text,
rgb(self.hovered()),
TEXT_CONTRAST,
),
(
"accent_text on accent",
rgb(self.accent_text),
rgb(self.accent),
TEXT_CONTRAST,
),
(
"focus_ring on surface_raised",
rgb(self.focus_ring),
rgb(self.surface_raised),
NON_TEXT_CONTRAST,
),
(
"accent on surface_raised",
rgb(self.accent),
rgb(self.surface_raised),
NON_TEXT_CONTRAST,
),
(
"outline on surface",
rgb(self.outline),
surface,
NON_TEXT_CONTRAST,
),
(
"text_muted on surface_raised",
rgb(self.text_muted),
rgb(self.surface_raised),
NON_TEXT_CONTRAST,
),
(
"accent on surface",
rgb(self.accent),
surface,
NON_TEXT_CONTRAST,
),
(
"danger on surface",
rgb(self.danger),
surface,
NON_TEXT_CONTRAST,
),
(
"text_muted on surface",
rgb(self.text_muted),
surface,
TEXT_CONTRAST,
),
(
"focus_ring on surface",
rgb(self.focus_ring),
surface,
NON_TEXT_CONTRAST,
),
("text on pressed", text, rgb(self.pressed()), TEXT_CONTRAST),
("text on card", text, rgb(self.card()), TEXT_CONTRAST),
(
"unchecked on surface",
rgb(self.unchecked()),
surface,
NON_TEXT_CONTRAST,
),
(
"preview_edge on surface",
rgb(self.preview_edge()),
surface,
NON_TEXT_CONTRAST,
),
(
"text on cooldown chip",
text,
[[0.0; 3], [1.0; 3]]
.map(|icon| composite(surface, COOLDOWN_CHIP_ALPHA, icon))
.into_iter()
.min_by(|a, b| contrast_ratio(text, *a).total_cmp(&contrast_ratio(text, *b)))
.unwrap_or(surface),
TEXT_CONTRAST,
),
];
let edge = self.surface_edge.map(|edge| {
(
"surface_edge on surface",
rgb(edge),
surface,
NON_TEXT_CONTRAST,
)
});
for (pair, a, b, needed) in pairs.into_iter().chain(edge) {
let needed = needed.max(floor);
let ratio = contrast_ratio(a, b);
if ratio.is_nan() || ratio < needed {
out.push(SkinProblem::LowContrast {
pair,
ratio,
needed,
});
}
}
let delta = (cie_lightness(rgb(self.pressed())) - cie_lightness(rgb(self.hovered()))).abs();
if delta.is_nan() || delta < STATE_STEP {
out.push(SkinProblem::Indistinguishable {
pair: "pressed and hovered",
delta_l: delta,
needed: STATE_STEP,
});
}
let target = self.target();
if target < MIN_TARGET_PX {
out.push(SkinProblem::TargetTooSmall { px: target });
}
let meter = self.meter_height * self.text_scale;
let frames = 4.0 * self.border;
if meter.is_finite() && meter > 0.0 && frames.is_finite() && meter <= frames {
out.push(SkinProblem::MeterTooThin {
px: meter,
needed: frames,
});
}
let positive = [
("text_size", self.text_size),
("heading_size", self.heading_size),
("text_scale", self.text_scale),
("min_target", self.min_target),
("meter_height", self.meter_height),
];
if !(0.0..=1.0).contains(&self.held_fade) {
out.push(SkinProblem::BadSize { field: "held_fade" });
}
let non_negative = [
("gap", self.gap),
("padding", self.padding),
("radius", self.radius),
("border", self.border),
];
for (field, value) in positive {
if !value.is_finite() || value <= 0.0 {
out.push(SkinProblem::BadSize { field });
}
}
for (field, value) in non_negative {
if !value.is_finite() || value < 0.0 {
out.push(SkinProblem::BadSize { field });
}
}
for (i, pair) in self.surfaces.windows(2).enumerate() {
let delta = (cie_lightness(rgb(pair[1])) - cie_lightness(rgb(pair[0]))).abs();
if delta.is_nan() || delta < ELEVATION_STEP {
out.push(SkinProblem::ElevationStep {
lower: i,
upper: i + 1,
delta_l: delta,
});
}
}
let usable = |values: &[f32], above_zero: bool| {
values
.iter()
.all(|v| v.is_finite() && if above_zero { *v > 0.0 } else { *v >= 0.0 })
&& values.windows(2).all(|pair| pair[0] <= pair[1])
};
let scales = [
("surfaces", !self.surfaces.is_empty()),
("spacing", usable(&self.spacing, false)),
("radii", usable(&self.radii, false)),
("type_scale", usable(&self.type_scale, true)),
];
for (field, fine) in scales {
if !fine {
out.push(SkinProblem::BadScale { field });
}
}
out
}
pub fn pad(&self) -> f32 {
match self.density {
Density::Comfortable => self.padding,
Density::Compact => self.padding * COMPACT_PADDING,
}
}
pub fn target(&self) -> f32 {
match self.density {
Density::Comfortable => self.min_target,
Density::Compact => self.min_target.min(MIN_TARGET_PX),
}
}
pub fn table_pitch(&self) -> f32 {
let text = self.text_size * self.text_scale;
(text * LIST_LINE_TEXT_SIZES + self.pad() * LIST_PADDING_FACTOR)
.max(self.target())
.ceil()
}
pub fn with_colours_of(self, look: &GooeySkin) -> Self {
Self {
background: look.background,
surface: look.surface,
surface_raised: look.surface_raised,
text: look.text,
text_muted: look.text_muted,
accent: look.accent,
accent_text: look.accent_text,
danger: look.danger,
focus_ring: look.focus_ring,
outline: look.outline,
surface_edge: look.surface_edge,
scrim: look.scrim,
surfaces: look.surfaces.clone(),
hover: look.hover,
pressed: look.pressed,
held: look.held,
held_ground: look.held_ground,
unchecked: look.unchecked,
card: look.card,
preview_edge: look.preview_edge,
..self
}
}
fn colour_distance(&self, other: &GooeySkin) -> f32 {
let colours = |skin: &GooeySkin| {
[
skin.background,
skin.surface,
skin.surface_raised,
skin.text,
skin.text_muted,
skin.accent,
skin.accent_text,
skin.danger,
skin.focus_ring,
skin.outline,
skin.surface_edge.unwrap_or(skin.surface_raised),
skin.scrim,
skin.hovered(),
skin.pressed(),
skin.held(),
skin.held_ground(),
skin.unchecked(),
skin.card(),
skin.preview_edge(),
]
};
colours(self)
.into_iter()
.zip(colours(other))
.chain(
self.surfaces
.iter()
.copied()
.zip(other.surfaces.iter().copied()),
)
.map(|(a, b)| {
let (a, b) = (Srgba::from(a), Srgba::from(b));
(a.red - b.red).powi(2)
+ (a.green - b.green).powi(2)
+ (a.blue - b.blue).powi(2)
+ (a.alpha - b.alpha).powi(2)
})
.sum()
}
}
impl Default for GooeySkin {
fn default() -> Self {
Self::dark()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum SkinPreset {
Dark,
Parchment,
HighContrast,
Tool,
}
impl SkinPreset {
pub const ALL: [SkinPreset; 4] = [Self::Dark, Self::Parchment, Self::HighContrast, Self::Tool];
pub const fn name(self) -> &'static str {
match self {
Self::Dark => "Dark",
Self::Parchment => "Parchment",
Self::HighContrast => "High contrast",
Self::Tool => "Tool",
}
}
pub fn skin(self) -> GooeySkin {
match self {
Self::Dark => GooeySkin::dark(),
Self::Parchment => GooeySkin::parchment(),
Self::HighContrast => GooeySkin::high_contrast(),
Self::Tool => GooeySkin::tool(),
}
}
pub fn of(skin: &GooeySkin) -> Self {
let mut best = (Self::Dark, f32::INFINITY);
for preset in Self::ALL {
let distance = skin.colour_distance(&preset.skin());
if distance < best.1 {
best = (preset, distance);
}
}
best.0
}
}
fn rgb(color: Color) -> [f32; 3] {
let c = Srgba::from(color);
[c.red, c.green, c.blue]
}
#[derive(Clone, Debug, PartialEq)]
pub enum SkinProblem {
LowContrast {
pair: &'static str,
ratio: f32,
needed: f32,
},
TargetTooSmall {
px: f32,
},
BadSize {
field: &'static str,
},
MeterTooThin {
px: f32,
needed: f32,
},
ElevationStep {
lower: usize,
upper: usize,
delta_l: f32,
},
BadScale {
field: &'static str,
},
Indistinguishable {
pair: &'static str,
delta_l: f32,
needed: f32,
},
UnknownTone {
name: String,
},
}
impl fmt::Display for SkinProblem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LowContrast {
pair,
ratio,
needed,
} => {
write!(
f,
"{pair} has contrast {ratio:.2}:1; {needed:.2}:1 is the minimum"
)
}
Self::TargetTooSmall { px } => write!(
f,
"min_target is {px} px; a target must be at least {MIN_TARGET_PX} px"
),
Self::BadSize { field } => write!(
f,
"{field} is not a usable size: it must be finite, and above zero for a text size, text_scale, min_target or meter_height, from 0 to 1 for held_fade, at least zero otherwise"
),
Self::MeterTooThin { px, needed } => write!(
f,
"a meter is {px} px high; its frame and gap take {needed} px (4 × border), leaving no room for its fill"
),
Self::ElevationStep {
lower,
upper,
delta_l,
} => write!(
f,
"surfaces {lower} and {upper} differ by ΔL* {delta_l:.2}; adjacent surfaces of the ladder need at least {ELEVATION_STEP} to be told apart"
),
Self::BadScale { field } => write!(
f,
"{field} is not a usable scale: it must have a step (surfaces), be finite and never fall (spacing, radii, type_scale), and be above zero (type_scale)"
),
Self::Indistinguishable {
pair,
delta_l,
needed,
} => write!(
f,
"{pair} differ by ΔL* {delta_l:.2}; two states a player must tell apart need at least {needed}"
),
Self::UnknownTone { name } => write!(
f,
"\"{name}\" is neither a tone of the skin nor one of its own colours"
),
}
}
}
#[derive(Component, Clone, Debug, PartialEq, Reflect)]
#[reflect(Component, Debug, PartialEq, Clone)]
pub struct SkinScope(pub GooeySkin);
#[derive(Resource, Clone, Debug, Default)]
pub struct GooeyFonts {
pub body: FontSource,
pub heading: FontSource,
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Eq, Reflect)]
#[reflect(Component, Debug, PartialEq, Clone)]
pub enum Role {
Screen,
Panel,
Surface(u8),
Tone(&'static str),
Hovered,
Pressed,
Held,
HeldGround,
Unchecked,
Card,
PreviewEdge,
Menu,
Heading,
Label,
Button,
ButtonText,
Meter,
MeterFill,
MeterTrail,
HoldTrack,
HoldFill,
Tooltip,
Popover,
Scrim,
Toggle,
ToggleTrack,
ToggleKnob,
Slider,
SliderHeader,
SliderTrack,
SliderRail,
SliderFill,
SliderGap,
SliderThumb,
SliderReadout,
ControlText,
Choice,
ChoiceArrow,
ChoiceBack,
ChoiceForward,
ChoiceCell,
ChoiceOption,
Binding,
BindingCap,
BindingText,
BindingPrompt,
Prompts,
Prompt,
PromptKey,
PromptButton,
ShortcutBadge,
ShortcutLegend,
ToastStack,
Toast,
ToastBody,
ToastMark,
ToastText,
List,
ListView,
ListRows,
ListRail,
ListThumb,
Table,
TableHeader,
TableHeaderCell,
TableSortUp,
TableSortDown,
TableEdge,
TableView,
TableRows,
TableRow,
TableCell,
TableCellText,
TreeTwisty,
TreeClosed,
TreeOpen,
Split,
SplitPane,
SplitDivider,
SplitGrip,
Dock,
DockBody,
DockDrop,
DockGhost,
DragGhost,
DragGhostRefused,
DropMark,
DropMarkRefused,
Tabs,
TabList,
Tab,
TabText,
Radial,
RadialTrack,
RadialSlot,
RadialText,
RadialIcon,
RadialCentre,
SlotGrid,
Slot,
SlotEmpty,
SlotName,
SlotCount,
SlotIcon,
Cooldown,
CooldownText,
TabPages,
TabPage,
TextInput,
TextArea,
TextCell,
TextField,
TextHint,
MenuBar,
MenuTitle,
MenuTitleText,
MenuPanel,
MenuItem,
MenuMark,
MenuCheck,
MenuDot,
MenuText,
MenuShortcut,
MenuChevron,
MenuSeparator,
MenuNote,
Palette,
PaletteRow,
PaletteCount,
PaletteField,
NumberPart,
NumberParts,
ColorSwatch,
ColorSwatchHalf,
ColorArea,
Viewport,
ColorAreaMarker,
ColorAreaMarkerInner,
ColorStripBox,
ColorRail,
Icon,
FieldMessage,
FieldMark,
Progress,
ProgressLine,
Spinner,
Toolbar,
ToolbarSeparator,
ToggleGroup,
GroupOption,
StatusBar,
StatusGroup,
StatusItem,
StatusMark,
Section,
SectionBody,
Inspector,
NotificationCenter,
ToastAction,
ToastActionText,
SectionNote,
}
type Styled = (
Entity,
&'static Role,
Option<&'static mut Node>,
Option<&'static mut BackgroundColor>,
Option<&'static mut BorderColor>,
Option<&'static mut TextColor>,
Option<&'static mut TextFont>,
Option<(
&'static mut Popover,
&'static mut BoxShadow,
Option<&'static TooltipBox>,
Option<(&'static PopoverPanel, Option<&'static PopoverSlide>)>,
)>,
Option<&'static ComputedUiRenderTargetInfo>,
Option<&'static mut TextCursorStyle>,
Option<&'static mut ImageNode>,
Option<&'static IconTone>,
Option<&'static IconSize>,
);
#[derive(Clone, Copy)]
struct Look<'a> {
skin: &'a GooeySkin,
fonts: &'a GooeyFonts,
focused: Option<Entity>,
editing: Option<Entity>,
}
fn look_of<'a>(
look: Look<'a>,
entity: Entity,
any_scope: bool,
scopes: &'a Query<&SkinScope>,
parents: &Query<&ChildOf>,
) -> Look<'a> {
if !any_scope {
return look;
}
let scope = std::iter::once(entity)
.chain(parents.iter_ancestors(entity))
.find_map(|e| scopes.get(e).ok());
Look {
skin: scope.map_or(look.skin, |scope| &scope.0),
..look
}
}
pub(crate) fn skin_of<'a>(
skin: &'a GooeySkin,
entity: Entity,
scopes: &'a Query<&SkinScope>,
parents: &Query<&ChildOf>,
) -> &'a GooeySkin {
std::iter::once(entity)
.chain(parents.iter_ancestors(entity))
.find_map(|e| scopes.get(e).ok())
.map_or(skin, |scope| &scope.0)
}
#[derive(Clone, Copy)]
struct State {
pressed: bool,
disabled: bool,
hovered: bool,
checked: bool,
selected: bool,
scrim: bool,
alpha: f32,
alert: bool,
grow: f32,
body_px: f32,
at_start: bool,
at_end: bool,
rows: f32,
capturing: bool,
focused: bool,
open: bool,
marked: bool,
band: bool,
candidate: bool,
refusing: bool,
problem: Option<FieldProblemKind>,
problem_room: f32,
status: Option<StatusKind>,
anchor: PromptsAnchor,
status_item: bool,
}
impl Default for State {
fn default() -> Self {
Self {
pressed: false,
disabled: false,
hovered: false,
checked: false,
selected: false,
scrim: false,
alpha: 1.0,
alert: false,
grow: 1.0,
body_px: 0.0,
at_start: false,
at_end: false,
rows: 0.0,
capturing: false,
focused: false,
open: false,
marked: false,
band: false,
candidate: false,
refusing: false,
problem: None,
problem_room: 0.0,
status: None,
status_item: false,
anchor: PromptsAnchor::default(),
}
}
}
type StateQuery = (
Has<Pressed>,
Option<&'static SliderDragState>,
Has<InteractionDisabled>,
Has<Checked>,
Has<Selected>,
Option<&'static Hovered>,
Option<&'static Toast>,
Option<(&'static ChoiceValue, &'static ChoiceCount)>,
Option<&'static ScrollbarDragState>,
Option<&'static ListView>,
Has<Capturing>,
Has<MenuOpen>,
(
Has<MenuMarked>,
Option<(&'static FieldProblem, &'static ProblemRoom)>,
Option<&'static StatusKind>,
Has<DropCandidate>,
Has<DropRefusing>,
Option<&'static PromptsAnchor>,
),
Option<&'static TableRow>,
Option<&'static Role>,
);
type Restyle = (
With<Role>,
Or<(
Changed<Role>,
Changed<Pressed>,
Changed<Hovered>,
Changed<InteractionDisabled>,
Added<Selected>,
Changed<Checked>,
Changed<SliderDragState>,
Changed<ScrollbarDragState>,
Changed<ListView>,
Changed<TooltipBox>,
Changed<Toast>,
Changed<ChoiceValue>,
Changed<ComputedUiRenderTargetInfo>,
Added<Capturing>,
Or<(
Added<MenuOpen>,
Added<PopoverPanel>,
Changed<PopoverSlide>,
Added<MenuMarked>,
Changed<TableRow>,
Changed<FieldProblem>,
Changed<ProblemRoom>,
Changed<IconTone>,
Changed<IconSize>,
Changed<StatusKind>,
Added<DropCandidate>,
Added<DropRefusing>,
Changed<PromptsAnchor>,
)>,
)>,
);
type Removals<'w, 's> = (
RemovedComponents<'w, 's, Pressed>,
RemovedComponents<'w, 's, InteractionDisabled>,
RemovedComponents<'w, 's, Checked>,
RemovedComponents<'w, 's, Selected>,
RemovedComponents<'w, 's, Capturing>,
RemovedComponents<'w, 's, MenuOpen>,
RemovedComponents<'w, 's, MenuMarked>,
RemovedComponents<'w, 's, SkinScope>,
RemovedComponents<'w, 's, ChildOf>,
RemovedComponents<'w, 's, FieldProblem>,
RemovedComponents<'w, 's, DropCandidate>,
RemovedComponents<'w, 's, DropRefusing>,
);
fn owns_parts(role: Option<&Role>) -> bool {
matches!(
role,
Some(
Role::Button
| Role::Toggle
| Role::Slider
| Role::Choice
| Role::Binding
| Role::Radial
| Role::Toast
| Role::TextInput
| Role::TextArea
| Role::PaletteField
| Role::NumberPart
| Role::MenuItem
| Role::PaletteRow
| Role::TableRow
| Role::SplitDivider
)
)
}
#[allow(clippy::too_many_arguments)]
pub fn apply_skin(
skin: Res<GooeySkin>,
fonts: Res<GooeyFonts>,
focus: Res<InputFocus>,
visible: Res<InputFocusVisible>,
changed: Query<Entity, Restyle>,
mut removed: Removals<'_, '_>,
scopes: Query<&SkinScope>,
changed_scopes: Query<(Entity, &SkinScope), Changed<SkinScope>>,
moved: Query<Entity, Changed<ChildOf>>,
states: Query<StateQuery>,
parents: Query<&ChildOf>,
children: Query<&Children>,
mut styled: Query<Styled>,
) {
if skin.is_changed() {
for problem in skin.problems() {
warn!("bevy_gooey: {problem}");
}
}
for (_, scope) in &changed_scopes {
for problem in scope.0.problems() {
warn!("bevy_gooey: scoped skin: {problem}");
}
}
let (
unpressed,
enabled,
unchecked,
unselected,
stopped,
closed,
unmarked,
unscoped,
orphaned,
unproblemed,
undropped,
unrefused,
) = &mut removed;
let any_scope = !scopes.is_empty();
let look = Look {
skin: &skin,
fonts: &fonts,
focused: visible.0.then(|| focus.get()).flatten(),
editing: focus.get(),
};
let state_of = |entity: Entity| {
states
.get(entity)
.map(
|(
pressed,
drag,
disabled,
checked,
selected,
hovered,
toast,
choice,
bar,
list,
capturing,
open,
(marked, problem, status, candidate, refusing, anchor),
row,
role,
)| {
State {
pressed: pressed
|| drag.is_some_and(|d| d.dragging)
|| bar.is_some_and(|d| d.dragging),
disabled,
hovered: hovered.is_some_and(|h| h.0),
checked,
selected: selected || (checked && role == Some(&Role::GroupOption)),
scrim: role == Some(&Role::Scrim),
alpha: toast.map_or(1.0, Toast::alpha),
alert: toast.is_some_and(|t| t.kind() == ToastKind::Alert),
grow: toast.map_or(1.0, Toast::grow),
body_px: toast.map_or(0.0, Toast::body_px),
at_start: choice.is_some_and(|(value, _)| value.0 == 0),
at_end: choice.is_some_and(|(value, count)| value.0 + 1 >= count.0),
rows: list.map_or(0.0, |list| list.rows),
capturing,
focused: look.focused == Some(entity)
|| row.is_some_and(|r| r.cursor && look.focused == Some(r.table)),
open,
marked,
candidate,
refusing,
problem: problem.map(|(problem, _)| problem.kind),
problem_room: problem.map_or(0.0, |(_, room)| room.0),
status: status.copied(),
anchor: anchor.copied().unwrap_or_default(),
status_item: role == Some(&Role::StatusItem),
band: row.is_some_and(|r| r.index % 2 == 1),
}
},
)
.unwrap_or_default()
};
let owner_of = |entity: Entity| {
parents
.iter_ancestors(entity)
.find(|&a| {
states.get(a).is_ok_and(|(.., role)| {
matches!(
role,
Some(
Role::Toggle
| Role::Slider
| Role::Choice
| Role::Binding
| Role::Radial
| Role::TextInput
| Role::TextArea
| Role::PaletteField
| Role::NumberPart
| Role::Toast
| Role::MenuItem
| Role::PaletteRow
| Role::TableRow
| Role::TableHeaderCell
| Role::TableEdge
| Role::SplitDivider
)
)
})
})
.map(state_of)
.unwrap_or_default()
};
if skin.is_changed() || fonts.is_changed() {
unpressed.clear();
enabled.clear();
unchecked.clear();
unselected.clear();
stopped.clear();
closed.clear();
unmarked.clear();
unproblemed.clear();
undropped.clear();
unrefused.clear();
unscoped.clear();
orphaned.clear();
for item in &mut styled {
let entity = item.0;
let parent = parents.get(entity).map(|p| p.parent()).ok();
restyle(
item,
&look_of(look, entity, any_scope, &scopes, &parents),
state_of(entity),
parent.map(state_of),
owner_of(entity),
);
}
return;
}
let mut dirty: Vec<Entity> = changed.iter().collect();
dirty.extend(unpressed.read());
dirty.extend(enabled.read());
dirty.extend(unchecked.read());
dirty.extend(unselected.read());
let mut regions: Vec<Entity> = changed_scopes.iter().map(|(e, _)| e).collect();
regions.extend(unscoped.read());
let orphans = orphaned.read();
if any_scope {
regions.extend(&moved);
regions.extend(orphans);
}
for region in regions {
dirty.push(region);
dirty.extend(children.iter_descendants(region));
}
dirty.extend(stopped.read());
dirty.extend(closed.read());
dirty.extend(unmarked.read());
dirty.extend(unproblemed.read());
dirty.extend(undropped.read());
dirty.extend(unrefused.read());
if focus.is_changed() || visible.is_changed() {
dirty.extend(
styled
.iter()
.filter(|item| {
matches!(
item.1,
Role::Button
| Role::Toggle
| Role::Slider
| Role::Choice
| Role::Binding
| Role::Radial
| Role::Slot
| Role::SlotEmpty
| Role::Tab
| Role::GroupOption
| Role::StatusItem
| Role::ToastAction
| Role::TextField
| Role::MenuItem
| Role::MenuTitle
| Role::TableRow
| Role::TableHeaderCell
| Role::SplitDivider
| Role::ColorSwatch
| Role::ColorArea
| Role::Viewport
)
})
.map(|item| item.0),
);
}
let texts: Vec<Entity> = dirty
.iter()
.filter_map(|&e| children.get(e).ok())
.flat_map(|c| c.iter())
.collect();
let parts: Vec<Entity> = dirty
.iter()
.filter(|&&e| states.get(e).is_ok_and(|(.., role)| owns_parts(role)))
.flat_map(|&e| children.iter_descendants(e))
.collect();
dirty.extend(texts);
dirty.extend(parts);
dirty.sort_unstable();
dirty.dedup();
for entity in dirty {
let Ok(item) = styled.get_mut(entity) else {
continue;
};
let parent = parents.get(entity).map(|p| p.parent()).ok();
restyle(
item,
&look_of(look, entity, any_scope, &scopes, &parents),
state_of(entity),
parent.map(state_of),
owner_of(entity),
);
}
}
fn problem_colour(skin: &GooeySkin, kind: Option<FieldProblemKind>) -> Color {
match kind {
Some(FieldProblemKind::Error) => skin.danger,
Some(FieldProblemKind::Warning) => skin.accent,
None => Color::NONE,
}
}
fn restyle(
(
entity,
role,
node,
background,
border,
text_color,
text_font,
floating,
target,
cursor,
image,
tone,
icon_size,
): bevy::ecs::query::QueryItem<'_, '_, Styled>,
look: &Look,
state: State,
parent: Option<State>,
owner: State,
) {
let skin = look.skin;
let scaled = |size: f32| FontSize::Px(size * skin.text_scale);
let tip = floating.as_ref().and_then(|(_, _, tip, _)| tip.copied());
let line = snap_line_width(skin.border, target.map_or(1.0, |t| t.scale_factor()));
let text_px = skin.text_size * skin.text_scale;
let wheel_px = target.map_or(text_px, |t| {
let size = t.logical_size();
text_px.min(RADIAL_WINDOW_SHARE * size.x.min(size.y) / RADIAL_DIAMETER_TEXT_SIZES)
});
let rail = skin.meter_height * skin.text_scale;
let thumb = Vec2::new(text_px * THUMB_TEXT_SIZES, rail * THUMB_RAIL_RATIO);
let switch = text_px * SWITCH_TEXT_SIZES;
let knob = (switch - 4.0 * line).max(0.0);
let chevron = text_px * CHEVRON_TEXT_SIZES;
let arrow = skin.target().max(chevron * CHEVRON_ARROW_FIT);
let in_dialog = parent.is_some_and(|p| p.scrim);
let problem_room = Val::Px(if state.problem.is_some() {
state.problem_room + line + skin.gap * 0.25
} else {
0.0
});
let fill = match role {
Role::Panel if in_dialog => Some(skin.solid_surface()),
Role::Panel => Some(skin.surface),
Role::Button | Role::TableHeaderCell => Some(if state.disabled {
skin.surface
} else if state.pressed {
skin.accent
} else if state.hovered || state.open {
skin.hovered()
} else {
skin.surface_raised
}),
Role::Radial => Some(skin.surface),
Role::RadialSlot => Some(if owner.disabled {
skin.surface
} else if state.selected {
skin.accent
} else {
skin.surface_raised
}),
Role::Slot => Some(if state.refusing {
skin.surface
} else if state.pressed {
skin.accent
} else if state.hovered {
skin.hovered()
} else {
skin.surface_raised
}),
Role::SlotEmpty => Some(if state.hovered {
skin.surface_raised
} else {
skin.surface
}),
Role::SlotCount => Some(skin.solid_surface()),
Role::CooldownText => Some(skin.cooldown_chip()),
Role::Tab | Role::GroupOption => Some(if state.disabled {
skin.surface
} else if state.selected || state.pressed {
skin.accent
} else if state.hovered {
skin.hovered()
} else {
skin.surface_raised
}),
Role::StatusItem => Some(if state.disabled {
Color::NONE
} else if state.pressed {
skin.accent
} else if state.hovered || state.focused {
skin.hovered()
} else {
Color::NONE
}),
Role::StatusMark => Some(match parent.and_then(|p| p.status) {
Some(StatusKind::Warning) => skin.accent,
Some(StatusKind::Error) => skin.danger,
_ => Color::NONE,
}),
Role::ToolbarSeparator => Some(skin.outline),
Role::Meter | Role::HoldTrack | Role::SliderRail => Some(skin.surface_raised),
Role::ColorSwatch => Some(skin.surface_raised),
Role::Tooltip | Role::Popover | Role::MenuPanel | Role::MenuBar | Role::Palette
| Role::Toolbar | Role::StatusBar => {
Some(skin.solid_surface())
}
Role::MenuTitle | Role::MenuItem => Some(if state.disabled {
Color::NONE
} else if state.pressed {
skin.accent
} else if state.hovered || state.focused || state.open {
skin.hovered()
} else {
Color::NONE
}),
Role::PaletteRow => Some(if state.disabled {
Color::NONE
} else if state.pressed {
skin.accent
} else if state.selected {
skin.hovered()
} else {
Color::NONE
}),
Role::MenuSeparator => Some(skin.outline),
Role::MenuDot => Some(menu_ink(owner, skin)),
Role::ToastAction => Some(faded(
if state.disabled {
skin.surface
} else if state.pressed {
skin.accent
} else if state.hovered {
skin.hovered()
} else {
skin.surface_raised
},
owner.alpha,
)),
Role::ToastBody => Some(faded(skin.solid_surface(), owner.alpha)),
Role::ToastMark => Some(faded(
if owner.alert {
skin.danger
} else {
skin.accent
},
owner.alpha,
)),
Role::MeterFill | Role::HoldFill => Some(skin.accent),
Role::MeterTrail => Some(skin.danger),
Role::FieldMark => Some(problem_colour(skin, owner.problem)),
Role::ListRail => Some(skin.surface_raised),
Role::TableRow => Some(if state.disabled {
Color::NONE
} else if state.selected {
skin.accent
} else if state.hovered {
skin.hovered()
} else if state.band {
skin.surface_raised.with_alpha(TABLE_BAND_ALPHA)
} else {
Color::NONE
}),
Role::SplitDivider => Some(if state.hovered || state.pressed {
skin.hovered()
} else {
skin.surface_raised
}),
Role::SplitGrip => Some(if owner.disabled {
skin.text_muted
} else if owner.pressed {
skin.accent
} else if owner.hovered || owner.focused {
skin.text
} else {
skin.text_muted
}),
Role::ListThumb => Some(if state.pressed {
skin.accent
} else if state.hovered {
skin.text
} else {
skin.text_muted
}),
Role::Scrim => Some(skin.scrim),
Role::Surface(level) => Some(skin.surface_at(*level)),
Role::Tone(name) => Some(skin.tone(name).unwrap_or(Color::NONE)),
Role::Hovered => Some(skin.hovered()),
Role::Pressed => Some(skin.pressed()),
Role::Held => Some(skin.held()),
Role::HeldGround => Some(skin.held_ground()),
Role::Unchecked => Some(skin.unchecked()),
Role::Card => Some(skin.card()),
Role::PreviewEdge => Some(Color::NONE),
Role::ToggleTrack => Some(if owner.disabled {
skin.surface
} else if owner.checked {
skin.accent
} else {
skin.surface_raised
}),
Role::ToggleKnob => Some(if owner.checked && !owner.disabled {
skin.accent_text
} else {
skin.text_muted
}),
Role::SliderFill => Some(if owner.disabled {
skin.text_muted
} else {
skin.accent
}),
Role::SliderThumb => Some(if owner.disabled {
skin.text_muted
} else if owner.pressed {
skin.focus_ring
} else {
skin.text
}),
Role::BindingCap => Some(if owner.disabled {
skin.surface
} else if owner.capturing {
skin.accent
} else if owner.hovered {
skin.hovered()
} else {
skin.surface_raised
}),
Role::Prompts => Some(skin.surface),
Role::PromptKey | Role::PromptButton => Some(skin.surface_raised),
Role::ShortcutBadge => Some(skin.text),
Role::ShortcutLegend => Some(skin.solid_surface()),
Role::DockDrop => Some(skin.focus_ring.with_alpha(DOCK_DROP_ALPHA)),
Role::DropMark => Some(skin.focus_ring.with_alpha(DROP_MARK_ALPHA)),
Role::DropMarkRefused => Some(skin.danger.with_alpha(DROP_MARK_ALPHA)),
Role::DockGhost | Role::DragGhost | Role::DragGhostRefused => Some(skin.solid_surface()),
Role::TextField => Some(skin.solid_surface()),
Role::Screen
| Role::Menu
| Role::Heading
| Role::Label
| Role::ButtonText
| Role::MenuTitleText
| Role::Toggle
| Role::Slider
| Role::Choice
| Role::ChoiceArrow
| Role::ChoiceBack
| Role::ChoiceForward
| Role::ChoiceCell
| Role::ChoiceOption
| Role::Binding
| Role::BindingText
| Role::BindingPrompt
| Role::Prompt
| Role::SliderHeader
| Role::SliderTrack
| Role::SliderGap
| Role::SliderReadout
| Role::ControlText
| Role::Toast
| Role::ToastStack
| Role::ToastText
| Role::ToastActionText
| Role::List
| Role::ListView
| Role::ListRows
| Role::Table
| Role::TableHeader
| Role::TableSortUp
| Role::TableSortDown
| Role::TableEdge
| Role::TableView
| Role::TableRows
| Role::TableCell
| Role::TableCellText
| Role::TreeTwisty
| Role::Section
| Role::SectionBody
| Role::SectionNote
| Role::Inspector
| Role::NotificationCenter
| Role::TreeClosed
| Role::TreeOpen
| Role::Split
| Role::SplitPane
| Role::Dock
| Role::DockBody
| Role::Tabs
| Role::TabList
| Role::TabText
| Role::SlotGrid
| Role::SlotName
| Role::SlotIcon
| Role::Cooldown
| Role::RadialIcon
| Role::RadialText
| Role::RadialCentre
| Role::RadialTrack
| Role::TabPages
| Role::TabPage
| Role::TextInput
| Role::TextArea
| Role::PaletteField
| Role::NumberPart
| Role::NumberParts
| Role::ColorSwatchHalf
| Role::ColorStripBox
| Role::ColorArea
| Role::Viewport
| Role::ColorAreaMarker
| Role::ColorAreaMarkerInner
| Role::ColorRail
| Role::TextCell
| Role::TextHint
| Role::MenuMark
| Role::MenuCheck
| Role::MenuText
| Role::MenuShortcut
| Role::MenuChevron
| Role::MenuNote
| Role::Icon
| Role::FieldMessage
| Role::Progress
| Role::ProgressLine
| Role::Spinner
| Role::ToggleGroup
| Role::StatusGroup
| Role::PaletteCount => None,
};
if let (Some(fill), Some(mut background)) = (fill, background) {
background.set_if_neq(BackgroundColor(fill));
}
let edge = match role {
Role::Panel if in_dialog => Some(BorderColor::all(skin.outline)),
Role::Popover | Role::MenuPanel | Role::Palette => Some(BorderColor::all(skin.outline)),
Role::DockDrop | Role::DockGhost | Role::DropMark | Role::DragGhost => {
Some(BorderColor::all(skin.focus_ring))
}
Role::DropMarkRefused | Role::DragGhostRefused => Some(BorderColor::all(skin.danger)),
Role::MenuBar | Role::Toolbar | Role::StatusBar => Some(BorderColor::all(
skin.surface_edge.unwrap_or(skin.surface_raised),
)),
Role::MenuTitle | Role::MenuItem if state.focused => {
Some(BorderColor::all(skin.focus_ring))
}
Role::MenuTitle | Role::MenuItem => Some(BorderColor::all(Color::NONE)),
Role::PaletteRow => Some(BorderColor::all(if state.selected {
skin.focus_ring
} else {
Color::NONE
})),
Role::MenuCheck | Role::MenuChevron => Some(BorderColor::all(menu_ink(owner, skin))),
Role::Panel | Role::Tooltip | Role::Prompts => Some(BorderColor::all(
skin.surface_edge.unwrap_or(skin.surface_raised),
)),
Role::ToastBody => Some(BorderColor::all(faded(skin.outline, owner.alpha))),
Role::Meter
| Role::HoldTrack
| Role::ToggleTrack
| Role::SliderRail
| Role::ColorRail
| Role::BindingCap
| Role::PromptKey
| Role::PromptButton
| Role::ShortcutLegend => Some(BorderColor::all(skin.outline)),
Role::ShortcutBadge => Some(BorderColor::all(skin.text)),
Role::PreviewEdge => Some(BorderColor::all(skin.preview_edge())),
Role::ToastAction => Some(BorderColor::all(if look.focused == Some(entity) {
faded(skin.focus_ring, owner.alpha)
} else {
Color::NONE
})),
Role::Button | Role::Tab | Role::GroupOption | Role::StatusItem | Role::TableHeaderCell
if look.focused == Some(entity) =>
{
Some(BorderColor::all(skin.focus_ring))
}
Role::Button | Role::Tab | Role::GroupOption | Role::StatusItem | Role::TableHeaderCell => {
Some(BorderColor::all(Color::NONE))
}
Role::TableRow => Some(BorderColor::all(if state.focused {
skin.focus_ring
} else {
Color::NONE
})),
Role::TableSortUp | Role::TableSortDown => Some(BorderColor::all(menu_ink(owner, skin))),
Role::TreeClosed | Role::TreeOpen => Some(BorderColor::all(
if owner.selected || parent.is_some_and(|p| p.pressed && !p.disabled) {
skin.accent_text
} else if parent.is_some_and(|p| p.disabled) {
skin.text_muted
} else {
skin.text
},
)),
Role::Slot | Role::SlotEmpty if look.focused == Some(entity) => {
Some(BorderColor::all(skin.focus_ring))
}
Role::Slot => Some(BorderColor::all(if state.refusing {
skin.surface_edge.unwrap_or(skin.surface_raised)
} else if state.candidate {
skin.outline
} else if state.pressed {
skin.accent
} else if state.hovered {
skin.hovered()
} else {
skin.surface_raised
})),
Role::SlotEmpty => Some(BorderColor::all(if state.candidate {
skin.outline
} else {
skin.surface_edge.unwrap_or(skin.surface_raised)
})),
Role::RadialTrack => Some(BorderColor::all(skin.surface_raised)),
Role::Radial => Some(BorderColor::all(if look.focused == Some(entity) {
skin.focus_ring
} else {
skin.surface_edge.unwrap_or(skin.surface_raised)
})),
Role::Toggle | Role::Slider | Role::Choice | Role::Binding => {
Some(BorderColor::all(if look.focused == Some(entity) {
skin.focus_ring
} else if (state.hovered || state.pressed) && !state.disabled {
skin.outline
} else {
Color::NONE
}))
}
Role::ChoiceBack | Role::ChoiceForward => {
let spent = if *role == Role::ChoiceBack {
owner.at_start
} else {
owner.at_end
};
Some(BorderColor::all(if owner.disabled || spent {
skin.text_muted
} else if parent.is_some_and(|p| p.hovered) {
skin.accent
} else {
skin.text
}))
}
Role::TextInput | Role::TextArea | Role::PaletteField | Role::NumberPart => {
Some(BorderColor::all(Color::NONE))
}
Role::TextField => Some(BorderColor::all(if state.disabled {
skin.outline
} else if look.editing == Some(entity) {
skin.focus_ring
} else if owner.problem == Some(FieldProblemKind::Error) {
skin.danger
} else if state.hovered {
skin.text_muted
} else {
skin.outline
})),
Role::Spinner => Some(BorderColor {
top: skin.accent,
right: skin.outline,
bottom: skin.outline,
left: skin.outline,
}),
Role::SliderThumb => Some(BorderColor::all(skin.solid_surface())),
Role::ColorSwatch | Role::ColorArea | Role::Viewport => {
Some(BorderColor::all(if look.focused == Some(entity) {
skin.focus_ring
} else {
skin.outline
}))
}
Role::ColorAreaMarker => Some(BorderColor::all(skin.solid_surface())),
Role::ColorAreaMarkerInner => Some(BorderColor::all(skin.text)),
Role::SplitDivider => Some(BorderColor::all(if look.focused == Some(entity) {
skin.focus_ring
} else {
Color::NONE
})),
_ => None,
};
if let (Some(edge), Some(mut border)) = (edge, border) {
border.set_if_neq(edge);
}
if let Some(mut node) = node {
let mut next = node.clone();
let radius = BorderRadius::all(Val::Px(skin.radius));
match role {
Role::Panel => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
let room = if in_dialog { DIALOG_ROOM_FACTOR } else { 1.0 };
next.padding = UiRect::all(Val::Px(skin.pad() * room));
next.row_gap = Val::Px(skin.gap * room);
if in_dialog {
next.min_width = Val::Px(text_px * DIALOG_MIN_TEXT_SIZES);
next.max_width = Val::Px(text_px * DIALOG_MAX_TEXT_SIZES);
}
}
Role::Radial => {
let diameter = wheel_px * RADIAL_DIAMETER_TEXT_SIZES;
next.width = Val::Px(diameter);
next.height = Val::Px(diameter);
next.border = UiRect::all(Val::Px(line));
next.border_radius = BorderRadius::MAX;
next.flex_shrink = 0.0;
}
Role::RadialTrack => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = BorderRadius::MAX;
}
Role::RadialSlot => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 1.5), Val::Px(skin.pad() * 0.6));
next.min_width = Val::Px(skin.target());
next.min_height = Val::Px(skin.target());
next.justify_content = JustifyContent::Center;
next.align_items = AlignItems::Center;
next.flex_shrink = 0.0;
}
Role::SlotGrid => {
next.justify_content = JustifyContent::Start;
next.align_content = AlignContent::Start;
next.column_gap = Val::Px(skin.gap * 0.5);
next.row_gap = Val::Px(skin.gap * 0.5);
}
Role::Slot | Role::SlotEmpty => {
let side = Val::Px(text_px * SLOT_TEXT_SIZES);
next.width = side;
next.height = side;
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::all(Val::Px(skin.gap * 0.25));
next.justify_content = JustifyContent::Center;
next.align_items = AlignItems::Center;
next.overflow = Overflow::clip();
next.flex_shrink = 0.0;
}
Role::SlotCount => {
next.right = Val::Px(line + 1.0);
next.bottom = Val::Px(line + 1.0);
next.padding = UiRect::horizontal(Val::Px(skin.gap * 0.5));
next.border_radius = BorderRadius::all(Val::Px(skin.radius * 0.5));
}
Role::Cooldown => {
next.position_type = PositionType::Absolute;
next.left = Val::Px(0.0);
next.top = Val::Px(0.0);
next.right = Val::Px(0.0);
next.bottom = Val::Px(0.0);
next.align_items = AlignItems::Center;
next.justify_content = JustifyContent::Center;
next.border_radius = radius;
}
Role::CooldownText => {
next.padding = UiRect::axes(Val::Px(skin.gap * 0.5), Val::Px(skin.gap * 0.25));
next.border_radius = BorderRadius::all(Val::Px(skin.radius * 0.5));
}
Role::SlotIcon => {
let side = Val::Px(text_px * SLOT_TEXT_SIZES * SLOT_ICON_SHARE);
next.width = side;
next.height = side;
next.flex_shrink = 0.0;
}
Role::RadialIcon => {
let side = Val::Px(wheel_px * RADIAL_ICON_TEXT_SIZES);
next.width = side;
next.height = side;
next.margin = UiRect::horizontal(Val::Px(-skin.pad() * 0.9));
next.flex_shrink = 0.0;
}
Role::Menu | Role::TabPage | Role::Tabs | Role::Inspector => {
next.row_gap = Val::Px(skin.gap)
}
Role::Section => next.row_gap = Val::Px(skin.gap * 0.5),
Role::SectionBody => {
next.row_gap = Val::Px(skin.gap);
next.padding = UiRect::left(Val::Px(skin.pad() * 1.5));
}
Role::SectionNote => next.padding = UiRect::left(Val::Px(skin.pad() * 1.5)),
Role::TabList => {
next.column_gap = Val::Px(skin.gap);
next.row_gap = Val::Px(skin.gap);
}
Role::Button
| Role::Tab
| Role::GroupOption
| Role::TableHeaderCell
| Role::ToastAction => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 1.5), Val::Px(skin.pad() * 0.6));
next.min_width = if *role == Role::Tab {
Val::Auto
} else {
Val::Px(skin.target())
};
if *role == Role::Button {
next.column_gap = Val::Px(skin.gap * 0.5);
}
next.min_height = Val::Px(skin.target());
if *role == Role::ToastAction {
next.flex_shrink = 0.0;
next.align_self = AlignSelf::Center;
next.margin = UiRect::horizontal(Val::Px(skin.gap * 0.5));
}
}
Role::Toolbar | Role::StatusBar => {
next.border = if *role == Role::Toolbar {
UiRect::bottom(Val::Px(line))
} else {
UiRect::top(Val::Px(line))
};
next.padding = UiRect::axes(Val::Px(skin.pad() * 0.5), Val::Px(skin.pad() * 0.3));
next.column_gap = Val::Px(skin.gap * 0.5);
next.align_items = AlignItems::Center;
next.flex_shrink = 0.0;
}
Role::StatusGroup | Role::ToggleGroup => {
next.column_gap = Val::Px(
skin.gap
* if *role == Role::ToggleGroup {
0.25
} else {
0.5
},
);
next.align_items = AlignItems::Center;
}
Role::ToolbarSeparator => {
next.width = Val::Px(line);
next.align_self = AlignSelf::Stretch;
next.margin = UiRect::axes(Val::Px(skin.gap * 0.25), Val::Px(skin.pad() * 0.3));
next.flex_shrink = 0.0;
}
Role::StatusItem => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad()), Val::Px(skin.pad() * 0.3));
next.column_gap = Val::Px(skin.gap * 0.5);
next.align_items = AlignItems::Center;
next.justify_content = JustifyContent::Center;
next.min_height = Val::Px(skin.target());
}
Role::StatusMark => {
next.width = Val::Px(line * 2.0);
next.align_self = AlignSelf::Stretch;
next.flex_shrink = 0.0;
next.border_radius = radius;
let shown = parent.is_some_and(|p| p.status.is_some_and(|k| k != StatusKind::Info));
next.display = if shown { Display::Flex } else { Display::None };
}
Role::Meter => {
next.height = Val::Px(skin.meter_height * skin.text_scale);
next.min_width = Val::Px(skin.text_size * skin.text_scale * METER_MIN_TEXT_SIZES);
next.border = UiRect::all(Val::Px(line));
next.padding = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::HoldTrack => {
next.height = Val::Px(skin.meter_height * skin.text_scale);
next.border = UiRect::all(Val::Px(line));
next.padding = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.align_self = AlignSelf::Stretch;
next.flex_shrink = 0.0;
next.margin = UiRect::top(Val::Px(skin.gap * 0.5));
}
Role::MeterFill | Role::MeterTrail | Role::HoldFill | Role::SliderFill => {
next.border_radius =
BorderRadius::all(Val::Px((skin.radius - 2.0 * line).max(0.0)));
}
Role::ToastStack => {
let edge = skin.pad() * 2.0;
next.padding =
UiRect::axes(Val::Px(edge), Val::Px((edge - skin.gap / 2.0).max(0.0)));
}
Role::Toast => {
next.max_height = if state.grow >= 1.0 {
Val::Auto
} else {
Val::Px((state.body_px + skin.gap) * state.grow)
};
}
Role::ToastBody => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.max_width = Val::Px(text_px * TOAST_MEASURE_TEXT_SIZES);
next.margin = UiRect::vertical(Val::Px(skin.gap / 2.0));
next.flex_shrink = 0.0;
}
Role::ToastMark => {
let inner = Val::Px((skin.radius - line).max(0.0));
let marks = if owner.alert { 2.0 } else { 1.0 };
next.width = Val::Px(line * TOAST_MARK_LINES * marks);
next.flex_shrink = 0.0;
next.border_radius = BorderRadius {
top_left: inner.into(),
bottom_left: inner.into(),
top_right: Val::ZERO.into(),
bottom_right: Val::ZERO.into(),
};
}
Role::ToastText => {
next.padding = UiRect::axes(Val::Px(skin.pad()), Val::Px(skin.pad() * 0.5));
next.flex_shrink = 1.0;
}
Role::Popover => {
next.display = Display::Flex;
next.flex_direction = FlexDirection::Column;
next.align_items = AlignItems::Stretch;
if let Some(window) = target {
next.max_width = Val::Px((window.logical_size().x - 2.0 * skin.gap).max(0.0));
}
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::all(Val::Px(skin.pad()));
next.row_gap = Val::Px(skin.gap);
}
Role::Tooltip => {
next.padding = UiRect::axes(Val::Px(skin.pad()), Val::Px(skin.pad() * 0.5));
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.max_width = Val::Px(tooltip_width(skin, tip.unwrap_or_default(), line));
next.align_items = AlignItems::Center;
next.column_gap = Val::Px(skin.gap);
}
Role::Toggle => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 1.5), Val::Px(skin.pad() * 0.6));
next.min_height = Val::Px(skin.target());
next.column_gap = Val::Px(skin.gap);
}
Role::ToggleTrack => {
next.width = Val::Px(switch * 2.0);
next.height = Val::Px(switch);
next.flex_shrink = 0.0;
next.border = UiRect::all(Val::Px(line));
next.padding = UiRect::all(Val::Px(line));
next.border_radius = BorderRadius::all(Val::Px(switch / 2.0));
next.justify_content = if owner.checked {
JustifyContent::FlexEnd
} else {
JustifyContent::FlexStart
};
}
Role::ToggleKnob => {
next.width = Val::Px(knob);
next.height = Val::Px(knob);
next.flex_shrink = 0.0;
next.border_radius = BorderRadius::all(Val::Px(knob / 2.0));
}
Role::Slider => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(0.0), Val::Px(skin.pad() * 0.6));
next.min_width = Val::Px(text_px * SLIDER_MIN_TEXT_SIZES);
next.row_gap = Val::Px(skin.gap * 0.5);
}
Role::SliderHeader => next.padding = UiRect::horizontal(Val::Px(skin.pad() * 1.5)),
Role::SliderTrack => next.height = Val::Px(thumb.y),
Role::SliderRail | Role::ColorRail => {
next.left = Val::Px(thumb.x / 2.0);
next.right = Val::Px(thumb.x / 2.0);
next.top = Val::Px((thumb.y - rail) / 2.0);
next.height = Val::Px(rail);
next.border = UiRect::all(Val::Px(line));
next.padding = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::SliderThumb => {
next.width = Val::Px(thumb.x);
next.height = Val::Px(thumb.y);
next.flex_shrink = 0.0;
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::Choice => {
let ink = chevron * std::f32::consts::FRAC_1_SQRT_2;
let room = skin.pad() * 1.5;
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect {
left: Val::Px(room),
right: Val::Px((room - (arrow - ink) / 2.0).max(0.0)),
top: Val::ZERO,
bottom: Val::ZERO,
};
next.min_height = Val::Px(skin.target());
next.column_gap = Val::Px(skin.gap);
}
Role::ChoiceArrow => {
next.min_width = Val::Px(arrow);
next.min_height = Val::Px((switch + skin.pad() * 1.2).max(skin.target()));
next.flex_shrink = 0.0;
}
Role::ChoiceBack | Role::ChoiceForward => {
next.width = Val::Px(chevron);
next.height = Val::Px(chevron);
next.flex_shrink = 0.0;
let line = Val::Px(line);
next.border = if *role == Role::ChoiceForward {
UiRect {
top: line,
right: line,
..UiRect::ZERO
}
} else {
UiRect {
bottom: line,
left: line,
..UiRect::ZERO
}
};
}
Role::ChoiceCell => {
next.padding = UiRect::horizontal(Val::Px(skin.gap / 2.0));
next.min_width = Val::Px(text_px * CHOICE_MIN_TEXT_SIZES);
next.flex_shrink = 0.0;
}
Role::Binding => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 1.5), Val::Px(skin.pad() * 0.3));
next.min_height = Val::Px(skin.target());
next.column_gap = Val::Px(skin.gap);
}
Role::BindingCap => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 0.5), Val::Px(skin.pad() * 0.3));
next.min_width = Val::Px((text_px * BINDING_CAP_TEXT_SIZES).max(skin.target()));
next.flex_shrink = 0.0;
}
Role::TextInput | Role::TextArea | Role::PaletteField => {
next.margin = UiRect::bottom(problem_room);
let palette = *role == Role::PaletteField;
next.border = if palette {
UiRect::ZERO
} else {
UiRect::all(Val::Px(line))
};
next.border_radius = radius;
next.padding = UiRect::axes(
Val::Px(if palette { 0.0 } else { skin.pad() * 1.5 }),
Val::Px(skin.pad() * 0.1),
);
next.min_height = Val::Px(skin.target());
next.column_gap = Val::Px(skin.gap);
next.row_gap = Val::Px(if *role == Role::TextArea {
skin.gap * 0.5
} else {
0.0
});
}
Role::NumberPart => {
next.margin = UiRect::bottom(problem_room);
next.border = UiRect::ZERO;
next.padding = UiRect {
left: Val::Px(skin.pad()),
right: Val::ZERO,
top: Val::ZERO,
bottom: Val::ZERO,
};
next.min_height = Val::Px(skin.target());
next.column_gap = Val::Px(skin.gap * 0.5);
}
Role::NumberParts => next.column_gap = Val::Px(skin.gap),
Role::ColorStripBox => next.row_gap = Val::Px(skin.gap * 0.5),
Role::ColorSwatch => {
next.width = Val::Px(skin.target());
next.height = Val::Px(skin.target());
next.flex_shrink = 0.0;
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.overflow = Overflow::clip();
}
Role::Viewport => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::ColorArea => {
next.min_height = Val::Px(COLOR_AREA_HEIGHT_PX);
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::ColorAreaMarker => {
let size = Val::Px(text_px * COLOR_MARKER_TEXT_SIZES);
next.width = size;
next.height = size;
next.border = UiRect::all(Val::Px(line));
next.border_radius = BorderRadius::MAX;
}
Role::ColorAreaMarkerInner => {
next.width = Val::Percent(100.0);
next.height = Val::Percent(100.0);
next.border = UiRect::all(Val::Px(line));
next.border_radius = BorderRadius::MAX;
}
Role::TextField => {
let (x, y) = text_field_inset(skin, text_px, line);
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(x), Val::Px(y));
}
Role::TextHint => {
let (x, y) = text_field_inset(skin, text_px, line);
next.left = Val::Px(line + x);
next.right = Val::Px(line + x);
next.top = Val::Px(line + y);
}
Role::Prompts => {
let edge = Val::Percent(SAFE_EDGE_PERCENT);
let anchor = state.anchor;
next.left = if anchor.is_centred() {
Val::Percent(50.0)
} else if anchor.at_right() {
Val::Auto
} else {
edge
};
next.right = if anchor.at_right() { edge } else { Val::Auto };
next.bottom = if anchor.at_bottom() { edge } else { Val::Auto };
next.top = if anchor.at_bottom() { Val::Auto } else { edge };
next.max_width = Val::Percent(PROMPTS_MAX_WIDTH_PERCENT);
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad()), Val::Px(skin.pad() * 0.3));
next.column_gap = Val::Px(skin.pad() * PROMPT_SPACING_PADDINGS);
}
Role::Prompt => next.column_gap = Val::Px(skin.gap),
Role::PromptKey | Role::PromptButton => {
let cap = text_px * LIST_LINE_TEXT_SIZES + skin.pad() * 0.6 + 2.0 * line;
next.border = UiRect::all(Val::Px(line));
next.border_radius = if *role == Role::PromptButton {
BorderRadius::MAX
} else {
radius
};
next.padding = UiRect::axes(Val::Px(skin.pad() * 0.5), Val::Px(skin.pad() * 0.3));
next.min_width = Val::Px(cap);
next.flex_shrink = 0.0;
}
Role::ShortcutBadge => {
let hair = snap_line_width(1.0, target.map_or(1.0, |t| t.scale_factor()));
next.position_type = PositionType::Absolute;
next.right = Val::Px(skin.pad() * 0.5);
next.align_self = AlignSelf::Center;
next.border = UiRect::all(Val::Px(hair));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 0.3), Val::Px(skin.pad() * 0.1));
next.flex_shrink = 0.0;
}
Role::ShortcutLegend => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::all(Val::Px(skin.pad() * 1.5));
next.column_gap = Val::Px(skin.pad() * PROMPT_SPACING_PADDINGS);
next.row_gap = Val::Px(skin.gap);
next.max_width = Val::Percent(PROMPTS_MAX_WIDTH_PERCENT);
}
Role::ListView | Role::TableView => {
let (row, gap) = if *role == Role::TableView {
(skin.table_pitch(), 0.0)
} else {
let row = (text_px * LIST_LINE_TEXT_SIZES
+ skin.pad() * LIST_PADDING_FACTOR
+ 2.0 * line)
.max(skin.target());
(row, skin.gap)
};
next.max_height = Val::Px(list_height(state.rows, row, gap));
next.padding = UiRect::right(Val::Px(RAIL_LINES * line + skin.gap));
}
Role::ListRows => next.row_gap = Val::Px(skin.gap),
Role::TableHeader => {
next.padding = UiRect::right(Val::Px(RAIL_LINES * line + skin.gap));
}
Role::TableRow => {
next.height = Val::Px(skin.table_pitch());
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::TableCell => {
next.padding = UiRect::axes(Val::Px(skin.pad() * 1.5), Val::ZERO);
}
Role::TableSortUp | Role::TableSortDown => {
let size = chevron * TABLE_SORT_MARK_SCALE;
next.width = Val::Px(size);
next.height = Val::Px(size);
next.margin = UiRect::left(Val::Px(skin.gap));
next.flex_shrink = 0.0;
let line = Val::Px(line);
next.border = if *role == Role::TableSortUp {
UiRect {
top: line,
left: line,
..UiRect::ZERO
}
} else {
UiRect {
bottom: line,
right: line,
..UiRect::ZERO
}
};
}
Role::TreeTwisty => {
next.width = Val::Px(tree_indent(text_px));
next.flex_shrink = 0.0;
}
Role::TreeClosed | Role::TreeOpen => {
let size = chevron;
next.width = Val::Px(size);
next.height = Val::Px(size);
next.flex_shrink = 0.0;
next.margin = UiRect::horizontal(Val::Px(size * 0.5));
let line = Val::Px(line);
next.border = if *role == Role::TreeClosed {
UiRect {
top: line,
right: line,
..UiRect::ZERO
}
} else {
UiRect {
bottom: line,
right: line,
..UiRect::ZERO
}
};
}
Role::SplitDivider => {
next.min_width = Val::Px(MIN_TARGET_PX);
next.min_height = Val::Px(MIN_TARGET_PX);
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::SplitGrip => next.border_radius = BorderRadius::MAX,
Role::DockDrop | Role::DropMark | Role::DropMarkRefused => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
}
Role::DockGhost | Role::DragGhost | Role::DragGhostRefused => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 0.8), Val::Px(skin.pad() * 0.4));
}
Role::ListRail => {
let width = RAIL_LINES * line;
next.width = Val::Px(width);
next.border_radius = BorderRadius::all(Val::Px(width / 2.0));
}
Role::MenuBar => {
next.border = UiRect::bottom(Val::Px(line));
next.padding = UiRect::axes(Val::Px(skin.pad() * 0.5), Val::Px(skin.pad() * 0.3));
next.column_gap = Val::Px(skin.gap * 0.5);
next.align_items = AlignItems::Center;
}
Role::MenuTitle => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad() * 1.2), Val::Px(skin.pad() * 0.6));
next.min_height = Val::Px(skin.target());
}
Role::MenuPanel => {
next.flex_direction = FlexDirection::Column;
next.align_items = AlignItems::Stretch;
if let Some(window) = target {
next.max_width = Val::Px((window.logical_size().x - 2.0 * skin.gap).max(0.0));
}
next.min_width = Val::Px(text_px * MENU_MIN_TEXT_SIZES);
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::all(Val::Px(skin.pad() * 0.5));
next.row_gap = Val::Px(skin.gap * 0.25);
}
Role::Palette => {
next.flex_direction = FlexDirection::Column;
next.align_items = AlignItems::Stretch;
next.flex_shrink = 0.0;
next.width = Val::Px(text_px * PALETTE_TEXT_SIZES);
next.max_width = Val::Percent(100.0);
next.margin = UiRect::top(Val::Vh(PALETTE_TOP_VH));
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::all(Val::Px(skin.pad() * 0.5));
next.row_gap = Val::Px(skin.gap * 0.5);
}
Role::PaletteCount => {
next.align_self = AlignSelf::FlexEnd;
next.padding = UiRect::axes(Val::Px(skin.pad()), Val::Px(skin.pad() * 0.2));
}
Role::MenuItem | Role::MenuNote | Role::PaletteRow => {
next.border = UiRect::all(Val::Px(line));
next.border_radius = radius;
next.padding = UiRect::axes(Val::Px(skin.pad()), Val::Px(skin.pad() * 0.4));
next.min_height = Val::Px(skin.target());
next.column_gap = Val::Px(skin.gap);
next.align_items = AlignItems::Center;
}
Role::MenuMark => {
next.display = if owner.marked {
Display::Flex
} else {
Display::None
};
next.width = Val::Px(text_px * MENU_MARK_TEXT_SIZES);
next.height = Val::Px(text_px * MENU_MARK_TEXT_SIZES);
next.flex_shrink = 0.0;
next.justify_content = JustifyContent::Center;
next.align_items = AlignItems::Center;
}
Role::MenuCheck => {
next.display = if owner.checked {
Display::Flex
} else {
Display::None
};
next.width = Val::Px(text_px * 0.4);
next.height = Val::Px(text_px * 0.7);
next.border = UiRect {
right: Val::Px(line),
bottom: Val::Px(line),
..UiRect::ZERO
};
next.flex_shrink = 0.0;
}
Role::MenuDot => {
next.display = if owner.checked {
Display::Flex
} else {
Display::None
};
next.width = Val::Px(text_px * 0.5);
next.height = Val::Px(text_px * 0.5);
next.border_radius = BorderRadius::MAX;
next.flex_shrink = 0.0;
}
Role::MenuText => next.flex_grow = 1.0,
Role::Icon => {
let side = Val::Px(skin.type_size(icon_size.map_or(2, |size| size.0)));
next.width = side;
next.height = side;
next.flex_shrink = 0.0;
}
Role::FieldMessage => {
next.padding = UiRect::horizontal(Val::Px(skin.pad() * 1.5));
next.column_gap = Val::Px(skin.gap * 0.5);
next.margin = UiRect::top(Val::Px(line + skin.gap * 0.25));
}
Role::FieldMark => {
next.width = Val::Px(line * 2.0);
next.flex_shrink = 0.0;
next.border_radius = radius;
}
Role::Progress => {
next.row_gap = Val::Px(skin.gap * 0.5);
next.min_width = Val::Px(
text_px
* if parent.is_some_and(|p| p.status_item) {
STATUS_PROGRESS_TEXT_SIZES
} else {
PROGRESS_MIN_TEXT_SIZES
},
);
}
Role::Spinner => {
let side = Val::Px(text_px * SPINNER_TEXT_SIZES);
next.width = side;
next.height = side;
next.border = UiRect::all(Val::Px(line));
next.border_radius = BorderRadius::MAX;
next.flex_shrink = 0.0;
}
Role::MenuShortcut => {
next.margin = UiRect::left(Val::Px(skin.gap + text_px * MENU_CHORD_TEXT_SIZES));
next.flex_shrink = 0.0;
}
Role::MenuChevron => {
next.width = Val::Px(chevron);
next.height = Val::Px(chevron);
next.border = UiRect {
top: Val::Px(line),
right: Val::Px(line),
..UiRect::ZERO
};
next.flex_shrink = 0.0;
}
Role::MenuSeparator => {
next.height = Val::Px(line);
next.margin = UiRect::axes(Val::Px(skin.pad() * 0.5), Val::Px(skin.gap * 0.5));
next.flex_shrink = 0.0;
}
Role::Scrim => next.padding = UiRect::all(Val::Percent(SAFE_EDGE_PERCENT)),
_ => {}
}
node.set_if_neq(next);
}
if let (Role::Icon | Role::SlotIcon | Role::RadialIcon, Some(mut image)) = (role, image) {
let tone = tone.copied().unwrap_or_default();
let parent = parent.unwrap_or_default();
let disabled = parent.disabled || owner.disabled || parent.refusing;
let color = match tone {
IconTone::Text if disabled => skin.text_muted,
IconTone::Text if parent.pressed || parent.selected || owner.pressed => {
skin.accent_text
}
IconTone::Text => skin.text,
IconTone::Muted => skin.text_muted,
IconTone::Accent => skin.accent,
IconTone::Danger => skin.danger,
};
if image.color != color {
image.color = color;
}
}
let text = match role {
Role::Heading => Some((skin.text, &look.fonts.heading, skin.heading_size)),
Role::Label | Role::ProgressLine => Some((skin.text, &look.fonts.body, skin.text_size)),
Role::SectionNote => Some((skin.text_muted, &look.fonts.body, skin.text_size)),
Role::ToastActionText => {
let parent = parent.unwrap_or_default();
let color = if parent.pressed {
skin.accent_text
} else {
skin.text
};
Some((faded(color, owner.alpha), &look.fonts.body, skin.text_size))
}
Role::ButtonText | Role::MenuTitleText => {
let parent = parent.unwrap_or_default();
let color = if parent.disabled {
skin.text_muted
} else if parent.pressed {
skin.accent_text
} else {
skin.text
};
Some((color, &look.fonts.body, skin.text_size))
}
Role::RadialText => {
let parent = parent.unwrap_or_default();
let color = if owner.disabled {
skin.text_muted
} else if parent.selected {
skin.accent_text
} else {
skin.text
};
Some((color, &look.fonts.body, wheel_px / skin.text_scale))
}
Role::RadialCentre => Some((
if owner.disabled {
skin.text_muted
} else {
skin.text
},
&look.fonts.heading,
skin.heading_size * wheel_px / text_px,
)),
Role::SlotName => {
let parent = parent.unwrap_or_default();
let color = if parent.pressed {
skin.accent_text
} else if parent.refusing {
skin.text_muted
} else {
skin.text
};
Some((color, &look.fonts.body, skin.type_scale[1]))
}
Role::SlotCount => Some((skin.text, &look.fonts.body, skin.type_scale[2])),
Role::CooldownText => Some((skin.text, &look.fonts.body, skin.type_scale[1])),
Role::TabText => {
let parent = parent.unwrap_or_default();
let color = if parent.disabled {
skin.text_muted
} else if parent.selected || parent.pressed {
skin.accent_text
} else {
skin.text
};
Some((color, &look.fonts.body, skin.text_size))
}
Role::TableCellText => {
let color = if owner.selected {
skin.accent_text
} else {
skin.text
};
Some((color, &look.fonts.body, skin.text_size))
}
Role::ControlText | Role::SliderReadout | Role::ChoiceOption | Role::BindingText => {
let color = if owner.disabled {
skin.text_muted
} else {
skin.text
};
Some((color, &look.fonts.body, skin.text_size))
}
Role::TextField => {
let color = if state.disabled || owner.disabled {
skin.text_muted
} else {
skin.text
};
Some((color, &look.fonts.body, skin.text_size))
}
Role::TextHint => Some((skin.text_muted, &look.fonts.body, skin.text_size)),
Role::MenuText => Some((menu_ink(owner, skin), &look.fonts.body, skin.text_size)),
Role::MenuShortcut => {
let color = if owner.disabled {
skin.text_muted
} else if owner.pressed {
skin.accent_text
} else if owner.hovered || owner.focused || owner.open || owner.selected {
skin.text
} else {
skin.text_muted
};
Some((color, &look.fonts.body, skin.text_size))
}
Role::MenuNote | Role::PaletteCount => {
Some((skin.text_muted, &look.fonts.body, skin.text_size))
}
Role::BindingPrompt => Some((skin.accent_text, &look.fonts.body, skin.text_size)),
Role::ShortcutBadge => Some((skin.solid_surface(), &look.fonts.body, skin.type_scale[0])),
Role::PromptKey
| Role::PromptButton
| Role::DockGhost
| Role::DragGhost
| Role::DragGhostRefused => Some((skin.text, &look.fonts.body, skin.text_size)),
Role::ToastText => Some((
faded(skin.text, owner.alpha),
&look.fonts.body,
skin.text_size,
)),
_ => None,
};
if let Some((color, font, size)) = text {
if let Some(mut text_color) = text_color {
text_color.set_if_neq(TextColor(color));
}
if let Some(mut text_font) = text_font {
let mut next = text_font.clone();
next.font = font.clone();
next.font_size = scaled(size);
text_font.set_if_neq(next);
}
}
if let (Role::TextField, Some(mut caret)) = (role, cursor) {
caret.set_if_neq(TextCursorStyle {
color: skin.text,
selection_color: skin.accent,
unfocused_selection_color: Color::NONE,
selected_text_color: (look.editing == Some(entity)).then_some(skin.accent_text),
selection_radius: 0.0,
});
}
if let Some((mut popover, mut shadow, tip, panel)) = floating {
let next = if let Some(tip) = tip {
let gap = skin.gap + skin.pad() + line * 2.0;
tooltip_placement(gap, skin.gap, tip.in_row)
} else if let Some(panel) = panel {
let (panel, slide) = panel;
let reach = slide.map_or(0.0, PopoverSlide::reach);
placement_of(panel.placement, skin.gap * 0.5 + line - reach, skin.gap)
} else {
return;
};
if *popover != next {
*popover = next;
}
shadow.set_if_neq(BoxShadow::new(
skin.background.with_alpha(0.7),
Val::Px(0.0),
Val::Px(skin.border * 2.0),
Val::Px(0.0),
Val::Px(skin.pad()),
));
}
}
fn menu_ink(row: State, skin: &GooeySkin) -> Color {
if row.disabled {
skin.text_muted
} else if row.pressed {
skin.accent_text
} else {
skin.text
}
}
fn faded(color: Color, opacity: f32) -> Color {
if opacity >= 1.0 {
return color;
}
let c = Srgba::from(color);
Color::from(Srgba {
alpha: c.alpha * opacity.max(0.0),
..c
})
}
fn tooltip_width(skin: &GooeySkin, tip: TooltipBox, line: f32) -> f32 {
let size = skin.text_size * skin.text_scale;
let measure = size * 24.0;
let width = if tip.in_row {
measure
} else {
let fit = tip.room - (skin.gap + skin.pad() + line) - skin.gap;
if fit >= size * 8.0 {
measure.min(fit)
} else {
measure
}
};
if tip.cap > 0.0 {
width.max(tip.cap + skin.gap + size * 6.0 + 2.0 * (skin.pad() + line))
} else {
width
}
}
fn tooltip_placement(gap: f32, margin: f32, in_row: bool) -> Popover {
use PopoverSide::{Bottom, Left, Right, Top};
let sides = if in_row {
[Bottom, Top, Right, Left]
} else {
[Right, Left, Bottom, Top]
};
let aligns: &[PopoverAlign] = if in_row {
&[PopoverAlign::Center, PopoverAlign::Start, PopoverAlign::End]
} else {
&[PopoverAlign::Center]
};
Popover {
positions: sides
.into_iter()
.flat_map(|side| {
aligns
.iter()
.map(move |&align| PopoverPlacement { side, align, gap })
})
.collect(),
window_margin: margin,
}
}
fn text_field_inset(skin: &GooeySkin, text_px: f32, line: f32) -> (f32, f32) {
let x = skin.pad() * 0.6;
let centred = (skin.target() - 2.0 * line - text_px * LIST_LINE_TEXT_SIZES) / 2.0;
(x, centred.max(skin.pad() * 0.3))
}