1use std::fmt;
4
5use iced::widget::text::LineHeight;
6use iced::widget::text_input::{Status, Style};
7use iced::widget::{self, Id, container, stack, text_input};
8use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme};
9
10use crate::icon::{Glyph, tinted};
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 =
223 tinted(glyph, metrics.icon, None).style(move |theme: &Theme, _| widget::svg::Style {
224 color: Some(style(&Tokens::of(theme), status, invalid).icon),
225 });
226 let icon = container(icon)
227 .padding(Padding::ZERO.left(metrics.padding_x))
228 .height(Length::Fill)
229 .align_y(Alignment::Center);
230
231 stack![field, icon].width(input.width).into()
232 }
233}
234
235pub fn style(tokens: &Tokens, status: Status, invalid: bool) -> Style {
237 let border = match (invalid, status) {
238 (true, _) => tokens.destructive,
239 (false, Status::Focused { .. }) => mix(tokens.border, tokens.foreground, 0.6),
240 (false, Status::Hovered) => mix(tokens.border, tokens.foreground, 0.25),
241 (false, Status::Active | Status::Disabled) => tokens.border,
242 };
243
244 let active = Style {
245 background: Background::Color(tokens.background),
246 border: Border {
247 color: border,
248 width: 1.0,
249 radius: radius::MD.into(),
250 },
251 icon: tokens.muted_foreground,
252 placeholder: tokens.muted_foreground,
253 value: tokens.foreground,
254 selection: fade(tokens.primary, 0.25),
255 };
256
257 if status != Status::Disabled {
258 return active;
259 }
260 Style {
261 background: Background::Color(fade(tokens.muted, 0.5)),
262 border: Border {
263 color: fade(border, 0.5),
264 ..active.border
265 },
266 icon: fade(active.icon, 0.5),
267 placeholder: fade(active.placeholder, 0.5),
268 value: fade(active.value, 0.5),
269 selection: Color::TRANSPARENT,
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::theme::{dark, light};
277
278 const STATES: [Status; 5] = [
279 Status::Active,
280 Status::Hovered,
281 Status::Focused { is_hovered: false },
282 Status::Focused { is_hovered: true },
283 Status::Disabled,
284 ];
285
286 fn border(style: Style) -> Color {
287 style.border.color
288 }
289
290 #[test]
291 fn default_builder_is_medium_fill_and_disabled() {
292 let i: Input<'_, ()> = input("Email", "");
293 assert_eq!(i.size, Size::Md);
294 assert_eq!(i.width, Length::Fill);
295 assert!(!i.secure && !i.invalid);
296 assert!(i.icon.is_none() && i.id.is_none());
297 assert!(!i.is_enabled());
298 }
299
300 #[test]
301 fn on_input_enables_and_on_input_maybe_none_disables() {
302 assert!(input::<String>("", "").on_input(|s| s).is_enabled());
303 let none: Option<fn(String) -> String> = None;
304 assert!(
305 !input::<String>("", "")
306 .on_input(|s| s)
307 .on_input_maybe(none)
308 .is_enabled()
309 );
310 }
311
312 #[test]
313 fn builder_keeps_placeholder_and_value() {
314 let i: Input<'_, ()> = input("Search", "iced").secure(true).invalid(true);
315 assert_eq!(i.placeholder, "Search");
316 assert_eq!(i.value, "iced");
317 assert!(i.secure && i.invalid);
318 }
319
320 #[test]
321 fn sizes_grow_monotonically() {
322 let heights = Size::ALL.map(|s| s.metrics().height);
323 assert!(heights.windows(2).all(|w| w[0] < w[1]));
324 }
325
326 #[test]
327 fn padding_produces_the_target_height() {
328 for size in Size::ALL {
329 let m = size.metrics();
330 let p = m.padding(false);
331 let height = m.text * LINE_HEIGHT + p.top + p.bottom;
332 assert!((height - m.height).abs() < 1e-3, "{size:?}");
333 }
334 }
335
336 #[test]
337 fn icon_reserves_space_on_the_left_only() {
338 let m = Size::Md.metrics();
339 let plain = m.padding(false);
340 let with_icon = m.padding(true);
341 assert!(with_icon.left > plain.left + m.icon);
342 assert_eq!(with_icon.right, plain.right);
343 assert_eq!(with_icon.top, plain.top);
344 }
345
346 #[test]
347 fn hover_and_focus_strengthen_the_border() {
348 for theme in [light(), dark()] {
349 let tokens = Tokens::of(&theme);
350 let active = border(style(&tokens, Status::Active, false));
351 let hovered = border(style(&tokens, Status::Hovered, false));
352 let focused = border(style(&tokens, Status::Focused { is_hovered: false }, false));
353 assert_ne!(active, hovered);
354 assert_ne!(hovered, focused);
355 assert_eq!(
356 focused,
357 border(style(&tokens, Status::Focused { is_hovered: true }, false))
358 );
359 }
360 }
361
362 #[test]
363 fn invalid_uses_destructive_border_in_every_enabled_status() {
364 for theme in [light(), dark()] {
365 let tokens = Tokens::of(&theme);
366 for status in STATES {
367 let style = style(&tokens, status, true);
368 let expected = if status == Status::Disabled {
369 fade(tokens.destructive, 0.5)
370 } else {
371 tokens.destructive
372 };
373 assert_eq!(style.border.color, expected, "{status:?}");
374 }
375 }
376 }
377
378 #[test]
379 fn disabled_halves_text_alpha_and_mutes_background() {
380 for theme in [light(), dark()] {
381 let tokens = Tokens::of(&theme);
382 let active = style(&tokens, Status::Active, false);
383 let disabled = style(&tokens, Status::Disabled, false);
384 assert!((disabled.value.a - active.value.a * 0.5).abs() < 1e-6);
385 assert!((disabled.placeholder.a - active.placeholder.a * 0.5).abs() < 1e-6);
386 assert_ne!(disabled.background, active.background);
387 }
388 }
389
390 #[test]
391 fn every_status_has_a_visible_border_and_readable_value() {
392 for theme in [light(), dark()] {
393 let tokens = Tokens::of(&theme);
394 for status in STATES {
395 for invalid in [false, true] {
396 let style = style(&tokens, status, invalid);
397 assert_eq!(style.border.width, 1.0);
398 assert!(style.border.color.a > 0.0);
399 assert_ne!(style.value, tokens.background);
400 }
401 }
402 }
403 }
404}