Skip to main content

beuvy_runtime/
text.rs

1mod build;
2mod sync;
3
4use crate::style::{font_size_control, text_primary_color};
5use crate::utility::{
6    UtilityFontFamilyRole, UtilityFontStyle, UtilityStylePatch, UtilityTextTransform,
7};
8use bevy::prelude::*;
9use bevy::text::{FontWeight, LineBreak, LineHeight, TextLayout};
10use bevy_localization::{Localization, TextKey};
11
12/// Materializes [`AddText`] components and keeps localized text in sync.
13pub struct TextPlugin;
14
15impl Plugin for TextPlugin {
16    fn build(&self, app: &mut App) {
17        app.add_systems(Startup, build::setup)
18            .add_systems(Update, build::add_text)
19            .add_systems(
20                PostUpdate,
21                (
22                    sync::sync_localized_text_on_binding_change,
23                    sync::sync_localized_text_on_locale_change,
24                    sync::sync_localized_text_format_on_binding_change,
25                    sync::sync_localized_text_format_on_locale_change,
26                ),
27            );
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum FontFamilyRole {
33    Sans,
34    Serif,
35    Mono,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TypographyFontStyle {
40    Normal,
41    Italic,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum TypographyTextTransform {
46    None,
47    Uppercase,
48    Lowercase,
49    Capitalize,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53pub struct TypographyStyle {
54    pub family_role: FontFamilyRole,
55    pub font_size: f32,
56    pub font_weight: FontWeight,
57    pub font_style: TypographyFontStyle,
58    pub line_height: LineHeight,
59    pub letter_spacing_em: f32,
60    pub text_transform: TypographyTextTransform,
61}
62
63impl Default for TypographyStyle {
64    fn default() -> Self {
65        Self {
66            family_role: FontFamilyRole::Sans,
67            font_size: font_size_control(),
68            font_weight: FontWeight::NORMAL,
69            font_style: TypographyFontStyle::Normal,
70            line_height: LineHeight::RelativeToFont(1.5),
71            letter_spacing_em: 0.0,
72            text_transform: TypographyTextTransform::None,
73        }
74    }
75}
76
77#[derive(Debug, Clone)]
78pub struct FontFaceEntry {
79    pub handle: Handle<Font>,
80    pub weight: FontWeight,
81    pub style: TypographyFontStyle,
82}
83
84#[derive(Resource, Debug, Default, Clone)]
85pub struct FontRegistry {
86    pub sans: Vec<FontFaceEntry>,
87    pub serif: Vec<FontFaceEntry>,
88    pub mono: Vec<FontFaceEntry>,
89}
90
91impl FontRegistry {
92    pub fn resolve(&self, style: &TypographyStyle) -> Option<Handle<Font>> {
93        let faces = match style.family_role {
94            FontFamilyRole::Sans => &self.sans,
95            FontFamilyRole::Serif => &self.serif,
96            FontFamilyRole::Mono => &self.mono,
97        };
98
99        faces
100            .iter()
101            .min_by_key(|face| {
102                let style_penalty = if face.style == style.font_style { 0u32 } else { 10_000u32 };
103                let weight_penalty = face.weight.0.abs_diff(style.font_weight.0) as u32;
104                style_penalty + weight_penalty
105            })
106            .map(|face| face.handle.clone())
107    }
108}
109
110#[derive(Resource)]
111pub struct FontResource {
112    pub primary_font: Option<Handle<Font>>,
113}
114
115impl FontResource {
116    pub fn from_handle(font: Handle<Font>) -> Self {
117        Self {
118            primary_font: Some(font),
119        }
120    }
121}
122
123impl Default for FontResource {
124    fn default() -> Self {
125        Self { primary_font: None }
126    }
127}
128
129/// Declarative request to materialize a text entity using the active UI theme.
130#[derive(Component, Debug, Clone)]
131pub struct AddText {
132    pub text: String,
133    pub line_height: LineHeight,
134    pub size: f32,
135    pub color: Color,
136    pub layout: TextLayout,
137    pub localized_text: Option<TextKey>,
138    pub localized_text_format: Option<LocalizedTextFormat>,
139    pub typography: TypographyStyle,
140}
141
142#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
143pub struct LocalizedText {
144    pub key: TextKey,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct LocalizedArg {
149    pub name: &'static str,
150    pub value: String,
151}
152
153impl LocalizedArg {
154    pub fn new(name: &'static str, value: impl std::fmt::Display) -> Self {
155        Self {
156            name,
157            value: value.to_string(),
158        }
159    }
160}
161
162#[derive(Component, Debug, Clone, PartialEq, Eq)]
163pub struct LocalizedTextFormat {
164    pub key: TextKey,
165    pub args: Vec<LocalizedArg>,
166}
167
168impl LocalizedTextFormat {
169    pub fn new(key: TextKey) -> Self {
170        Self {
171            key,
172            args: Vec::new(),
173        }
174    }
175
176    pub fn with_arg(mut self, name: &'static str, value: impl std::fmt::Display) -> Self {
177        self.args.push(LocalizedArg::new(name, value));
178        self
179    }
180}
181
182impl Default for AddText {
183    fn default() -> Self {
184        Self {
185            text: "[missing text]".to_string(),
186            line_height: TypographyStyle::default().line_height,
187            size: TypographyStyle::default().font_size,
188            color: text_primary_color(),
189            layout: TextLayout::new_with_linebreak(LineBreak::WordBoundary),
190            localized_text: None,
191            localized_text_format: None,
192            typography: TypographyStyle::default(),
193        }
194    }
195}
196
197impl AddText {
198    /// Uses a localized text key instead of the raw `text` field.
199    pub fn with_localized(mut self, key: TextKey) -> Self {
200        self.localized_text = Some(key);
201        self.localized_text_format = None;
202        self
203    }
204
205    /// Uses a localized format string instead of the raw `text` field.
206    pub fn with_localized_format(mut self, localized_text_format: LocalizedTextFormat) -> Self {
207        self.localized_text = None;
208        self.localized_text_format = Some(localized_text_format);
209        self
210    }
211
212    pub fn typography(mut self, typography: TypographyStyle) -> Self {
213        self.size = typography.font_size;
214        self.line_height = typography.line_height;
215        self.typography = typography;
216        self
217    }
218}
219
220pub fn apply_text_transform(
221    raw: &str,
222    text_transform: TypographyTextTransform,
223) -> String {
224    match text_transform {
225        TypographyTextTransform::None => raw.to_string(),
226        TypographyTextTransform::Uppercase => raw.to_uppercase(),
227        TypographyTextTransform::Lowercase => raw.to_lowercase(),
228        TypographyTextTransform::Capitalize => {
229            let mut result = String::with_capacity(raw.len());
230            let mut new_word = true;
231            for chr in raw.chars() {
232                if chr.is_alphanumeric() {
233                    if new_word {
234                        result.extend(chr.to_uppercase());
235                        new_word = false;
236                    } else {
237                        result.push(chr);
238                    }
239                } else {
240                    new_word = chr.is_whitespace() || matches!(chr, '-' | '_' | '/');
241                    result.push(chr);
242                }
243            }
244            result
245        }
246    }
247}
248
249pub fn typography_from_patch(
250    patch: &UtilityStylePatch,
251    mut base: TypographyStyle,
252) -> TypographyStyle {
253    if let Some(role) = patch.font_family_role {
254        base.family_role = match role {
255            UtilityFontFamilyRole::Sans => FontFamilyRole::Sans,
256            UtilityFontFamilyRole::Serif => FontFamilyRole::Serif,
257            UtilityFontFamilyRole::Mono => FontFamilyRole::Mono,
258        };
259    }
260    if let Some(font_size) = patch.font_size {
261        base.font_size = font_size;
262    }
263    if let Some(font_weight) = patch.font_weight {
264        base.font_weight = FontWeight(font_weight);
265    }
266    if let Some(font_style) = patch.font_style {
267        base.font_style = match font_style {
268            UtilityFontStyle::Normal => TypographyFontStyle::Normal,
269            UtilityFontStyle::Italic => TypographyFontStyle::Italic,
270        };
271    }
272    if let Some(line_height) = patch.line_height {
273        base.line_height = if line_height > 8.0 {
274            LineHeight::Px(line_height)
275        } else {
276            LineHeight::RelativeToFont(line_height)
277        };
278    }
279    if let Some(letter_spacing_em) = patch.letter_spacing_em {
280        base.letter_spacing_em = letter_spacing_em;
281    }
282    if let Some(text_transform) = patch.text_transform {
283        base.text_transform = match text_transform {
284            UtilityTextTransform::None => TypographyTextTransform::None,
285            UtilityTextTransform::Uppercase => TypographyTextTransform::Uppercase,
286            UtilityTextTransform::Lowercase => TypographyTextTransform::Lowercase,
287            UtilityTextTransform::Capitalize => TypographyTextTransform::Capitalize,
288        };
289    }
290    base
291}
292
293pub fn control_typography() -> TypographyStyle {
294    TypographyStyle {
295        font_size: font_size_control(),
296        line_height: LineHeight::RelativeToFont(1.5),
297        ..Default::default()
298    }
299}
300
301/// Replaces the text content with plain text and clears localization bindings.
302pub fn set_plain_text(commands: &mut Commands, entity: Entity, text: impl Into<String>) {
303    let Ok(mut entity_commands) = commands.get_entity(entity) else {
304        return;
305    };
306    entity_commands
307        .try_insert(Text::new(text.into()))
308        .try_remove::<LocalizedText>()
309        .try_remove::<LocalizedTextFormat>();
310}
311
312/// Replaces the text content with a localized string resolved from `key`.
313pub fn set_localized_text(
314    commands: &mut Commands,
315    entity: Entity,
316    localization: &Localization,
317    key: TextKey,
318) {
319    let Ok(mut entity_commands) = commands.get_entity(entity) else {
320        return;
321    };
322    entity_commands
323        .try_insert((Text::new(localization.text(key)), LocalizedText { key }))
324        .try_remove::<LocalizedTextFormat>();
325}
326
327/// Replaces the text content with a localized format string.
328pub fn set_localized_text_format(
329    commands: &mut Commands,
330    entity: Entity,
331    localization: &Localization,
332    localized_text_format: LocalizedTextFormat,
333) {
334    let text = localization.format_text(
335        localized_text_format.key,
336        localized_text_format
337            .args
338            .iter()
339            .map(|arg| (arg.name, arg.value.as_str())),
340    );
341    let Ok(mut entity_commands) = commands.get_entity(entity) else {
342        return;
343    };
344    entity_commands
345        .try_insert((Text::new(text), localized_text_format))
346        .try_remove::<LocalizedText>();
347}