1use 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#[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#[derive(Debug)]
43pub struct Label<'a> {
44 text: text::Fragment<'a>,
45 state: State,
46 required: bool,
47}
48
49pub 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 pub fn disabled(self, disabled: bool) -> Self {
66 self.toggle(State::Disabled, disabled)
67 }
68
69 pub fn invalid(self, invalid: bool) -> Self {
71 self.toggle(State::Invalid, invalid)
72 }
73
74 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
112pub 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
132pub 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 pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
150 self.description = Some(description.into_fragment());
151 self
152 }
153
154 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 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
212pub 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
221pub 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
229pub fn description_colour(tokens: &Tokens) -> Color {
231 tokens.muted_foreground
232}
233
234pub 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}