1use std::sync::Arc;
5
6use derive_where::derive_where;
7use toasty::stmt::{Expr, OrderByExpr};
8use topcoat::{context::Cx, view::*};
9
10use super::{Column, ColumnBase, ColumnWidth, sort_order};
11use crate::{
12 Lens, Text,
13 display::{Alignment, Color, Currency, Numeric},
14 values::FormScalar,
15};
16
17#[derive_where(Clone, Debug)]
61pub struct TextColumn<M, T> {
62 lens: Lens<M, T>,
63 base: ColumnBase,
64 #[derive_where(skip(Debug))]
65 format: Arc<dyn Fn(&T) -> Text + Send + Sync>,
66 #[derive_where(skip(Debug))]
68 search: Option<SearchFn>,
69 sortable: bool,
70 #[derive_where(skip(Debug))]
73 color: ColorFn<T>,
74 tinted: bool,
76 badge: bool,
77 tabular: bool,
79 alignment: Alignment,
80 #[derive_where(skip(Debug))]
81 description: Option<DescriptionFn<M>>,
82 description_reads: Option<crate::lens::Reads<M>>,
84}
85
86type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
88
89type ColorFn<T> = Arc<dyn Fn(&T) -> Option<Color> + Send + Sync>;
91
92type DescriptionFn<M> = Arc<dyn Fn(&M) -> String + Send + Sync>;
94
95const LIKE_ESCAPE: char = '\\';
97
98pub(super) fn escape_like_pattern(term: &str) -> String {
100 let mut pattern = String::with_capacity(term.len() + 2);
101 pattern.push('%');
102 for c in term.chars() {
103 if c == LIKE_ESCAPE || c == '%' || c == '_' {
104 pattern.push(LIKE_ESCAPE);
105 }
106 pattern.push(c);
107 }
108 pattern.push('%');
109 pattern
110}
111
112pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
114where
115 M: toasty::schema::Model,
116 T: toasty::schema::Field<Inner = String>,
117{
118 let trimmed = term.trim();
119 (!trimmed.is_empty()).then(|| {
120 lens.path()
121 .clone()
122 .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
123 })
124}
125
126impl<M, T> TextColumn<M, T>
127where
128 M: toasty::schema::Model,
129{
130 pub fn new(lens: Lens<M, T>) -> Self
132 where
133 T: FormScalar + Send + Sync + 'static,
134 {
135 Self {
136 base: ColumnBase::new(lens.path(), ColumnWidth::Grow),
137 lens,
138 format: Arc::new(T::to_label),
139 search: None,
140 sortable: false,
141 color: Arc::new(T::color),
142 tinted: false,
143 badge: false,
144 tabular: false,
145 alignment: Alignment::Start,
146 description: None,
147 description_reads: None,
148 }
149 }
150
151 pub fn format<L: Into<Text>>(
154 mut self,
155 format: impl Fn(&T) -> L + Send + Sync + 'static,
156 ) -> Self {
157 self.format = Arc::new(move |value| format(value).into());
158 self
159 }
160
161 pub fn badge(mut self) -> Self {
164 self.badge = true;
165 self
166 }
167
168 pub fn color(mut self, color: impl Fn(&T) -> Option<Color> + Send + Sync + 'static) -> Self {
170 self.color = Arc::new(color);
171 self.tinted = true;
172 self
173 }
174
175 pub fn description(
182 mut self,
183 description: impl Fn(&M) -> String + Send + Sync + 'static,
184 ) -> Self {
185 self.description = Some(Arc::new(description));
186 self
187 }
188
189 pub fn reading(mut self, reads: crate::lens::Reads<M>) -> Self {
191 self.description_reads = Some(reads);
192 self
193 }
194
195 pub fn align(mut self, alignment: Alignment) -> Self {
197 self.alignment = alignment;
198 self
199 }
200
201 base_builders!(sortable);
202}
203
204impl<M, T> TextColumn<M, T>
205where
206 M: toasty::schema::Model,
207 T: Numeric + 'static,
208{
209 pub fn numeric(mut self, decimals: u8) -> Self {
212 self.format = Arc::new(move |value: &T| {
213 value
214 .number()
215 .map(|number| crate::display::format_number(number, decimals))
216 .unwrap_or_default()
217 .into()
218 });
219 self.amount()
220 }
221
222 pub fn money(mut self, currency: Currency) -> Self {
225 self.format = Arc::new(move |value: &T| currency.format(value).into());
226 self.amount()
227 }
228
229 fn amount(mut self) -> Self {
231 self.alignment = Alignment::End;
232 self.tabular = true;
233 self.base.width = ColumnWidth::Auto;
234 self
235 }
236}
237
238impl<M, T> TextColumn<M, T>
239where
240 M: toasty::schema::Model + Send + Sync + 'static,
241 T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
242{
243 pub fn searchable(mut self) -> Self {
245 let path = self.lens.path().clone();
246 self.search = Some(Arc::new(move |pattern| {
247 path.clone().like_with_escape(pattern, LIKE_ESCAPE)
248 }));
249 self
250 }
251}
252
253impl<M, T> Column<M> for TextColumn<M, T>
254where
255 M: toasty::schema::Model + Send + Sync + 'static,
256 T: Send + Sync + 'static,
257{
258 base_column_methods!(binding, reads);
259
260 fn reads(&self) -> Option<crate::lens::Reads<M>> {
261 let own = self.base.reads();
262 match (&self.description, &self.description_reads) {
263 (None, _) => Some(own),
264 (Some(_), None) => None,
265 (Some(_), Some(declared)) => Some(own.union(declared)),
266 }
267 }
268
269 fn text(&self, cx: &Cx, row: &M) -> String {
270 (self.format)(self.lens.read(row)).localize(cx)
271 }
272
273 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
274 let value = self.lens.read(row);
275 let text = (self.format)(value).localize(cx);
276 let color = (!text.trim().is_empty() && (self.badge || self.tinted))
277 .then(|| (self.color)(value))
278 .flatten();
279 let description = self.description.as_ref().map(|describe| describe(row));
280 text_cell(cx, text, color, self.badge, self.tabular, description)
281 }
282
283 fn alignment(&self) -> Alignment {
284 self.alignment
285 }
286
287 fn is_searchable(&self) -> bool {
288 self.search.is_some()
289 }
290
291 fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
293 let t = term.trim();
294 let search = self.search.as_ref()?;
295 (!t.is_empty()).then(|| search(escape_like_pattern(t)))
296 }
297
298 fn is_sortable(&self) -> bool {
299 self.sortable
300 }
301
302 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
303 self.sortable
304 .then(|| sort_order(self.lens.path(), descending))
305 }
306}
307
308fn text_cell<'a>(
311 cx: &'a Cx,
312 text: String,
313 color: Option<Color>,
314 badge: bool,
315 tabular: bool,
316 description: Option<String>,
317) -> BoxView<'a> {
318 let value: BoxView<'a> = if text.trim().is_empty() {
319 crate::schema::value_cell(cx, &text)
320 } else if badge {
321 badge_cell(cx, text, color)
322 } else {
323 let class = [
324 color.map(Color::text_class),
325 tabular.then_some("tabular-nums"),
326 ]
327 .into_iter()
328 .flatten()
329 .collect::<Vec<_>>()
330 .join(" ");
331 if class.is_empty() {
332 view! { cx => (text) }.boxed()
333 } else {
334 view! { cx => <span class=(class)>(text)</span> }.boxed()
335 }
336 };
337 match description.filter(|description| !description.trim().is_empty()) {
338 None => value,
339 Some(description) => view! {
340 cx =>
341 <div class="flex min-w-0 flex-col">
342 <span class="truncate">(value)</span>
343 <span class="truncate text-xs text-muted-foreground">
344 (description)
345 </span>
346 </div>
347 }
348 .boxed(),
349 }
350}
351
352fn badge_cell<'a>(cx: &'a Cx, text: String, color: Option<Color>) -> BoxView<'a> {
354 view! {
355 cx =>
356 tablo_ui::badge(
357 variant: tablo_ui::BadgeVariant::Outline,
358 if let Some(color) = color {
359 <span class=(color.dot_class()) aria-hidden="true"></span>
360 }
361 (text)
362 )
363 }
364 .boxed()
365}