Skip to main content

omp_tui/components/
callout.rs

1use omp_core::Str;
2
3use super::text::{append, put_clipped, truncate_rich};
4use crate::{
5	component::{Component, MemoKey, PaintCtx, Slot, next_slot},
6	frame::{Color, Rect, Style},
7	markdown::MdTheme,
8	props::{Prop, PropValue, Props},
9	rich::{RichText, cell_width},
10};
11
12/// A highlighted Markdown notice backing the `<callout>` markup tag.
13pub struct Callout {
14	props:        Props,
15	slot:         Slot,
16	text:         Str,
17	rich:         RichText,
18	version:      u64,
19	cached_width: u16,
20	cached:       Option<MemoKey>,
21}
22
23impl Callout {
24	/// Creates an empty callout.
25	pub fn new() -> Self {
26		Self {
27			props:        Props::new(),
28			slot:         next_slot(),
29			text:         Str::default(),
30			rich:         RichText::default(),
31			version:      1,
32			cached_width: 0,
33			cached:       None,
34		}
35	}
36
37	/// Sets one callout property.
38	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
39		self.props.set(prop, value);
40		self.version = self.version.wrapping_add(1);
41		self
42	}
43
44	/// Sets one callout property from a string.
45	pub fn with_str(self, prop: Prop, value: &str) -> Self {
46		self.with(prop, value)
47	}
48
49	/// Appends Markdown source text.
50	pub fn text(mut self, text: impl Into<Str>) -> Self {
51		append(&mut self.text, text.into());
52		self.version = self.version.wrapping_add(1);
53		self
54	}
55
56	fn has_header(&self) -> bool {
57		self.props.title().is_some()
58			|| self.props.str_of(Prop::Badge).is_some()
59			|| self.props.str_of(Prop::Icon).is_some()
60	}
61
62	fn accent(&self, ctx: &crate::UiContext) -> Color {
63		let color = self.props.style(&ctx.theme).foreground_color();
64		if matches!(color, Color::Default) {
65			ctx.theme.info
66		} else {
67			color
68		}
69	}
70
71	fn icon<'a>(&'a self, ctx: &'a crate::UiContext) -> &'a str {
72		self.props.str_of(Prop::Icon).map_or_else(
73			|| ctx.charset.note_icon(),
74			|name| ctx.charset.icon_named(name).unwrap_or(name),
75		)
76	}
77
78	fn header_width(&self, ctx: &crate::UiContext) -> u16 {
79		if !self.has_header() {
80			return 0;
81		}
82		let icon = cell_width(self.icon(ctx)).saturating_add(1);
83		let title = self.props.title().map_or(0, |title| cell_width(title));
84		let badge = self
85			.props
86			.str_of(Prop::Badge)
87			.map_or(0, |badge| cell_width(badge).saturating_add(1));
88		icon.saturating_add(title).saturating_add(badge)
89	}
90
91	fn render(&mut self, ctx: &crate::UiContext, width: u16) {
92		let width = width.saturating_sub(2).max(1);
93		let key = MemoKey::new(self.version, ctx);
94		if self.cached_width == width && self.cached == Some(key) {
95			return;
96		}
97		let style = self.props.style(&ctx.theme);
98		let theme = MdTheme::from_context(ctx).cascade(style);
99		self.rich.clear();
100		crate::markdown::render(&self.text, width, &theme, &mut self.rich);
101		truncate_rich(&mut self.rich, width, style, self.props.truncate());
102		self.cached_width = width;
103		self.cached = Some(key);
104	}
105}
106
107impl Default for Callout {
108	fn default() -> Self {
109		Self::new()
110	}
111}
112
113impl Component for Callout {
114	fn props(&self) -> &Props {
115		&self.props
116	}
117
118	fn props_mut(&mut self) -> &mut Props {
119		&mut self.props
120	}
121
122	fn slot(&self) -> Slot {
123		self.slot
124	}
125
126	fn measure(&mut self, ctx: &crate::UiContext) -> (u16, u16) {
127		let body = self
128			.text
129			.lines()
130			.map(cell_width)
131			.max()
132			.unwrap_or(0)
133			.saturating_add(2);
134		(14, body.max(self.header_width(ctx)).max(16))
135	}
136
137	fn height(&mut self, ctx: &crate::UiContext, width: u16) -> u16 {
138		self.render(ctx, width);
139		u16::from(self.has_header()).saturating_add(RichText::rows(&self.rich))
140	}
141
142	fn place(&mut self, ctx: &crate::UiContext, content: Rect) {
143		self.render(ctx, content.width);
144	}
145
146	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
147		self.render(pc.ctx, rect.width);
148		let accent = self.accent(pc.ctx);
149		let clip = pc.clip.min(rect.y.saturating_add(rect.height));
150		let mut y = rect.y;
151		if self.has_header() {
152			if y < clip {
153				let mut x = pc
154					.frame
155					.put(rect.x, y, self.icon(pc.ctx), Style::new().fg(accent));
156				x = pc.frame.put(x, y, " ", Style::new().fg(pc.ctx.theme.fg));
157				if let Some(title) = self.props.title() {
158					x = pc.frame.put(x, y, title, Style::new().fg(accent).bold());
159				}
160				if let Some(badge) = self.props.str_of(Prop::Badge) {
161					x = pc.frame.put(x, y, " ", Style::new().fg(pc.ctx.theme.fg));
162					pc.frame
163						.put(x, y, badge, Style::new().fg(pc.ctx.theme.muted));
164				}
165			}
166			y = y.saturating_add(1);
167		}
168		let right = rect.x.saturating_add(rect.width);
169		for row in 0..RichText::rows(&self.rich) {
170			let line_y = y.saturating_add(row);
171			if line_y >= clip {
172				break;
173			}
174			let mut x = pc
175				.frame
176				.put(rect.x, line_y, pc.ctx.charset.rail(), Style::new().fg(accent));
177			for (style, text) in self.rich.row_runs(row) {
178				x = put_clipped(pc.frame, x, line_y, right, text, style);
179				if x >= right {
180					break;
181				}
182			}
183		}
184	}
185
186	fn set_text(&mut self, _ctx: &crate::UiContext, text: Str) -> bool {
187		if self.text == text {
188			return false;
189		}
190		self.text = text;
191		self.version = self.version.wrapping_add(1);
192		true
193	}
194}