Skip to main content

iced_cube/forms/
label.rs

1//! Labels, and fields that stack a label, a control and helper text.
2//!
3//! ```no_run
4//! # #[cfg(feature = "input")]
5//! # fn main() {
6//! use iced_cube::{field, input};
7//!
8//! #[derive(Debug, Clone)]
9//! enum Message { Email(String) }
10//!
11//! let email = "";
12//! let view: iced::Element<'_, Message> = field("Email", input("you@example.com", email).on_input(Message::Email))
13//!     .description("We never share it.")
14//!     .required(true)
15//!     .into();
16//! # }
17//! # #[cfg(not(feature = "input"))]
18//! # fn main() {}
19//! ```
20
21use std::fmt;
22
23use iced::widget::{Column, column, row, text};
24use iced::{Color, Element, Length};
25
26use crate::theme::{Tokens, fade, space, text_size};
27
28/// Tone of a label.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
30pub enum State {
31    #[default]
32    Normal,
33    Disabled,
34    Invalid,
35}
36
37impl State {
38    pub const ALL: [State; 3] = [State::Normal, State::Disabled, State::Invalid];
39}
40
41/// A label builder. Convert it into an [`Element`] to render.
42#[derive(Debug)]
43pub struct Label<'a> {
44    text: text::Fragment<'a>,
45    state: State,
46    required: bool,
47}
48
49/// Creates a label naming a control.
50pub fn label<'a>(text: impl text::IntoFragment<'a>) -> Label<'a> {
51    Label {
52        text: text.into_fragment(),
53        state: State::default(),
54        required: false,
55    }
56}
57
58impl<'a> Label<'a> {
59    pub fn state(mut self, state: State) -> Self {
60        self.state = state;
61        self
62    }
63
64    /// Mutes the label to match a disabled control.
65    pub fn disabled(self, disabled: bool) -> Self {
66        self.toggle(State::Disabled, disabled)
67    }
68
69    /// Colours the label to match an invalid control.
70    pub fn invalid(self, invalid: bool) -> Self {
71        self.toggle(State::Invalid, invalid)
72    }
73
74    /// Appends a required marker.
75    pub fn required(mut self, required: bool) -> Self {
76        self.required = required;
77        self
78    }
79
80    fn toggle(mut self, state: State, on: bool) -> Self {
81        if on {
82            self.state = state;
83        } else if self.state == state {
84            self.state = State::Normal;
85        }
86        self
87    }
88}
89
90impl<'a, Message: 'a> From<Label<'a>> for Element<'a, Message> {
91    fn from(label: Label<'a>) -> Self {
92        let state = label.state;
93        let name = text(label.text)
94            .size(text_size::SM)
95            .style(move |theme| text::Style {
96                color: Some(colour(&Tokens::of(theme), state)),
97            });
98
99        if !label.required {
100            return name.into();
101        }
102
103        let marker = text("*")
104            .size(text_size::SM)
105            .style(move |theme| text::Style {
106                color: Some(required_colour(&Tokens::of(theme), state)),
107            });
108        row![name, marker].spacing(2).into()
109    }
110}
111
112/// A labelled control with optional description and error text.
113pub struct Field<'a, Message> {
114    label: Label<'a>,
115    control: Element<'a, Message>,
116    description: Option<text::Fragment<'a>>,
117    error: Option<text::Fragment<'a>>,
118    width: Length,
119}
120
121impl<Message> fmt::Debug for Field<'_, Message> {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        f.debug_struct("Field")
124            .field("label", &self.label)
125            .field("description", &self.description)
126            .field("error", &self.error)
127            .field("width", &self.width)
128            .finish_non_exhaustive()
129    }
130}
131
132/// Stacks a label above `control`. Add helper text with
133/// [`Field::description`] and [`Field::error`].
134pub fn field<'a, Message>(
135    label_text: impl text::IntoFragment<'a>,
136    control: impl Into<Element<'a, Message>>,
137) -> Field<'a, Message> {
138    Field {
139        label: label(label_text),
140        control: control.into(),
141        description: None,
142        error: None,
143        width: Length::Fill,
144    }
145}
146
147impl<'a, Message> Field<'a, Message> {
148    /// Adds muted helper text below the control.
149    pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
150        self.description = Some(description.into_fragment());
151        self
152    }
153
154    /// Shows an error below the control and colours the label to match.
155    pub fn error(mut self, error: impl text::IntoFragment<'a>) -> Self {
156        self.error = Some(error.into_fragment());
157        self
158    }
159
160    pub fn error_maybe(mut self, error: Option<impl text::IntoFragment<'a>>) -> Self {
161        self.error = error.map(text::IntoFragment::into_fragment);
162        self
163    }
164
165    pub fn required(mut self, required: bool) -> Self {
166        self.label = self.label.required(required);
167        self
168    }
169
170    /// Mutes the label to match a disabled control.
171    pub fn disabled(mut self, disabled: bool) -> Self {
172        self.label = self.label.disabled(disabled);
173        self
174    }
175
176    pub fn width(mut self, width: impl Into<Length>) -> Self {
177        self.width = width.into();
178        self
179    }
180
181    pub fn has_error(&self) -> bool {
182        self.error.is_some()
183    }
184}
185
186impl<'a, Message: 'a> From<Field<'a, Message>> for Element<'a, Message> {
187    fn from(field: Field<'a, Message>) -> Self {
188        let invalid = field.error.is_some();
189        let label = if invalid {
190            field.label.state(State::Invalid)
191        } else {
192            field.label
193        };
194
195        let helper = |content: text::Fragment<'a>, tone: fn(&Tokens) -> Color| {
196            text(content)
197                .size(text_size::XS)
198                .style(move |theme| text::Style {
199                    color: Some(tone(&Tokens::of(theme))),
200                })
201        };
202
203        let content: Column<'a, Message> = column![label, field.control]
204            .push(field.description.map(|d| helper(d, description_colour)))
205            .push(field.error.map(|e| helper(e, error_colour)))
206            .spacing(space::SM)
207            .width(field.width);
208        content.into()
209    }
210}
211
212/// Label colour for a state.
213pub fn colour(tokens: &Tokens, state: State) -> Color {
214    match state {
215        State::Normal => tokens.foreground,
216        State::Disabled => fade(tokens.foreground, 0.5),
217        State::Invalid => tokens.destructive,
218    }
219}
220
221/// Colour of the required marker.
222pub fn required_colour(tokens: &Tokens, state: State) -> Color {
223    match state {
224        State::Disabled => fade(tokens.destructive, 0.5),
225        State::Normal | State::Invalid => tokens.destructive,
226    }
227}
228
229/// Colour of a field's description.
230pub fn description_colour(tokens: &Tokens) -> Color {
231    tokens.muted_foreground
232}
233
234/// Colour of a field's error message.
235pub fn error_colour(tokens: &Tokens) -> Color {
236    tokens.destructive
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::theme::{dark, light};
243    use iced::widget::Space;
244
245    fn control<'a>() -> Element<'a, ()> {
246        Space::new().into()
247    }
248
249    #[test]
250    fn default_label_is_normal_and_optional() {
251        let l = label("Name");
252        assert_eq!(l.state, State::Normal);
253        assert!(!l.required);
254        assert_eq!(&*l.text, "Name");
255    }
256
257    #[test]
258    fn toggles_set_and_clear_their_own_state() {
259        assert_eq!(label("x").disabled(true).state, State::Disabled);
260        assert_eq!(label("x").invalid(true).state, State::Invalid);
261        assert_eq!(
262            label("x").disabled(true).disabled(false).state,
263            State::Normal
264        );
265        assert_eq!(
266            label("x").invalid(true).disabled(false).state,
267            State::Invalid
268        );
269    }
270
271    #[test]
272    fn states_have_distinct_colours_in_both_themes() {
273        for theme in [light(), dark()] {
274            let tokens = Tokens::of(&theme);
275            let colours = State::ALL.map(|s| colour(&tokens, s));
276            assert_ne!(colours[0], colours[1]);
277            assert_ne!(colours[0], colours[2]);
278            assert_ne!(colours[1], colours[2]);
279            assert!((colours[1].a - colours[0].a * 0.5).abs() < 1e-6);
280        }
281    }
282
283    #[test]
284    fn required_marker_is_destructive_and_fades_when_disabled() {
285        let tokens = Tokens::of(&dark());
286        assert_eq!(required_colour(&tokens, State::Normal), tokens.destructive);
287        assert_eq!(required_colour(&tokens, State::Invalid), tokens.destructive);
288        assert!(required_colour(&tokens, State::Disabled).a < tokens.destructive.a);
289    }
290
291    #[test]
292    fn helper_text_colours_come_from_tokens() {
293        for theme in [light(), dark()] {
294            let tokens = Tokens::of(&theme);
295            assert_eq!(description_colour(&tokens), tokens.muted_foreground);
296            assert_eq!(error_colour(&tokens), tokens.destructive);
297        }
298    }
299
300    #[test]
301    fn field_defaults_have_no_helper_text() {
302        let f = field("Email", control());
303        assert!(f.description.is_none());
304        assert!(!f.has_error());
305        assert_eq!(f.width, Length::Fill);
306    }
307
308    #[test]
309    fn field_builder_forwards_to_label() {
310        let f = field("Email", control()).required(true).disabled(true);
311        assert!(f.label.required);
312        assert_eq!(f.label.state, State::Disabled);
313    }
314
315    #[test]
316    fn error_maybe_none_clears_the_error() {
317        let f = field("Email", control())
318            .error("Required")
319            .error_maybe(None::<&str>);
320        assert!(!f.has_error());
321        let f = field("Email", control()).error_maybe(Some("Required"));
322        assert!(f.has_error());
323    }
324}