Skip to main content

omp_tui/components/
markdown.rs

1use omp_core::Str;
2
3use super::text::{append, paint_rich, truncate_rich};
4use crate::{
5	component::{Cached, Component, IntoChildren, MemoKey, PaintCtx, Slot, next_slot},
6	frame::Rect,
7	markdown::MdTheme,
8	props::{Prop, PropValue, Props},
9	rich::{Measure, RichText},
10};
11
12/// Rendered Markdown content backing the `<markdown>` markup tag.
13pub struct Markdown {
14	props:        Props,
15	slot:         Slot,
16	text:         Str,
17	source:       Str,
18	rich:         RichText,
19	embedded:     Vec<Cached>,
20	version:      u64,
21	cached_width: u16,
22	cached:       Option<MemoKey>,
23}
24
25impl Markdown {
26	/// Creates an empty Markdown block.
27	pub fn new() -> Self {
28		Self {
29			props:        Props::new(),
30			slot:         next_slot(),
31			text:         Str::default(),
32			source:       Str::default(),
33			rich:         RichText::default(),
34			embedded:     Vec::new(),
35			version:      1,
36			cached_width: 0,
37			cached:       None,
38		}
39	}
40
41	/// Creates a Markdown block containing the supplied source.
42	pub fn text_of(text: impl Into<Str>) -> Self {
43		Self::new().text(text)
44	}
45
46	/// Sets one Markdown property.
47	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
48		self.props.set(prop, value);
49		self.version = self.version.wrapping_add(1);
50		self
51	}
52
53	/// Sets one Markdown property from a string.
54	pub fn with_str(self, prop: Prop, value: &str) -> Self {
55		self.with(prop, value)
56	}
57
58	/// Appends Markdown source text.
59	pub fn text(mut self, text: impl Into<Str>) -> Self {
60		let text = text.into();
61		append(&mut self.source, text.clone());
62		append(&mut self.text, text);
63		self.version = self.version.wrapping_add(1);
64		self
65	}
66
67	/// Appends embedded components referenced by the Markdown source.
68	pub fn child(mut self, child: impl IntoChildren) -> Self {
69		child.extend_children(&mut self.embedded);
70		self
71	}
72
73	fn theme(&self, ctx: &crate::UiContext) -> MdTheme {
74		MdTheme::from_context(ctx).cascade(self.props.style(&ctx.theme))
75	}
76
77	fn render(&mut self, ctx: &crate::UiContext, width: u16) {
78		let width = width.max(1);
79		let key = MemoKey::new(self.version, ctx);
80		if self.cached_width == width && self.cached == Some(key) {
81			return;
82		}
83		let theme = self.theme(ctx);
84		let style = self.props.style(&ctx.theme);
85		self.rich.clear();
86		crate::markdown::render(&self.text, width, &theme, &mut self.rich);
87		truncate_rich(&mut self.rich, width, style, self.props.truncate());
88		self.cached_width = width;
89		self.cached = Some(key);
90	}
91}
92
93impl Default for Markdown {
94	fn default() -> Self {
95		Self::new()
96	}
97}
98
99impl Component for Markdown {
100	fn props(&self) -> &Props {
101		&self.props
102	}
103
104	fn props_mut(&mut self) -> &mut Props {
105		&mut self.props
106	}
107
108	fn slot(&self) -> Slot {
109		self.slot
110	}
111
112	fn children(&self) -> &[Cached] {
113		&self.embedded
114	}
115
116	fn children_mut(&mut self) -> &mut [Cached] {
117		&mut self.embedded
118	}
119
120	fn measure(&mut self, ctx: &crate::UiContext) -> (u16, u16) {
121		let theme = self.theme(ctx);
122		let mut natural = Measure::default();
123		crate::markdown::render(&self.text, u16::MAX, &theme, &mut natural);
124		let mut min = natural.widest.clamp(1, 12);
125		let mut nat = natural.widest.max(min);
126		for child in &mut self.embedded {
127			if child.visible {
128				let (child_min, child_nat) = child.measure(ctx);
129				min = min.max(child_min);
130				nat = nat.max(child_nat);
131			}
132		}
133		(min, nat)
134	}
135
136	fn height(&mut self, ctx: &crate::UiContext, width: u16) -> u16 {
137		self.render(ctx, width);
138		let mut height = if self.text.is_empty() {
139			0
140		} else {
141			RichText::rows(&self.rich)
142		};
143		let mut placed = !self.text.is_empty();
144		for child in &mut self.embedded {
145			if !child.visible {
146				continue;
147			}
148			if placed {
149				height = height.saturating_add(1);
150			}
151			height = height.saturating_add(child.height(ctx, width));
152			placed = true;
153		}
154		height
155	}
156
157	fn place(&mut self, ctx: &crate::UiContext, content: Rect) {
158		self.render(ctx, content.width);
159		let mut cursor = content.y;
160		let mut placed = if self.text.is_empty() {
161			false
162		} else {
163			cursor = cursor.saturating_add(RichText::rows(&self.rich));
164			true
165		};
166		for child in &mut self.embedded {
167			if !child.visible {
168				continue;
169			}
170			if placed {
171				cursor = cursor.saturating_add(1);
172			}
173			let height = child.height(ctx, content.width);
174			child.place(ctx, Rect::new(content.x, cursor, content.width, height));
175			cursor = cursor.saturating_add(height);
176			placed = true;
177		}
178	}
179
180	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
181		if !self.text.is_empty() {
182			self.render(pc.ctx, rect.width);
183			let own = Rect::new(rect.x, rect.y, rect.width, RichText::rows(&self.rich));
184			paint_rich(pc, own, &self.rich, self.props.align());
185		}
186		for child in &mut self.embedded {
187			if child.visible {
188				child.paint(pc);
189			}
190		}
191	}
192
193	fn set_text(&mut self, ctx: &crate::UiContext, text: Str) -> bool {
194		if self.source == text {
195			return false;
196		}
197		self.source = text.clone();
198		if crate::markup::md_embeds_markup(&text) {
199			if let Ok(children) = crate::markup::parse_md_fragment_inheriting(&text, ctx, &self.props)
200			{
201				self.text = Str::default();
202				self.embedded = children;
203			} else {
204				self.text = text;
205				self.embedded.clear();
206			}
207		} else {
208			self.text = text;
209			self.embedded.clear();
210		}
211		self.version = self.version.wrapping_add(1);
212		true
213	}
214}
215
216#[cfg(test)]
217mod tests {
218	use super::*;
219	use crate::context::UiContext;
220
221	#[test]
222	fn set_text_regrafts_static_markup_in_document_order() {
223		let ctx = UiContext::default();
224		let mut markdown = Markdown::text_of("old");
225		assert!(markdown.set_text(&ctx, Str::new("before\n<box><text>inside</text></box>\nafter"),));
226		assert!(markdown.text.is_empty());
227		assert_eq!(markdown.embedded.len(), 3);
228	}
229
230	#[test]
231	fn set_text_degrades_rejected_dynamic_markup_to_literal_text() {
232		let ctx = UiContext::default();
233		for source in ["<input/>", "<box id=duplicate/>", "<box when=\"x == y\"/>", "</md>"] {
234			let mut markdown = Markdown::text_of("old");
235			assert!(markdown.set_text(&ctx, Str::new(source)));
236			assert_eq!(markdown.text, source);
237			assert!(markdown.embedded.is_empty());
238		}
239	}
240}