1use crate::{Color, Font, FontSpec, FontWeight, TextRun, runs_are_valid};
18
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
29pub enum Underline {
30 #[default]
31 None,
32 Single,
33 Double,
34 Dotted,
35 Wavy,
37}
38
39impl Underline {
40 pub fn is_on(self) -> bool {
41 self != Underline::None
42 }
43 pub fn toggled(self) -> Self {
45 if self.is_on() {
46 Underline::None
47 } else {
48 Underline::Single
49 }
50 }
51}
52
53#[derive(Clone, Debug, Default, PartialEq)]
56pub struct RunStyle {
57 pub font: FontSpec,
58 pub color: Option<Color>,
59 pub background: Option<Color>,
60 pub underline: Underline,
61 pub strikethrough: bool,
62 pub link: Option<String>,
63}
64
65impl RunStyle {
66 pub fn plain(base: Font) -> Self {
68 RunStyle {
69 font: FontSpec::new(base),
70 ..RunStyle::default()
71 }
72 }
73 pub fn bold(&self) -> bool {
75 self.font.weight.is_some_and(|w| w >= FontWeight::Semibold)
76 }
77 pub fn set_bold(&mut self, on: bool) {
78 self.font.weight = on.then_some(FontWeight::Bold);
79 }
80 pub fn italic(&self) -> bool {
81 self.font.italic
82 }
83 pub fn set_italic(&mut self, on: bool) {
84 self.font.italic = on;
85 }
86}
87
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
94pub enum ParagraphAlign {
95 #[default]
97 Natural,
98 Center,
99 Trailing,
101 Justified,
102}
103
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
106pub enum ListStyle {
107 #[default]
108 None,
109 Bullet,
111 Ordered(u32),
114}
115
116#[derive(Clone, Copy, Debug, Default, PartialEq)]
118pub struct ParagraphStyle {
119 pub align: ParagraphAlign,
120 pub indent: f64,
123 pub space_before: f64,
124 pub space_after: f64,
125 pub list: ListStyle,
126 pub list_level: u8,
128}
129
130#[derive(Clone, Debug, Default, PartialEq)]
140pub struct ParagraphRun {
141 pub range: std::ops::Range<usize>,
142 pub align: ParagraphAlign,
143 pub indent: f64,
144 pub space_before: f64,
145 pub space_after: f64,
146 pub list: ListStyle,
147 pub list_level: u8,
148}
149
150impl ParagraphRun {
151 pub fn new(range: std::ops::Range<usize>, style: ParagraphStyle) -> Self {
152 ParagraphRun {
153 range,
154 align: style.align,
155 indent: style.indent,
156 space_before: style.space_before,
157 space_after: style.space_after,
158 list: style.list,
159 list_level: style.list_level,
160 }
161 }
162 pub fn style(&self) -> ParagraphStyle {
163 ParagraphStyle {
164 align: self.align,
165 indent: self.indent,
166 space_before: self.space_before,
167 space_after: self.space_after,
168 list: self.list,
169 list_level: self.list_level,
170 }
171 }
172}
173
174#[derive(Clone, Debug, Default, PartialEq)]
181pub struct StyledText {
182 pub text: String,
183 pub runs: Vec<TextRun>,
184 pub paragraphs: Vec<ParagraphRun>,
185}
186
187impl From<String> for StyledText {
188 fn from(text: String) -> Self {
189 StyledText::plain(text)
190 }
191}
192
193impl From<&str> for StyledText {
194 fn from(text: &str) -> Self {
195 StyledText::plain(text)
196 }
197}
198
199impl StyledText {
200 pub fn plain(text: impl Into<String>) -> Self {
202 StyledText {
203 text: text.into(),
204 runs: Vec::new(),
205 paragraphs: Vec::new(),
206 }
207 }
208
209 pub fn markdown(md: &str, base: Font) -> Self {
212 crate::styled_codec::markdown_to_styled(
213 md,
214 crate::styled_codec::DocStyle {
215 base,
216 ..Default::default()
217 },
218 )
219 }
220
221 pub fn html(html: &str, base: Font) -> Self {
223 crate::styled_codec::html_to_styled(
224 html,
225 crate::styled_codec::DocStyle {
226 base,
227 ..Default::default()
228 },
229 )
230 }
231
232 pub fn rtf(rtf: &str, base: Font) -> Self {
234 crate::styled_codec::rtf_to_styled(
235 rtf,
236 crate::styled_codec::DocStyle {
237 base,
238 ..Default::default()
239 },
240 )
241 }
242
243 pub fn to_markdown(&self, base: Font) -> String {
246 crate::styled_codec::styled_to_markdown(
247 self,
248 crate::styled_codec::DocStyle {
249 base,
250 ..Default::default()
251 },
252 )
253 }
254
255 pub fn to_html(&self, base: Font) -> String {
256 crate::styled_codec::styled_to_html(
257 self,
258 crate::styled_codec::DocStyle {
259 base,
260 ..Default::default()
261 },
262 )
263 }
264
265 pub fn to_rtf(&self, base: Font) -> String {
266 crate::styled_codec::styled_to_rtf(
267 self,
268 crate::styled_codec::DocStyle {
269 base,
270 ..Default::default()
271 },
272 )
273 }
274
275 pub fn new(text: impl Into<String>, runs: Vec<TextRun>) -> Self {
277 StyledText {
278 text: text.into(),
279 runs,
280 paragraphs: Vec::new(),
281 }
282 }
283
284 pub fn is_empty(&self) -> bool {
285 self.text.is_empty()
286 }
287
288 pub fn validate(&self) -> Result<(), String> {
291 runs_are_valid(&self.text, &self.runs)?;
292 paragraphs_are_valid(&self.text, &self.paragraphs)
293 }
294
295 pub fn segments(&self, base: Font) -> Vec<(std::ops::Range<usize>, RunStyle)> {
300 let plain = RunStyle::plain(base);
301 let mut out = Vec::with_capacity(self.runs.len() * 2 + 1);
302 let mut at = 0usize;
303 for r in &self.runs {
304 if r.range.start > at {
305 out.push((at..r.range.start, plain.clone()));
306 }
307 if !r.range.is_empty() {
308 out.push((r.range.clone(), r.style()));
309 }
310 at = at.max(r.range.end);
311 }
312 if at < self.text.len() {
313 out.push((at..self.text.len(), plain));
314 }
315 out
316 }
317
318 pub fn style_of(&self, range: std::ops::Range<usize>, base: Font) -> RunStyle {
328 let segs = self.segments(base);
329 if range.is_empty() {
330 let at = range.start;
331 return segs
332 .iter()
333 .find(|(r, _)| r.start < at && r.end >= at)
334 .or_else(|| segs.iter().find(|(r, _)| r.contains(&at)))
335 .map(|(_, s)| s.clone())
336 .unwrap_or_else(|| RunStyle::plain(base));
337 }
338 let mut acc: Option<RunStyle> = None;
339 for (r, s) in &segs {
340 if r.end <= range.start || r.start >= range.end {
341 continue;
342 }
343 acc = Some(merge_style(acc, s));
344 }
345 acc.unwrap_or_else(|| RunStyle::plain(base))
346 }
347
348 pub fn apply(&mut self, range: std::ops::Range<usize>, base: Font, f: impl Fn(&mut RunStyle)) {
351 if range.start >= range.end || range.start >= self.text.len() {
352 return;
353 }
354 let range = range.start..range.end.min(self.text.len());
355 let mut out: Vec<TextRun> = Vec::new();
356 for (r, s) in self.segments(base) {
357 for (piece, inside) in [
359 (r.start..r.end.min(range.start), false),
360 (r.start.max(range.start)..r.end.min(range.end), true),
361 (r.start.max(range.end)..r.end, false),
362 ] {
363 if piece.start >= piece.end {
364 continue;
365 }
366 let mut style = s.clone();
367 if inside {
368 f(&mut style);
369 }
370 out.push(TextRun::styled(piece, style));
371 }
372 }
373 self.runs = coalesce_runs(out, base);
374 }
375
376 pub fn paragraph_style_of(&self, range: std::ops::Range<usize>) -> ParagraphStyle {
379 let mut acc: Option<ParagraphStyle> = None;
380 for (start, end) in paragraph_bounds(&self.text) {
381 if !touches(start, end, &range) {
382 continue;
383 }
384 let style = self.paragraph_at(start, end);
385 acc = Some(match acc {
386 None => style,
387 Some(a) if a == style => a,
388 Some(_) => ParagraphStyle::default(),
389 });
390 }
391 acc.unwrap_or_default()
392 }
393
394 pub fn apply_paragraph(
397 &mut self,
398 range: std::ops::Range<usize>,
399 f: impl Fn(&mut ParagraphStyle),
400 ) {
401 let mut out: Vec<ParagraphRun> = Vec::new();
402 for (start, end) in paragraph_bounds(&self.text) {
403 let mut style = self.paragraph_at(start, end);
404 if touches(start, end, &range) {
405 f(&mut style);
406 }
407 if style != ParagraphStyle::default() {
408 out.push(ParagraphRun::new(start..end, style));
409 }
410 }
411 self.paragraphs = out;
412 }
413
414 fn paragraph_at(&self, start: usize, end: usize) -> ParagraphStyle {
416 self.paragraphs
417 .iter()
418 .find(|p| p.range.start <= start && p.range.end >= end.min(p.range.end).max(start + 1))
419 .map(|p| p.style())
420 .unwrap_or_default()
421 }
422
423 pub fn reflow(&mut self, offset: usize, removed: usize, inserted: usize) {
433 let shift = |p: usize| -> usize {
434 if p <= offset {
435 p
436 } else if p >= offset + removed {
437 p - removed + inserted
438 } else {
439 offset }
441 };
442 for r in &mut self.runs {
443 r.range = shift(r.range.start)..shift(r.range.end);
444 }
445 for p in &mut self.paragraphs {
446 p.range = shift(p.range.start)..shift(p.range.end);
447 }
448 if inserted > 0 {
451 for r in &mut self.runs {
452 if r.range.end == offset && r.range.start < offset {
453 r.range.end = offset + inserted;
454 }
455 }
456 for p in &mut self.paragraphs {
457 if p.range.end == offset && p.range.start < offset {
458 p.range.end = offset + inserted;
459 }
460 }
461 }
462 self.clamp();
463 }
464
465 pub fn clamp(&mut self) {
468 let text = std::mem::take(&mut self.text);
469 let fix = |p: usize| -> usize {
470 let mut p = p.min(text.len());
471 while p > 0 && !text.is_char_boundary(p) {
472 p -= 1;
473 }
474 p
475 };
476 for r in &mut self.runs {
477 r.range = fix(r.range.start)..fix(r.range.end);
478 }
479 for p in &mut self.paragraphs {
480 p.range = fix(p.range.start)..fix(p.range.end);
481 }
482 self.text = text;
483 self.runs.retain(|r| r.range.start < r.range.end);
484 self.paragraphs.retain(|p| p.range.start < p.range.end);
485 }
486
487 pub fn splice(&mut self, range: std::ops::Range<usize>, text: &str) {
490 let range = range.start.min(self.text.len())..range.end.min(self.text.len());
491 if !self.text.is_char_boundary(range.start) || !self.text.is_char_boundary(range.end) {
492 return;
493 }
494 let removed = range.end.saturating_sub(range.start);
495 self.text.replace_range(range.clone(), text);
496 self.reflow(range.start, removed, text.len());
497 }
498}
499
500fn merge_style(acc: Option<RunStyle>, b: &RunStyle) -> RunStyle {
503 let Some(a) = acc else { return b.clone() };
504 RunStyle {
505 font: FontSpec {
506 style: if a.font.style == b.font.style {
507 a.font.style
508 } else {
509 Font::Body
510 },
511 weight: if a.font.weight == b.font.weight {
512 a.font.weight
513 } else {
514 None
515 },
516 italic: a.font.italic && b.font.italic,
517 tabular: a.font.tabular && b.font.tabular,
518 monospace: a.font.monospace && b.font.monospace,
519 scale: if a.font.scale == b.font.scale {
520 a.font.scale
521 } else {
522 1.0
523 },
524 },
525 color: if a.color == b.color { a.color } else { None },
526 background: if a.background == b.background {
527 a.background
528 } else {
529 None
530 },
531 underline: if a.underline == b.underline {
532 a.underline
533 } else {
534 Underline::None
535 },
536 strikethrough: a.strikethrough && b.strikethrough,
537 link: if a.link == b.link { a.link } else { None },
538 }
539}
540
541fn touches(start: usize, end: usize, sel: &std::ops::Range<usize>) -> bool {
544 if sel.start == sel.end {
545 return sel.start >= start && sel.start <= end;
546 }
547 start < sel.end && end > sel.start
548}
549
550pub fn coalesce_runs(runs: Vec<TextRun>, base: Font) -> Vec<TextRun> {
555 let plain = RunStyle::plain(base);
556 let mut out: Vec<TextRun> = Vec::with_capacity(runs.len());
557 for r in runs {
558 if r.range.is_empty() {
559 continue;
560 }
561 match out.last_mut() {
562 Some(prev) if prev.range.end == r.range.start && prev.style() == r.style() => {
563 prev.range.end = r.range.end;
564 }
565 _ => out.push(r),
566 }
567 }
568 out.retain(|r| r.style() != plain);
569 out
570}
571
572pub fn paragraph_bounds(text: &str) -> Vec<(usize, usize)> {
577 if text.is_empty() {
578 return Vec::new();
579 }
580 let mut out = Vec::new();
581 let mut start = 0usize;
582 for (i, b) in text.bytes().enumerate() {
583 if b == b'\n' {
584 out.push((start, i + 1));
585 start = i + 1;
586 }
587 }
588 out.push((start, text.len()));
589 out
590}
591
592pub fn paragraphs_are_valid(text: &str, paragraphs: &[ParagraphRun]) -> Result<(), String> {
595 let mut prev_end = 0usize;
596 for (i, p) in paragraphs.iter().enumerate() {
597 if p.range.start < prev_end {
598 return Err(format!(
599 "paragraph {i} starts at {} but the previous ends at {prev_end} — paragraph runs \
600 must be ascending and non-overlapping",
601 p.range.start
602 ));
603 }
604 if p.range.end > text.len() {
605 return Err(format!(
606 "paragraph {i} ends at {} but the text is {} bytes",
607 p.range.end,
608 text.len()
609 ));
610 }
611 if p.range.start > p.range.end {
612 return Err(format!("paragraph {i} has an inverted range {:?}", p.range));
613 }
614 if !text.is_char_boundary(p.range.start) || !text.is_char_boundary(p.range.end) {
615 return Err(format!(
616 "paragraph {i}'s range {:?} splits a multi-byte character",
617 p.range
618 ));
619 }
620 prev_end = p.range.end;
621 }
622 Ok(())
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn bold_run(range: std::ops::Range<usize>) -> TextRun {
630 TextRun::styled(range, {
631 let mut s = RunStyle::plain(Font::Body);
632 s.set_bold(true);
633 s
634 })
635 }
636
637 fn doc() -> StyledText {
638 StyledText::new("hello world", vec![bold_run(0..5)])
640 }
641
642 #[test]
643 fn segments_cover_the_whole_text_including_gaps() {
644 let segs = doc().segments(Font::Body);
645 assert_eq!(
646 segs.iter().map(|(r, _)| r.clone()).collect::<Vec<_>>(),
647 vec![0..5, 5..11]
648 );
649 assert!(segs[0].1.bold());
650 assert!(!segs[1].1.bold());
651 }
652
653 #[test]
654 fn style_of_a_uniform_selection_is_that_style() {
655 assert!(doc().style_of(0..5, Font::Body).bold());
656 assert!(!doc().style_of(5..11, Font::Body).bold());
657 }
658
659 #[test]
660 fn style_of_a_mixed_selection_resets_what_differs() {
661 let s = doc().style_of(0..11, Font::Body);
663 assert!(!s.bold(), "a mixed weight comes back as the default");
664 }
665
666 #[test]
667 fn a_caret_takes_the_style_of_the_run_it_ends() {
668 assert!(doc().style_of(5..5, Font::Body).bold());
670 assert!(!doc().style_of(11..11, Font::Body).bold());
672 assert!(doc().style_of(0..0, Font::Body).bold());
676 }
677
678 #[test]
679 fn apply_splits_runs_at_the_selection_boundaries() {
680 let mut d = doc();
681 d.apply(3..8, Font::Body, |s| s.set_italic(true));
682 let got: Vec<_> = d
684 .runs
685 .iter()
686 .map(|r| (r.range.clone(), r.style().bold(), r.style().italic()))
687 .collect();
688 assert_eq!(
689 got,
690 vec![(0..3, true, false), (3..5, true, true), (5..8, false, true),]
691 );
692 }
693
694 #[test]
695 fn apply_coalesces_and_drops_plain() {
696 let mut d = doc();
697 d.apply(0..11, Font::Body, |s| s.set_bold(false));
698 assert!(
699 d.runs.is_empty(),
700 "unbolding everything leaves nothing to say: {:?}",
701 d.runs
702 );
703 }
704
705 #[test]
706 fn reflow_moves_ranges_over_an_insert() {
707 let mut d = doc();
708 d.text = "hello woXXrld".into();
710 d.reflow(8, 0, 2);
711 assert_eq!(d.runs[0].range, 0..5, "a run before the edit is untouched");
712
713 let mut d = doc();
714 d.text = "heXXllo world".into();
716 d.reflow(2, 0, 2);
717 assert_eq!(d.runs[0].range, 0..7);
718 }
719
720 #[test]
721 fn typing_at_the_end_of_a_run_continues_it() {
722 let mut d = doc();
723 d.text = "helloX world".into();
724 d.reflow(5, 0, 1);
725 assert_eq!(d.runs[0].range, 0..6, "the bold run took the new character");
726 }
727
728 #[test]
729 fn reflow_shrinks_and_drops_over_a_delete() {
730 let mut d = doc();
731 d.text = "hworld".into();
733 d.reflow(1, 5, 0);
734 assert_eq!(d.runs[0].range, 0..1);
735
736 let mut d = doc();
737 d.text = " world".into();
739 d.reflow(0, 5, 0);
740 assert!(d.runs.is_empty(), "{:?}", d.runs);
741 }
742
743 #[test]
744 fn splice_edits_text_and_ranges_together() {
745 let mut d = doc();
746 d.splice(5..5, ",");
747 assert_eq!(d.text, "hello, world");
748 assert_eq!(d.runs[0].range, 0..6);
749 assert!(d.validate().is_ok());
750 }
751
752 #[test]
753 fn clamp_pulls_ranges_off_multi_byte_boundaries() {
754 let mut d = StyledText::new("héllo", vec![bold_run(0..2)]);
755 d.text = "h".into(); d.clamp();
757 assert!(d.validate().is_ok());
758 assert!(d.runs.iter().all(|r| r.range.end <= 1));
759 }
760
761 #[test]
762 fn paragraph_bounds_include_the_terminator_and_the_blank_last_line() {
763 assert_eq!(paragraph_bounds(""), Vec::new());
764 assert_eq!(paragraph_bounds("a"), vec![(0, 1)]);
765 assert_eq!(paragraph_bounds("a\n"), vec![(0, 2), (2, 2)]);
766 assert_eq!(paragraph_bounds("a\nbb\nc"), vec![(0, 2), (2, 5), (5, 6)]);
767 }
768
769 #[test]
770 fn apply_paragraph_styles_the_whole_paragraph_a_caret_sits_in() {
771 let mut d = StyledText::plain("one\ntwo\nthree");
772 d.apply_paragraph(5..5, |p| p.align = ParagraphAlign::Center);
773 assert_eq!(d.paragraphs.len(), 1);
774 assert_eq!(d.paragraphs[0].range, 4..8, "the whole second paragraph");
775 assert_eq!(d.paragraphs[0].align, ParagraphAlign::Center);
776 assert_eq!(d.paragraph_style_of(5..5).align, ParagraphAlign::Center);
777 assert_eq!(
778 d.paragraph_style_of(0..13).align,
779 ParagraphAlign::Natural,
780 "a mixed selection reports the default"
781 );
782 assert!(d.validate().is_ok());
783 }
784
785 #[test]
786 fn paragraph_ranges_reflow_with_the_text() {
787 let mut d = StyledText::plain("one\ntwo");
788 d.apply_paragraph(5..5, |p| p.list = ListStyle::Bullet);
789 assert_eq!(d.paragraphs[0].range, 4..7);
790 d.splice(0..0, "XY");
791 assert_eq!(d.paragraphs[0].range, 6..9);
792 assert!(d.validate().is_ok());
793 }
794
795 #[test]
796 fn font_scale_resolves_against_the_platforms_style_size() {
797 let s = FontSpec::new(Font::Body).scaled(1.5);
798 assert_eq!(s.resolved_points(16.0), 24.0);
799 let abs = FontSpec::new(Font::System(14.0));
801 assert_eq!(abs.resolved_points(16.0), 14.0);
802 assert_eq!(abs.scaled(2.0).resolved_points(16.0), 28.0);
803 }
804}