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
12pub 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#[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 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 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
301pub 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
312pub 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
327pub 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}