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
12pub 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 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 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 pub fn with_str(self, prop: Prop, value: &str) -> Self {
46 self.with(prop, value)
47 }
48
49 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}