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}