Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/formatted_text.rs
StratoSDK / crates / strato-ui-core / src / formatted_text.rs
1use std::ops::Range;
2use std::sync::Arc;
3 
4use crate::text::header::BlockHeaderSize;
5use crate::Action;
6 
7pub mod weight {
8 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9 pub enum CustomWeight {
10 Thin,
11 ExtraLight,
12 Light,
13 Medium,
14 Semibold,
15 Bold,
16 ExtraBold,
17 Black,
18 }
19}
20 
21use weight::CustomWeight;
22 
23#[derive(Clone, Debug, Default)]
24pub struct FormattedText {
25 pub lines: Vec<FormattedTextLine>,
26}
27 
28impl FormattedText {
29 pub fn new<I>(lines: I) -> Self
30 where
31 I: IntoIterator<Item = FormattedTextLine>,
32 {
33 Self {
34 lines: lines.into_iter().collect(),
35 }
36 }
37 
38 pub fn append_line(mut self, line: FormattedTextLine) -> Self {
39 self.lines.push(line);
40 self
41 }
42}
43 
44#[derive(Clone, Debug)]
45pub enum FormattedTextLine {
46 Heading(HeadingLine),
47 Line(Vec<FormattedTextFragment>),
48 TaskList(TaskListLine),
49 OrderedList(OrderedListLine),
50 UnorderedList(UnorderedListLine),
51 CodeBlock(CodeBlockLine),
52 Table(TableBlock),
53 LineBreak,
54 HorizontalRule,
55 Embedded(String),
56 Image(String),
57}
58 
59impl FormattedTextLine {
60 pub fn set_weight(&mut self, weight: Option<CustomWeight>) {
61 for fragment in self.fragments_mut() {
62 fragment.styles.weight = weight;
63 }
64 }
65 
66 pub fn hyperlinks(&self, _include_images: bool) -> Vec<(Range<usize>, Hyperlink)> {
67 let mut links = Vec::new();
68 let mut offset = 0;
69 
70 for fragment in self.fragments() {
71 let len = fragment.text.chars().count();
72 if let Some(link) = &fragment.link {
73 links.push((offset..offset + len, link.clone()));
74 }
75 offset += len;
76 }
77 
78 links
79 }
80 
81 fn fragments(&self) -> &[FormattedTextFragment] {
82 match self {
83 Self::Heading(line) => &line.text,
84 Self::Line(text)
85 | Self::OrderedList(OrderedListLine {
86 indented_text: IndentedText { text, .. },
87 ..
88 })
89 | Self::UnorderedList(UnorderedListLine { text, .. })
90 | Self::TaskList(TaskListLine { text, .. }) => text,
91 Self::CodeBlock(_)
92 | Self::Table(_)
93 | Self::LineBreak
94 | Self::HorizontalRule
95 | Self::Embedded(_)
96 | Self::Image(_) => &[],
97 }
98 }
99 
100 fn fragments_mut(&mut self) -> &mut [FormattedTextFragment] {
101 match self {
102 Self::Heading(line) => &mut line.text,
103 Self::Line(text)
104 | Self::OrderedList(OrderedListLine {
105 indented_text: IndentedText { text, .. },
106 ..
107 })
108 | Self::UnorderedList(UnorderedListLine { text, .. })
109 | Self::TaskList(TaskListLine { text, .. }) => text,
110 Self::CodeBlock(_)
111 | Self::Table(_)
112 | Self::LineBreak
113 | Self::HorizontalRule
114 | Self::Embedded(_)
115 | Self::Image(_) => &mut [],
116 }
117 }
118}
119 
120#[derive(Clone, Debug)]
121pub struct HeadingLine {
122 pub heading_size: BlockHeaderSize,
123 pub text: Vec<FormattedTextFragment>,
124}
125 
126#[derive(Clone, Debug)]
127pub struct TaskListLine {
128 pub text: Vec<FormattedTextFragment>,
129 pub checked: bool,
130}
131 
132#[derive(Clone, Debug)]
133pub struct OrderedListLine {
134 pub indented_text: IndentedText,
135 pub number: usize,
136}
137 
138#[derive(Clone, Debug)]
139pub struct UnorderedListLine {
140 pub text: Vec<FormattedTextFragment>,
141 pub indent_level: usize,
142}
143 
144#[derive(Clone, Debug)]
145pub struct IndentedText {
146 pub text: Vec<FormattedTextFragment>,
147 pub indent_level: usize,
148}
149 
150#[derive(Clone, Debug)]
151pub struct CodeBlockLine {
152 pub language: Option<String>,
153 pub code: String,
154}
155 
156#[derive(Clone, Debug, Default)]
157pub struct TableBlock {
158 pub rows: Vec<Vec<String>>,
159}
160 
161impl TableBlock {
162 pub fn to_plain_text(&self) -> String {
163 self.rows
164 .iter()
165 .map(|row| row.join("\t"))
166 .collect::<Vec<_>>()
167 .join("\n")
168 }
169}
170 
171#[derive(Clone, Debug)]
172pub struct FormattedTextFragment {
173 pub text: String,
174 pub styles: FragmentStyles,
175 pub link: Option<Hyperlink>,
176}
177 
178impl FormattedTextFragment {
179 pub fn plain_text(text: impl Into<String>) -> Self {
180 Self {
181 text: text.into(),
182 styles: FragmentStyles::default(),
183 link: None,
184 }
185 }
186 
187 pub fn hyperlink_url(text: impl Into<String>, url: impl Into<String>) -> Self {
188 Self {
189 text: text.into(),
190 styles: FragmentStyles {
191 underline: true,
192 ..Default::default()
193 },
194 link: Some(Hyperlink::Url(url.into())),
195 }
196 }
197 
198 pub fn hyperlink_action<A>(text: impl Into<String>, action: A) -> Self
199 where
200 A: Action + 'static,
201 {
202 Self {
203 text: text.into(),
204 styles: FragmentStyles {
205 underline: true,
206 ..Default::default()
207 },
208 link: Some(Hyperlink::Action(Arc::new(action))),
209 }
210 }
211}
212 
213#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
214pub struct FragmentStyles {
215 pub weight: Option<CustomWeight>,
216 pub italic: bool,
217 pub strikethrough: bool,
218 pub underline: bool,
219 pub inline_code: bool,
220}
221 
222#[derive(Clone, Debug)]
223pub enum Hyperlink {
224 Url(String),
225 Action(Arc<dyn Action>),
226}
227 
228pub fn parse_markdown(markdown: &str) -> Result<FormattedText, String> {
229 let mut lines = Vec::new();
230 let mut code_block_language: Option<String> = None;
231 let mut code_block = String::new();
232 
233 for line in markdown.lines() {
234 if let Some(language) = line.strip_prefix("```") {
235 if code_block_language.is_some() {
236 lines.push(FormattedTextLine::CodeBlock(CodeBlockLine {
237 language: code_block_language.take().filter(|value| !value.is_empty()),
238 code: code_block.trim_end_matches('\n').to_owned(),
239 }));
240 code_block.clear();
241 } else {
242 code_block_language = Some(language.trim().to_owned());
243 }
244 continue;
245 }
246 
247 if code_block_language.is_some() {
248 code_block.push_str(line);
249 code_block.push('\n');
250 continue;
251 }
252 
253 lines.push(parse_line(line));
254 }
255 
256 if code_block_language.is_some() {
257 lines.push(FormattedTextLine::CodeBlock(CodeBlockLine {
258 language: code_block_language.filter(|value| !value.is_empty()),
259 code: code_block.trim_end_matches('\n').to_owned(),
260 }));
261 }
262 
263 Ok(FormattedText::new(lines))
264}
265 
266fn parse_line(line: &str) -> FormattedTextLine {
267 if line.is_empty() {
268 return FormattedTextLine::LineBreak;
269 }
270 
271 if let Some((level, text)) = heading(line) {
272 return FormattedTextLine::Heading(HeadingLine {
273 heading_size: BlockHeaderSize::try_from(level).unwrap_or(BlockHeaderSize::Header6),
274 text: parse_fragments(text),
275 });
276 }
277 
278 if let Some(text) = line.strip_prefix("* ").or_else(|| line.strip_prefix("- ")) {
279 return FormattedTextLine::UnorderedList(UnorderedListLine {
280 text: parse_fragments(text),
281 indent_level: 0,
282 });
283 }
284 
285 FormattedTextLine::Line(parse_fragments(line))
286}
287 
288fn heading(line: &str) -> Option<(usize, &str)> {
289 let hashes = line.chars().take_while(|ch| *ch == '#').count();
290 if (1..=6).contains(&hashes) && line.as_bytes().get(hashes) == Some(&b' ') {
291 Some((hashes, line[hashes + 1..].trim()))
292 } else {
293 None
294 }
295}
296 
297fn parse_fragments(text: &str) -> Vec<FormattedTextFragment> {
298 let mut fragments = Vec::new();
299 let mut remaining = text;
300 
301 while let Some(open_label) = remaining.find('[') {
302 let before = &remaining[..open_label];
303 if !before.is_empty() {
304 fragments.push(FormattedTextFragment::plain_text(before));
305 }
306 
307 let after_open_label = &remaining[open_label + 1..];
308 let Some(close_label) = after_open_label.find(']') else {
309 fragments.push(FormattedTextFragment::plain_text(after_open_label));
310 return fragments;
311 };
312 
313 let after_close_label = &after_open_label[close_label + 1..];
314 if !after_close_label.starts_with('(') {
315 fragments.push(FormattedTextFragment::plain_text("["));
316 remaining = after_open_label;
317 continue;
318 }
319 
320 let Some(close_url) = after_close_label[1..].find(')') else {
321 fragments.push(FormattedTextFragment::plain_text("["));
322 remaining = after_open_label;
323 continue;
324 };
325 
326 let label = &after_open_label[..close_label];
327 let url = &after_close_label[1..1 + close_url];
328 fragments.push(FormattedTextFragment::hyperlink_url(label, url));
329 remaining = &after_close_label[close_url + 2..];
330 }
331 
332 if !remaining.is_empty() {
333 fragments.push(FormattedTextFragment::plain_text(remaining));
334 }
335 
336 fragments
337}
338 
339#[cfg(test)]
340mod tests {
341 use super::*;
342 
343 #[test]
344 fn parses_basic_strato_markdown_smoke_test() {
345 let formatted = parse_markdown(
346 "# Strato UI\n\n- [Core](https://stratosdk.dev)\n```rust\nlet ui = \"strato\";\n```",
347 )
348 .expect("markdown should parse");
349 
350 assert_eq!(formatted.lines.len(), 4);
351 
352 match &formatted.lines[0] {
353 FormattedTextLine::Heading(line) => {
354 assert_eq!(line.text[0].text, "Strato UI");
355 }
356 other => panic!("expected heading, got {other:?}"),
357 }
358 
359 match &formatted.lines[2] {
360 FormattedTextLine::UnorderedList(line) => {
361 assert_eq!(line.text[0].text, "Core");
362 assert!(matches!(line.text[0].link, Some(Hyperlink::Url(_))));
363 }
364 other => panic!("expected unordered list, got {other:?}"),
365 }
366 
367 match &formatted.lines[3] {
368 FormattedTextLine::CodeBlock(block) => {
369 assert_eq!(block.language.as_deref(), Some("rust"));
370 assert_eq!(block.code, "let ui = \"strato\";");
371 }
372 other => panic!("expected code block, got {other:?}"),
373 }
374 }
375}
376