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::cell::Cell;
23use std::fmt;
24use std::rc::Rc;
25
26use iced::advanced::layout::{self, Layout};
27use iced::advanced::text::highlighter::PlainText;
28use iced::advanced::widget::{Operation, Tree, Widget, tree};
29use iced::advanced::{Clipboard, Shell, input_method, overlay, renderer};
30use iced::widget::text::LineHeight;
31use iced::widget::text_editor::{self, Status, Style};
32use iced::widget::{Id, container, text_input};
33use iced::{
34    Color, Element, Event, Length, Padding, Rectangle, Renderer, Size, Theme, Vector, mouse,
35};
36
37pub use iced::widget::text_editor::{Action, Content};
38
39use crate::primitives::input;
40use crate::primitives::touch_clicks::touch_clicks;
41use crate::theme::{Tokens, space, text_size};
42
43/// Minimum height while the height is [`Length::Shrink`], roughly three lines.
44pub const MIN_HEIGHT: f32 = 80.0;
45/// Line height as a multiple of the text size.
46const LINE_HEIGHT: f32 = 1.5;
47
48/// A textarea builder. Convert it into an [`Element`] to render.
49///
50/// A textarea without an `on_action` message is rendered disabled.
51pub struct Textarea<'a, Message> {
52    content: &'a Content,
53    placeholder: Option<iced::widget::text::Fragment<'a>>,
54    id: Option<Id>,
55    on_action: Option<Box<dyn Fn(Action) -> Message + 'a>>,
56    invalid: bool,
57    width: Length,
58    height: Length,
59    min_height: f32,
60}
61
62/// Creates a textarea editing `content`.
63pub fn textarea<'a, Message>(content: &'a Content) -> Textarea<'a, Message> {
64    Textarea {
65        content,
66        placeholder: None,
67        id: None,
68        on_action: None,
69        invalid: false,
70        width: Length::Fill,
71        height: Length::Shrink,
72        min_height: MIN_HEIGHT,
73    }
74}
75
76impl<'a, Message> Textarea<'a, Message> {
77    pub fn placeholder(mut self, placeholder: impl iced::widget::text::IntoFragment<'a>) -> Self {
78        self.placeholder = Some(placeholder.into_fragment());
79        self
80    }
81
82    /// Sets the widget id, used to focus the textarea or find it in tests.
83    pub fn id(mut self, id: impl Into<Id>) -> Self {
84        self.id = Some(id.into());
85        self
86    }
87
88    /// Sets the message produced for every edit. Without it the textarea is disabled.
89    pub fn on_action(mut self, on_action: impl Fn(Action) -> Message + 'a) -> Self {
90        self.on_action = Some(Box::new(on_action));
91        self
92    }
93
94    pub fn on_action_maybe(mut self, on_action: Option<impl Fn(Action) -> Message + 'a>) -> Self {
95        self.on_action = on_action.map(|f| Box::new(f) as Box<dyn Fn(Action) -> Message + 'a>);
96        self
97    }
98
99    /// Marks the value as invalid, drawing a destructive border.
100    pub fn invalid(mut self, invalid: bool) -> Self {
101        self.invalid = invalid;
102        self
103    }
104
105    /// Overrides the width. Textareas fill the available width by default.
106    pub fn width(mut self, width: impl Into<Length>) -> Self {
107        self.width = width.into();
108        self
109    }
110
111    /// Sets the height. The default shrinks to the content and grows as
112    /// lines are added, starting at [`MIN_HEIGHT`].
113    pub fn height(mut self, height: impl Into<Length>) -> Self {
114        self.height = height.into();
115        self
116    }
117
118    /// Sets the minimum height used while the height is [`Length::Shrink`].
119    pub fn min_height(mut self, min_height: f32) -> Self {
120        self.min_height = min_height.max(0.0);
121        self
122    }
123
124    pub fn is_enabled(&self) -> bool {
125        self.on_action.is_some()
126    }
127}
128
129impl<Message> fmt::Debug for Textarea<'_, Message> {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.debug_struct("Textarea")
132            .field("placeholder", &self.placeholder)
133            .field("id", &self.id)
134            .field("enabled", &self.is_enabled())
135            .field("invalid", &self.invalid)
136            .field("width", &self.width)
137            .field("height", &self.height)
138            .field("min_height", &self.min_height)
139            .finish()
140    }
141}
142
143impl<'a, Message: Clone + 'a> From<Textarea<'a, Message>> for Element<'a, Message> {
144    fn from(textarea: Textarea<'a, Message>) -> Self {
145        let invalid = textarea.invalid;
146        let enabled = textarea.is_enabled();
147        let composing = Rc::new(Cell::new(false));
148        let hide_placeholder = Rc::clone(&composing);
149
150        // iced never reports Disabled for an editor without `on_action`.
151        let mut editor = iced::widget::text_editor(textarea.content)
152            .size(text_size::SM)
153            .line_height(LineHeight::Relative(LINE_HEIGHT))
154            .padding(Padding::from([space::SM, space::MD]))
155            .height(textarea.height)
156            .style(move |theme, status| {
157                let status = if enabled { status } else { Status::Disabled };
158                let mut style = style(&Tokens::of(theme), status, invalid);
159                // The keyboard draws the word it is composing over the
160                // editor, so the placeholder would show through it.
161                if hide_placeholder.get() && matches!(status, Status::Focused { .. }) {
162                    style.placeholder = Color::TRANSPARENT;
163                }
164                style
165            });
166        if textarea.height == Length::Shrink {
167            editor = editor.min_height(textarea.min_height);
168        }
169        if let Some(placeholder) = textarea.placeholder {
170            editor = editor.placeholder(placeholder);
171        }
172        if let Some(id) = textarea.id {
173            editor = editor.id(id);
174        }
175        if let Some(on_action) = textarea.on_action {
176            editor = editor.on_action(on_action);
177        }
178
179        let editor = Element::new(Composing {
180            content: editor.into(),
181            composing,
182        });
183
184        // iced's editor reads only the mouse, so taps and drags are
185        // translated for it.
186        touch_clicks(container(editor).width(textarea.width))
187            .scroll(true)
188            .line_height(text_size::SM * LINE_HEIGHT)
189            .into()
190    }
191}
192
193/// Tracks whether the input method is composing a word, which iced's editor
194/// does not consider when it draws its placeholder.
195struct Composing<'a, Message> {
196    content: Element<'a, Message>,
197    composing: Rc<Cell<bool>>,
198}
199
200impl<Message> Widget<Message, Theme, Renderer> for Composing<'_, Message> {
201    fn tag(&self) -> tree::Tag {
202        tree::Tag::of::<bool>()
203    }
204
205    fn state(&self) -> tree::State {
206        tree::State::new(false)
207    }
208
209    fn children(&self) -> Vec<Tree> {
210        vec![Tree::new(&self.content)]
211    }
212
213    fn diff(&self, tree: &mut Tree) {
214        tree.diff_children(std::slice::from_ref(&self.content));
215    }
216
217    fn size(&self) -> Size<Length> {
218        self.content.as_widget().size()
219    }
220
221    fn size_hint(&self) -> Size<Length> {
222        self.content.as_widget().size_hint()
223    }
224
225    fn layout(
226        &mut self,
227        tree: &mut Tree,
228        renderer: &Renderer,
229        limits: &layout::Limits,
230    ) -> layout::Node {
231        self.content
232            .as_widget_mut()
233            .layout(&mut tree.children[0], renderer, limits)
234    }
235
236    fn draw(
237        &self,
238        tree: &Tree,
239        renderer: &mut Renderer,
240        theme: &Theme,
241        style: &renderer::Style,
242        layout: Layout<'_>,
243        cursor: mouse::Cursor,
244        viewport: &Rectangle,
245    ) {
246        self.composing.set(*tree.state.downcast_ref::<bool>());
247        self.content.as_widget().draw(
248            &tree.children[0],
249            renderer,
250            theme,
251            style,
252            layout,
253            cursor,
254            viewport,
255        );
256    }
257
258    fn operate(
259        &mut self,
260        tree: &mut Tree,
261        layout: Layout<'_>,
262        renderer: &Renderer,
263        operation: &mut dyn Operation,
264    ) {
265        self.content
266            .as_widget_mut()
267            .operate(&mut tree.children[0], layout, renderer, operation);
268    }
269
270    fn update(
271        &mut self,
272        tree: &mut Tree,
273        event: &Event,
274        layout: Layout<'_>,
275        cursor: mouse::Cursor,
276        renderer: &Renderer,
277        clipboard: &mut dyn Clipboard,
278        shell: &mut Shell<'_, Message>,
279        viewport: &Rectangle,
280    ) {
281        // Mirrors the editor, which keeps a composing word only while focused,
282        // so a word composed in another field is never counted.
283        if let Event::InputMethod(event) = event {
284            match event {
285                input_method::Event::Opened | input_method::Event::Closed => {
286                    *tree.state.downcast_mut::<bool>() = false;
287                }
288                input_method::Event::Preedit(text, _) if editor_focused(&tree.children[0]) => {
289                    *tree.state.downcast_mut::<bool>() = !text.is_empty();
290                }
291                _ => {}
292            }
293        }
294        self.content.as_widget_mut().update(
295            &mut tree.children[0],
296            event,
297            layout,
298            cursor,
299            renderer,
300            clipboard,
301            shell,
302            viewport,
303        );
304    }
305
306    fn mouse_interaction(
307        &self,
308        tree: &Tree,
309        layout: Layout<'_>,
310        cursor: mouse::Cursor,
311        viewport: &Rectangle,
312        renderer: &Renderer,
313    ) -> mouse::Interaction {
314        self.content.as_widget().mouse_interaction(
315            &tree.children[0],
316            layout,
317            cursor,
318            viewport,
319            renderer,
320        )
321    }
322
323    fn overlay<'b>(
324        &'b mut self,
325        tree: &'b mut Tree,
326        layout: Layout<'b>,
327        renderer: &Renderer,
328        viewport: &Rectangle,
329        translation: Vector,
330    ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
331        self.content.as_widget_mut().overlay(
332            &mut tree.children[0],
333            layout,
334            renderer,
335            viewport,
336            translation,
337        )
338    }
339}
340
341fn editor_focused(tree: &Tree) -> bool {
342    let tree::State::Some(state) = &tree.state else {
343        return false;
344    };
345    state
346        .downcast_ref::<text_editor::State<PlainText>>()
347        .is_some_and(text_editor::State::is_focused)
348}
349
350/// The iced text editor style for a status, matching the input's look.
351pub fn style(tokens: &Tokens, status: Status, invalid: bool) -> Style {
352    let status = match status {
353        Status::Active => text_input::Status::Active,
354        Status::Hovered => text_input::Status::Hovered,
355        Status::Focused { is_hovered } => text_input::Status::Focused { is_hovered },
356        Status::Disabled => text_input::Status::Disabled,
357    };
358    let style = input::style(tokens, status, invalid);
359
360    text_editor::Style {
361        background: style.background,
362        border: style.border,
363        placeholder: style.placeholder,
364        value: style.value,
365        selection: style.selection,
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::theme::{dark, light};
373
374    const STATES: [Status; 5] = [
375        Status::Active,
376        Status::Hovered,
377        Status::Focused { is_hovered: false },
378        Status::Focused { is_hovered: true },
379        Status::Disabled,
380    ];
381
382    #[test]
383    fn default_builder_shrinks_with_minimum_and_is_disabled() {
384        let content = Content::new();
385        let t: Textarea<'_, ()> = textarea(&content);
386        assert_eq!(t.height, Length::Shrink);
387        assert_eq!(t.width, Length::Fill);
388        assert_eq!(t.min_height, MIN_HEIGHT);
389        assert!(t.placeholder.is_none() && !t.invalid);
390        assert!(!t.is_enabled());
391    }
392
393    #[test]
394    fn on_action_enables_and_none_disables() {
395        let content = Content::new();
396        assert!(textarea::<Action>(&content).on_action(|a| a).is_enabled());
397        let none: Option<fn(Action) -> Action> = None;
398        assert!(
399            !textarea::<Action>(&content)
400                .on_action(|a| a)
401                .on_action_maybe(none)
402                .is_enabled()
403        );
404    }
405
406    #[test]
407    fn negative_min_height_clamps_to_zero() {
408        let content = Content::new();
409        let t: Textarea<'_, ()> = textarea(&content).min_height(-5.0);
410        assert_eq!(t.min_height, 0.0);
411    }
412
413    #[test]
414    fn style_matches_input_for_every_status() {
415        for theme in [light(), dark()] {
416            let tokens = Tokens::of(&theme);
417            for (editor, field) in STATES.into_iter().zip([
418                text_input::Status::Active,
419                text_input::Status::Hovered,
420                text_input::Status::Focused { is_hovered: false },
421                text_input::Status::Focused { is_hovered: true },
422                text_input::Status::Disabled,
423            ]) {
424                for invalid in [false, true] {
425                    let a = style(&tokens, editor, invalid);
426                    let b = input::style(&tokens, field, invalid);
427                    assert_eq!(a.border, b.border, "{editor:?}");
428                    assert_eq!(a.background, b.background);
429                    assert_eq!(a.value, b.value);
430                    assert_eq!(a.placeholder, b.placeholder);
431                }
432            }
433        }
434    }
435
436    #[test]
437    fn invalid_and_disabled_are_distinct_from_active() {
438        let tokens = Tokens::of(&light());
439        let active = style(&tokens, Status::Active, false);
440        assert_ne!(style(&tokens, Status::Active, true).border, active.border);
441        assert_ne!(style(&tokens, Status::Disabled, false).value, active.value);
442    }
443}