1use omp_core::{Str, StrMut};
71
72use crate::{
73 component::{Cached, Component},
74 components::{
75 Boxed, Button, Callout, Col, CustomElement, EditorPane, Field, Form, Hr, Icon, Img, Input,
76 Latex, Markdown, Pre, Progress, Radio, Row, Scroll, Segment, Select, SelectOption, Spacer,
77 Spinner, Status, Table, TableCell, TableRow, Tabs, TaskStatus, TextLeaf, Todo, TodoTask,
78 Tree, TreeNode, Wizard,
79 },
80 context::{Charset, UiContext},
81 props::{Prop, PropValue, Props},
82};
83
84#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
86pub enum Border {
87 #[default]
89 Square,
90 Dash,
92 Round,
94 Heavy,
96 Double,
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum Dim {
103 Cells(u16),
105 Pct(u8),
107}
108
109#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
111pub enum Align {
112 #[default]
113 Start,
114 Center,
115 End,
116}
117#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
119pub enum Justify {
120 #[default]
121 Start,
122 Center,
123 End,
124 Between,
125}
126
127#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
129pub enum Truncate {
130 #[default]
132 End,
133 Start,
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum VAlign {
145 Start,
147 Center,
149 End,
151 Stretch,
153}
154
155#[derive(Debug)]
157pub struct ParseError {
158 pub message: String,
160 pub at: usize,
162}
163
164impl std::fmt::Display for ParseError {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 write!(f, "markup error at byte {}: {}", self.at, self.message)
167 }
168}
169
170impl std::error::Error for ParseError {}
171
172pub fn parse(source: &Str, ctx: &UiContext) -> Result<Cached, ParseError> {
174 let mut parser = Parser { source, src: source, ctx, fragment: false };
175 let (parts, _) = parser.parse_children(0, None, false, 0, "col", &Props::new())?;
176 let children = cached_children(parts, "col")?;
177 let root = build("col", Props::new(), children, &Str::default())
178 .expect("the root col is a catalog component");
179 Ok(Cached::new(root))
180}
181
182pub fn parse_md_fragment_inheriting(
183 text: &Str,
184 ctx: &UiContext,
185 host: &Props,
186) -> Result<Vec<Cached>, ParseError> {
187 if text.is_empty() {
188 return Ok(Vec::new());
189 }
190 let inherited = child_props(host);
191 let mut parser = Parser { source: text, src: text, ctx, fragment: true };
192 let (first, mut children, _) = parser.scan_md(0, 0, &inherited, false)?;
193 children.insert(0, markdown_part(first, inherited));
194 Ok(children)
195}
196
197#[allow(
198 clippy::large_enum_variant,
199 reason = "parser nodes move once into their final owners; boxing the larger variants would add \
200 one allocation per parsed node"
201)]
202enum Parsed {
203 Cached { cached: Box<Cached>, text: Option<Str>, at: usize, implicit: bool },
204 Option { option: SelectOption, at: usize },
205 Segment { segment: Segment, at: usize },
206 Tab { title: Str, children: Vec<Cached>, at: usize },
207 TreeItem { node: TreeNode, at: usize },
208 Task { task: TodoTask, at: usize },
209 Field { field: Field, at: usize },
210 Step { title: Str, children: Vec<Cached>, at: usize },
211 TableRow { row: Box<TableRow>, at: usize },
212 Cell { cell: TableCell, at: usize },
213}
214
215impl Parsed {
216 fn into_cached(self, parent: &str) -> Result<Cached, ParseError> {
217 match self {
218 Self::Cached { cached, .. } => Ok(*cached),
219 Self::Option { at, .. } => Err(parent_error("option", parent, at)),
220 Self::Segment { at, .. } => Err(parent_error("segment", parent, at)),
221 Self::Tab { at, .. } => Err(parent_error("tab", parent, at)),
222 Self::TreeItem { at, .. } => Err(parent_error("node", parent, at)),
223 Self::Task { at, .. } => Err(parent_error("task", parent, at)),
224 Self::Field { at, .. } => Err(parent_error("field", parent, at)),
225 Self::Step { at, .. } => Err(parent_error("step", parent, at)),
226 Self::TableRow { at, .. } => Err(parent_error("tr", parent, at)),
227 Self::Cell { at, .. } => Err(parent_error("td", parent, at)),
228 }
229 }
230
231 const fn name(&self) -> &'static str {
232 match self {
233 Self::Cached { .. } => "content",
234 Self::Option { .. } => "option",
235 Self::Segment { .. } => "segment",
236 Self::Tab { .. } => "tab",
237 Self::TreeItem { .. } => "node",
238 Self::Task { .. } => "task",
239 Self::Field { .. } => "field",
240 Self::Step { .. } => "step",
241 Self::TableRow { .. } => "tr",
242 Self::Cell { .. } => "td",
243 }
244 }
245}
246
247fn parent_error(tag: &str, parent: &str, at: usize) -> ParseError {
248 ParseError { message: format!("<{tag}> is not allowed directly inside <{parent}>"), at }
249}
250
251fn cached_children(parts: Vec<Parsed>, parent: &str) -> Result<Vec<Cached>, ParseError> {
252 parts
253 .into_iter()
254 .map(|part| part.into_cached(parent))
255 .collect()
256}
257
258struct Parser<'a> {
259 source: &'a Str,
260 src: &'a str,
261 ctx: &'a UiContext,
262 fragment: bool,
263}
264
265impl Parser<'_> {
266 fn parse_children(
268 &mut self,
269 body_start: usize,
270 closing: Option<&str>,
271 restricted: bool,
272 indent: usize,
273 parent_tag: &str,
274 parent_props: &Props,
275 ) -> Result<(Vec<Parsed>, usize), ParseError> {
276 let mut parts = Vec::new();
277 let mut segment_start = body_start;
278 let mut at = body_start;
279 let mut fence = FenceScan::segment(indent);
280 while at < self.src.len() {
281 let Some(offset) = self.src[at..].find('<') else {
282 break;
283 };
284 fence.consume(&self.src[at..at + offset]);
285 at += offset;
286 if self.src[at..].starts_with("<!--") {
287 let skip = self.src[at + 4..]
288 .find("-->")
289 .map_or(1, |end| end + 4 + "-->".len());
290 fence.skip(&self.src[at..at + skip]);
291 at += skip;
292 continue;
293 }
294 let name = tag_name(&self.src[at + 1..]);
295 let escaped = self.src[segment_start..at]
296 .bytes()
297 .rev()
298 .take_while(|&byte| byte == b'\\')
299 .count() % 2
300 == 1;
301 let literal = escaped
302 || fence.in_code()
303 || fence.in_code_span(&self.src[at..])
304 || in_math_span(&self.src[segment_start..], at - segment_start);
305 if literal || name.is_none() {
306 fence.consume(&self.src[at..=at]);
307 at += 1;
308 continue;
309 }
310 let name = name.unwrap_or_default();
311 let Some(close) = tag_close(&self.src[at + 1..]).map(|end| end + at + 1) else {
312 break;
313 };
314 let is_closing = self.src[at + 1..].starts_with('/');
315 let raw = &self.src[at + 1..close];
316 let self_closing = raw.ends_with('/');
317 let catalog = is_catalog_tag(name);
318 let custom = !catalog
319 && !is_markdown_html_tag(name)
320 && if is_closing {
321 closing == Some(name)
322 } else {
323 self_closing || has_matching_close(&self.src[close + 1..], name)
324 };
325 if !catalog && !custom {
326 fence.consume(&self.src[at..=at]);
327 at += 1;
328 continue;
329 }
330 if is_closing {
331 if closing != Some(name) {
332 return Err(ParseError {
333 message: format!(
334 "closing </{name}> does not match open <{}>",
335 closing.unwrap_or("nothing")
336 ),
337 at,
338 });
339 }
340 self.add_text(&mut parts, parent_tag, parent_props, segment_start, at, indent);
341 return Ok((parts, close + 1));
342 }
343 self.add_text(&mut parts, parent_tag, parent_props, segment_start, at, indent);
344 let (part, next) = self.parse_element(at, close, restricted, parent_props)?;
345 parts.push(part);
346 at = next;
347 segment_start = at;
348 fence = FenceScan::segment(indent);
349 }
350 if closing.is_some() {
351 return Err(ParseError {
352 message: format!("unclosed <{}> tag", closing.unwrap_or_default()),
353 at: self.src.len(),
354 });
355 }
356 self.add_text(&mut parts, parent_tag, parent_props, segment_start, self.src.len(), indent);
357 Ok((parts, self.src.len()))
358 }
359
360 fn parse_element(
361 &mut self,
362 at: usize,
363 close: usize,
364 restricted: bool,
365 inherited: &Props,
366 ) -> Result<(Parsed, usize), ParseError> {
367 let raw = &self.src[at + 1..close];
368 let self_closing = raw.ends_with('/');
369 let raw = raw.strip_suffix('/').unwrap_or(raw);
370 let (name, attrs) = raw.split_once(char::is_whitespace).unwrap_or((raw, ""));
371 let indent = leading_spaces(line_of(self.src, at));
372 if restricted && is_interactive_tag(name) {
373 return Err(ParseError {
374 message: format!("interactive tag <{name}> is not allowed inside <md>"),
375 at,
376 });
377 }
378 let mut props = apply_attrs(attrs, at, self.source, self.ctx, inherited)?;
379 let tag = self.source.slice_ref(name);
380 let name = tag.as_str();
381 if self.fragment && (props.get(Prop::Id).is_some() || props.get(Prop::When).is_some()) {
382 return Err(ParseError {
383 message: "id= and when= are not allowed in dynamic Markdown".into(),
384 at,
385 });
386 }
387 if name == "box" {
388 if props.get(Prop::Border).is_none() {
389 props
390 .try_set(Prop::Border, PropValue::Border(Border::Square))
391 .unwrap();
392 }
393 if props.get(Prop::PadX).is_none() {
394 props.try_set(Prop::PadX, PropValue::U16(1)).unwrap();
395 }
396 } else if name == "spacer" && props.get(Prop::Grow).is_none() {
397 props.try_set(Prop::Grow, PropValue::F32(1.0)).unwrap();
398 }
399 let body_start = close + 1;
400 if self_closing {
401 return finish_element(name, props, Vec::new(), Str::default(), at)
402 .map(|part| (part, body_start));
403 }
404 if matches!(name, "pre" | "latex" | "callout") {
405 let closer = match name {
406 "pre" => "</pre>",
407 "latex" => "</latex>",
408 "callout" => "</callout>",
409 _ => unreachable!(),
410 };
411 let end = self.src[body_start..]
412 .find(closer)
413 .map_or(self.src.len(), |offset| body_start + offset);
414 let trim: &[_] = if name == "pre" {
415 &['\n', '\r']
416 } else {
417 &['\n']
418 };
419 let body = self.src[body_start..end].trim_matches(trim);
420 let body = self.source.slice_ref(body);
421 let part = finish_element(name, props, Vec::new(), body, at)?;
422 return Ok((part, (end + closer.len()).min(self.src.len())));
423 }
424 if matches!(name, "text" | "button" | "icon") {
425 let closer = match name {
426 "text" => "</text>",
427 "button" => "</button>",
428 "icon" => "</icon>",
429 _ => unreachable!(),
430 };
431 let end = self.src[body_start..]
432 .find(closer)
433 .map(|offset| body_start + offset)
434 .ok_or_else(|| ParseError {
435 message: format!("unclosed <{name}> tag"),
436 at: self.src.len(),
437 })?;
438 let body = self
439 .source
440 .slice_ref(self.src[body_start..end].trim_matches(['\n', '\r']));
441 let part = finish_element(name, props, Vec::new(), body, at)?;
442 return Ok((part, end + closer.len()));
443 }
444 if name == "md" {
445 return self.parse_md(body_start, indent, props, true);
446 }
447 if is_leaf_tag(name) {
448 return finish_element(name, props, Vec::new(), Str::default(), at)
449 .map(|part| (part, body_start));
450 }
451 let child_props = child_props(&props);
452 let (parts, end) =
453 self.parse_children(body_start, Some(name), restricted, indent, name, &child_props)?;
454 let part = finish_element(name, props, parts, Str::default(), at)?;
455 Ok((part, end))
456 }
457
458 fn parse_md(
459 &mut self,
460 body_start: usize,
461 indent: usize,
462 props: Props,
463 require_close: bool,
464 ) -> Result<(Parsed, usize), ParseError> {
465 let (text, children, end) = self.scan_md(body_start, indent, &props, require_close)?;
466 Ok((markdown_with_parts(props, text, children, body_start, false), end))
467 }
468
469 fn scan_md(
470 &mut self,
471 body_start: usize,
472 indent: usize,
473 props: &Props,
474 require_close: bool,
475 ) -> Result<(Str, Vec<Cached>, usize), ParseError> {
476 let mut segment_start = body_start;
477 let mut first = true;
478 let mut first_text = Str::default();
479 let mut embedded = Vec::new();
480 let child_props = child_props(props);
481 loop {
482 let event = self.next_md_event(segment_start, indent, require_close)?;
483 let (end, element) = match event {
484 MdEvent::Close(at) | MdEvent::End(at) => (at, None),
485 MdEvent::Element(at, close) => (at, Some((at, close))),
486 };
487 let body = self.src[segment_start..end].trim_matches('\n');
488 let text = dedent(self.source, body, indent);
489 if first {
490 first_text = text;
491 first = false;
492 } else {
493 embedded.push(markdown_part(text, child_props.clone()));
494 }
495 let Some((at, close)) = element else {
496 let next = if require_close {
497 end + "</md>".len()
498 } else {
499 end
500 };
501 return Ok((first_text, embedded, next));
502 };
503 let (part, next) = self.parse_element(at, close, true, &child_props)?;
504 embedded.push(part.into_cached("md")?);
505 segment_start = next;
506 }
507 }
508
509 fn next_md_event(
510 &self,
511 mut at: usize,
512 indent: usize,
513 require_close: bool,
514 ) -> Result<MdEvent, ParseError> {
515 let mut fence: Option<(u8, usize)> = None;
516 while at < self.src.len() {
517 let line_end = self.src[at..].find('\n').map_or(self.src.len(), |p| p + at);
518 let line = self.src[at..line_end]
519 .strip_suffix('\r')
520 .unwrap_or_else(|| &self.src[at..line_end]);
521 let trimmed = line.trim_start();
522 let prefix = &line[..line.len() - trimmed.len()];
523 let spaces = prefix.as_bytes().iter().take_while(|&&b| b == b' ').count();
524 let indented_code = spaces >= indent + 4 || prefix.contains('\t');
525 if let Some((marker, length)) = fence {
526 let run = trimmed
527 .as_bytes()
528 .iter()
529 .take_while(|&&b| b == marker)
530 .count();
531 if run >= length {
532 fence = None;
533 if let Some(pos) = trimmed[run..].find("</md>") {
534 let close_at = at + (line.len() - trimmed.len()) + run + pos;
535 if require_close {
536 return Ok(MdEvent::Close(close_at));
537 }
538 return Err(stray_md_close(close_at));
539 }
540 }
541 } else {
542 let marker = trimmed.as_bytes().first().copied();
543 let run =
544 marker.map_or(0, |m| trimmed.as_bytes().iter().take_while(|&&b| b == m).count());
545 if matches!(marker, Some(b'`' | b'~')) && run >= 3 && !indented_code {
546 fence = Some((marker.unwrap_or_default(), run));
547 } else {
548 let tag_at = at + (line.len() - trimmed.len());
549 if !indented_code && let Some((name, close)) = line_tag(trimmed, tag_at) {
550 if is_md_block_tag(name) || is_custom_tag_at(self.src, name, tag_at, close) {
551 return Ok(MdEvent::Element(tag_at, close));
552 }
553 if is_interactive_tag(name) {
554 return Err(ParseError {
555 message: format!("interactive tag <{name}> is not allowed inside <md>"),
556 at: tag_at,
557 });
558 }
559 }
560 if let Some(close) = line.find("</md>") {
561 let close_at = at + close;
562 if require_close {
563 return Ok(MdEvent::Close(close_at));
564 }
565 return Err(stray_md_close(close_at));
566 }
567 }
568 }
569 at = if line_end < self.src.len() {
570 line_end + 1
571 } else {
572 line_end
573 };
574 }
575 if require_close {
576 Err(ParseError { message: "unclosed <md> tag".into(), at: self.src.len() })
577 } else {
578 Ok(MdEvent::End(self.src.len()))
579 }
580 }
581
582 fn add_text(
583 &self,
584 parts: &mut Vec<Parsed>,
585 parent_tag: &str,
586 parent_props: &Props,
587 start: usize,
588 end: usize,
589 indent: usize,
590 ) {
591 let raw = &self.src[start..end];
592 if raw.trim().is_empty() {
593 return;
594 }
595 let props = parent_props.clone();
598 if parent_tag == "row" {
599 let mut table_start: Option<usize> = None;
600 let mut offset = 0;
601 for line in raw.split_inclusive('\n') {
602 let content = line.trim();
603 let at = start + offset;
604 offset += line.len();
605 if content.starts_with('|') {
606 table_start.get_or_insert(at);
607 continue;
608 }
609 if let Some(from) = table_start.take() {
610 let chunk = self.src[from..at].trim_matches(['\n', '\r']);
611 if !chunk.trim().is_empty() {
612 let text = dedent(self.source, chunk, indent);
613 parts.push(markdown_parsed(text, props.clone(), from));
614 }
615 }
616 if !content.is_empty() {
617 let text = self.source.slice_ref(content);
618 parts.push(markdown_parsed(text, props.clone(), at));
619 }
620 }
621 if let Some(from) = table_start {
622 let chunk = self.src[from..end].trim_matches(['\n', '\r']);
623 if !chunk.trim().is_empty() {
624 let text = dedent(self.source, chunk, indent);
625 parts.push(markdown_parsed(text, props, from));
626 }
627 }
628 } else {
629 let text = dedent(self.source, raw.trim_matches(['\n', '\r']), indent);
630 parts.push(markdown_parsed(text, props, start));
631 }
632 }
633}
634fn dedent(source: &Str, body: &str, indent: usize) -> Str {
640 if indent == 0 || !body.lines().any(|line| line.starts_with(' ')) {
641 return source.slice_ref(body);
642 }
643 let mut out = StrMut::with_capacity(body.len());
644 for (index, line) in body.split('\n').enumerate() {
645 if index > 0 {
646 out.push_str("\n");
647 }
648 out.push_str(&line[leading_spaces(line).min(indent)..]);
649 }
650 out.freeze()
651}
652
653fn leading_spaces(line: &str) -> usize {
655 line.bytes().take_while(|byte| *byte == b' ').count()
656}
657
658fn line_of(src: &str, at: usize) -> &str {
662 let start = src[..at].rfind('\n').map_or(0, |newline| newline + 1);
663 &src[start..at]
664}
665
666struct FenceScan {
669 fence: Option<(u8, usize)>,
670 code: Option<usize>,
672 indented: bool,
674 indent: usize,
678 at_line_start: bool,
679}
680
681impl FenceScan {
682 const fn segment(indent: usize) -> Self {
687 Self { fence: None, code: None, indented: false, indent, at_line_start: true }
688 }
689
690 const fn in_code(&self) -> bool {
693 self.fence.is_some() || self.indented
694 }
695
696 fn in_code_span(&self, tail: &str) -> bool {
703 let Some(length) = self.code else {
704 return false;
705 };
706 let bytes = tail.as_bytes();
707 let mut at = 0;
708 while at < bytes.len() {
709 if bytes[at] != b'`' {
710 at += 1;
711 continue;
712 }
713 let run = bytes[at..].iter().take_while(|&&byte| byte == b'`').count();
714 if run == length {
715 return true;
716 }
717 at += run;
718 }
719 false
720 }
721
722 fn skip(&mut self, text: &str) {
725 for piece in text.split_inclusive('\n') {
726 self.at_line_start = piece.ends_with('\n');
727 if self.at_line_start {
728 self.indented = false;
729 }
730 }
731 }
732
733 fn consume(&mut self, run: &str) {
734 for piece in run.split_inclusive('\n') {
735 let line = piece.trim_end_matches(['\n', '\r']);
736 if self.at_line_start {
737 self.indented = leading_spaces(line) >= self.indent + 4;
740 }
741 let fenced = self.at_line_start && self.fence_marker(line);
742 if !fenced && self.fence.is_none() {
743 self.code_spans(line);
744 }
745 self.at_line_start = piece.ends_with('\n');
746 if self.at_line_start {
747 self.indented = false;
750 }
751 }
752 }
753
754 fn fence_marker(&mut self, line: &str) -> bool {
760 let trimmed = line.trim_start_matches(' ');
761 if line.len().saturating_sub(trimmed.len()) > 3 {
762 return false;
763 }
764 let Some(marker) = trimmed.as_bytes().first().copied() else {
765 return false;
766 };
767 let run = trimmed.bytes().take_while(|&byte| byte == marker).count();
768 if let Some((open, length)) = self.fence {
769 let closes = marker == open && run >= length && trimmed[run..].trim().is_empty();
770 if closes {
771 self.fence = None;
772 }
773 return closes;
774 }
775 let opens = matches!(marker, b'`' | b'~') && run >= 3;
776 if opens {
777 self.fence = Some((marker, run));
778 self.code = None;
779 }
780 opens
781 }
782
783 fn code_spans(&mut self, line: &str) {
786 let bytes = line.as_bytes();
787 let mut at = 0;
788 while at < bytes.len() {
789 if bytes[at] != b'`' {
790 at += 1;
791 continue;
792 }
793 let run = bytes[at..].iter().take_while(|&&byte| byte == b'`').count();
794 match self.code {
795 Some(length) if length == run => self.code = None,
796 Some(_) => {},
797 None => self.code = Some(run),
798 }
799 at += run;
800 }
801 }
802}
803
804fn in_math_span(text: &str, offset: usize) -> bool {
808 let mut at = 0;
809 while at < offset {
810 let Some(next) = text[at..].find(['$', '\\']) else {
811 return false;
812 };
813 let start = at + next;
814 if start >= offset {
815 return false;
816 }
817 match crate::markdown::math_span(&text[start..]) {
818 Some((_, consumed)) if offset < start + consumed => return true,
819 Some((_, consumed)) => at = start + consumed,
820 None => at = start + 1,
821 }
822 }
823 false
824}
825
826fn tag_name(after: &str) -> Option<&str> {
832 let after = after.strip_prefix('/').unwrap_or(after);
833 let end = after
834 .find(|character: char| !(character.is_ascii_alphanumeric() || character == '-'))
835 .unwrap_or(after.len());
836 let name = &after[..end];
837 let rest = &after[end..];
838 let delimited =
839 rest.starts_with('>') || rest.starts_with("/>") || rest.starts_with(char::is_whitespace);
840 (delimited && name.starts_with(|character: char| character.is_ascii_alphabetic()))
841 .then_some(name)
842}
843
844const fn tag_close(after: &str) -> Option<usize> {
848 let bytes = after.as_bytes();
849 let mut index = 0;
850 while index < bytes.len() {
851 match bytes[index] {
852 b'>' => return Some(index),
853 quote @ (b'"' | b'\'') => {
854 index += 1;
855 while index < bytes.len() && bytes[index] != quote {
856 index += 1;
857 }
858 if index == bytes.len() {
859 return None;
860 }
861 index += 1;
862 },
863 _ => index += 1,
864 }
865 }
866 None
867}
868
869pub fn ico_tag(text: &str) -> Option<(&str, usize)> {
875 let rest = text.strip_prefix("<ico:")?;
876 let end = rest
877 .find(|character: char| {
878 !(character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-'))
879 })
880 .unwrap_or(rest.len());
881 let name = &rest[..end];
882 let tail = rest[end..].trim_start_matches(' ');
883 let tail = tail.strip_prefix('/').unwrap_or(tail);
884 let tail = tail.strip_prefix('>')?;
885 (!name.is_empty()).then(|| (name, text.len() - tail.len()))
886}
887
888fn resolve_icons(charset: Charset, source: &Str, value: &str) -> Str {
892 if !value.contains("<ico:") {
893 return source.slice_ref(value);
894 }
895 let mut resolved = StrMut::new("");
896 let mut rest = value;
897 while let Some(at) = rest.find("<ico:") {
898 if let Some((name, consumed)) = ico_tag(&rest[at..]) {
899 resolved.push_str(&rest[..at]);
900 resolved.push_str(charset.icon_named(name).unwrap_or(name));
901 rest = &rest[at + consumed..];
902 } else {
903 resolved.push_str(&rest[..at + "<ico:".len()]);
904 rest = &rest[at + "<ico:".len()..];
905 }
906 }
907 resolved.push_str(rest);
908 resolved.freeze()
909}
910
911enum MdEvent {
912 Close(usize),
913 Element(usize, usize),
914 End(usize),
915}
916
917fn line_tag(line: &str, at: usize) -> Option<(&str, usize)> {
918 let raw = line.strip_prefix('<')?;
919 let close = tag_close(raw)?;
920 let tag = raw[..close].strip_suffix('/').unwrap_or(&raw[..close]);
921 let name = tag
922 .split_once(char::is_whitespace)
923 .map_or(tag, |(name, _)| name);
924 Some((name, at + close + 1))
925}
926
927fn is_md_block_tag(name: &str) -> bool {
928 is_catalog_tag(name) && !is_interactive_tag(name)
929}
930
931fn is_markdown_html_tag(name: &str) -> bool {
932 matches!(name, "br" | "p" | "span" | "code" | "li" | "ul" | "ol" | "blockquote")
933}
934
935fn is_catalog_tag(name: &str) -> bool {
936 matches!(
937 name,
938 "col"
939 | "row"
940 | "box"
941 | "text"
942 | "pre"
943 | "md" | "latex"
944 | "hr" | "spacer"
945 | "select"
946 | "option"
947 | "radio"
948 | "spinner"
949 | "status"
950 | "segment"
951 | "input"
952 | "button"
953 | "scroll"
954 | "tabs"
955 | "tab"
956 | "tree"
957 | "node"
958 | "todo"
959 | "task"
960 | "form"
961 | "field"
962 | "progress"
963 | "img"
964 | "editor"
965 | "wizard"
966 | "step"
967 | "callout"
968 | "icon"
969 | "table"
970 | "tr" | "td"
971 )
972}
973
974fn is_interactive_tag(name: &str) -> bool {
975 matches!(
976 name,
977 "select"
978 | "option"
979 | "radio"
980 | "input"
981 | "button"
982 | "scroll"
983 | "tabs"
984 | "tab"
985 | "tree"
986 | "node"
987 | "form"
988 | "field"
989 | "editor"
990 | "wizard"
991 | "step"
992 )
993}
994
995fn is_leaf_tag(name: &str) -> bool {
996 matches!(name, "pre" | "hr" | "spacer" | "radio" | "input" | "progress" | "img")
997}
998
999fn has_matching_close(mut after: &str, name: &str) -> bool {
1000 while let Some(at) = after.find("</") {
1001 let tail = &after[at + 2..];
1002 if tail
1003 .strip_prefix(name)
1004 .is_some_and(|tail| tail.starts_with('>'))
1005 {
1006 return true;
1007 }
1008 after = tail;
1009 }
1010 false
1011}
1012
1013fn is_custom_tag_at(src: &str, name: &str, at: usize, close: usize) -> bool {
1014 !name.starts_with('/')
1015 && !is_catalog_tag(name)
1016 && !is_markdown_html_tag(name)
1017 && (src[at..=close].trim_end().ends_with("/>") || has_matching_close(&src[close + 1..], name))
1018}
1019
1020fn stray_md_close(at: usize) -> ParseError {
1021 ParseError { message: "closing </md> does not match open <nothing>".into(), at }
1022}
1023
1024pub fn md_embeds_markup(text: &str) -> bool {
1027 let mut fence: Option<(u8, usize)> = None;
1028 let mut at = 0;
1029 for piece in text.split_inclusive('\n') {
1030 let line = piece.strip_suffix('\n').unwrap_or(piece);
1031 let line = line.strip_suffix('\r').unwrap_or(line);
1032 let trimmed = line.trim_start();
1033 let prefix = &line[..line.len() - trimmed.len()];
1034 let spaces = prefix.bytes().take_while(|&b| b == b' ').count();
1035 let indented = spaces >= 4 || prefix.contains('\t');
1036 if let Some((marker, length)) = fence {
1037 let run = trimmed.bytes().take_while(|&b| b == marker).count();
1038 if run >= length {
1039 fence = None;
1040 }
1041 at += piece.len();
1042 continue;
1043 }
1044 let marker = trimmed.as_bytes().first().copied();
1045 let run = marker.map_or(0, |m| trimmed.bytes().take_while(|&b| b == m).count());
1046 if matches!(marker, Some(b'`' | b'~')) && run >= 3 && !indented {
1047 fence = Some((marker.unwrap_or_default(), run));
1048 at += piece.len();
1049 continue;
1050 }
1051 let tag_at = at + prefix.len();
1052 if !indented
1053 && let Some((name, close)) = line_tag(trimmed, tag_at)
1054 && (is_md_block_tag(name)
1055 || is_interactive_tag(name)
1056 || is_custom_tag_at(text, name, tag_at, close))
1057 {
1058 return true;
1059 }
1060 at += piece.len();
1061 }
1062 false
1063}
1064
1065fn child_props(parent: &Props) -> Props {
1066 let mut child = Props::new();
1067 for prop in [
1068 Prop::Fg,
1069 Prop::Bold,
1070 Prop::Dim,
1071 Prop::Italic,
1072 Prop::Underline,
1073 Prop::Reverse,
1074 Prop::Strike,
1075 Prop::Truncate,
1076 ] {
1077 if let Some(value) = parent.get(prop).cloned()
1078 && !matches!(value, PropValue::Gradient(_))
1079 {
1080 child.try_set(prop, value).unwrap();
1081 }
1082 }
1083 child
1084}
1085
1086macro_rules! replay_props {
1087 ($component:ident, $props:expr) => {
1088 for prop in <Prop as strum::IntoEnumIterator>::iter() {
1089 if let Some(value) = $props.get(prop).cloned() {
1090 $component = $component.with(prop, value);
1091 }
1092 }
1093 };
1094}
1095
1096fn boxed_component<T: Component + 'static>(mut component: T, props: Props) -> Box<dyn Component> {
1097 *component.props_mut() = props;
1098 Box::new(component)
1099}
1100
1101fn build(tag: &str, props: Props, children: Vec<Cached>, body: &Str) -> Option<Box<dyn Component>> {
1102 macro_rules! configured {
1103 ($component:expr) => {{
1104 let mut component = $component;
1105 replay_props!(component, props);
1106 boxed_component(component, props)
1107 }};
1108 }
1109 Some(match tag {
1110 "col" => configured!(Col::new().child(children)),
1111 "row" => configured!(Row::new().child(children)),
1112 "box" => configured!(Boxed::new().child(children)),
1113 "text" => configured!(TextLeaf::new().text(body.clone())),
1114 "pre" => configured!(Pre::new().text(body.clone())),
1115 "md" => configured!(Markdown::text_of(body.clone()).child(children)),
1116 "latex" => configured!(Latex::new().text(body.clone())),
1117 "hr" => configured!(Hr::new()),
1118 "spacer" => configured!(Spacer::new()),
1119 "select" => configured!(Select::new()),
1120 "table" => configured!(Table::new()),
1121 "radio" => configured!(Radio::new()),
1122 "spinner" => configured!(Spinner::new().label(body.clone())),
1123 "input" => configured!(Input::new()),
1124 "button" => configured!(Button::new().child(body.clone())),
1125 "scroll" => configured!(Scroll::new().child(children)),
1126 "tabs" => configured!(Tabs::new().child(children)),
1127 "tree" => configured!(Tree::new()),
1128 "todo" => configured!(Todo::new()),
1129 "form" => configured!(Form::new()),
1130 "progress" => configured!(Progress::new()),
1131 "img" => configured!(Img::new()),
1132 "editor" => configured!(EditorPane::new()),
1133 "wizard" => configured!(Wizard::new().child(children)),
1134 "callout" => configured!(Callout::new().text(body.clone())),
1135 "icon" => {
1136 let name = if body.is_empty() {
1137 props.str_of(Prop::Icon).cloned().unwrap_or_default()
1138 } else {
1139 body.clone()
1140 };
1141 configured!(Icon::named(name))
1142 },
1143 "option" | "segment" | "tab" | "node" | "field" | "step" | "task" | "tr" | "td" => {
1144 return None;
1145 },
1146 _ => return None,
1147 })
1148}
1149
1150fn finish_element(
1151 tag: &str,
1152 props: Props,
1153 mut parts: Vec<Parsed>,
1154 body: Str,
1155 at: usize,
1156) -> Result<Parsed, ParseError> {
1157 match tag {
1158 "option" => {
1159 let label = take_label(&mut parts).unwrap_or_default();
1160 let mut option = SelectOption::new();
1161 replay_props!(option, props);
1162 if !label.is_empty() {
1163 option = option.label(label);
1164 }
1165 for part in parts {
1166 match part {
1167 Parsed::Cell { cell, .. } => option = option.cell(cell),
1170 other => option = option.child(other.into_cached("option")?),
1171 }
1172 }
1173 Ok(Parsed::Option { option, at })
1174 },
1175 "td" => {
1176 let children = cached_children(parts, "td")?;
1177 let mut cell = TableCell::new().child(children);
1178 *cell.props_mut() = props;
1179 Ok(Parsed::Cell { cell, at })
1180 },
1181 "tr" => {
1182 let mut row = TableRow::new();
1183 replay_props!(row, props);
1184 for part in parts {
1185 match part {
1186 Parsed::Cell { cell, .. } => row = row.cell(cell),
1187 other => return Err(parent_error(other.name(), "tr", at)),
1188 }
1189 }
1190 Ok(Parsed::TableRow { row: Box::new(row), at })
1191 },
1192 "table" => {
1193 let mut table = Table::new();
1194 for part in parts {
1195 match part {
1196 Parsed::TableRow { row, .. } => table = table.row(*row),
1197 other => return Err(parent_error(other.name(), "table", at)),
1198 }
1199 }
1200 Ok(Parsed::Cached {
1201 cached: Box::new(Cached::new(boxed_component(table, props))),
1202 text: None,
1203 at,
1204 implicit: false,
1205 })
1206 },
1207 "segment" => {
1208 let label = take_label(&mut parts).unwrap_or_default();
1209 if let Some(other) = parts.into_iter().next() {
1210 return Err(parent_error(other.name(), "segment", at));
1211 }
1212 let mut segment = Segment::new();
1213 replay_props!(segment, props);
1214 if !label.is_empty() {
1215 segment = segment.label(label);
1216 }
1217 Ok(Parsed::Segment { segment, at })
1218 },
1219 "tab" => {
1220 let title = props.title().cloned().unwrap_or_else(|| Str::new("tab"));
1221 let children = cached_children(parts, "tab")?;
1222 Ok(Parsed::Tab { title, children, at })
1223 },
1224 "node" => {
1225 let body_label = take_label(&mut parts);
1226 let label = body_label
1227 .or_else(|| props.str_of(Prop::Label).cloned())
1228 .unwrap_or_default();
1229 let mut node = TreeNode::new();
1230 replay_props!(node, props);
1231 if !label.is_empty() {
1232 node = node.label(label);
1233 }
1234 for part in parts {
1235 match part {
1236 Parsed::TreeItem { node: child, .. } => node = node.node(child),
1237 other => return Err(parent_error(other.name(), "node", at)),
1238 }
1239 }
1240 Ok(Parsed::TreeItem { node, at })
1241 },
1242 "task" => {
1243 let body_label = take_label(&mut parts);
1244 let label = body_label
1245 .or_else(|| props.str_of(Prop::Label).cloned())
1246 .unwrap_or_default();
1247 if let Some(status) = props.str_of(Prop::Status)
1248 && TaskStatus::parse(status).is_none()
1249 {
1250 return Err(ParseError {
1251 message: format!(
1252 "unknown task status {status:?} (use pending|active|done|dropped|blocked)"
1253 ),
1254 at,
1255 });
1256 }
1257 let mut task = TodoTask::new();
1258 replay_props!(task, props);
1259 if !label.is_empty() {
1260 task = task.label(label);
1261 }
1262 for part in parts {
1263 match part {
1264 Parsed::Task { task: child, .. } => task = task.task(child),
1265 other => return Err(parent_error(other.name(), "task", at)),
1266 }
1267 }
1268 Ok(Parsed::Task { task, at })
1269 },
1270 "field" => {
1271 let label = take_label(&mut parts);
1272 let children = cached_children(parts, "field")?;
1273 let mut field = Field::new();
1274 replay_props!(field, props);
1275 if let Some(label) = label {
1276 field = field.label(label);
1277 }
1278 if !children.is_empty() {
1279 field = field.child(children);
1280 }
1281 Ok(Parsed::Field { field, at })
1282 },
1283 "step" => {
1284 let title = props.title().cloned().unwrap_or_else(|| Str::new("step"));
1285 let children = cached_children(parts, "step")?;
1286 Ok(Parsed::Step { title, children, at })
1287 },
1288 "select" => {
1289 let mut select = Select::new();
1290 replay_props!(select, props);
1291 *select.props_mut() = props;
1292 for part in parts {
1293 match part {
1294 Parsed::Option { option, .. } => select = select.option(option),
1295 other => return Err(parent_error(other.name(), "select", at)),
1296 }
1297 }
1298 Ok(Parsed::Cached {
1299 cached: Box::new(Cached::new(Box::new(select))),
1300 text: None,
1301 at,
1302 implicit: false,
1303 })
1304 },
1305 "status" => {
1306 let mut status = Status::new();
1307 replay_props!(status, props);
1308 *status.props_mut() = props;
1309 for part in parts {
1310 match part {
1311 Parsed::Segment { segment, .. } => status = status.segment(segment),
1312 other => return Err(parent_error(other.name(), "status", at)),
1313 }
1314 }
1315 Ok(Parsed::Cached {
1316 cached: Box::new(Cached::new(Box::new(status))),
1317 text: None,
1318 at,
1319 implicit: false,
1320 })
1321 },
1322 "editor" => {
1323 let mut editor = EditorPane::new();
1324 replay_props!(editor, props);
1325 let mut has_input = false;
1326 let mut has_status = false;
1327 for part in parts {
1328 let Parsed::Cached { cached, at: child_at, implicit, .. } = part else {
1329 return Err(ParseError {
1330 message: "<editor> takes at most one input child and one <status>".into(),
1331 at,
1332 });
1333 };
1334 if implicit {
1335 return Err(ParseError {
1336 message: "<editor> takes at most one input child and one <status>".into(),
1337 at: child_at,
1338 });
1339 }
1340 if cached.comp().is::<Status>() {
1341 if has_status {
1342 return Err(ParseError {
1343 message: "<editor> takes at most one input child and one <status>".into(),
1344 at: child_at,
1345 });
1346 }
1347 editor = editor.status(cached.into_comp());
1348 has_status = true;
1349 } else {
1350 if has_input {
1351 return Err(ParseError {
1352 message: "<editor> takes at most one input child and one <status>".into(),
1353 at: child_at,
1354 });
1355 }
1356 editor = editor.input(cached.into_comp());
1357 has_input = true;
1358 }
1359 }
1360 Ok(Parsed::Cached {
1361 cached: Box::new(Cached::new(boxed_component(editor, props))),
1362 text: None,
1363 at,
1364 implicit: false,
1365 })
1366 },
1367 "tabs" => {
1368 let mut tabs = Tabs::new();
1369 replay_props!(tabs, props);
1370 *tabs.props_mut() = props;
1371 for part in parts {
1372 match part {
1373 Parsed::Tab { title, children, .. } => tabs = tabs.pane(title, children),
1374 other => return Err(parent_error(other.name(), "tabs", at)),
1375 }
1376 }
1377 Ok(Parsed::Cached {
1378 cached: Box::new(Cached::new(Box::new(tabs))),
1379 text: None,
1380 at,
1381 implicit: false,
1382 })
1383 },
1384 "tree" => {
1385 let mut tree = Tree::new();
1386 replay_props!(tree, props);
1387 *tree.props_mut() = props;
1388 for part in parts {
1389 match part {
1390 Parsed::TreeItem { node, .. } => tree = tree.node(node),
1391 other => return Err(parent_error(other.name(), "tree", at)),
1392 }
1393 }
1394 Ok(Parsed::Cached {
1395 cached: Box::new(Cached::new(Box::new(tree))),
1396 text: None,
1397 at,
1398 implicit: false,
1399 })
1400 },
1401 "todo" => {
1402 let mut todo = Todo::new();
1403 replay_props!(todo, props);
1404 *todo.props_mut() = props;
1405 for part in parts {
1406 match part {
1407 Parsed::Task { task, .. } => todo = todo.task(task),
1408 other => return Err(parent_error(other.name(), "todo", at)),
1409 }
1410 }
1411 Ok(Parsed::Cached {
1412 cached: Box::new(Cached::new(Box::new(todo))),
1413 text: None,
1414 at,
1415 implicit: false,
1416 })
1417 },
1418 "form" => {
1419 let mut form = Form::new();
1420 replay_props!(form, props);
1421 *form.props_mut() = props;
1422 for part in parts {
1423 match part {
1424 Parsed::Field { field, .. } => form = form.field(field),
1425 other => return Err(parent_error(other.name(), "form", at)),
1426 }
1427 }
1428 Ok(Parsed::Cached {
1429 cached: Box::new(Cached::new(Box::new(form))),
1430 text: None,
1431 at,
1432 implicit: false,
1433 })
1434 },
1435 "wizard" => {
1436 let mut wizard = Wizard::new();
1437 replay_props!(wizard, props);
1438 *wizard.props_mut() = props;
1439 for part in parts {
1440 match part {
1441 Parsed::Step { title, children, .. } => wizard = wizard.step(title, children),
1442 other => return Err(parent_error(other.name(), "wizard", at)),
1443 }
1444 }
1445 Ok(Parsed::Cached {
1446 cached: Box::new(Cached::new(Box::new(wizard))),
1447 text: None,
1448 at,
1449 implicit: false,
1450 })
1451 },
1452 _ => {
1453 let children = cached_children(parts, tag)?;
1454 let text = matches!(tag, "text" | "pre" | "md" | "latex").then(|| body.clone());
1455 let component = if is_catalog_tag(tag) {
1456 build(tag, props, children, &body).expect("catalog tag has a component")
1457 } else {
1458 let mut custom = CustomElement::new(tag).child(children);
1459 replay_props!(custom, props);
1460 boxed_component(custom, props)
1461 };
1462 Ok(Parsed::Cached { cached: Box::new(Cached::new(component)), text, at, implicit: false })
1463 },
1464 }
1465}
1466
1467fn take_label(parts: &mut Vec<Parsed>) -> Option<Str> {
1468 let index = parts
1469 .iter()
1470 .position(|part| matches!(part, Parsed::Cached { text: Some(_), .. }))?;
1471 match parts.remove(index) {
1472 Parsed::Cached { text: Some(text), .. } => Some(text),
1473 _ => unreachable!(),
1474 }
1475}
1476fn markdown_with_parts(
1477 props: Props,
1478 text: Str,
1479 children: Vec<Cached>,
1480 at: usize,
1481 implicit: bool,
1482) -> Parsed {
1483 let metadata = text.clone();
1484 let component = build("md", props, children, &text).expect("markdown is a catalog component");
1485 Parsed::Cached { cached: Box::new(Cached::new(component)), text: Some(metadata), at, implicit }
1486}
1487
1488fn markdown_part(text: Str, props: Props) -> Cached {
1489 let visible = !text.is_empty();
1490 let Parsed::Cached { cached, .. } = markdown_with_parts(props, text, Vec::new(), 0, false)
1491 else {
1492 unreachable!();
1493 };
1494 let mut cached = *cached;
1495 cached.visible = visible;
1496 cached
1497}
1498
1499fn markdown_parsed(text: Str, props: Props, at: usize) -> Parsed {
1500 markdown_with_parts(props, text, Vec::new(), at, true)
1501}
1502
1503fn apply_attrs(
1504 attrs: &str,
1505 at: usize,
1506 source: &Str,
1507 ctx: &UiContext,
1508 inherited: &Props,
1509) -> Result<Props, ParseError> {
1510 let mut props = inherited.clone();
1511 for (key, value) in (AttrIter { rest: attrs }) {
1512 if matches!(key, "gradient" | "dir") {
1513 return Err(ParseError {
1514 message: format!("{key} was replaced by fg=/bg= and angle="),
1515 at,
1516 });
1517 }
1518 if value.is_none() && ctx.theme.token(key).is_some() {
1519 props
1520 .try_set(Prop::Fg, PropValue::Token(source.slice_ref(key)))
1521 .map_err(|error| bad(key, &error.value, at))?;
1522 } else if let Some(prop) = Props::prop_of(key) {
1523 let value = match (prop, value) {
1524 (Prop::Title, Some(value)) => PropValue::Str(resolve_icons(ctx.charset, source, value)),
1525 (Prop::Gap | Prop::Grow, None) => PropValue::Str(Str::new("1")),
1526 (_, Some(value)) => PropValue::Str(source.slice_ref(value)),
1527 (_, None) => PropValue::Bool(true),
1528 };
1529 props
1530 .try_set(prop, value)
1531 .map_err(|error| bad(key, &error.value, at))?;
1532 } else {
1533 let value =
1534 value.map_or(PropValue::Bool(true), |value| PropValue::Str(source.slice_ref(value)));
1535 props.set_custom(source.slice_ref(key), value);
1536 }
1537 }
1538 Ok(props)
1539}
1540
1541fn bad(key: &str, value: &str, at: usize) -> ParseError {
1542 ParseError { message: format!("bad value {value:?} for attribute {key}"), at }
1543}
1544
1545struct AttrIter<'a> {
1547 rest: &'a str,
1548}
1549
1550impl<'a> Iterator for AttrIter<'a> {
1551 type Item = (&'a str, Option<&'a str>);
1552
1553 fn next(&mut self) -> Option<Self::Item> {
1554 self.rest = self.rest.trim_start();
1555 if self.rest.is_empty() {
1556 return None;
1557 }
1558 let key_end = self.rest.find(|c: char| c == '=' || c.is_whitespace());
1559 let (key, after) = match key_end {
1560 Some(p) => (&self.rest[..p], &self.rest[p..]),
1561 None => (self.rest, ""),
1562 };
1563 if let Some(after_eq) = after.strip_prefix('=') {
1564 for quote in ['"', '\''] {
1566 if let Some(quoted) = after_eq.strip_prefix(quote) {
1567 let end = quoted.find(quote).unwrap_or(quoted.len());
1568 self.rest = quoted.get(end + 1..).unwrap_or("");
1569 return Some((key, Some("ed[..end])));
1570 }
1571 }
1572 let end = after_eq.find(char::is_whitespace).unwrap_or(after_eq.len());
1573 self.rest = &after_eq[end..];
1574 return Some((key, Some(&after_eq[..end])));
1575 }
1576 self.rest = after;
1577 Some((key, None))
1578 }
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583 use super::*;
1584 use crate::frame::{Color, Style};
1585
1586 fn child(node: &Cached, index: usize) -> &Cached {
1587 &node.comp().children()[index]
1588 }
1589
1590 #[test]
1591 fn well_known_bad_values_are_parse_errors() {
1592 let ctx = UiContext::default();
1593 assert!(parse(&Str::new("<text fg=nosuch>x</text>"), &ctx).is_err());
1594 assert!(parse(&Str::new("<text fg=\"rgb(300,0)\">x</text>"), &ctx).is_err());
1595 assert!(parse(&Str::new("<text fg=💥>x</text>"), &ctx).is_err());
1596 assert!(parse(&Str::new("<text fg=\"rgb💥(1,2,3)\">x</text>"), &ctx).is_err());
1597 assert!(parse(&Str::new("<text fg=#héx>x</text>"), &ctx).is_err());
1598 }
1599
1600 #[test]
1601 fn chrome_attributes_parse_with_aliases_and_flags() {
1602 let ctx = UiContext::default();
1603 let root = parse(
1604 &Str::new(
1605 "<col><box border=dash on=navy edge=red><text accent reverse strike truncate \
1606 mystery>x</text></box><spacer/></col>",
1607 ),
1608 &ctx,
1609 )
1610 .unwrap();
1611 let col = child(&root, 0);
1612 let boxed = child(col, 0);
1613 assert_eq!(boxed.comp().props().border(), Some(Border::Dash));
1614 assert_eq!(boxed.comp().props().style(&ctx.theme).background_color(), Color::Rgb(0, 0, 0x80));
1615 assert_eq!(boxed.comp().props().edge(&ctx.theme), Some(Color::Rgb(0xff, 0, 0)));
1616 assert_eq!(boxed.comp().props().pad().1, 1);
1617 let text = child(boxed, 0).comp().props();
1618 assert_eq!(
1619 text.style(&ctx.theme),
1620 Style::new().fg(ctx.theme.accent).reverse().strikethrough()
1621 );
1622 assert!(text.flag(Prop::Truncate));
1623 assert_eq!(text.custom("mystery"), Some(&PropValue::Bool(true)));
1624 assert_eq!(child(col, 1).comp().props().grow(), Some(1.0));
1625
1626 let overrides =
1627 parse(&Str::new("<col><box pad='2 3'></box><spacer grow=2/></col>"), &ctx).unwrap();
1628 let col = child(&overrides, 0);
1629 assert_eq!(child(col, 0).comp().props().pad(), (2, 3));
1630 assert_eq!(child(col, 1).comp().props().grow(), Some(2.0));
1631 }
1632
1633 #[test]
1634 fn attribute_quoting_styles_are_equivalent() {
1635 let ctx = UiContext::default();
1636 for src in [
1637 "<box title=b><text>x</text></box>",
1638 "<box title='b'><text>x</text></box>",
1639 "<box title=\"b\"><text>x</text></box>",
1640 ] {
1641 let root = parse(&Str::new(src), &ctx).unwrap();
1642 assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("b"));
1643 }
1644 let root =
1645 parse(&Str::new("<box title='say \"hi\" now'><text>x</text></box>"), &ctx).unwrap();
1646 assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("say \"hi\" now"));
1647 }
1648
1649 #[test]
1650 fn title_resolves_ico_tags_through_the_charset() {
1651 let unicode = UiContext::default();
1652 let src = Str::new("<box title=\"<ico:folder/> Files\"><text>x</text></box>");
1653 let root = parse(&src, &unicode).unwrap();
1654 assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("📁 Files"));
1655
1656 let ascii = UiContext { charset: Charset::Ascii, ..UiContext::default() };
1657 let root = parse(&src, &ascii).unwrap();
1658 assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("[D] Files"));
1659
1660 let src = Str::new("<hr title=\"<ico:icon.folder/> <ico:nope/>\"/>");
1661 let root = parse(&src, &unicode).unwrap();
1662 assert_eq!(child(&root, 0).comp().props().title().map(Str::as_str), Some("📁 nope"));
1663 }
1664
1665 #[test]
1666 fn styles_cascade_to_children() {
1667 let ctx = UiContext::default();
1668 let root = parse(
1669 &Str::new(
1670 "<col fg=#0000ff bold><text>a</text><text fg=#ff0000>b</text><box \
1671 bg=#00ff00><text>c</text></box></col>",
1672 ),
1673 &ctx,
1674 )
1675 .unwrap();
1676 let col = child(&root, 0);
1677 let blue = Color::Rgb(0, 0, 0xff);
1678 assert_eq!(child(col, 0).comp().props().style(&ctx.theme), Style::new().fg(blue).bold());
1679 assert_eq!(
1680 child(col, 1).comp().props().style(&ctx.theme),
1681 Style::new().fg(Color::Rgb(0xff, 0, 0)).bold()
1682 );
1683 let nested = child(child(col, 2), 0).comp().props().style(&ctx.theme);
1684 assert_eq!(nested, Style::new().fg(blue).bold());
1685 assert_eq!(nested.background_color(), Color::Default);
1686 }
1687
1688 #[test]
1689 fn gradients_live_in_property_values() {
1690 let ctx = UiContext::default();
1691 let root = parse(
1692 &Str::new(
1693 r##"<box bg="#000000..#ffffff" angle=90><pre fg="accent..info" angle=45> ██
1694 █</pre></box>"##,
1695 ),
1696 &ctx,
1697 )
1698 .unwrap();
1699 let boxed = child(&root, 0);
1700 assert_eq!(
1701 boxed.comp().props().get(Prop::Bg),
1702 Some(&PropValue::Gradient(Str::new("#000000..#ffffff")))
1703 );
1704 assert_eq!(boxed.comp().props().angle(), 90);
1705 let pre = child(boxed, 0);
1706 assert_eq!(
1707 pre.comp().props().get(Prop::Fg),
1708 Some(&PropValue::Gradient(Str::new("accent..info")))
1709 );
1710 assert_eq!(pre.comp().props().angle(), 45);
1711 for source in ["<pre gradient=\"accent..info\">x</pre>", "<pre dir=h>x</pre>"] {
1712 assert!(parse(&Str::new(source), &ctx).is_err(), "{source}");
1713 }
1714 }
1715
1716 #[test]
1717 fn custom_elements_require_a_complete_tag_pair() {
1718 let ctx = UiContext::default();
1719 let root = parse(&Str::new("<panel mystery><text>x</text></panel>"), &ctx).unwrap();
1720 let panel = child(&root, 0);
1721 assert_eq!(panel.comp().props().custom("mystery"), Some(&PropValue::Bool(true)));
1722 assert_eq!(panel.comp().children().len(), 1);
1723
1724 let literal = parse(&Str::new("before <panel> after"), &ctx).unwrap();
1725 assert_eq!(literal.comp().children().len(), 1);
1726 }
1727
1728 #[test]
1729 fn markdown_html_stays_literal_but_line_start_custom_elements_embed() {
1730 let ctx = UiContext::default();
1731 let html = parse(&Str::new("<md>before <span>inside</span> after</md>"), &ctx).unwrap();
1732 assert!(child(&html, 0).comp().children().is_empty());
1733
1734 let custom = parse(&Str::new("<md>before\n<panel/>\nafter</md>"), &ctx).unwrap();
1735 assert_eq!(child(&custom, 0).comp().children().len(), 2);
1736 }
1737}