use super::layout::into_node;
use super::render_child;
use super::widgets::screen_href;
use crate::i18n::Text;
use crate::render::{Render, escape};
use crate::screen::{Component, Node, ScreenDef};
use serde_json::{Value as Json, json};
into_node!(
Heading, Paragraph, MetaLine, PrevNext, AutoGrid, Avatar, Spoiler
);
pub struct Heading {
level: u8,
text: Text,
id: Option<String>,
}
impl Heading {
fn level(level: u8, text: impl Into<Text>) -> Self {
Heading {
level,
text: text.into(),
id: None,
}
}
pub fn h2(text: impl Into<Text>) -> Self {
Self::level(2, text)
}
pub fn h3(text: impl Into<Text>) -> Self {
Self::level(3, text)
}
pub fn h4(text: impl Into<Text>) -> Self {
Self::level(4, text)
}
pub fn h5(text: impl Into<Text>) -> Self {
Self::level(5, text)
}
pub fn h6(text: impl Into<Text>) -> Self {
Self::level(6, text)
}
pub fn id(mut self, id: &str) -> Self {
let clean: String = id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
self.id = (!clean.is_empty()).then_some(clean);
self
}
}
impl Component for Heading {
fn render(&self, out: &mut String, r: &Render) {
let id = self
.id
.as_ref()
.map(|i| format!(r#" id="{i}""#))
.unwrap_or_default();
out.push_str(&format!(
r#"<h{0} class="z-heading z-heading-{0}"{id}>{1}</h{0}>"#,
self.level,
r.text(&self.text)
));
}
fn schema(&self) -> Json {
json!({ "type": "heading", "level": self.level, "text": self.text, "id": self.id })
}
}
pub struct Paragraph {
text: Text,
muted: bool,
small: bool,
}
impl Paragraph {
pub fn new(text: impl Into<Text>) -> Self {
Paragraph {
text: text.into(),
muted: false,
small: false,
}
}
pub fn muted(mut self) -> Self {
self.muted = true;
self
}
pub fn small(mut self) -> Self {
self.small = true;
self
}
}
impl Component for Paragraph {
fn render(&self, out: &mut String, r: &Render) {
let mut class = String::from("z-paragraph");
if self.muted {
class.push_str(" z-muted");
}
if self.small {
class.push_str(" z-small");
}
out.push_str(&format!(r#"<p class="{class}">{}</p>"#, r.text(&self.text)));
}
fn schema(&self) -> Json {
json!({ "type": "paragraph", "text": self.text, "muted": self.muted, "small": self.small })
}
}
pub struct MetaLine {
parts: Vec<Text>,
separator: &'static str,
}
impl Default for MetaLine {
fn default() -> Self {
MetaLine {
parts: Vec::new(),
separator: "\u{00b7}",
}
}
}
impl MetaLine {
pub fn new() -> Self {
Self::default()
}
pub fn part(mut self, text: impl Into<Text>) -> Self {
self.parts.push(text.into());
self
}
pub fn separator(mut self, separator: &'static str) -> Self {
self.separator = separator;
self
}
}
impl Component for MetaLine {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(r#"<p class="z-meta">"#);
let sep = crate::render::escape(self.separator);
let mut first = true;
for part in &self.parts {
let text = r.text(part);
if text.is_empty() {
continue;
}
if !first {
out.push_str(&format!(
r#"<span class="z-meta-sep" aria-hidden="true">{sep}</span>"#
));
}
first = false;
out.push_str(&format!(r#"<bdi class="z-meta-part">{text}</bdi>"#));
}
out.push_str("</p>");
}
fn schema(&self) -> Json {
json!({ "type": "meta_line", "parts": self.parts, "separator": self.separator })
}
}
struct Step {
screen: &'static str,
params: Json,
title: Option<Text>,
shortcut: Option<String>,
}
#[derive(Default)]
pub struct PrevNext {
prev: Option<Step>,
next: Option<Step>,
next_last: bool,
}
impl PrevNext {
pub fn new() -> Self {
Self::default()
}
pub fn prev<S: ScreenDef>(mut self, _screen: S, params: S::Params) -> Self {
self.prev = Some(Step {
screen: S::META.name,
params: serde_json::to_value(params).unwrap_or(Json::Null),
title: None,
shortcut: None,
});
self.next_last = false;
self
}
pub fn next<S: ScreenDef>(mut self, _screen: S, params: S::Params) -> Self {
self.next = Some(Step {
screen: S::META.name,
params: serde_json::to_value(params).unwrap_or(Json::Null),
title: None,
shortcut: None,
});
self.next_last = true;
self
}
pub fn prev_title(mut self, title: impl Into<Text>) -> Self {
if let Some(s) = &mut self.prev {
s.title = Some(title.into());
}
self
}
pub fn next_title(mut self, title: impl Into<Text>) -> Self {
if let Some(s) = &mut self.next {
s.title = Some(title.into());
}
self
}
pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
let side = if self.next_last {
&mut self.next
} else {
&mut self.prev
};
if let Some(s) = side {
s.shortcut = Some(keys.into());
}
self
}
}
impl Component for PrevNext {
fn render(&self, out: &mut String, r: &Render) {
let t = |key: &str| r.text(&Text::key(format!("zeyn.ui.prevnext.{key}")));
out.push_str(&format!(
r#"<nav class="z-prevnext" aria-label="{}">"#,
t("label")
));
for (step, rel, arrow) in [
(&self.prev, "prev", "←"),
(&self.next, "next", "→"),
] {
let Some(s) = step else {
out.push_str(r#"<span class="z-prevnext-gap"></span>"#);
continue;
};
let word = t(if rel == "prev" { "previous" } else { "next" });
let title = s
.title
.as_ref()
.map(|title| format!(r#"<span class="z-prevnext-title">{}</span>"#, r.text(title)))
.unwrap_or_default();
let (keys, hint) = crate::command::link_shortcut(s.shortcut.as_deref());
out.push_str(&format!(
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>"#,
screen_href(r, s.screen, &s.params)
));
}
out.push_str("</nav>");
}
fn schema(&self) -> Json {
let side = |s: &Option<Step>| {
s.as_ref()
.map(|s| json!({ "screen": s.screen, "params": s.params, "title": s.title, "shortcut": s.shortcut }))
};
json!({ "type": "prev_next", "prev": side(&self.prev), "next": side(&self.next) })
}
fn shortcuts(&self) -> Vec<&str> {
[&self.prev, &self.next]
.into_iter()
.flatten()
.filter_map(|s| s.shortcut.as_deref())
.collect()
}
}
pub struct AutoGrid {
nodes: Vec<Node>,
min: u16,
gap: u8,
}
impl Default for AutoGrid {
fn default() -> Self {
AutoGrid {
nodes: Vec::new(),
min: 240,
gap: 4,
}
}
}
impl AutoGrid {
pub fn new() -> Self {
Self::default()
}
#[allow(
clippy::should_implement_trait,
reason = "a builder method that appends a child component; it is not arithmetic and `add` is the public name"
)]
pub fn add(mut self, node: impl Into<Node>) -> Self {
self.nodes.push(node.into());
self
}
pub fn min(mut self, px: u16) -> Self {
self.min = px.clamp(80, 1200);
self
}
pub fn gap(mut self, units: u8) -> Self {
self.gap = units.min(12);
self
}
}
impl Component for AutoGrid {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&format!(
r#"<div class="z-autogrid" style="--z-autogrid-min:{}px;--z-autogrid-gap:{}">"#,
self.min, self.gap
));
for (i, node) in self.nodes.iter().enumerate() {
render_child(out, r, i, node);
}
out.push_str("</div>");
}
fn schema(&self) -> Json {
json!({
"type": "auto_grid",
"min": self.min,
"gap": self.gap,
"children": self.nodes.iter().map(Node::schema).collect::<Vec<_>>(),
})
}
fn children(&self) -> &[Node] {
&self.nodes
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AvatarSize {
Small,
#[default]
Medium,
Large,
}
impl AvatarSize {
fn px(self) -> u32 {
match self {
AvatarSize::Small => 32,
AvatarSize::Medium => 40,
AvatarSize::Large => 64,
}
}
}
pub struct Avatar {
name: Text,
image: Option<String>,
size: AvatarSize,
decorative: bool,
}
impl Avatar {
pub fn new(name: impl Into<Text>) -> Self {
Avatar {
name: name.into(),
image: None,
size: AvatarSize::Medium,
decorative: false,
}
}
pub fn image(mut self, src: impl Into<String>) -> Self {
self.image = Some(src.into());
self
}
pub fn size(mut self, size: AvatarSize) -> Self {
self.size = size;
self
}
pub fn decorative(mut self) -> Self {
self.decorative = true;
self
}
}
fn initials(name: &str, locale: &str) -> String {
let words: Vec<&str> = name.split_whitespace().collect();
let firsts = match words.as_slice() {
[] => Vec::new(),
[one] => vec![one],
[first, .., last] => vec![first, last],
};
let turkish = locale.starts_with("tr") || locale.starts_with("az");
firsts
.iter()
.filter_map(|w| w.chars().find(|c| c.is_alphanumeric()))
.flat_map(|c| match c {
'i' if turkish => "İ".chars().collect::<Vec<_>>(),
'ı' if turkish => vec!['I'],
c => c.to_uppercase().collect(),
})
.collect()
}
impl Component for Avatar {
fn render(&self, out: &mut String, r: &Render) {
let px = self.size.px();
let name = r.i18n.text(r.locale, &self.name);
let tone = name.bytes().fold(0u32, |h, b| {
h.wrapping_mul(31)
.wrapping_add(u32::from(b))
}) % 5;
let class = format!("z-avatar z-avatar-{} z-avatar-tone-{tone}", px);
let hidden = if self.decorative {
r#" aria-hidden="true""#
} else {
""
};
let image = self
.image
.as_deref()
.filter(|src| super::embed::policy(r).allows_image(src));
match image {
Some(src) => out.push_str(&format!(
r#"<img class="{class}" src="{}" alt="{}" width="{px}" height="{px}" loading="lazy" decoding="async"{hidden}>"#,
escape(src),
if self.decorative { String::new() } else { escape(&name) }
)),
None => {
let label = if self.decorative { String::new() } else { format!(r#" role="img" aria-label="{}""#, escape(&name)) };
out.push_str(&format!(
r#"<span class="{class}"{label}{hidden}><span aria-hidden="true">{}</span></span>"#,
escape(&initials(&name, r.locale))
));
}
}
}
fn schema(&self) -> Json {
json!({ "type": "avatar", "name": self.name, "image": self.image, "size": self.size, "decorative": self.decorative })
}
}
pub struct Spoiler {
label: Text,
nodes: Vec<Node>,
}
impl Spoiler {
pub fn new(label: impl Into<Text>) -> Self {
Spoiler {
label: label.into(),
nodes: Vec::new(),
}
}
#[allow(
clippy::should_implement_trait,
reason = "a builder method that appends a child component; it is not arithmetic and `add` is the public name"
)]
pub fn add(mut self, node: impl Into<Node>) -> Self {
self.nodes.push(node.into());
self
}
}
impl Component for Spoiler {
fn render(&self, out: &mut String, r: &Render) {
out.push_str(&format!(
r#"<details class="z-spoiler"><summary class="z-spoiler-label">{}</summary><div class="z-spoiler-body">"#,
r.text(&self.label)
));
for (i, node) in self.nodes.iter().enumerate() {
render_child(out, r, i, node);
}
out.push_str("</div></details>");
}
fn schema(&self) -> Json {
json!({ "type": "spoiler", "label": self.label, "children": self.nodes.iter().map(Node::schema).collect::<Vec<_>>() })
}
fn children(&self) -> &[Node] {
&self.nodes
}
}