Skip to main content

pepl_ui/components/
content.rs

1//! Content component builders — Text, ProgressBar.
2//!
3//! These are leaf components with no children. They render visible content
4//! for PEPL UI views.
5
6use crate::accessibility;
7use crate::prop_value::PropValue;
8use crate::surface::SurfaceNode;
9use crate::types::ColorValue;
10
11// ── Text Size Enum ────────────────────────────────────────────────────────────
12
13/// Predefined text sizes.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum TextSize {
16    Small,
17    Body,
18    Title,
19    Heading,
20    Display,
21}
22
23impl TextSize {
24    fn as_str(self) -> &'static str {
25        match self {
26            Self::Small => "small",
27            Self::Body => "body",
28            Self::Title => "title",
29            Self::Heading => "heading",
30            Self::Display => "display",
31        }
32    }
33}
34
35// ── Text Weight Enum ──────────────────────────────────────────────────────────
36
37/// Font weight options.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum TextWeight {
40    Normal,
41    Medium,
42    Bold,
43}
44
45impl TextWeight {
46    fn as_str(self) -> &'static str {
47        match self {
48            Self::Normal => "normal",
49            Self::Medium => "medium",
50            Self::Bold => "bold",
51        }
52    }
53}
54
55// ── Text Align Enum ───────────────────────────────────────────────────────────
56
57/// Text alignment options.
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub enum TextAlign {
60    Start,
61    Center,
62    End,
63}
64
65impl TextAlign {
66    fn as_str(self) -> &'static str {
67        match self {
68            Self::Start => "start",
69            Self::Center => "center",
70            Self::End => "end",
71        }
72    }
73}
74
75// ── Text Overflow Enum ────────────────────────────────────────────────────────
76
77/// Text overflow behaviour.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum TextOverflow {
80    Clip,
81    Ellipsis,
82    Wrap,
83}
84
85impl TextOverflow {
86    fn as_str(self) -> &'static str {
87        match self {
88            Self::Clip => "clip",
89            Self::Ellipsis => "ellipsis",
90            Self::Wrap => "wrap",
91        }
92    }
93}
94
95// ── TextBuilder ───────────────────────────────────────────────────────────────
96
97/// Builder for the `Text` component.
98///
99/// `Text` is a leaf component (no children) that displays a string value
100/// with optional styling props.
101///
102/// # Example
103/// ```
104/// use pepl_ui::TextBuilder;
105/// use pepl_ui::components::content::{TextSize, TextWeight};
106///
107/// let node = TextBuilder::new("Hello, PEPL!")
108///     .size(TextSize::Title)
109///     .weight(TextWeight::Bold)
110///     .build();
111///
112/// assert_eq!(node.component_type, "Text");
113/// ```
114pub struct TextBuilder {
115    value: String,
116    size: Option<TextSize>,
117    weight: Option<TextWeight>,
118    color: Option<ColorValue>,
119    align: Option<TextAlign>,
120    max_lines: Option<f64>,
121    overflow: Option<TextOverflow>,
122}
123
124impl TextBuilder {
125    /// Create a new `TextBuilder` with the required `value` prop.
126    pub fn new(value: impl Into<String>) -> Self {
127        Self {
128            value: value.into(),
129            size: None,
130            weight: None,
131            color: None,
132            align: None,
133            max_lines: None,
134            overflow: None,
135        }
136    }
137
138    /// Set the text size preset.
139    pub fn size(mut self, size: TextSize) -> Self {
140        self.size = Some(size);
141        self
142    }
143
144    /// Set the font weight.
145    pub fn weight(mut self, weight: TextWeight) -> Self {
146        self.weight = Some(weight);
147        self
148    }
149
150    /// Set the text color.
151    pub fn color(mut self, color: ColorValue) -> Self {
152        self.color = Some(color);
153        self
154    }
155
156    /// Set the text alignment.
157    pub fn align(mut self, align: TextAlign) -> Self {
158        self.align = Some(align);
159        self
160    }
161
162    /// Set maximum number of lines (clipped/ellipsized after).
163    pub fn max_lines(mut self, max_lines: f64) -> Self {
164        self.max_lines = Some(max_lines);
165        self
166    }
167
168    /// Set overflow behaviour.
169    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
170        self.overflow = Some(overflow);
171        self
172    }
173
174    /// Build the `SurfaceNode`.
175    pub fn build(self) -> SurfaceNode {
176        let mut node = SurfaceNode::new("Text");
177        node.set_prop("value", PropValue::String(self.value));
178        if let Some(size) = self.size {
179            node.set_prop("size", PropValue::String(size.as_str().to_string()));
180        }
181        if let Some(weight) = self.weight {
182            node.set_prop("weight", PropValue::String(weight.as_str().to_string()));
183        }
184        if let Some(color) = self.color {
185            node.set_prop(
186                "color",
187                PropValue::color(color.r, color.g, color.b, color.a),
188            );
189        }
190        if let Some(align) = self.align {
191            node.set_prop("align", PropValue::String(align.as_str().to_string()));
192        }
193        if let Some(max_lines) = self.max_lines {
194            node.set_prop("max_lines", PropValue::Number(max_lines));
195        }
196        if let Some(overflow) = self.overflow {
197            node.set_prop("overflow", PropValue::String(overflow.as_str().to_string()));
198        }
199        accessibility::ensure_accessible(&mut node);
200        node
201    }
202}
203
204// ── ProgressBarBuilder ────────────────────────────────────────────────────────
205
206/// Builder for the `ProgressBar` component.
207///
208/// `ProgressBar` is a leaf component (no children) that displays a
209/// horizontal progress indicator. The `value` prop is clamped to 0.0–1.0.
210///
211/// # Example
212/// ```
213/// use pepl_ui::ProgressBarBuilder;
214///
215/// let node = ProgressBarBuilder::new(0.75).build();
216/// assert_eq!(node.component_type, "ProgressBar");
217/// ```
218pub struct ProgressBarBuilder {
219    value: f64,
220    color: Option<ColorValue>,
221    background: Option<ColorValue>,
222    height: Option<f64>,
223}
224
225impl ProgressBarBuilder {
226    /// Create a new `ProgressBarBuilder` with the required `value` prop.
227    ///
228    /// Values outside 0.0–1.0 are clamped.
229    pub fn new(value: f64) -> Self {
230        Self {
231            value: value.clamp(0.0, 1.0),
232            color: None,
233            background: None,
234            height: None,
235        }
236    }
237
238    /// Set the fill color.
239    pub fn color(mut self, color: ColorValue) -> Self {
240        self.color = Some(color);
241        self
242    }
243
244    /// Set the background (track) color.
245    pub fn background(mut self, background: ColorValue) -> Self {
246        self.background = Some(background);
247        self
248    }
249
250    /// Set the bar height in logical pixels.
251    pub fn height(mut self, height: f64) -> Self {
252        self.height = Some(height);
253        self
254    }
255
256    /// Build the `SurfaceNode`.
257    pub fn build(self) -> SurfaceNode {
258        let mut node = SurfaceNode::new("ProgressBar");
259        node.set_prop("value", PropValue::Number(self.value));
260        if let Some(color) = self.color {
261            node.set_prop(
262                "color",
263                PropValue::color(color.r, color.g, color.b, color.a),
264            );
265        }
266        if let Some(background) = self.background {
267            node.set_prop(
268                "background",
269                PropValue::color(background.r, background.g, background.b, background.a),
270            );
271        }
272        if let Some(height) = self.height {
273            node.set_prop("height", PropValue::Number(height));
274        }
275        accessibility::ensure_accessible(&mut node);
276        node
277    }
278}
279
280// ── Validation ────────────────────────────────────────────────────────────────
281
282/// Validates a content component node's props.
283///
284/// Returns a list of human-readable error strings. An empty list means
285/// the node is valid.
286pub fn validate_content_node(node: &SurfaceNode) -> Vec<String> {
287    match node.component_type.as_str() {
288        "Text" => validate_text(node),
289        "ProgressBar" => validate_progress_bar(node),
290        _ => vec![format!(
291            "Unknown content component: {}",
292            node.component_type
293        )],
294    }
295}
296
297fn validate_text(node: &SurfaceNode) -> Vec<String> {
298    let mut errors = Vec::new();
299
300    // Required: value must be a string
301    match node.props.get("value") {
302        Some(PropValue::String(_)) => {}
303        Some(other) => errors.push(format!(
304            "Text.value: expected string, got {}",
305            other.type_name()
306        )),
307        None => errors.push("Text.value: required prop missing".to_string()),
308    }
309
310    // Optional: size must be one of the allowed values
311    if let Some(prop) = node.props.get("size") {
312        match prop {
313            PropValue::String(s)
314                if matches!(
315                    s.as_str(),
316                    "small" | "body" | "title" | "heading" | "display"
317                ) => {}
318            _ => errors.push(format!(
319                "Text.size: expected one of [small, body, title, heading, display], got {:?}",
320                prop
321            )),
322        }
323    }
324
325    // Optional: weight
326    if let Some(prop) = node.props.get("weight") {
327        match prop {
328            PropValue::String(s) if matches!(s.as_str(), "normal" | "medium" | "bold") => {}
329            _ => errors.push(format!(
330                "Text.weight: expected one of [normal, medium, bold], got {:?}",
331                prop
332            )),
333        }
334    }
335
336    // Optional: color
337    if let Some(prop) = node.props.get("color") {
338        if !matches!(prop, PropValue::Color { .. }) {
339            errors.push(format!(
340                "Text.color: expected color, got {}",
341                prop.type_name()
342            ));
343        }
344    }
345
346    // Optional: align
347    if let Some(prop) = node.props.get("align") {
348        match prop {
349            PropValue::String(s) if matches!(s.as_str(), "start" | "center" | "end") => {}
350            _ => errors.push(format!(
351                "Text.align: expected one of [start, center, end], got {:?}",
352                prop
353            )),
354        }
355    }
356
357    // Optional: max_lines
358    if let Some(prop) = node.props.get("max_lines") {
359        if !matches!(prop, PropValue::Number(_)) {
360            errors.push(format!(
361                "Text.max_lines: expected number, got {}",
362                prop.type_name()
363            ));
364        }
365    }
366
367    // Optional: overflow
368    if let Some(prop) = node.props.get("overflow") {
369        match prop {
370            PropValue::String(s) if matches!(s.as_str(), "clip" | "ellipsis" | "wrap") => {}
371            _ => errors.push(format!(
372                "Text.overflow: expected one of [clip, ellipsis, wrap], got {:?}",
373                prop
374            )),
375        }
376    }
377
378    // No children allowed
379    if !node.children.is_empty() {
380        errors.push(format!(
381            "Text: does not accept children, but got {}",
382            node.children.len()
383        ));
384    }
385
386    // Optional: accessible (record)
387    if let Some(prop) = node.props.get("accessible") {
388        errors.extend(accessibility::validate_accessible_prop("Text", prop));
389    }
390
391    // Check for unknown props
392    for key in node.props.keys() {
393        if !matches!(
394            key.as_str(),
395            "value"
396                | "size"
397                | "weight"
398                | "color"
399                | "align"
400                | "max_lines"
401                | "overflow"
402                | "accessible"
403        ) {
404            errors.push(format!("Text: unknown prop '{key}'"));
405        }
406    }
407
408    errors
409}
410
411fn validate_progress_bar(node: &SurfaceNode) -> Vec<String> {
412    let mut errors = Vec::new();
413
414    // Required: value must be a number
415    match node.props.get("value") {
416        Some(PropValue::Number(_)) => {}
417        Some(other) => errors.push(format!(
418            "ProgressBar.value: expected number, got {}",
419            other.type_name()
420        )),
421        None => errors.push("ProgressBar.value: required prop missing".to_string()),
422    }
423
424    // Optional: color
425    if let Some(prop) = node.props.get("color") {
426        if !matches!(prop, PropValue::Color { .. }) {
427            errors.push(format!(
428                "ProgressBar.color: expected color, got {}",
429                prop.type_name()
430            ));
431        }
432    }
433
434    // Optional: background
435    if let Some(prop) = node.props.get("background") {
436        if !matches!(prop, PropValue::Color { .. }) {
437            errors.push(format!(
438                "ProgressBar.background: expected color, got {}",
439                prop.type_name()
440            ));
441        }
442    }
443
444    // Optional: height
445    if let Some(prop) = node.props.get("height") {
446        if !matches!(prop, PropValue::Number(_)) {
447            errors.push(format!(
448                "ProgressBar.height: expected number, got {}",
449                prop.type_name()
450            ));
451        }
452    }
453
454    // No children allowed
455    if !node.children.is_empty() {
456        errors.push(format!(
457            "ProgressBar: does not accept children, but got {}",
458            node.children.len()
459        ));
460    }
461
462    // Optional: accessible (record)
463    if let Some(prop) = node.props.get("accessible") {
464        errors.extend(accessibility::validate_accessible_prop("ProgressBar", prop));
465    }
466
467    // Check for unknown props
468    for key in node.props.keys() {
469        if !matches!(
470            key.as_str(),
471            "value" | "color" | "background" | "height" | "accessible"
472        ) {
473            errors.push(format!("ProgressBar: unknown prop '{key}'"));
474        }
475    }
476
477    errors
478}