1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12zng_wgt::enable_widget_macros!();
13
14use std::mem;
15
16pub use pulldown_cmark::HeadingLevel;
17
18use zng_wgt::prelude::*;
19use zng_wgt_input::{CursorIcon, cursor};
20
21#[doc(hidden)]
22pub use zng_wgt_text::__formatx;
23
24use zng_wgt_text as text;
25
26mod resolvers;
27mod view_fn;
28
29pub use resolvers::*;
30pub use view_fn::*;
31
32#[widget($crate::Markdown {
34 ($txt:literal) => {
35 txt = $crate::__formatx!($txt);
36 };
37 ($txt:expr) => {
38 txt = $txt;
39 };
40 ($txt:tt, $($format:tt)*) => {
41 txt = $crate::__formatx!($txt, $($format)*);
42 };
43})]
44#[rustfmt::skip]
45pub struct Markdown(
46 text::FontMix<
47 text::TextSpacingMix<
48 text::ParagraphMix<
49 text::LangMix<
50 WidgetBase
51 >>>>
52);
53impl Markdown {
54 fn widget_intrinsic(&mut self) {
55 widget_set! {
56 self;
57 on_link = hn!(|args| {
58 try_default_link_action(args);
59 });
60 zng_wgt_text::rich_text = true;
61
62 when #txt_selectable {
63 cursor = CursorIcon::Text;
64 }
65 };
66
67 self.widget_builder().push_build_action(|wgt| {
68 let md = wgt.capture_var_or_default(property_id!(text::txt));
69 let child = markdown_node(md);
70 wgt.set_child(child);
71 });
72 }
73
74 widget_impl! {
75 pub text::txt(txt: impl IntoVar<Txt>);
77
78 pub zng_wgt_text::txt_selectable(enabled: impl IntoVar<bool>);
82 }
83}
84
85pub fn markdown_node(md: impl IntoVar<Txt>) -> UiNode {
87 let md = md.into_var();
88 match_node(UiNode::nil(), move |c, op| match op {
89 UiNodeOp::Init => {
90 WIDGET
91 .sub_var(&md)
92 .sub_var(&TEXT_FN_VAR)
93 .sub_var(&LINK_FN_VAR)
94 .sub_var(&CODE_INLINE_FN_VAR)
95 .sub_var(&CODE_BLOCK_FN_VAR)
96 .sub_var(&PARAGRAPH_FN_VAR)
97 .sub_var(&HEADING_FN_VAR)
98 .sub_var(&LIST_FN_VAR)
99 .sub_var(&LIST_ITEM_BULLET_FN_VAR)
100 .sub_var(&LIST_ITEM_FN_VAR)
101 .sub_var(&IMAGE_FN_VAR)
102 .sub_var(&RULE_FN_VAR)
103 .sub_var(&BLOCK_QUOTE_FN_VAR)
104 .sub_var(&TABLE_FN_VAR)
105 .sub_var(&TABLE_CELL_FN_VAR)
106 .sub_var(&PANEL_FN_VAR)
107 .sub_var(&IMAGE_RESOLVER_VAR)
108 .sub_var(&LINK_RESOLVER_VAR);
109
110 *c.node() = md.with(|md| markdown_view_fn(md.as_str()));
111 }
112 UiNodeOp::Deinit => {
113 c.deinit();
114 *c.node() = UiNode::nil();
115 }
116 UiNodeOp::Info { info } => {
117 info.flag_meta(*MARKDOWN_INFO_ID);
118 }
119 UiNodeOp::Update { .. } => {
120 use resolvers::*;
121 use view_fn::*;
122
123 if md.is_new()
124 || TEXT_FN_VAR.is_new()
125 || LINK_FN_VAR.is_new()
126 || CODE_INLINE_FN_VAR.is_new()
127 || CODE_BLOCK_FN_VAR.is_new()
128 || PARAGRAPH_FN_VAR.is_new()
129 || HEADING_FN_VAR.is_new()
130 || LIST_FN_VAR.is_new()
131 || LIST_ITEM_BULLET_FN_VAR.is_new()
132 || LIST_ITEM_FN_VAR.is_new()
133 || IMAGE_FN_VAR.is_new()
134 || RULE_FN_VAR.is_new()
135 || BLOCK_QUOTE_FN_VAR.is_new()
136 || TABLE_FN_VAR.is_new()
137 || TABLE_CELL_FN_VAR.is_new()
138 || PANEL_FN_VAR.is_new()
139 || IMAGE_RESOLVER_VAR.is_new()
140 || LINK_RESOLVER_VAR.is_new()
141 {
142 c.delegated();
143 c.node().deinit();
144 *c.node() = md.with(|md| markdown_view_fn(md.as_str()));
145 c.node().init();
146 WIDGET.update_info().layout().render();
147 }
148 }
149 _ => {}
150 })
151}
152
153fn markdown_parser<'a>(md: &'a str, mut next_event: impl FnMut(pulldown_cmark::Event<'a>)) {
155 use pulldown_cmark::*;
156
157 let parse_options = Options::ENABLE_TABLES
158 | Options::ENABLE_FOOTNOTES
159 | Options::ENABLE_STRIKETHROUGH
160 | Options::ENABLE_TASKLISTS
161 | Options::ENABLE_SMART_PUNCTUATION
162 | Options::ENABLE_DEFINITION_LIST
163 | Options::ENABLE_SUBSCRIPT
164 | Options::ENABLE_SUPERSCRIPT;
165
166 let mut broken_link_handler = |b: BrokenLink<'a>| Some((b.reference, "".into()));
167 let parser = Parser::new_with_broken_link_callback(md, parse_options, Some(&mut broken_link_handler));
168
169 enum Str<'a> {
170 Md(CowStr<'a>),
171 Buf(String),
172 }
173 impl<'a> Str<'a> {
174 fn buf(&mut self) -> &mut String {
175 if let Str::Md(s) = self {
176 *self = Str::Buf(mem::replace(s, CowStr::Borrowed("")).into_string());
177 }
178 match self {
179 Str::Buf(b) => b,
180 _ => unreachable!(),
181 }
182 }
183
184 fn md(self) -> CowStr<'a> {
185 match self {
186 Str::Md(cow_str) => cow_str,
187 Str::Buf(b) => b.into(),
188 }
189 }
190 }
191 let mut pending_txt: Option<Str<'a>> = None;
192 let mut trim_start = false;
193
194 for event in parser {
195 let event = match event {
197 Event::SoftBreak => Event::Text(CowStr::Borrowed(" ")),
198 Event::HardBreak => Event::Text(CowStr::Borrowed("\n")),
199 ev => ev,
200 };
201 match event {
202 Event::Text(txt) => {
204 if let Some(p) = &mut pending_txt {
205 p.buf().push_str(&txt);
206 } else if mem::take(&mut trim_start) && txt.starts_with(' ') {
207 pending_txt = Some(match txt {
209 CowStr::Borrowed(s) => Str::Md(CowStr::Borrowed(s.trim_start())),
210 CowStr::Boxed(s) => Str::Buf(s.trim_start().to_owned()),
211 CowStr::Inlined(s) => Str::Buf(s.trim_start().to_owned()),
212 });
213 } else {
214 pending_txt = Some(Str::Md(txt));
215 }
216 }
217 e @ Event::End(_)
219 | e @ Event::Start(
220 Tag::Paragraph
221 | Tag::Heading { .. }
222 | Tag::Image { .. }
223 | Tag::Item
224 | Tag::List(_)
225 | Tag::CodeBlock(_)
226 | Tag::Table(_)
227 | Tag::TableHead
228 | Tag::TableRow
229 | Tag::TableCell
230 | Tag::BlockQuote(_)
231 | Tag::FootnoteDefinition(_)
232 | Tag::DefinitionList
233 | Tag::DefinitionListTitle
234 | Tag::DefinitionListDefinition
235 | Tag::HtmlBlock
236 | Tag::MetadataBlock(_),
237 )
238 | e @ Event::Code(_)
239 | e @ Event::Rule
240 | e @ Event::TaskListMarker(_)
241 | e @ Event::InlineMath(_)
242 | e @ Event::DisplayMath(_)
243 | e @ Event::Html(_)
244 | e @ Event::InlineHtml(_) => {
245 if let Some(txt) = pending_txt.take() {
246 next_event(Event::Text(txt.md()));
247 }
248 next_event(e)
249 }
250 Event::FootnoteReference(s) => {
252 if let Some(txt) = pending_txt.take() {
253 let txt = txt.md();
254 trim_start = txt.ends_with(' ');
255 next_event(Event::Text(txt));
256 }
257 if mem::take(&mut trim_start) && s.starts_with(' ') {
258 let s = match s {
259 CowStr::Borrowed(s) => CowStr::Borrowed(s.trim_start()),
260 CowStr::Boxed(s) => CowStr::Boxed(s.trim_start().to_owned().into()),
261 CowStr::Inlined(s) => CowStr::Boxed(s.trim_start().to_owned().into()),
262 };
263 next_event(Event::FootnoteReference(s))
264 } else {
265 next_event(Event::FootnoteReference(s))
266 }
267 }
268 Event::Start(tag) => match tag {
269 t @ Tag::Emphasis
270 | t @ Tag::Strong
271 | t @ Tag::Strikethrough
272 | t @ Tag::Superscript
273 | t @ Tag::Subscript
274 | t @ Tag::Link { .. } => {
275 if let Some(txt) = pending_txt.take() {
276 let txt = txt.md();
277 trim_start = txt.ends_with(' ');
278 next_event(Event::Text(txt));
279 }
280 next_event(Event::Start(t))
281 }
282 t => tracing::error!("unexpected start tag {t:?}"),
283 },
284 Event::HardBreak | Event::SoftBreak => unreachable!(),
286 }
287 if let Some(txt) = pending_txt.take() {
288 next_event(Event::Text(txt.md()));
289 }
290 }
291}
292
293fn markdown_view_fn(md: &str) -> UiNode {
294 use pulldown_cmark::*;
295 use resolvers::*;
296 use view_fn::*;
297
298 let text_view = TEXT_FN_VAR.get();
299 let link_view = LINK_FN_VAR.get();
300 let code_inline_view = CODE_INLINE_FN_VAR.get();
301 let code_block_view = CODE_BLOCK_FN_VAR.get();
302 let heading_view = HEADING_FN_VAR.get();
303 let paragraph_view = PARAGRAPH_FN_VAR.get();
304 let list_view = LIST_FN_VAR.get();
305 let definition_list_view = DEF_LIST_FN_VAR.get();
306 let list_item_bullet_view = LIST_ITEM_BULLET_FN_VAR.get();
307 let list_item_view = LIST_ITEM_FN_VAR.get();
308 let image_view = IMAGE_FN_VAR.get();
309 let rule_view = RULE_FN_VAR.get();
310 let block_quote_view = BLOCK_QUOTE_FN_VAR.get();
311 let footnote_ref_view = FOOTNOTE_REF_FN_VAR.get();
312 let footnote_def_view = FOOTNOTE_DEF_FN_VAR.get();
313 let def_list_item_title_view = DEF_LIST_ITEM_TITLE_FN_VAR.get();
314 let def_list_item_definition_view = DEF_LIST_ITEM_DEFINITION_FN_VAR.get();
315 let table_view = TABLE_FN_VAR.get();
316 let table_cell_view = TABLE_CELL_FN_VAR.get();
317
318 let image_resolver = IMAGE_RESOLVER_VAR.get();
319 let link_resolver = LINK_RESOLVER_VAR.get();
320
321 #[derive(Default)]
322 struct StyleBuilder {
323 strong: usize,
324 emphasis: usize,
325 strikethrough: usize,
326 superscript: usize,
327 subscript: usize,
328 }
329 impl StyleBuilder {
330 fn build(&self) -> MarkdownStyle {
331 MarkdownStyle {
332 strong: self.strong > 0,
333 emphasis: self.emphasis > 0,
334 strikethrough: self.strikethrough > 0,
335 subscript: self.subscript > self.superscript,
336 superscript: self.superscript > self.subscript,
337 }
338 }
339 }
340 struct ListInfo {
341 block_start: usize,
342 inline_start: usize,
343 first_num: Option<u64>,
344 item_num: Option<u64>,
345 item_checked: Option<bool>,
346 }
347 let mut blocks = vec![];
348 let mut inlines = vec![];
349 let mut txt_style = StyleBuilder::default();
350 let mut link = None;
351 let mut list_info = vec![];
352 let mut list_items = vec![];
353 let mut block_quote_start = vec![];
354 let mut code_block = None;
355 let mut html_block = None;
356 let mut image = None;
357 let mut heading_anchor_txt = None;
358 let mut footnote_def = None;
359 let mut table_cells = vec![];
360 let mut table_cols = vec![];
361 let mut table_col = 0;
362 let mut table_head = false;
363
364 markdown_parser(md, |event| match event {
365 Event::Start(tag) => match tag {
366 Tag::Paragraph => txt_style = StyleBuilder::default(),
367 Tag::Heading { .. } => {
368 txt_style = StyleBuilder::default();
369 heading_anchor_txt = Some(String::new());
370 }
371 Tag::BlockQuote(_) => {
372 txt_style = StyleBuilder::default();
373 block_quote_start.push(blocks.len());
374 }
375 Tag::CodeBlock(kind) => {
376 txt_style = StyleBuilder::default();
377 code_block = Some((String::new(), kind));
378 }
379 Tag::HtmlBlock => {
380 txt_style = StyleBuilder::default();
381 html_block = Some(String::new());
382 }
383 Tag::List(n) => {
384 txt_style = StyleBuilder::default();
385 list_info.push(ListInfo {
386 block_start: blocks.len(),
387 inline_start: inlines.len(),
388 first_num: n,
389 item_num: n,
390 item_checked: None,
391 });
392 }
393 Tag::DefinitionList => {
394 txt_style = StyleBuilder::default();
395 list_info.push(ListInfo {
396 block_start: blocks.len(),
397 inline_start: inlines.len(),
398 first_num: None,
399 item_num: None,
400 item_checked: None,
401 });
402 }
403 Tag::Item | Tag::DefinitionListTitle | Tag::DefinitionListDefinition => {
404 txt_style = StyleBuilder::default();
405 if let Some(list) = list_info.last_mut() {
406 list.block_start = blocks.len();
407 }
408 }
409 Tag::FootnoteDefinition(label) => {
410 txt_style = StyleBuilder::default();
411 footnote_def = Some((blocks.len(), label));
412 }
413 Tag::Table(columns) => {
414 txt_style = StyleBuilder::default();
415 table_cols = columns
416 .into_iter()
417 .map(|c| match c {
418 Alignment::None => Align::START,
419 Alignment::Left => Align::LEFT,
420 Alignment::Center => Align::CENTER,
421 Alignment::Right => Align::RIGHT,
422 })
423 .collect()
424 }
425 Tag::TableHead => {
426 txt_style = StyleBuilder::default();
427 table_head = true;
428 table_col = 0;
429 }
430 Tag::TableRow => {
431 txt_style = StyleBuilder::default();
432 table_col = 0;
433 }
434 Tag::TableCell => {
435 txt_style = StyleBuilder::default();
436 }
437 Tag::Emphasis => {
438 txt_style.emphasis += 1;
439 }
440 Tag::Strong => {
441 txt_style.strong += 1;
442 }
443 Tag::Strikethrough => {
444 txt_style.strong += 1;
445 }
446 Tag::Superscript => {
447 txt_style.superscript += 1;
448 }
449 Tag::Subscript => {
450 txt_style.subscript += 1;
451 }
452 Tag::Link {
453 link_type,
454 dest_url,
455 title,
456 id,
457 } => {
458 link = Some((inlines.len(), link_type, dest_url, title, id));
459 }
460 Tag::Image { dest_url, title, .. } => {
461 image = Some((String::new(), dest_url, title));
462 }
463 Tag::MetadataBlock(_) => unreachable!(), },
465 Event::End(tag_end) => match tag_end {
466 TagEnd::Paragraph => {
467 if !inlines.is_empty() {
468 blocks.push(paragraph_view(ParagraphFnArgs {
469 index: blocks.len() as u32,
470 items: mem::take(&mut inlines).into(),
471 }));
472 }
473 }
474 TagEnd::Heading(level) => {
475 if !inlines.is_empty() {
476 blocks.push(heading_view(HeadingFnArgs {
477 level,
478 anchor: heading_anchor(heading_anchor_txt.take().unwrap_or_default().as_str()),
479 items: mem::take(&mut inlines).into(),
480 }));
481 }
482 }
483 TagEnd::BlockQuote(_) => {
484 if let Some(start) = block_quote_start.pop() {
485 let items: UiVec = blocks.drain(start..).collect();
486 if !items.is_empty() {
487 blocks.push(block_quote_view(BlockQuoteFnArgs {
488 level: block_quote_start.len() as u32,
489 items,
490 }));
491 }
492 }
493 }
494 TagEnd::CodeBlock => {
495 let (mut txt, kind) = code_block.take().unwrap();
496 if txt.ends_with('\n') {
497 txt.pop();
498 }
499 blocks.push(code_block_view(CodeBlockFnArgs {
500 lang: match kind {
501 CodeBlockKind::Indented => Txt::from_str(""),
502 CodeBlockKind::Fenced(l) => l.to_txt(),
503 },
504 txt: txt.into(),
505 }))
506 }
507 TagEnd::HtmlBlock => {
508 let _html = html_block.take().unwrap();
510 }
511 TagEnd::List(_) => {
512 if let Some(list) = list_info.pop() {
513 blocks.push(list_view(ListFnArgs {
514 depth: list_info.len() as u32,
515 first_num: list.first_num,
516 items: mem::take(&mut list_items).into(),
517 }));
518 }
519 }
520 TagEnd::DefinitionList => {
521 if list_info.pop().is_some() {
522 blocks.push(definition_list_view(DefListArgs {
523 items: mem::take(&mut list_items).into(),
524 }));
525 }
526 }
527 TagEnd::Item => {
528 let depth = list_info.len().saturating_sub(1);
529 if let Some(list) = list_info.last_mut() {
530 let num = match &mut list.item_num {
531 Some(n) => {
532 let r = *n;
533 *n += 1;
534 Some(r)
535 }
536 None => None,
537 };
538
539 let bullet_args = ListItemBulletFnArgs {
540 depth: depth as u32,
541 num,
542 checked: list.item_checked.take(),
543 };
544 list_items.push(list_item_bullet_view(bullet_args));
545 list_items.push(list_item_view(ListItemFnArgs {
546 bullet: bullet_args,
547 items: inlines.drain(list.inline_start..).collect(),
548 blocks: blocks.drain(list.block_start..).collect(),
549 }));
550 }
551 }
552 TagEnd::DefinitionListTitle => {
553 if let Some(list) = list_info.last_mut() {
554 list_items.push(def_list_item_title_view(DefListItemTitleArgs {
555 items: inlines.drain(list.inline_start..).collect(),
556 }));
557 }
558 }
559 TagEnd::DefinitionListDefinition => {
560 if let Some(list) = list_info.last_mut() {
561 list_items.push(def_list_item_definition_view(DefListItemDefinitionArgs {
562 items: inlines.drain(list.inline_start..).collect(),
563 }));
564 }
565 }
566 TagEnd::FootnoteDefinition => {
567 if let Some((i, label)) = footnote_def.take() {
568 let label = html_escape::decode_html_entities(label.as_ref());
569 let items = blocks.drain(i..).collect();
570 blocks.push(footnote_def_view(FootnoteDefFnArgs {
571 label: label.to_txt(),
572 items,
573 }));
574 }
575 }
576 TagEnd::Table => {
577 if !table_cells.is_empty() {
578 blocks.push(table_view(TableFnArgs {
579 columns: mem::take(&mut table_cols),
580 cells: mem::take(&mut table_cells).into(),
581 }));
582 }
583 }
584 TagEnd::TableHead => {
585 table_head = false;
586 }
587 TagEnd::TableRow => {}
588 TagEnd::TableCell => {
589 table_cells.push(table_cell_view(TableCellFnArgs {
590 is_heading: table_head,
591 col_align: table_cols[table_col],
592 items: mem::take(&mut inlines).into(),
593 }));
594 table_col += 1;
595 }
596 TagEnd::Emphasis => {
597 txt_style.emphasis -= 1;
598 }
599 TagEnd::Strong => {
600 txt_style.strong -= 1;
601 }
602 TagEnd::Strikethrough => {
603 txt_style.strikethrough -= 1;
604 }
605 TagEnd::Superscript => {
606 txt_style.superscript -= 1;
607 }
608 TagEnd::Subscript => txt_style.subscript -= 1,
609 TagEnd::Link => {
610 let (inlines_start, kind, url, title, _id) = link.take().unwrap();
611 let title = html_escape::decode_html_entities(title.as_ref());
612 let url = link_resolver.resolve(url.as_ref());
613 match kind {
614 LinkType::Autolink | LinkType::Email => {
615 let url = html_escape::decode_html_entities(&url);
616 if let Some(txt) = text_view.call_checked(TextFnArgs {
617 txt: url.to_txt(),
618 style: txt_style.build(),
619 }) {
620 inlines.push(txt);
621 }
622 }
623 LinkType::Inline => {}
624 LinkType::Reference => {}
625 LinkType::ReferenceUnknown => {}
626 LinkType::Collapsed => {}
627 LinkType::CollapsedUnknown => {}
628 LinkType::Shortcut => {}
629 LinkType::ShortcutUnknown => {}
630 LinkType::WikiLink { .. } => {}
631 }
632 if !inlines.is_empty() {
633 let items = inlines.drain(inlines_start..).collect();
634 if let Some(lnk) = link_view.call_checked(LinkFnArgs {
635 url,
636 title: title.to_txt(),
637 items,
638 }) {
639 inlines.push(lnk);
640 }
641 }
642 }
643 TagEnd::Image => {
644 let (alt_txt, url, title) = image.take().unwrap();
645 let title = html_escape::decode_html_entities(title.as_ref());
646 blocks.push(image_view(ImageFnArgs {
647 source: image_resolver.resolve(&url),
648 title: title.to_txt(),
649 alt_items: mem::take(&mut inlines).into(),
650 alt_txt: alt_txt.into(),
651 }));
652 }
653 TagEnd::MetadataBlock(_) => unreachable!(),
654 },
655 Event::Text(txt) => {
656 if let Some(html) = &mut html_block {
657 html.push_str(&txt);
658 } else {
659 let txt = html_escape::decode_html_entities(txt.as_ref());
660 if let Some((code, _)) = &mut code_block {
661 code.push_str(&txt);
662 } else if !txt.is_empty() {
663 if let Some(anchor_txt) = &mut heading_anchor_txt {
664 anchor_txt.push_str(&txt);
665 }
666 if let Some((alt_txt, _, _)) = &mut image {
667 alt_txt.push_str(&txt);
668 }
669 if let Some(txt) = text_view.call_checked(TextFnArgs {
670 txt: Txt::from_str(&txt),
671 style: txt_style.build(),
672 }) {
673 inlines.push(txt);
674 }
675 }
676 }
677 }
678 Event::Code(txt) => {
679 let txt = html_escape::decode_html_entities(txt.as_ref());
680 if let Some(txt) = code_inline_view.call_checked(CodeInlineFnArgs {
681 txt: txt.to_txt(),
682 style: txt_style.build(),
683 }) {
684 inlines.push(txt);
685 }
686 }
687 Event::Html(h) => {
688 if let Some(html) = &mut html_block {
689 html.push_str(&h);
690 }
691 }
692 Event::InlineHtml(tag) => match tag.as_ref() {
693 "<b>" => txt_style.strong += 1,
694 "</b>" => txt_style.strong -= 1,
695 "<em>" => txt_style.emphasis += 1,
696 "</em>" => txt_style.emphasis -= 1,
697 "<s>" => txt_style.strikethrough += 1,
698 "</s>" => txt_style.strikethrough -= 1,
699 _ => {}
700 },
701 Event::FootnoteReference(label) => {
702 let label = html_escape::decode_html_entities(label.as_ref());
703 if let Some(txt) = footnote_ref_view.call_checked(FootnoteRefFnArgs { label: label.to_txt() }) {
704 inlines.push(txt);
705 }
706 }
707 Event::Rule => {
708 blocks.push(rule_view(RuleFnArgs {}));
709 }
710 Event::TaskListMarker(c) => {
711 if let Some(l) = &mut list_info.last_mut() {
712 l.item_checked = Some(c);
713 }
714 }
715
716 Event::InlineMath(_) => {} Event::DisplayMath(_) => {}
718 Event::SoftBreak | Event::HardBreak => unreachable!(),
719 });
720
721 PANEL_FN_VAR.get()(PanelFnArgs { items: blocks.into() })
722}