Skip to main content

iced_cube/primitives/
input.rs

1//! Single-line text inputs with an optional leading icon.
2
3use 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/// Input dimensions.
16#[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    /// Height, horizontal padding, text size and icon size.
28    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/// Resolved dimensions for a [`Size`].
53#[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    /// Inner padding that makes the input exactly `height` tall, leaving
63    /// room for a leading icon when there is one.
64    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
80/// A text input builder. Convert it into an [`Element`] to render.
81///
82/// An input without an `on_input` message is rendered disabled.
83pub 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
96/// Creates a text input showing `value`, with `placeholder` while empty.
97pub 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    /// Sets the widget id, used to focus the input or find it in tests.
114    pub fn id(mut self, id: impl Into<Id>) -> Self {
115        self.id = Some(id.into());
116        self
117    }
118
119    /// Sets the message produced for every edit. Without it the input is disabled.
120    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    /// Sets the message produced when Enter is pressed.
131    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    /// Adds a Lucide icon at the start of the input.
142    pub fn icon(mut self, glyph: Glyph) -> Self {
143        self.icon = Some(glyph);
144        self
145    }
146
147    /// Masks the value, for passwords and other secrets.
148    pub fn secure(mut self, secure: bool) -> Self {
149        self.secure = secure;
150        self
151    }
152
153    /// Marks the value as invalid, drawing a destructive border.
154    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    /// Overrides the width. Inputs fill the available width by default.
165    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
234/// The iced text input style for a status, optionally marked invalid.
235pub 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    // Transparent so the field takes the colour of whatever surface it sits
244    // on, such as a dialog or popover.
245    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}