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