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    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}