1use std::fmt;
4
5use iced::widget::text::LineHeight;
6use iced::widget::text_input::{Status, Style};
7use iced::widget::{Id, container, stack, text_input};
8use iced::{Alignment, Background, Border, Color, Element, Length, Padding};
9
10use crate::icon::{Glyph, opacity, themed};
11use crate::theme::{Tokens, fade, mix, radius, space, text_size};
12
13const LINE_HEIGHT: f32 = 1.3;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
17pub enum Size {
18 Sm,
19 #[default]
20 Md,
21 Lg,
22}
23
24impl Size {
25 pub const ALL: [Size; 3] = [Size::Sm, Size::Md, Size::Lg];
26
27 pub const fn metrics(self) -> Metrics {
29 match self {
30 Size::Sm => Metrics {
31 height: 32.0,
32 padding_x: 10.0,
33 text: text_size::SM,
34 icon: 14.0,
35 },
36 Size::Md => Metrics {
37 height: 36.0,
38 padding_x: 12.0,
39 text: text_size::SM,
40 icon: 16.0,
41 },
42 Size::Lg => Metrics {
43 height: 40.0,
44 padding_x: 14.0,
45 text: text_size::MD,
46 icon: 18.0,
47 },
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct Metrics {
55 pub height: f32,
56 pub padding_x: f32,
57 pub text: f32,
58 pub icon: f32,
59}
60
61impl Metrics {
62 pub fn padding(self, with_icon: bool) -> Padding {
65 let vertical = ((self.height - self.text * LINE_HEIGHT) / 2.0).max(0.0);
66 let left = if with_icon {
67 self.padding_x + self.icon + space::SM
68 } else {
69 self.padding_x
70 };
71 Padding {
72 top: vertical,
73 bottom: vertical,
74 left,
75 right: self.padding_x,
76 }
77 }
78}
79
80pub struct Input<'a, Message> {
84 placeholder: String,
85 value: String,
86 id: Option<Id>,
87 on_input: Option<Box<dyn Fn(String) -> Message + 'a>>,
88 on_submit: Option<Message>,
89 icon: Option<Glyph>,
90 secure: bool,
91 invalid: bool,
92 size: Size,
93 width: Length,
94}
95
96pub fn input<'a, Message>(placeholder: &str, value: &str) -> Input<'a, Message> {
98 Input {
99 placeholder: placeholder.to_owned(),
100 value: value.to_owned(),
101 id: None,
102 on_input: None,
103 on_submit: None,
104 icon: None,
105 secure: false,
106 invalid: false,
107 size: Size::default(),
108 width: Length::Fill,
109 }
110}
111
112impl<'a, Message> Input<'a, Message> {
113 pub fn id(mut self, id: impl Into<Id>) -> Self {
115 self.id = Some(id.into());
116 self
117 }
118
119 pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
121 self.on_input = Some(Box::new(on_input));
122 self
123 }
124
125 pub fn on_input_maybe(mut self, on_input: Option<impl Fn(String) -> Message + 'a>) -> Self {
126 self.on_input = on_input.map(|f| Box::new(f) as Box<dyn Fn(String) -> Message + 'a>);
127 self
128 }
129
130 pub fn on_submit(mut self, message: Message) -> Self {
132 self.on_submit = Some(message);
133 self
134 }
135
136 pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
137 self.on_submit = message;
138 self
139 }
140
141 pub fn icon(mut self, glyph: Glyph) -> Self {
143 self.icon = Some(glyph);
144 self
145 }
146
147 pub fn secure(mut self, secure: bool) -> Self {
149 self.secure = secure;
150 self
151 }
152
153 pub fn invalid(mut self, invalid: bool) -> Self {
155 self.invalid = invalid;
156 self
157 }
158
159 pub fn size(mut self, size: Size) -> Self {
160 self.size = size;
161 self
162 }
163
164 pub fn width(mut self, width: impl Into<Length>) -> Self {
166 self.width = width.into();
167 self
168 }
169
170 pub fn is_enabled(&self) -> bool {
171 self.on_input.is_some()
172 }
173}
174
175impl<Message: fmt::Debug> fmt::Debug for Input<'_, Message> {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 f.debug_struct("Input")
178 .field("placeholder", &self.placeholder)
179 .field("value", &self.value)
180 .field("id", &self.id)
181 .field("enabled", &self.is_enabled())
182 .field("on_submit", &self.on_submit)
183 .field("icon", &self.icon)
184 .field("secure", &self.secure)
185 .field("invalid", &self.invalid)
186 .field("size", &self.size)
187 .field("width", &self.width)
188 .finish()
189 }
190}
191
192impl<'a, Message: Clone + 'a> From<Input<'a, Message>> for Element<'a, Message> {
193 fn from(input: Input<'a, Message>) -> Self {
194 let metrics = input.size.metrics();
195 let invalid = input.invalid;
196 let enabled = input.is_enabled();
197
198 let mut field = text_input(&input.placeholder, &input.value)
199 .secure(input.secure)
200 .size(metrics.text)
201 .line_height(LineHeight::Relative(LINE_HEIGHT))
202 .padding(metrics.padding(input.icon.is_some()))
203 .width(Length::Fill)
204 .on_submit_maybe(input.on_submit.filter(|_| enabled))
205 .style(move |theme, status| style(&Tokens::of(theme), status, invalid));
206 if let Some(id) = input.id {
207 field = field.id(id);
208 }
209 if let Some(on_input) = input.on_input {
210 field = field.on_input(on_input);
211 }
212
213 let Some(glyph) = input.icon else {
214 return container(field).width(input.width).into();
215 };
216
217 let status = if enabled {
218 Status::Active
219 } else {
220 Status::Disabled
221 };
222 let icon = themed(glyph, metrics.icon, opacity(enabled), move |theme| {
223 style(&Tokens::of(theme), status, invalid).icon
224 });
225 let icon = container(icon)
226 .padding(Padding::ZERO.left(metrics.padding_x))
227 .height(Length::Fill)
228 .align_y(Alignment::Center);
229
230 stack![field, icon].width(input.width).into()
231 }
232}
233
234pub fn style(tokens: &Tokens, status: Status, invalid: bool) -> Style {
236 let border = match (invalid, status) {
237 (true, _) => tokens.destructive,
238 (false, Status::Focused { .. }) => mix(tokens.border, tokens.foreground, 0.6),
239 (false, Status::Hovered) => mix(tokens.border, tokens.foreground, 0.25),
240 (false, Status::Active | Status::Disabled) => tokens.border,
241 };
242
243 let active = Style {
246 background: Background::Color(Color::TRANSPARENT),
247 border: Border {
248 color: border,
249 width: 1.0,
250 radius: radius::MD.into(),
251 },
252 icon: tokens.muted_foreground,
253 placeholder: tokens.muted_foreground,
254 value: tokens.foreground,
255 selection: fade(tokens.primary, 0.25),
256 };
257
258 if status != Status::Disabled {
259 return active;
260 }
261 Style {
262 background: Background::Color(tokens.disabled_field()),
263 border: Border {
264 color: fade(border, 0.5),
265 ..active.border
266 },
267 icon: fade(active.icon, 0.5),
268 placeholder: fade(active.placeholder, 0.5),
269 value: fade(active.value, 0.5),
270 selection: Color::TRANSPARENT,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::theme::{dark, light};
278
279 const STATES: [Status; 5] = [
280 Status::Active,
281 Status::Hovered,
282 Status::Focused { is_hovered: false },
283 Status::Focused { is_hovered: true },
284 Status::Disabled,
285 ];
286
287 fn border(style: Style) -> Color {
288 style.border.color
289 }
290
291 #[test]
292 fn default_builder_is_medium_fill_and_disabled() {
293 let i: Input<'_, ()> = input("Email", "");
294 assert_eq!(i.size, Size::Md);
295 assert_eq!(i.width, Length::Fill);
296 assert!(!i.secure && !i.invalid);
297 assert!(i.icon.is_none() && i.id.is_none());
298 assert!(!i.is_enabled());
299 }
300
301 #[test]
302 fn on_input_enables_and_on_input_maybe_none_disables() {
303 assert!(input::<String>("", "").on_input(|s| s).is_enabled());
304 let none: Option<fn(String) -> String> = None;
305 assert!(
306 !input::<String>("", "")
307 .on_input(|s| s)
308 .on_input_maybe(none)
309 .is_enabled()
310 );
311 }
312
313 #[test]
314 fn builder_keeps_placeholder_and_value() {
315 let i: Input<'_, ()> = input("Search", "iced").secure(true).invalid(true);
316 assert_eq!(i.placeholder, "Search");
317 assert_eq!(i.value, "iced");
318 assert!(i.secure && i.invalid);
319 }
320
321 #[test]
322 fn sizes_grow_monotonically() {
323 let heights = Size::ALL.map(|s| s.metrics().height);
324 assert!(heights.windows(2).all(|w| w[0] < w[1]));
325 }
326
327 #[test]
328 fn padding_produces_the_target_height() {
329 for size in Size::ALL {
330 let m = size.metrics();
331 let p = m.padding(false);
332 let height = m.text * LINE_HEIGHT + p.top + p.bottom;
333 assert!((height - m.height).abs() < 1e-3, "{size:?}");
334 }
335 }
336
337 #[test]
338 fn icon_reserves_space_on_the_left_only() {
339 let m = Size::Md.metrics();
340 let plain = m.padding(false);
341 let with_icon = m.padding(true);
342 assert!(with_icon.left > plain.left + m.icon);
343 assert_eq!(with_icon.right, plain.right);
344 assert_eq!(with_icon.top, plain.top);
345 }
346
347 #[test]
348 fn hover_and_focus_strengthen_the_border() {
349 for theme in [light(), dark()] {
350 let tokens = Tokens::of(&theme);
351 let active = border(style(&tokens, Status::Active, false));
352 let hovered = border(style(&tokens, Status::Hovered, false));
353 let focused = border(style(&tokens, Status::Focused { is_hovered: false }, false));
354 assert_ne!(active, hovered);
355 assert_ne!(hovered, focused);
356 assert_eq!(
357 focused,
358 border(style(&tokens, Status::Focused { is_hovered: true }, false))
359 );
360 }
361 }
362
363 #[test]
364 fn invalid_uses_destructive_border_in_every_enabled_status() {
365 for theme in [light(), dark()] {
366 let tokens = Tokens::of(&theme);
367 for status in STATES {
368 let style = style(&tokens, status, true);
369 let expected = if status == Status::Disabled {
370 fade(tokens.destructive, 0.5)
371 } else {
372 tokens.destructive
373 };
374 assert_eq!(style.border.color, expected, "{status:?}");
375 }
376 }
377 }
378
379 #[test]
380 fn disabled_halves_text_alpha_and_mutes_background() {
381 for theme in [light(), dark()] {
382 let tokens = Tokens::of(&theme);
383 let active = style(&tokens, Status::Active, false);
384 let disabled = style(&tokens, Status::Disabled, false);
385 assert!((disabled.value.a - active.value.a * 0.5).abs() < 1e-6);
386 assert!((disabled.placeholder.a - active.placeholder.a * 0.5).abs() < 1e-6);
387 assert_ne!(disabled.background, active.background);
388 assert_eq!(
389 disabled.background,
390 Background::Color(tokens.disabled_field())
391 );
392 }
393 }
394
395 #[test]
396 fn enabled_fields_take_the_colour_of_their_surface() {
397 for theme in [light(), dark()] {
398 let tokens = Tokens::of(&theme);
399 for status in STATES.into_iter().filter(|s| *s != Status::Disabled) {
400 for invalid in [false, true] {
401 let style = style(&tokens, status, invalid);
402 assert_eq!(style.background, Background::Color(Color::TRANSPARENT));
403 }
404 }
405 }
406 }
407
408 #[test]
409 fn every_status_has_a_visible_border_and_readable_value() {
410 for theme in [light(), dark()] {
411 let tokens = Tokens::of(&theme);
412 for status in STATES {
413 for invalid in [false, true] {
414 let style = style(&tokens, status, invalid);
415 assert_eq!(style.border.width, 1.0);
416 assert!(style.border.color.a > 0.0);
417 assert_ne!(style.value, tokens.background);
418 }
419 }
420 }
421 }
422}