use bevy::{
color::Alpha,
prelude::{Color, Resource},
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const OPAQUE_ALPHA_PERMILLE: u16 = 1_000;
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TransparencyMode {
#[default]
Opaque,
Transparent,
}
impl TransparencyMode {
#[must_use]
pub const fn is_window_transparent(self) -> bool {
matches!(self, Self::Transparent)
}
}
#[derive(Clone, Copy, Debug, Eq, JsonSchema, PartialEq)]
pub struct BoundedAlpha(u16);
impl BoundedAlpha {
pub const fn try_new(permille: u16) -> Result<Self, StylePolicyError> {
if permille > OPAQUE_ALPHA_PERMILLE {
return Err(StylePolicyError::AlphaOutOfRange { permille });
}
Ok(Self(permille))
}
#[must_use]
pub const fn opaque() -> Self {
Self(OPAQUE_ALPHA_PERMILLE)
}
#[must_use]
pub const fn as_permille(self) -> u16 {
self.0
}
#[must_use]
pub fn as_f32(self) -> f32 {
f32::from(self.0) / f32::from(OPAQUE_ALPHA_PERMILLE)
}
}
impl Default for BoundedAlpha {
fn default() -> Self {
Self::opaque()
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SurfaceRole {
EditorText,
EditorBackground,
Cursor,
CursorText,
Selection,
SearchMatch,
StatusBar,
StatusText,
StatusErrorText,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SurfaceStyle {
pub tint: Color,
pub alpha: BoundedAlpha,
pub blur_px: u16,
}
impl SurfaceStyle {
#[must_use]
pub const fn opaque(tint: Color) -> Self {
Self {
tint,
alpha: BoundedAlpha::opaque(),
blur_px: 0,
}
}
#[must_use]
pub fn color(self) -> Color {
self.tint.with_alpha(self.alpha.as_f32())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StylePolicy {
pub minimum_alpha: BoundedAlpha,
pub maximum_blur_px: u16,
}
impl Default for StylePolicy {
fn default() -> Self {
Self {
minimum_alpha: BoundedAlpha(250),
maximum_blur_px: 48,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SurfaceStyleProposal {
pub role: SurfaceRole,
pub style: SurfaceStyle,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum StylePolicyError {
AlphaOutOfRange {
permille: u16,
},
AlphaBelowMinimum {
permille: u16,
minimum: u16,
},
BlurTooLarge {
blur_px: u16,
maximum: u16,
},
}
#[derive(Clone, Debug, PartialEq, Resource)]
pub struct ResolvedTheme {
pub transparency_mode: TransparencyMode,
pub editor_text: SurfaceStyle,
pub editor_background: SurfaceStyle,
pub cursor: SurfaceStyle,
pub cursor_text: SurfaceStyle,
pub selection: SurfaceStyle,
pub search_match: SurfaceStyle,
pub status_bar: SurfaceStyle,
pub status_text: SurfaceStyle,
pub status_error_text: SurfaceStyle,
}
impl ResolvedTheme {
#[must_use]
pub const fn default_opaque() -> Self {
Self {
transparency_mode: TransparencyMode::Opaque,
editor_text: SurfaceStyle::opaque(Color::srgb(0.610_009_9, 0.759_212_6, 0.763_073_15)),
editor_background: SurfaceStyle::opaque(Color::srgb_u8(0x00, 0x1E, 0x27)),
cursor: SurfaceStyle::opaque(Color::srgb(0.954_751_13, 0.293_346_46, 0.0)),
cursor_text: SurfaceStyle::opaque(Color::srgb(0.0, 0.155_759_26, 0.193_701_39)),
selection: SurfaceStyle::opaque(Color::srgb(0.049_706_57, 0.355_832_28, 0.392_785_07)),
search_match: SurfaceStyle::opaque(Color::srgb(0.38, 0.30, 0.04)),
status_bar: SurfaceStyle::opaque(Color::srgb(0.0, 0.12, 0.15)),
status_text: SurfaceStyle::opaque(Color::srgb_u8(0xD8, 0xF3, 0xF8)),
status_error_text: SurfaceStyle::opaque(Color::srgb_u8(0xff, 0x66, 0x66)),
}
}
#[must_use]
pub const fn surface(&self, role: SurfaceRole) -> SurfaceStyle {
match role {
SurfaceRole::EditorText => self.editor_text,
SurfaceRole::EditorBackground => self.editor_background,
SurfaceRole::Cursor => self.cursor,
SurfaceRole::CursorText => self.cursor_text,
SurfaceRole::Selection => self.selection,
SurfaceRole::SearchMatch => self.search_match,
SurfaceRole::StatusBar => self.status_bar,
SurfaceRole::StatusText => self.status_text,
SurfaceRole::StatusErrorText => self.status_error_text,
}
}
#[must_use]
pub const fn with_transparency_mode(mut self, transparency_mode: TransparencyMode) -> Self {
self.transparency_mode = transparency_mode;
self
}
pub fn apply_proposal(
&mut self,
proposal: SurfaceStyleProposal,
policy: StylePolicy,
) -> Result<(), StylePolicyError> {
validate_style(proposal.style, policy)?;
match proposal.role {
SurfaceRole::EditorText => self.editor_text = proposal.style,
SurfaceRole::EditorBackground => self.editor_background = proposal.style,
SurfaceRole::Cursor => self.cursor = proposal.style,
SurfaceRole::CursorText => self.cursor_text = proposal.style,
SurfaceRole::Selection => self.selection = proposal.style,
SurfaceRole::SearchMatch => self.search_match = proposal.style,
SurfaceRole::StatusBar => self.status_bar = proposal.style,
SurfaceRole::StatusText => self.status_text = proposal.style,
SurfaceRole::StatusErrorText => self.status_error_text = proposal.style,
}
Ok(())
}
}
impl Default for ResolvedTheme {
fn default() -> Self {
Self::default_opaque()
}
}
const fn validate_style(style: SurfaceStyle, policy: StylePolicy) -> Result<(), StylePolicyError> {
if style.alpha.as_permille() < policy.minimum_alpha.as_permille() {
return Err(StylePolicyError::AlphaBelowMinimum {
permille: style.alpha.as_permille(),
minimum: policy.minimum_alpha.as_permille(),
});
}
if style.blur_px > policy.maximum_blur_px {
return Err(StylePolicyError::BlurTooLarge {
blur_px: style.blur_px,
maximum: policy.maximum_blur_px,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
BoundedAlpha, ResolvedTheme, StylePolicy, StylePolicyError, SurfaceRole, SurfaceStyle,
SurfaceStyleProposal, TransparencyMode,
};
use bevy::prelude::Color;
use proptest::prelude::*;
#[test]
fn default_theme_preserves_current_opaque_surfaces() {
let theme = ResolvedTheme::default();
assert_eq!(theme.transparency_mode, TransparencyMode::Opaque);
assert_eq!(
theme.editor_text.color(),
Color::srgb(0.610_009_9, 0.759_212_6, 0.763_073_15)
);
assert_eq!(theme.status_bar.color(), Color::srgb(0.0, 0.12, 0.15));
assert_eq!(
theme.cursor.color(),
Color::srgb(0.954_751_13, 0.293_346_46, 0.0)
);
}
#[test]
fn surface_alpha_resolves_deterministically_by_role() {
let theme = ResolvedTheme::default();
for role in [
SurfaceRole::EditorText,
SurfaceRole::EditorBackground,
SurfaceRole::Cursor,
SurfaceRole::CursorText,
SurfaceRole::Selection,
SurfaceRole::SearchMatch,
SurfaceRole::StatusBar,
SurfaceRole::StatusText,
SurfaceRole::StatusErrorText,
] {
assert_eq!(theme.surface(role).alpha, BoundedAlpha::opaque());
}
}
#[test]
fn invalid_style_proposal_leaves_theme_unchanged() {
let mut theme = ResolvedTheme::default();
let before = theme.clone();
let proposal = SurfaceStyleProposal {
role: SurfaceRole::StatusBar,
style: SurfaceStyle {
tint: Color::srgb(1.0, 1.0, 1.0),
alpha: BoundedAlpha::try_new(100).expect("alpha is structurally valid"),
blur_px: 0,
},
};
let error = theme
.apply_proposal(proposal, StylePolicy::default())
.expect_err("proposal below policy minimum should fail");
assert_eq!(
error,
StylePolicyError::AlphaBelowMinimum {
permille: 100,
minimum: 250,
}
);
assert_eq!(theme, before);
}
fn surface_role_strategy() -> impl Strategy<Value = SurfaceRole> {
prop::sample::select(vec![
SurfaceRole::EditorText,
SurfaceRole::EditorBackground,
SurfaceRole::Cursor,
SurfaceRole::CursorText,
SurfaceRole::Selection,
SurfaceRole::SearchMatch,
SurfaceRole::StatusBar,
SurfaceRole::StatusText,
SurfaceRole::StatusErrorText,
])
}
fn bounded_alpha_strategy() -> impl Strategy<Value = BoundedAlpha> {
(0_u16..=1_000_u16)
.prop_map(|permille| BoundedAlpha::try_new(permille).expect("generated alpha is valid"))
}
fn color_strategy() -> impl Strategy<Value = Color> {
(0_u8..=255, 0_u8..=255, 0_u8..=255)
.prop_map(|(red, green, blue)| Color::srgb_u8(red, green, blue))
}
fn surface_style_strategy() -> impl Strategy<Value = SurfaceStyle> {
(color_strategy(), bounded_alpha_strategy(), 0_u16..=96_u16).prop_map(
|(tint, alpha, blur_px)| SurfaceStyle {
tint,
alpha,
blur_px,
},
)
}
proptest! {
#[test]
fn bounded_alpha_accepts_only_permille_range(permille in any::<u16>()) {
let result = BoundedAlpha::try_new(permille);
if permille <= 1_000 {
prop_assert_eq!(
result.expect("alpha in range should validate").as_permille(),
permille
);
} else {
prop_assert_eq!(
result,
Err(StylePolicyError::AlphaOutOfRange { permille })
);
}
}
#[test]
fn bounded_alpha_f32_projection_stays_in_unit_interval(alpha in bounded_alpha_strategy()) {
let value = alpha.as_f32();
prop_assert!((0.0..=1.0).contains(&value));
prop_assert_eq!(alpha, BoundedAlpha::try_new(alpha.as_permille()).expect("round trip should validate"));
}
#[test]
fn accepted_style_proposal_changes_only_target_role(
role in surface_role_strategy(),
style in surface_style_strategy(),
) {
let alpha = BoundedAlpha::try_new(style.alpha.as_permille().max(250))
.expect("clamped alpha should be valid");
let style = SurfaceStyle { alpha, blur_px: style.blur_px.min(48), ..style };
let mut theme = ResolvedTheme::default();
let before = theme.clone();
theme.apply_proposal(
SurfaceStyleProposal { role, style },
StylePolicy::default(),
)
.expect("policy-compliant proposal should apply");
for candidate in [
SurfaceRole::EditorText,
SurfaceRole::EditorBackground,
SurfaceRole::Cursor,
SurfaceRole::CursorText,
SurfaceRole::Selection,
SurfaceRole::SearchMatch,
SurfaceRole::StatusBar,
SurfaceRole::StatusText,
SurfaceRole::StatusErrorText,
] {
if candidate == role {
prop_assert_eq!(theme.surface(candidate), style);
} else {
prop_assert_eq!(theme.surface(candidate), before.surface(candidate));
}
}
}
#[test]
fn rejected_style_proposal_never_mutates_theme(
role in surface_role_strategy(),
tint in color_strategy(),
alpha_permille in 0_u16..250_u16,
blur_px in 49_u16..=u16::MAX,
reject_by_alpha in any::<bool>(),
) {
let mut theme = ResolvedTheme::default();
let before = theme.clone();
let style = SurfaceStyle {
tint,
alpha: if reject_by_alpha {
BoundedAlpha::try_new(alpha_permille).expect("generated alpha is valid")
} else {
BoundedAlpha::opaque()
},
blur_px: if reject_by_alpha { 0 } else { blur_px },
};
let rejected = theme.apply_proposal(
SurfaceStyleProposal { role, style },
StylePolicy::default(),
).is_err();
prop_assert!(rejected);
prop_assert_eq!(theme, before);
}
#[test]
fn transparency_mode_maps_directly_to_window_flag(mode in prop::sample::select(vec![
TransparencyMode::Opaque,
TransparencyMode::Transparent,
])) {
prop_assert_eq!(
mode.is_window_transparent(),
matches!(mode, TransparencyMode::Transparent)
);
}
}
}