Skip to main content

iced_cube/forms/
textarea.rs

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