use std::sync::Arc;
use derive_where::derive_where;
use toasty::stmt::{Expr, OrderByExpr};
use topcoat::{context::Cx, view::*};
use super::{Column, ColumnBase, ColumnWidth, sort_order};
use crate::{
Lens, Text,
display::{Alignment, Color, Currency, Numeric},
values::FormScalar,
};
#[derive_where(Clone, Debug)]
pub struct TextColumn<M, T> {
lens: Lens<M, T>,
base: ColumnBase,
#[derive_where(skip(Debug))]
format: Arc<dyn Fn(&T) -> Text + Send + Sync>,
#[derive_where(skip(Debug))]
search: Option<SearchFn>,
sortable: bool,
#[derive_where(skip(Debug))]
color: ColorFn<T>,
tinted: bool,
badge: bool,
tabular: bool,
alignment: Alignment,
#[derive_where(skip(Debug))]
description: Option<DescriptionFn<M>>,
description_reads: Option<crate::lens::Reads<M>>,
}
type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
type ColorFn<T> = Arc<dyn Fn(&T) -> Option<Color> + Send + Sync>;
type DescriptionFn<M> = Arc<dyn Fn(&M) -> String + Send + Sync>;
const LIKE_ESCAPE: char = '\\';
pub(super) fn escape_like_pattern(term: &str) -> String {
let mut pattern = String::with_capacity(term.len() + 2);
pattern.push('%');
for c in term.chars() {
if c == LIKE_ESCAPE || c == '%' || c == '_' {
pattern.push(LIKE_ESCAPE);
}
pattern.push(c);
}
pattern.push('%');
pattern
}
pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
where
M: toasty::schema::Model,
T: toasty::schema::Field<Inner = String>,
{
let trimmed = term.trim();
(!trimmed.is_empty()).then(|| {
lens.path()
.clone()
.like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
})
}
impl<M, T> TextColumn<M, T>
where
M: toasty::schema::Model,
{
pub fn new(lens: Lens<M, T>) -> Self
where
T: FormScalar + Send + Sync + 'static,
{
Self {
base: ColumnBase::new(lens.path(), ColumnWidth::Grow),
lens,
format: Arc::new(T::to_label),
search: None,
sortable: false,
color: Arc::new(T::color),
tinted: false,
badge: false,
tabular: false,
alignment: Alignment::Start,
description: None,
description_reads: None,
}
}
pub fn format<L: Into<Text>>(
mut self,
format: impl Fn(&T) -> L + Send + Sync + 'static,
) -> Self {
self.format = Arc::new(move |value| format(value).into());
self
}
pub fn badge(mut self) -> Self {
self.badge = true;
self
}
pub fn color(mut self, color: impl Fn(&T) -> Option<Color> + Send + Sync + 'static) -> Self {
self.color = Arc::new(color);
self.tinted = true;
self
}
pub fn description(
mut self,
description: impl Fn(&M) -> String + Send + Sync + 'static,
) -> Self {
self.description = Some(Arc::new(description));
self
}
pub fn reading(mut self, reads: crate::lens::Reads<M>) -> Self {
self.description_reads = Some(reads);
self
}
pub fn align(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
base_builders!(sortable);
}
impl<M, T> TextColumn<M, T>
where
M: toasty::schema::Model,
T: Numeric + 'static,
{
pub fn numeric(mut self, decimals: u8) -> Self {
self.format = Arc::new(move |value: &T| {
value
.number()
.map(|number| crate::display::format_number(number, decimals))
.unwrap_or_default()
.into()
});
self.amount()
}
pub fn money(mut self, currency: Currency) -> Self {
self.format = Arc::new(move |value: &T| currency.format(value).into());
self.amount()
}
fn amount(mut self) -> Self {
self.alignment = Alignment::End;
self.tabular = true;
self.base.width = ColumnWidth::Auto;
self
}
}
impl<M, T> TextColumn<M, T>
where
M: toasty::schema::Model + Send + Sync + 'static,
T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
{
pub fn searchable(mut self) -> Self {
let path = self.lens.path().clone();
self.search = Some(Arc::new(move |pattern| {
path.clone().like_with_escape(pattern, LIKE_ESCAPE)
}));
self
}
}
impl<M, T> Column<M> for TextColumn<M, T>
where
M: toasty::schema::Model + Send + Sync + 'static,
T: Send + Sync + 'static,
{
base_column_methods!(binding, reads);
fn reads(&self) -> Option<crate::lens::Reads<M>> {
let own = self.base.reads();
match (&self.description, &self.description_reads) {
(None, _) => Some(own),
(Some(_), None) => None,
(Some(_), Some(declared)) => Some(own.union(declared)),
}
}
fn text(&self, cx: &Cx, row: &M) -> String {
(self.format)(self.lens.read(row)).localize(cx)
}
fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
let value = self.lens.read(row);
let text = (self.format)(value).localize(cx);
let color = (!text.trim().is_empty() && (self.badge || self.tinted))
.then(|| (self.color)(value))
.flatten();
let description = self.description.as_ref().map(|describe| describe(row));
text_cell(cx, text, color, self.badge, self.tabular, description)
}
fn alignment(&self) -> Alignment {
self.alignment
}
fn is_searchable(&self) -> bool {
self.search.is_some()
}
fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
let t = term.trim();
let search = self.search.as_ref()?;
(!t.is_empty()).then(|| search(escape_like_pattern(t)))
}
fn is_sortable(&self) -> bool {
self.sortable
}
fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
self.sortable
.then(|| sort_order(self.lens.path(), descending))
}
}
fn text_cell<'a>(
cx: &'a Cx,
text: String,
color: Option<Color>,
badge: bool,
tabular: bool,
description: Option<String>,
) -> BoxView<'a> {
let value: BoxView<'a> = if text.trim().is_empty() {
crate::schema::value_cell(cx, &text)
} else if badge {
badge_cell(cx, text, color)
} else {
let class = [
color.map(Color::text_class),
tabular.then_some("tabular-nums"),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
if class.is_empty() {
view! { cx => (text) }.boxed()
} else {
view! { cx => <span class=(class)>(text)</span> }.boxed()
}
};
match description.filter(|description| !description.trim().is_empty()) {
None => value,
Some(description) => view! {
cx =>
<div class="flex min-w-0 flex-col">
<span class="truncate">(value)</span>
<span class="truncate text-xs text-muted-foreground">
(description)
</span>
</div>
}
.boxed(),
}
}
/// `text` as an outline badge, led by a dot in `color` when it has one.
fn badge_cell<'a>(cx: &'a Cx, text: String, color: Option<Color>) -> BoxView<'a> {
view! {
cx =>
tablo_ui::badge(
variant: tablo_ui::BadgeVariant::Outline,
if let Some(color) = color {
<span class=(color.dot_class()) aria-hidden="true"></span>
}
(text)
)
}
.boxed()
}