1use super::layout::into_node;
6use super::render_child;
7use super::widgets::screen_href;
8use crate::i18n::Text;
9use crate::render::{Render, escape};
10use crate::screen::{Component, Node, ScreenDef};
11use serde_json::{Value as Json, json};
12
13into_node!(
14 Heading, Paragraph, MetaLine, PrevNext, AutoGrid, Avatar, Spoiler
15);
16
17pub struct Heading {
33 level: u8,
34 text: Text,
35 id: Option<String>,
36}
37
38impl Heading {
39 fn level(level: u8, text: impl Into<Text>) -> Self {
40 Heading {
41 level,
42 text: text.into(),
43 id: None,
44 }
45 }
46
47 pub fn h2(text: impl Into<Text>) -> Self {
49 Self::level(2, text)
50 }
51
52 pub fn h3(text: impl Into<Text>) -> Self {
54 Self::level(3, text)
55 }
56
57 pub fn h4(text: impl Into<Text>) -> Self {
58 Self::level(4, text)
59 }
60
61 pub fn h5(text: impl Into<Text>) -> Self {
62 Self::level(5, text)
63 }
64
65 pub fn h6(text: impl Into<Text>) -> Self {
66 Self::level(6, text)
67 }
68
69 pub fn id(mut self, id: &str) -> Self {
72 let clean: String = id
73 .chars()
74 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
75 .take(64)
76 .collect();
77 self.id = (!clean.is_empty()).then_some(clean);
78 self
79 }
80}
81
82impl Component for Heading {
83 fn render(&self, out: &mut String, r: &Render) {
84 let id = self
85 .id
86 .as_ref()
87 .map(|i| format!(r#" id="{i}""#))
88 .unwrap_or_default();
89 out.push_str(&format!(
90 r#"<h{0} class="z-heading z-heading-{0}"{id}>{1}</h{0}>"#,
91 self.level,
92 r.text(&self.text)
93 ));
94 }
95
96 fn schema(&self) -> Json {
97 json!({ "type": "heading", "level": self.level, "text": self.text, "id": self.id })
98 }
99}
100
101pub struct Paragraph {
110 text: Text,
111 muted: bool,
112 small: bool,
113}
114
115impl Paragraph {
116 pub fn new(text: impl Into<Text>) -> Self {
117 Paragraph {
118 text: text.into(),
119 muted: false,
120 small: false,
121 }
122 }
123
124 pub fn muted(mut self) -> Self {
126 self.muted = true;
127 self
128 }
129
130 pub fn small(mut self) -> Self {
132 self.small = true;
133 self
134 }
135}
136
137impl Component for Paragraph {
138 fn render(&self, out: &mut String, r: &Render) {
139 let mut class = String::from("z-paragraph");
140 if self.muted {
141 class.push_str(" z-muted");
142 }
143 if self.small {
144 class.push_str(" z-small");
145 }
146 out.push_str(&format!(r#"<p class="{class}">{}</p>"#, r.text(&self.text)));
147 }
148
149 fn schema(&self) -> Json {
150 json!({ "type": "paragraph", "text": self.text, "muted": self.muted, "small": self.small })
151 }
152}
153
154pub struct MetaLine {
163 parts: Vec<Text>,
164 separator: &'static str,
165}
166
167impl Default for MetaLine {
168 fn default() -> Self {
169 MetaLine {
170 parts: Vec::new(),
171 separator: "\u{00b7}",
172 }
173 }
174}
175
176impl MetaLine {
177 pub fn new() -> Self {
178 Self::default()
179 }
180
181 pub fn part(mut self, text: impl Into<Text>) -> Self {
183 self.parts.push(text.into());
184 self
185 }
186
187 pub fn separator(mut self, separator: &'static str) -> Self {
189 self.separator = separator;
190 self
191 }
192}
193
194impl Component for MetaLine {
195 fn render(&self, out: &mut String, r: &Render) {
196 out.push_str(r#"<p class="z-meta">"#);
197 let sep = crate::render::escape(self.separator);
198 let mut first = true;
199 for part in &self.parts {
200 let text = r.text(part);
201 if text.is_empty() {
202 continue;
203 }
204 if !first {
205 out.push_str(&format!(
206 r#"<span class="z-meta-sep" aria-hidden="true">{sep}</span>"#
207 ));
208 }
209 first = false;
210 out.push_str(&format!(r#"<bdi class="z-meta-part">{text}</bdi>"#));
211 }
212 out.push_str("</p>");
213 }
214
215 fn schema(&self) -> Json {
216 json!({ "type": "meta_line", "parts": self.parts, "separator": self.separator })
217 }
218}
219
220struct Step {
221 screen: &'static str,
222 params: Json,
223 title: Option<Text>,
224 shortcut: Option<String>,
225}
226
227#[derive(Default)]
253pub struct PrevNext {
254 prev: Option<Step>,
255 next: Option<Step>,
256 next_last: bool,
257}
258
259impl PrevNext {
260 pub fn new() -> Self {
261 Self::default()
262 }
263
264 pub fn prev<S: ScreenDef>(mut self, _screen: S, params: S::Params) -> Self {
266 self.prev = Some(Step {
267 screen: S::META.name,
268 params: serde_json::to_value(params).unwrap_or(Json::Null),
269 title: None,
270 shortcut: None,
271 });
272 self.next_last = false;
273 self
274 }
275
276 pub fn next<S: ScreenDef>(mut self, _screen: S, params: S::Params) -> Self {
278 self.next = Some(Step {
279 screen: S::META.name,
280 params: serde_json::to_value(params).unwrap_or(Json::Null),
281 title: None,
282 shortcut: None,
283 });
284 self.next_last = true;
285 self
286 }
287
288 pub fn prev_title(mut self, title: impl Into<Text>) -> Self {
290 if let Some(s) = &mut self.prev {
291 s.title = Some(title.into());
292 }
293 self
294 }
295
296 pub fn next_title(mut self, title: impl Into<Text>) -> Self {
298 if let Some(s) = &mut self.next {
299 s.title = Some(title.into());
300 }
301 self
302 }
303
304 pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
309 let side = if self.next_last {
310 &mut self.next
311 } else {
312 &mut self.prev
313 };
314 if let Some(s) = side {
315 s.shortcut = Some(keys.into());
316 }
317 self
318 }
319}
320
321impl Component for PrevNext {
322 fn render(&self, out: &mut String, r: &Render) {
323 let t = |key: &str| r.text(&Text::key(format!("zeyn.ui.prevnext.{key}")));
324 out.push_str(&format!(
325 r#"<nav class="z-prevnext" aria-label="{}">"#,
326 t("label")
327 ));
328 for (step, rel, arrow) in [
329 (&self.prev, "prev", "←"),
330 (&self.next, "next", "→"),
331 ] {
332 let Some(s) = step else {
333 out.push_str(r#"<span class="z-prevnext-gap"></span>"#);
334 continue;
335 };
336 let word = t(if rel == "prev" { "previous" } else { "next" });
337 let title = s
338 .title
339 .as_ref()
340 .map(|title| format!(r#"<span class="z-prevnext-title">{}</span>"#, r.text(title)))
341 .unwrap_or_default();
342 let (keys, hint) = crate::command::link_shortcut(s.shortcut.as_deref());
343 out.push_str(&format!(
344 r#"<a class="z-prevnext-link z-prevnext-{rel}" href="{}" rel="{rel}" data-zeyn-base-href{keys}><span class="z-prevnext-arrow" aria-hidden="true">{arrow}</span><span class="z-prevnext-text"><span class="z-prevnext-word">{word}{hint}</span>{title}</span></a>"#,
345 screen_href(r, s.screen, &s.params)
346 ));
347 }
348 out.push_str("</nav>");
349 }
350
351 fn schema(&self) -> Json {
352 let side = |s: &Option<Step>| {
353 s.as_ref()
354 .map(|s| json!({ "screen": s.screen, "params": s.params, "title": s.title, "shortcut": s.shortcut }))
355 };
356 json!({ "type": "prev_next", "prev": side(&self.prev), "next": side(&self.next) })
357 }
358
359 fn shortcuts(&self) -> Vec<&str> {
360 [&self.prev, &self.next]
361 .into_iter()
362 .flatten()
363 .filter_map(|s| s.shortcut.as_deref())
364 .collect()
365 }
366}
367
368pub struct AutoGrid {
384 nodes: Vec<Node>,
385 min: u16,
386 gap: u8,
387}
388
389impl Default for AutoGrid {
390 fn default() -> Self {
391 AutoGrid {
392 nodes: Vec::new(),
393 min: 240,
394 gap: 4,
395 }
396 }
397}
398
399impl AutoGrid {
400 pub fn new() -> Self {
401 Self::default()
402 }
403
404 #[allow(
405 clippy::should_implement_trait,
406 reason = "a builder method that appends a child component; it is not arithmetic and `add` is the public name"
407 )]
408 pub fn add(mut self, node: impl Into<Node>) -> Self {
409 self.nodes.push(node.into());
410 self
411 }
412
413 pub fn min(mut self, px: u16) -> Self {
415 self.min = px.clamp(80, 1200);
416 self
417 }
418
419 pub fn gap(mut self, units: u8) -> Self {
421 self.gap = units.min(12);
422 self
423 }
424}
425
426impl Component for AutoGrid {
427 fn render(&self, out: &mut String, r: &Render) {
428 out.push_str(&format!(
429 r#"<div class="z-autogrid" style="--z-autogrid-min:{}px;--z-autogrid-gap:{}">"#,
430 self.min, self.gap
431 ));
432 for (i, node) in self.nodes.iter().enumerate() {
433 render_child(out, r, i, node);
434 }
435 out.push_str("</div>");
436 }
437
438 fn schema(&self) -> Json {
439 json!({
440 "type": "auto_grid",
441 "min": self.min,
442 "gap": self.gap,
443 "children": self.nodes.iter().map(Node::schema).collect::<Vec<_>>(),
444 })
445 }
446
447 fn children(&self) -> &[Node] {
448 &self.nodes
449 }
450}
451
452#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
454#[serde(rename_all = "snake_case")]
455pub enum AvatarSize {
456 Small,
457 #[default]
458 Medium,
459 Large,
460}
461
462impl AvatarSize {
463 fn px(self) -> u32 {
464 match self {
465 AvatarSize::Small => 32,
466 AvatarSize::Medium => 40,
467 AvatarSize::Large => 64,
468 }
469 }
470}
471
472pub struct Avatar {
487 name: Text,
488 image: Option<String>,
489 size: AvatarSize,
490 decorative: bool,
491}
492
493impl Avatar {
494 pub fn new(name: impl Into<Text>) -> Self {
495 Avatar {
496 name: name.into(),
497 image: None,
498 size: AvatarSize::Medium,
499 decorative: false,
500 }
501 }
502
503 pub fn image(mut self, src: impl Into<String>) -> Self {
505 self.image = Some(src.into());
506 self
507 }
508
509 pub fn size(mut self, size: AvatarSize) -> Self {
510 self.size = size;
511 self
512 }
513
514 pub fn decorative(mut self) -> Self {
516 self.decorative = true;
517 self
518 }
519}
520
521fn initials(name: &str, locale: &str) -> String {
524 let words: Vec<&str> = name.split_whitespace().collect();
525 let firsts = match words.as_slice() {
526 [] => Vec::new(),
527 [one] => vec![one],
528 [first, .., last] => vec![first, last],
529 };
530 let turkish = locale.starts_with("tr") || locale.starts_with("az");
531 firsts
532 .iter()
533 .filter_map(|w| w.chars().find(|c| c.is_alphanumeric()))
534 .flat_map(|c| match c {
535 'i' if turkish => "İ".chars().collect::<Vec<_>>(),
536 'ı' if turkish => vec!['I'],
537 c => c.to_uppercase().collect(),
538 })
539 .collect()
540}
541
542impl Component for Avatar {
543 fn render(&self, out: &mut String, r: &Render) {
544 let px = self.size.px();
545 let name = r.i18n.text(r.locale, &self.name);
546 let tone = name.bytes().fold(0u32, |h, b| {
547 h.wrapping_mul(31)
548 .wrapping_add(u32::from(b))
549 }) % 5;
550 let class = format!("z-avatar z-avatar-{} z-avatar-tone-{tone}", px);
551 let hidden = if self.decorative {
552 r#" aria-hidden="true""#
553 } else {
554 ""
555 };
556 let image = self
557 .image
558 .as_deref()
559 .filter(|src| super::embed::policy(r).allows_image(src));
560 match image {
561 Some(src) => out.push_str(&format!(
562 r#"<img class="{class}" src="{}" alt="{}" width="{px}" height="{px}" loading="lazy" decoding="async"{hidden}>"#,
563 escape(src),
564 if self.decorative { String::new() } else { escape(&name) }
565 )),
566 None => {
567 let label = if self.decorative { String::new() } else { format!(r#" role="img" aria-label="{}""#, escape(&name)) };
568 out.push_str(&format!(
569 r#"<span class="{class}"{label}{hidden}><span aria-hidden="true">{}</span></span>"#,
570 escape(&initials(&name, r.locale))
571 ));
572 }
573 }
574 }
575
576 fn schema(&self) -> Json {
577 json!({ "type": "avatar", "name": self.name, "image": self.image, "size": self.size, "decorative": self.decorative })
578 }
579}
580
581pub struct Spoiler {
592 label: Text,
593 nodes: Vec<Node>,
594}
595
596impl Spoiler {
597 pub fn new(label: impl Into<Text>) -> Self {
598 Spoiler {
599 label: label.into(),
600 nodes: Vec::new(),
601 }
602 }
603
604 #[allow(
605 clippy::should_implement_trait,
606 reason = "a builder method that appends a child component; it is not arithmetic and `add` is the public name"
607 )]
608 pub fn add(mut self, node: impl Into<Node>) -> Self {
609 self.nodes.push(node.into());
610 self
611 }
612}
613
614impl Component for Spoiler {
615 fn render(&self, out: &mut String, r: &Render) {
616 out.push_str(&format!(
617 r#"<details class="z-spoiler"><summary class="z-spoiler-label">{}</summary><div class="z-spoiler-body">"#,
618 r.text(&self.label)
619 ));
620 for (i, node) in self.nodes.iter().enumerate() {
621 render_child(out, r, i, node);
622 }
623 out.push_str("</div></details>");
624 }
625
626 fn schema(&self) -> Json {
627 json!({ "type": "spoiler", "label": self.label, "children": self.nodes.iter().map(Node::schema).collect::<Vec<_>>() })
628 }
629
630 fn children(&self) -> &[Node] {
631 &self.nodes
632 }
633}