1use 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#[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#[derive(Debug)]
38pub struct Label<'a> {
39 text: text::Fragment<'a>,
40 state: State,
41 required: bool,
42}
43
44pub 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 pub fn disabled(self, disabled: bool) -> Self {
61 self.toggle(State::Disabled, disabled)
62 }
63
64 pub fn invalid(self, invalid: bool) -> Self {
66 self.toggle(State::Invalid, invalid)
67 }
68
69 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
107pub 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
127pub 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 pub fn description(mut self, description: impl text::IntoFragment<'a>) -> Self {
145 self.description = Some(description.into_fragment());
146 self
147 }
148
149 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 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
207pub 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
216pub 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
224pub fn description_colour(tokens: &Tokens) -> Color {
226 tokens.muted_foreground
227}
228
229pub 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}