Skip to main content

tablo_core/i18n/
text.rs

1//! [`Text`]: a word a panel renders, plain or a catalog's message.
2
3use std::borrow::Cow;
4
5use topcoat::{
6    context::Cx,
7    view::{AttributeValueViewParts, NodeViewParts, PartsWriter},
8};
9
10use crate::naming::{capitalize, derived_id, pluralize, sentence_case, slug_case, type_short_name};
11
12/// A word a panel renders: plain text, or a message the request's
13/// [`Locale`](topcoat::l10n::Locale) spells.
14///
15/// Every builder that takes a label, a description or a placeholder takes `impl Into<Text>`, so a
16/// string literal stays plain and a message translates:
17///
18/// ```rust
19/// # use tablo_core::{Text, TextColumn, lens, text};
20/// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
21/// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
22/// TextColumn::new(lens!(Post.title)).label("Headline"); // plain: every locale reads "Headline"
23/// TextColumn::new(lens!(Post.title)).label(Text::message("post-headline"));
24/// TextColumn::new(lens!(Post.title)).label(text!("post-headline")); // the same message
25/// ```
26///
27/// A message names a Fluent message of the panel's catalogs, or one of its attributes as
28/// `id.attribute`. Each argument fills the placeable of its name: a number selects a plural
29/// variant, and a `Text` argument renders in the same locale first.
30///
31/// ```ftl
32/// post-headline = Headline
33/// posts-published = { $count ->
34///     [one] One post published
35///    *[other] { $count } posts published
36/// }
37/// ```
38///
39/// A message no catalog of the request's locale holds reads from the default locale's, then from
40/// English, and one no catalog holds at all renders its id, so a missing translation shows on the
41/// page rather than an empty label.
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct Text(pub(super) Repr);
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
46pub(super) enum Repr {
47    Plain(Cow<'static, str>),
48    /// A number argument, which a message's plural selector reads as a number.
49    Integer(i128),
50    Message(Box<Message>),
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub(super) struct Message {
55    /// The message's id, with `.attribute` when it names an attribute.
56    pub(super) id: Cow<'static, str>,
57    pub(super) args: Vec<(Cow<'static, str>, Text)>,
58    /// What renders when no catalog holds the message: a derived label's English spelling.
59    pub(super) fallback: Option<Cow<'static, str>>,
60}
61
62impl Text {
63    /// The message `id` of the panel's catalogs, or its attribute as `id.attribute`.
64    #[must_use]
65    pub fn message(id: impl Into<Cow<'static, str>>) -> Self {
66        Self(Repr::Message(Box::new(Message {
67            id: id.into(),
68            args: Vec::new(),
69            fallback: None,
70        })))
71    }
72
73    /// Fills the message's placeable `name` with `value`.
74    ///
75    /// # Panics
76    ///
77    /// In a debug build, on plain text, which has no placeable to fill. A release build ignores
78    /// the argument.
79    #[must_use]
80    pub fn arg(mut self, name: impl Into<Cow<'static, str>>, value: impl Into<Self>) -> Self {
81        let name = name.into();
82        match &mut self.0 {
83            Repr::Message(message) => message.args.push((name, value.into())),
84            _ => debug_assert!(
85                false,
86                "`Text::arg({name:?})` on plain text, which reads none"
87            ),
88        }
89        self
90    }
91
92    /// The text as the request's [`Locale`](topcoat::l10n::Locale) spells it: in a panel, the one
93    /// it negotiated; outside every panel, English.
94    pub fn localize(&self, cx: &Cx) -> String {
95        let (catalog, locale) = super::catalog_and_locale(cx);
96        catalog.format(locale, self)
97    }
98
99    /// The message `id`, which renders `fallback` when no catalog holds it: the label a
100    /// declaration derives from a name, which an app translates by defining `id`.
101    pub(crate) fn derived(id: String, fallback: String) -> Self {
102        Self(Repr::Message(Box::new(Message {
103            id: id.into(),
104            args: Vec::new(),
105            fallback: Some(fallback.into()),
106        })))
107    }
108
109    /// The name a column takes after its label: the plain text in lowercase, or the message's id.
110    pub(crate) fn key(&self) -> String {
111        match &self.0 {
112            Repr::Plain(text) => text.to_lowercase(),
113            Repr::Integer(n) => n.to_string(),
114            Repr::Message(message) => message.id.to_string(),
115        }
116    }
117
118    /// The label Tablo derives for the field, column or filter of `name`: the message
119    /// `field-{name}`, else `name` capitalized, `word_count` reading `Word count`. A field, a
120    /// column and a filter of one name share it.
121    pub(crate) fn field(name: &str) -> Self {
122        Self::derived(derived_id("field", name), capitalize(name))
123    }
124
125    /// The label Tablo derives for the variant `variant` of the enum `enum_name`, both in snake
126    /// case: the message attribute `option-{enum}.{variant}`, the enum in kebab case, else the
127    /// variant capitalized. `PostStatus::PublishedLate` reads `option-post-status.published_late`,
128    /// else `Published late`.
129    pub(crate) fn option(enum_name: &str, variant: &str) -> Self {
130        let id = format!("option-{}.{variant}", enum_name.replace('_', "-"));
131        Self::derived(id, capitalize(variant))
132    }
133
134    /// The label Tablo derives for the model `M`'s records: the message `resource-{model}`, else
135    /// the model's type name in sentence case. `BlogPost` reads `resource-blog-post`, else
136    /// `Blog post`.
137    pub(crate) fn model<M: ?Sized>() -> Self {
138        let noun = sentence_case(type_short_name::<M>());
139        Self::derived(derived_id("resource", &slug_case(&noun)), noun)
140    }
141
142    /// The plural of a resource's label: plain text pluralized in English, and a message's
143    /// attribute `plural`, with its arguments, which renders its fallback pluralized when no
144    /// catalog holds it.
145    pub(crate) fn plural(&self) -> Self {
146        match &self.0 {
147            Repr::Plain(text) => Self::from(pluralize(text)),
148            Repr::Integer(_) => self.clone(),
149            Repr::Message(message) => Self(Repr::Message(Box::new(Message {
150                id: format!("{}.plural", message.id).into(),
151                args: message.args.clone(),
152                fallback: message
153                    .fallback
154                    .as_deref()
155                    .map(|fallback| pluralize(fallback).into()),
156            }))),
157        }
158    }
159
160    /// The English spelling, as Tablo's own catalog writes it, for what has no request: a
161    /// declaration error, a log line.
162    pub(crate) fn english(&self) -> String {
163        super::Catalog::english().format(&super::ENGLISH, self)
164    }
165}
166
167/// The label `#[derive(Options)]` gives a variant it declares no label for: the message
168/// `option-{enum}.{variant}`, else the variant capitalized.
169pub fn option_label(enum_name: &str, variant: &str) -> Text {
170    Text::option(enum_name, variant)
171}
172
173/// A text renders in the request's locale, in a `view!`'s content or an attribute's value.
174impl NodeViewParts for Text {
175    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
176        parts.push_string(self.localize(cx));
177    }
178}
179
180impl NodeViewParts for &Text {
181    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
182        parts.push_string(self.localize(cx));
183    }
184}
185
186impl AttributeValueViewParts for Text {
187    fn attribute_present(&self) -> bool {
188        true
189    }
190
191    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
192        parts.push_string(self.localize(cx));
193    }
194}
195
196impl AttributeValueViewParts for &Text {
197    fn attribute_present(&self) -> bool {
198        true
199    }
200
201    fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
202        parts.push_string(self.localize(cx));
203    }
204}
205
206/// A test compares a text with its English spelling.
207#[cfg(test)]
208impl PartialEq<&str> for Text {
209    fn eq(&self, other: &&str) -> bool {
210        self.english() == *other
211    }
212}
213
214/// The empty plain text.
215impl Default for Text {
216    fn default() -> Self {
217        Self(Repr::Plain(Cow::Borrowed("")))
218    }
219}
220
221impl From<&str> for Text {
222    fn from(text: &str) -> Self {
223        Self(Repr::Plain(Cow::Owned(text.to_owned())))
224    }
225}
226
227impl From<&Text> for Text {
228    fn from(text: &Text) -> Self {
229        text.clone()
230    }
231}
232
233impl From<&String> for Text {
234    fn from(text: &String) -> Self {
235        Self(Repr::Plain(Cow::Owned(text.clone())))
236    }
237}
238
239impl From<String> for Text {
240    fn from(text: String) -> Self {
241        Self(Repr::Plain(Cow::Owned(text)))
242    }
243}
244
245impl From<Cow<'static, str>> for Text {
246    fn from(text: Cow<'static, str>) -> Self {
247        Self(Repr::Plain(text))
248    }
249}
250
251macro_rules! from_integer {
252    ($($int:ty)*) => {$(
253        impl From<$int> for Text {
254            fn from(n: $int) -> Self {
255                Self(Repr::Integer(i128::from(n)))
256            }
257        }
258    )*};
259}
260from_integer!(i8 i16 i32 i64 u8 u16 u32 u64);
261
262impl From<usize> for Text {
263    fn from(n: usize) -> Self {
264        Self(Repr::Integer(i128::try_from(n).unwrap_or(i128::MAX)))
265    }
266}
267
268impl From<isize> for Text {
269    fn from(n: isize) -> Self {
270        Self(Repr::Integer(i128::try_from(n).unwrap_or(i128::MAX)))
271    }
272}
273
274/// A [`Text`] message with its arguments: `text!("id", name = value, ..)` is
275/// `Text::message("id").arg("name", value)..`.
276///
277/// ```rust
278/// # use tablo_core::text;
279/// let shipped = text!("orders-shipped", count = 3);
280/// ```
281#[macro_export]
282macro_rules! text {
283    ($id:expr $(, $name:ident = $value:expr)* $(,)?) => {
284        $crate::Text::message($id)$(.arg(stringify!($name), $value))*
285    };
286}
287
288/// A message spelled in the request's locale: `t!(cx, "id", name = value, ..)` is
289/// `text!("id", name = value, ..).localize(cx)`, for what takes a `String`, such as a
290/// [`Notification`](crate::Notification)'s title.
291///
292/// ```rust
293/// # use tablo_core::{Notification, t};
294/// # fn shipped(cx: &topcoat::context::Cx) -> Notification {
295/// Notification::success(t!(cx, "orders-shipped", count = 3))
296/// # }
297/// ```
298#[macro_export]
299macro_rules! t {
300    ($cx:expr, $id:expr $(, $name:ident = $value:expr)* $(,)?) => {
301        $crate::text!($id $(, $name = $value)*).localize($cx)
302    };
303}