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