mod build;
mod sync;
use crate::style::{font_size_control, text_primary_color};
use crate::utility::{
UtilityFontFamilyRole, UtilityFontStyle, UtilityStylePatch, UtilityTextTransform,
};
use bevy::prelude::*;
use bevy::text::{FontWeight, LineBreak, LineHeight, TextLayout};
use bevy_localization::{Localization, TextKey};
pub struct TextPlugin;
impl Plugin for TextPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, build::setup)
.add_systems(Update, build::add_text)
.add_systems(
PostUpdate,
(
sync::sync_localized_text_on_binding_change,
sync::sync_localized_text_on_locale_change,
sync::sync_localized_text_format_on_binding_change,
sync::sync_localized_text_format_on_locale_change,
),
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontFamilyRole {
Sans,
Serif,
Mono,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypographyFontStyle {
Normal,
Italic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypographyTextTransform {
None,
Uppercase,
Lowercase,
Capitalize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TypographyStyle {
pub family_role: FontFamilyRole,
pub font_size: f32,
pub font_weight: FontWeight,
pub font_style: TypographyFontStyle,
pub line_height: LineHeight,
pub letter_spacing_em: f32,
pub text_transform: TypographyTextTransform,
}
impl Default for TypographyStyle {
fn default() -> Self {
Self {
family_role: FontFamilyRole::Sans,
font_size: font_size_control(),
font_weight: FontWeight::NORMAL,
font_style: TypographyFontStyle::Normal,
line_height: LineHeight::RelativeToFont(1.5),
letter_spacing_em: 0.0,
text_transform: TypographyTextTransform::None,
}
}
}
#[derive(Debug, Clone)]
pub struct FontFaceEntry {
pub handle: Handle<Font>,
pub weight: FontWeight,
pub style: TypographyFontStyle,
}
#[derive(Resource, Debug, Default, Clone)]
pub struct FontRegistry {
pub sans: Vec<FontFaceEntry>,
pub serif: Vec<FontFaceEntry>,
pub mono: Vec<FontFaceEntry>,
}
impl FontRegistry {
pub fn resolve(&self, style: &TypographyStyle) -> Option<Handle<Font>> {
let faces = match style.family_role {
FontFamilyRole::Sans => &self.sans,
FontFamilyRole::Serif => &self.serif,
FontFamilyRole::Mono => &self.mono,
};
faces
.iter()
.min_by_key(|face| {
let style_penalty = if face.style == style.font_style { 0u32 } else { 10_000u32 };
let weight_penalty = face.weight.0.abs_diff(style.font_weight.0) as u32;
style_penalty + weight_penalty
})
.map(|face| face.handle.clone())
}
}
#[derive(Resource)]
pub struct FontResource {
pub primary_font: Option<Handle<Font>>,
}
impl FontResource {
pub fn from_handle(font: Handle<Font>) -> Self {
Self {
primary_font: Some(font),
}
}
}
impl Default for FontResource {
fn default() -> Self {
Self { primary_font: None }
}
}
#[derive(Component, Debug, Clone)]
pub struct AddText {
pub text: String,
pub line_height: LineHeight,
pub size: f32,
pub color: Color,
pub layout: TextLayout,
pub localized_text: Option<TextKey>,
pub localized_text_format: Option<LocalizedTextFormat>,
pub typography: TypographyStyle,
}
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalizedText {
pub key: TextKey,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalizedArg {
pub name: &'static str,
pub value: String,
}
impl LocalizedArg {
pub fn new(name: &'static str, value: impl std::fmt::Display) -> Self {
Self {
name,
value: value.to_string(),
}
}
}
#[derive(Component, Debug, Clone, PartialEq, Eq)]
pub struct LocalizedTextFormat {
pub key: TextKey,
pub args: Vec<LocalizedArg>,
}
impl LocalizedTextFormat {
pub fn new(key: TextKey) -> Self {
Self {
key,
args: Vec::new(),
}
}
pub fn with_arg(mut self, name: &'static str, value: impl std::fmt::Display) -> Self {
self.args.push(LocalizedArg::new(name, value));
self
}
}
impl Default for AddText {
fn default() -> Self {
Self {
text: "[missing text]".to_string(),
line_height: TypographyStyle::default().line_height,
size: TypographyStyle::default().font_size,
color: text_primary_color(),
layout: TextLayout::new_with_linebreak(LineBreak::WordBoundary),
localized_text: None,
localized_text_format: None,
typography: TypographyStyle::default(),
}
}
}
impl AddText {
pub fn with_localized(mut self, key: TextKey) -> Self {
self.localized_text = Some(key);
self.localized_text_format = None;
self
}
pub fn with_localized_format(mut self, localized_text_format: LocalizedTextFormat) -> Self {
self.localized_text = None;
self.localized_text_format = Some(localized_text_format);
self
}
pub fn typography(mut self, typography: TypographyStyle) -> Self {
self.size = typography.font_size;
self.line_height = typography.line_height;
self.typography = typography;
self
}
}
pub fn apply_text_transform(
raw: &str,
text_transform: TypographyTextTransform,
) -> String {
match text_transform {
TypographyTextTransform::None => raw.to_string(),
TypographyTextTransform::Uppercase => raw.to_uppercase(),
TypographyTextTransform::Lowercase => raw.to_lowercase(),
TypographyTextTransform::Capitalize => {
let mut result = String::with_capacity(raw.len());
let mut new_word = true;
for chr in raw.chars() {
if chr.is_alphanumeric() {
if new_word {
result.extend(chr.to_uppercase());
new_word = false;
} else {
result.push(chr);
}
} else {
new_word = chr.is_whitespace() || matches!(chr, '-' | '_' | '/');
result.push(chr);
}
}
result
}
}
}
pub fn typography_from_patch(
patch: &UtilityStylePatch,
mut base: TypographyStyle,
) -> TypographyStyle {
if let Some(role) = patch.font_family_role {
base.family_role = match role {
UtilityFontFamilyRole::Sans => FontFamilyRole::Sans,
UtilityFontFamilyRole::Serif => FontFamilyRole::Serif,
UtilityFontFamilyRole::Mono => FontFamilyRole::Mono,
};
}
if let Some(font_size) = patch.font_size {
base.font_size = font_size;
}
if let Some(font_weight) = patch.font_weight {
base.font_weight = FontWeight(font_weight);
}
if let Some(font_style) = patch.font_style {
base.font_style = match font_style {
UtilityFontStyle::Normal => TypographyFontStyle::Normal,
UtilityFontStyle::Italic => TypographyFontStyle::Italic,
};
}
if let Some(line_height) = patch.line_height {
base.line_height = if line_height > 8.0 {
LineHeight::Px(line_height)
} else {
LineHeight::RelativeToFont(line_height)
};
}
if let Some(letter_spacing_em) = patch.letter_spacing_em {
base.letter_spacing_em = letter_spacing_em;
}
if let Some(text_transform) = patch.text_transform {
base.text_transform = match text_transform {
UtilityTextTransform::None => TypographyTextTransform::None,
UtilityTextTransform::Uppercase => TypographyTextTransform::Uppercase,
UtilityTextTransform::Lowercase => TypographyTextTransform::Lowercase,
UtilityTextTransform::Capitalize => TypographyTextTransform::Capitalize,
};
}
base
}
pub fn control_typography() -> TypographyStyle {
TypographyStyle {
font_size: font_size_control(),
line_height: LineHeight::RelativeToFont(1.5),
..Default::default()
}
}
pub fn set_plain_text(commands: &mut Commands, entity: Entity, text: impl Into<String>) {
let Ok(mut entity_commands) = commands.get_entity(entity) else {
return;
};
entity_commands
.try_insert(Text::new(text.into()))
.try_remove::<LocalizedText>()
.try_remove::<LocalizedTextFormat>();
}
pub fn set_localized_text(
commands: &mut Commands,
entity: Entity,
localization: &Localization,
key: TextKey,
) {
let Ok(mut entity_commands) = commands.get_entity(entity) else {
return;
};
entity_commands
.try_insert((Text::new(localization.text(key)), LocalizedText { key }))
.try_remove::<LocalizedTextFormat>();
}
pub fn set_localized_text_format(
commands: &mut Commands,
entity: Entity,
localization: &Localization,
localized_text_format: LocalizedTextFormat,
) {
let text = localization.format_text(
localized_text_format.key,
localized_text_format
.args
.iter()
.map(|arg| (arg.name, arg.value.as_str())),
);
let Ok(mut entity_commands) = commands.get_entity(entity) else {
return;
};
entity_commands
.try_insert((Text::new(text), localized_text_format))
.try_remove::<LocalizedText>();
}