Skip to main content

iced_cube/forms/
textarea.rs

1//! Multi-line text editing.
2//!
3//! The app owns the [`Content`] and applies each [`Action`] in `update`:
4//!
5//! ```no_run
6//! use iced::widget::text_editor::{Action, Content};
7//! use iced_cube::textarea;
8//!
9//! #[derive(Debug, Clone)]
10//! enum Message { Edit(Action) }
11//!
12//! let content = Content::new();
13//! let view: iced::Element<'_, Message> = textarea(&content)
14//!     .placeholder("Write a note")
15//!     .on_action(Message::Edit)
16//!     .into();
17//! ```
18
19use std::fmt;
20
21use iced::widget::text::LineHeight;
22use iced::widget::text_editor::{self, Status, Style};
23use iced::widget::{Id, container, text_input};
24use iced::{Element, Length, Padding};
25
26pub use iced::widget::text_editor::{Action, Content};
27
28use crate::primitives::input;
29use crate::theme::{Tokens, space, text_size};
30
31/// Minimum height while the height is [`Length::Shrink`], roughly three lines.
32pub const MIN_HEIGHT: f32 = 80.0;
33
34/// A textarea builder. Convert it into an [`Element`] to render.
35///
36/// A textarea without an `on_action` message is rendered disabled.
37pub struct Textarea<'a, Message> {
38    content: &'a Content,
39    placeholder: Option<iced::widget::text::Fragment<'a>>,
40    id: Option<Id>,
41    on_action: Option<Box<dyn Fn(Action) -> Message + 'a>>,
42    invalid: bool,
43    width: Length,
44    height: Length,
45    min_height: f32,
46}
47
48/// Creates a textarea editing `content`.
49pub fn textarea<'a, Message>(content: &'a Content) -> Textarea<'a, Message> {
50    Textarea {
51        content,
52        placeholder: None,
53        id: None,
54        on_action: None,
55        invalid: false,
56        width: Length::Fill,
57        height: Length::Shrink,
58        min_height: MIN_HEIGHT,
59    }
60}
61
62impl<'a, Message> Textarea<'a, Message> {
63    pub fn placeholder(mut self, placeholder: impl iced::widget::text::IntoFragment<'a>) -> Self {
64        self.placeholder = Some(placeholder.into_fragment());
65        self
66    }
67
68    /// Sets the widget id, used to focus the textarea or find it in tests.
69    pub fn id(mut self, id: impl Into<Id>) -> Self {
70        self.id = Some(id.into());
71        self
72    }
73
74    /// Sets the message produced for every edit. Without it the textarea is disabled.
75    pub fn on_action(mut self, on_action: impl Fn(Action) -> Message + 'a) -> Self {
76        self.on_action = Some(Box::new(on_action));
77        self
78    }
79
80    pub fn on_action_maybe(mut self, on_action: Option<impl Fn(Action) -> Message + 'a>) -> Self {
81        self.on_action = on_action.map(|f| Box::new(f) as Box<dyn Fn(Action) -> Message + 'a>);
82        self
83    }
84
85    /// Marks the value as invalid, drawing a destructive border.
86    pub fn invalid(mut self, invalid: bool) -> Self {
87        self.invalid = invalid;
88        self
89    }
90
91    /// Overrides the width. Textareas fill the available width by default.
92    pub fn width(mut self, width: impl Into<Length>) -> Self {
93        self.width = width.into();
94        self
95    }
96
97    /// Sets the height. The default shrinks to the content and grows as
98    /// lines are added, starting at [`MIN_HEIGHT`].
99    pub fn height(mut self, height: impl Into<Length>) -> Self {
100        self.height = height.into();
101        self
102    }
103
104    /// Sets the minimum height used while the height is [`Length::Shrink`].
105    pub fn min_height(mut self, min_height: f32) -> Self {
106        self.min_height = min_height.max(0.0);
107        self
108    }
109
110    pub fn is_enabled(&self) -> bool {
111        self.on_action.is_some()
112    }
113}
114
115impl<Message> fmt::Debug for Textarea<'_, Message> {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.debug_struct("Textarea")
118            .field("placeholder", &self.placeholder)
119            .field("id", &self.id)
120            .field("enabled", &self.is_enabled())
121            .field("invalid", &self.invalid)
122            .field("width", &self.width)
123            .field("height", &self.height)
124            .field("min_height", &self.min_height)
125            .finish()
126    }
127}
128
129impl<'a, Message: Clone + 'a> From<Textarea<'a, Message>> for Element<'a, Message> {
130    fn from(textarea: Textarea<'a, Message>) -> Self {
131        let invalid = textarea.invalid;
132        let enabled = textarea.is_enabled();
133
134        // iced never reports Disabled for an editor without `on_action`.
135        let mut editor = iced::widget::text_editor(textarea.content)
136            .size(text_size::SM)
137            .line_height(LineHeight::Relative(1.5))
138            .padding(Padding::from([space::SM, space::MD]))
139            .height(textarea.height)
140            .style(move |theme, status| {
141                let status = if enabled { status } else { Status::Disabled };
142                style(&Tokens::of(theme), status, invalid)
143            });
144        if textarea.height == Length::Shrink {
145            editor = editor.min_height(textarea.min_height);
146        }
147        if let Some(placeholder) = textarea.placeholder {
148            editor = editor.placeholder(placeholder);
149        }
150        if let Some(id) = textarea.id {
151            editor = editor.id(id);
152        }
153        if let Some(on_action) = textarea.on_action {
154            editor = editor.on_action(on_action);
155        }
156
157        container(editor).width(textarea.width).into()
158    }
159}
160
161/// The iced text editor style for a status, matching the input's look.
162pub fn style(tokens: &Tokens, status: Status, invalid: bool) -> Style {
163    let status = match status {
164        Status::Active => text_input::Status::Active,
165        Status::Hovered => text_input::Status::Hovered,
166        Status::Focused { is_hovered } => text_input::Status::Focused { is_hovered },
167        Status::Disabled => text_input::Status::Disabled,
168    };
169    let style = input::style(tokens, status, invalid);
170
171    text_editor::Style {
172        background: style.background,
173        border: style.border,
174        placeholder: style.placeholder,
175        value: style.value,
176        selection: style.selection,
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::theme::{dark, light};
184
185    const STATES: [Status; 5] = [
186        Status::Active,
187        Status::Hovered,
188        Status::Focused { is_hovered: false },
189        Status::Focused { is_hovered: true },
190        Status::Disabled,
191    ];
192
193    #[test]
194    fn default_builder_shrinks_with_minimum_and_is_disabled() {
195        let content = Content::new();
196        let t: Textarea<'_, ()> = textarea(&content);
197        assert_eq!(t.height, Length::Shrink);
198        assert_eq!(t.width, Length::Fill);
199        assert_eq!(t.min_height, MIN_HEIGHT);
200        assert!(t.placeholder.is_none() && !t.invalid);
201        assert!(!t.is_enabled());
202    }
203
204    #[test]
205    fn on_action_enables_and_none_disables() {
206        let content = Content::new();
207        assert!(textarea::<Action>(&content).on_action(|a| a).is_enabled());
208        let none: Option<fn(Action) -> Action> = None;
209        assert!(
210            !textarea::<Action>(&content)
211                .on_action(|a| a)
212                .on_action_maybe(none)
213                .is_enabled()
214        );
215    }
216
217    #[test]
218    fn negative_min_height_clamps_to_zero() {
219        let content = Content::new();
220        let t: Textarea<'_, ()> = textarea(&content).min_height(-5.0);
221        assert_eq!(t.min_height, 0.0);
222    }
223
224    #[test]
225    fn style_matches_input_for_every_status() {
226        for theme in [light(), dark()] {
227            let tokens = Tokens::of(&theme);
228            for (editor, field) in STATES.into_iter().zip([
229                text_input::Status::Active,
230                text_input::Status::Hovered,
231                text_input::Status::Focused { is_hovered: false },
232                text_input::Status::Focused { is_hovered: true },
233                text_input::Status::Disabled,
234            ]) {
235                for invalid in [false, true] {
236                    let a = style(&tokens, editor, invalid);
237                    let b = input::style(&tokens, field, invalid);
238                    assert_eq!(a.border, b.border, "{editor:?}");
239                    assert_eq!(a.background, b.background);
240                    assert_eq!(a.value, b.value);
241                    assert_eq!(a.placeholder, b.placeholder);
242                }
243            }
244        }
245    }
246
247    #[test]
248    fn invalid_and_disabled_are_distinct_from_active() {
249        let tokens = Tokens::of(&light());
250        let active = style(&tokens, Status::Active, false);
251        assert_ne!(style(&tokens, Status::Active, true).border, active.border);
252        assert_ne!(style(&tokens, Status::Disabled, false).value, active.value);
253    }
254}