1use std::path::Path;
35use std::sync::{Arc, Mutex};
36
37use cosmic_text::{Attrs, Buffer, Family, FontSystem, Metrics, Shaping, Style, Weight, Wrap};
38
39use super::{WispText, WispTextAlign, WispTextEngine, WispTextLayout, WispTextMetrics};
40
41pub const REFERENCE_PX: f32 = 1000.0;
43
44pub struct FlexibleTextLayout {
51 pub(crate) buffer: Buffer,
55 metrics: WispTextMetrics,
56}
57
58impl std::fmt::Debug for FlexibleTextLayout {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 f.debug_struct("FlexibleTextLayout")
61 .field("metrics", &self.metrics)
62 .finish_non_exhaustive()
63 }
64}
65
66impl WispTextLayout for FlexibleTextLayout {
67 fn metrics(&self) -> WispTextMetrics {
68 self.metrics
69 }
70}
71
72pub struct FlexibleTextEngine {
79 font_system: Arc<Mutex<FontSystem>>,
80}
81
82impl std::fmt::Debug for FlexibleTextEngine {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("FlexibleTextEngine").finish_non_exhaustive()
85 }
86}
87
88impl Default for FlexibleTextEngine {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl FlexibleTextEngine {
95 #[must_use]
97 pub fn new() -> Self {
98 Self {
99 font_system: Arc::new(Mutex::new(FontSystem::new())),
100 }
101 }
102
103 #[must_use]
109 pub fn with_font_system(font_system: FontSystem) -> Self {
110 Self {
111 font_system: Arc::new(Mutex::new(font_system)),
112 }
113 }
114
115 pub fn from_font_paths<P: AsRef<Path>>(
129 paths: impl IntoIterator<Item = P>,
130 ) -> std::io::Result<Self> {
131 let mut db = cosmic_text::fontdb::Database::new();
132 for p in paths {
133 db.load_font_file(p.as_ref())?;
134 }
135 Ok(Self::with_font_system(FontSystem::new_with_locale_and_db(
136 "en-US".to_owned(),
137 db,
138 )))
139 }
140
141 #[must_use]
150 pub fn from_font_bytes(bytes: impl IntoIterator<Item = Vec<u8>>) -> Self {
151 let mut db = cosmic_text::fontdb::Database::new();
152 for data in bytes {
153 db.load_font_data(data);
154 }
155 Self::with_font_system(FontSystem::new_with_locale_and_db("en-US".to_owned(), db))
156 }
157
158 #[must_use]
163 pub fn font_system_handle(&self) -> Arc<Mutex<FontSystem>> {
164 Arc::clone(&self.font_system)
165 }
166
167 #[must_use]
171 pub fn layout_concrete(&self, text: &WispText) -> FlexibleTextLayout {
172 let mut fs = self
173 .font_system
174 .lock()
175 .expect("FlexibleTextEngine font_system poisoned");
176 layout_flexible(&mut fs, text)
177 }
178}
179
180impl WispTextEngine for FlexibleTextEngine {
181 fn layout(&self, text: &WispText) -> Box<dyn WispTextLayout> {
182 Box::new(self.layout_concrete(text))
183 }
184}
185
186fn layout_flexible(font_system: &mut FontSystem, text: &WispText) -> FlexibleTextLayout {
187 let style = text.style;
188 let font_size_px = style.size_ndc * REFERENCE_PX;
189 let line_height_px = font_size_px * style.line_height;
190 let metrics = Metrics::new(font_size_px, line_height_px);
191
192 let mut buffer = Buffer::new(font_system, metrics);
193
194 let wrap_width_px = text.max_width_ndc.map(|w| w * REFERENCE_PX);
195 buffer.set_wrap(
196 font_system,
197 if wrap_width_px.is_some() {
198 Wrap::Word
199 } else {
200 Wrap::None
201 },
202 );
203 let wrap_height_px = wrap_width_px.map_or(f32::INFINITY, |_| f32::INFINITY);
204 buffer.set_size(font_system, wrap_width_px, Some(wrap_height_px));
205
206 let family = text
207 .font_family
208 .as_deref()
209 .map_or(Family::SansSerif, Family::Name);
210 let attrs = Attrs::new()
211 .family(family)
212 .weight(Weight(style.weight.value()))
213 .style(match style.style {
214 super::WispFontStyle::Normal => Style::Normal,
215 super::WispFontStyle::Italic => Style::Italic,
216 });
217 buffer.set_text(font_system, &text.content, attrs, Shaping::Advanced);
218
219 buffer.shape_until_scroll(font_system, false);
220
221 let mut max_width_px: f32 = 0.0;
222 let mut line_count: u32 = 0;
223 let mut last_baseline_px: f32 = 0.0;
224 for run in buffer.layout_runs() {
225 line_count += 1;
226 max_width_px = max_width_px.max(run.line_w);
227 last_baseline_px = run.line_top + line_height_px;
228 }
229 let total_height_px = if line_count == 0 {
230 0.0
231 } else {
232 last_baseline_px
233 };
234
235 let metrics_out = WispTextMetrics {
236 line_count,
237 max_width_ndc: max_width_px / REFERENCE_PX,
238 total_height_ndc: total_height_px / REFERENCE_PX,
239 baseline_ndc: line_height_px / REFERENCE_PX,
240 };
241
242 let _ = WispTextAlign::Left;
246
247 FlexibleTextLayout {
248 buffer,
249 metrics: metrics_out,
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::text::{WispFontWeight, WispText, WispTextStyle};
257
258 fn engine() -> FlexibleTextEngine {
259 FlexibleTextEngine::new()
260 }
261
262 #[test]
263 fn empty_string_yields_metrics_with_zero_width() {
264 let eng = engine();
265 let layout = eng.layout_concrete(&WispText::new(""));
266 let m = layout.metrics();
267 assert!(
268 m.max_width_ndc.abs() < 1e-3,
269 "expected ~0 width for empty content, got {}",
270 m.max_width_ndc
271 );
272 }
273
274 #[test]
275 fn single_line_has_one_run_and_positive_width() {
276 let eng = engine();
277 let layout = eng.layout_concrete(&WispText::new("Hello, world!"));
278 let m = layout.metrics();
279 assert_eq!(m.line_count, 1);
280 assert!(m.max_width_ndc > 0.0, "expected positive width");
281 let expected = 0.06_f32 * 1.2;
283 assert!(
284 (m.baseline_ndc - expected).abs() < 1e-3,
285 "baseline_ndc={} expected~{expected}",
286 m.baseline_ndc
287 );
288 }
289
290 #[test]
291 fn explicit_newlines_produce_multiple_runs() {
292 let eng = engine();
293 let layout = eng.layout_concrete(&WispText::new("first\nsecond\nthird"));
294 let m = layout.metrics();
295 assert_eq!(m.line_count, 3, "expected 3 runs, got {}", m.line_count);
296 let expected = 0.06_f32 * 1.2 * 3.0;
298 assert!(
299 (m.total_height_ndc - expected).abs() < 1e-3,
300 "total_height={} expected~{expected}",
301 m.total_height_ndc
302 );
303 }
304
305 #[test]
306 fn word_wrap_increases_line_count_when_wrap_width_is_tight() {
307 let eng = engine();
308 let unwrapped = eng.layout_concrete(&WispText::new(
309 "the quick brown fox jumps over the lazy dog",
310 ));
311 let wrapped = eng.layout_concrete(
312 &WispText::new("the quick brown fox jumps over the lazy dog").with_wrap(0.20),
313 );
314 assert_eq!(unwrapped.metrics().line_count, 1);
315 assert!(
316 wrapped.metrics().line_count >= 2,
317 "wrap=0.20 should have produced ≥2 lines, got {}",
318 wrapped.metrics().line_count
319 );
320 }
321
322 #[test]
323 fn weight_and_italic_style_are_passed_through_attrs() {
324 let eng = engine();
328 let style = WispTextStyle::default()
329 .with_weight(WispFontWeight::Bold)
330 .italic();
331 let layout = eng.layout_concrete(&WispText::new("Bold italic").with_style(style));
332 assert_eq!(layout.metrics().line_count, 1);
333 assert!(layout.metrics().max_width_ndc > 0.0);
334 }
335
336 #[test]
337 fn custom_font_family_lays_out_without_panic() {
338 let eng = engine();
343 let layout = eng.layout_concrete(&WispText::new("hello").with_font_family("Inter"));
344 assert_eq!(layout.metrics().line_count, 1);
345 assert!(layout.metrics().max_width_ndc > 0.0);
346 }
347
348 #[test]
349 fn engine_is_send_and_sync() {
350 fn assert_send<T: Send>() {}
351 fn assert_sync<T: Sync>() {}
352 assert_send::<FlexibleTextEngine>();
353 assert_sync::<FlexibleTextEngine>();
354 }
355}