Skip to main content

fc_dioxus/core/
text.rs

1//! Text with explicit accessibility intent and validation.
2
3use std::fmt;
4
5/// A trimmed, non-empty accessible name.
6///
7/// ```
8/// use fc_dioxus::prelude::AccessibleName;
9/// assert!(AccessibleName::try_from("Close").is_ok());
10/// assert!(AccessibleName::try_from("  ").is_err());
11/// ```
12#[derive(Debug, Clone, PartialEq, Eq, Hash)]
13pub struct AccessibleName(String);
14
15impl AccessibleName {
16    /// Backs [`name!`](crate::name), which has already rejected a blank literal at
17    /// compile time. Not for direct use.
18    #[doc(hidden)]
19    #[must_use]
20    pub fn from_checked_literal(text: &'static str) -> Self {
21        let trimmed = text.trim();
22        // The compile-time check sees ASCII whitespace only; a literal of other Unicode
23        // spaces keeps its text rather than becoming empty.
24        Self(if trimmed.is_empty() { text } else { trimmed }.to_owned())
25    }
26    /// The validated name.
27    #[must_use]
28    pub fn as_str(&self) -> &str {
29        &self.0
30    }
31}
32impl TryFrom<&str> for AccessibleName {
33    type Error = EmptyName;
34    /// # Errors
35    /// Returns `EmptyName` for empty or whitespace-only text.
36    fn try_from(value: &str) -> Result<Self, Self::Error> {
37        let value = value.trim();
38        if value.is_empty() {
39            Err(EmptyName)
40        } else {
41            Ok(Self(value.to_owned()))
42        }
43    }
44}
45impl TryFrom<String> for AccessibleName {
46    type Error = EmptyName;
47    fn try_from(value: String) -> Result<Self, Self::Error> {
48        Self::try_from(value.as_str())
49    }
50}
51impl fmt::Display for AccessibleName {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(&self.0)
54    }
55}
56
57/// An accessible name contained no text.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct EmptyName;
60impl fmt::Display for EmptyName {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        f.write_str("accessible names must contain text")
63    }
64}
65impl std::error::Error for EmptyName {}
66
67/// An image must explicitly be meaningful or decorative; there is no default.
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub enum Alt {
70    /// A meaningful alternative.
71    Text(AccessibleName),
72    /// Intentionally empty alternative text.
73    Decorative,
74}
75
76/// How much of a [`Clipped`] value is shown.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
78#[non_exhaustive]
79pub enum ClipMode {
80    /// Shown in full.
81    Full,
82    /// Clamped by the CSS to this many lines; the full text is still in the DOM.
83    Lines(u8),
84    /// Shortened to this many characters, with an ellipsis.
85    Chars(usize),
86}
87
88/// Text that may be shown shortened. Holding the full value is not optional, so a
89/// component can always declare `data-fc-truncated` and keep the full value reachable.
90///
91/// ```
92/// use fc_dioxus::prelude::Clipped;
93/// let id = Clipped::chars("9f2c1e04b7a3", 7);
94/// assert_eq!(id.shown(), "9f2c1e0…");
95/// assert!(id.is_truncated());
96/// assert!(!Clipped::chars("short", 7).is_truncated());
97/// ```
98#[derive(Debug, Clone, PartialEq, Eq, Hash)]
99pub struct Clipped {
100    full: String,
101    mode: ClipMode,
102}
103impl Clipped {
104    /// Text shown in full.
105    #[must_use]
106    pub fn full(text: impl Into<String>) -> Self {
107        Self {
108            full: text.into(),
109            mode: ClipMode::Full,
110        }
111    }
112    /// Text the CSS clamps to `lines`. It may or may not overflow, so it is declared
113    /// truncated whenever it is clamped at all.
114    #[must_use]
115    pub fn lines(text: impl Into<String>, lines: u8) -> Self {
116        Self {
117            full: text.into(),
118            mode: ClipMode::Lines(lines),
119        }
120    }
121    /// Text shortened to at most `chars` characters, never splitting one.
122    #[must_use]
123    pub fn chars(text: impl Into<String>, chars: usize) -> Self {
124        Self {
125            full: text.into(),
126            mode: ClipMode::Chars(chars),
127        }
128    }
129    /// The complete value.
130    #[must_use]
131    pub fn full_text(&self) -> &str {
132        &self.full
133    }
134    /// The text to display.
135    #[must_use]
136    pub fn shown(&self) -> String {
137        match self.mode {
138            ClipMode::Chars(limit) if self.full.chars().count() > limit => {
139                let mut shown: String = self.full.chars().take(limit).collect();
140                shown.push('…');
141                shown
142            }
143            _ => self.full.clone(),
144        }
145    }
146    /// Whether the displayed text may be less than the full value.
147    #[must_use]
148    pub fn is_truncated(&self) -> bool {
149        match self.mode {
150            ClipMode::Full => false,
151            ClipMode::Lines(_) => true,
152            ClipMode::Chars(limit) => self.full.chars().count() > limit,
153        }
154    }
155}
156
157/// Whether a literal has no visible text, for [`name!`](crate::name)'s compile-time check.
158#[doc(hidden)]
159#[must_use]
160pub const fn is_blank(text: &str) -> bool {
161    let mut rest = text.as_bytes();
162    while let [first, tail @ ..] = rest {
163        if !first.is_ascii_whitespace() {
164            return false;
165        }
166        rest = tail;
167    }
168    true
169}
170
171/// An [`AccessibleName`] from a string literal, checked when the program compiles.
172///
173/// ```
174/// use fc_dioxus::{name, prelude::*};
175/// let close: AccessibleName = name!("Close");
176/// assert_eq!(close.as_str(), "Close");
177/// ```
178///
179/// A blank literal does not compile:
180///
181/// ```compile_fail
182/// let _ = fc_dioxus::name!("  ");
183/// ```
184#[macro_export]
185macro_rules! name {
186    ($text:literal) => {{
187        const _: () = assert!(
188            !$crate::core::text::is_blank($text),
189            "accessible names must contain text"
190        );
191        $crate::core::text::AccessibleName::from_checked_literal($text)
192    }};
193}