1use 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
43pub const MIN_HEIGHT: f32 = 80.0;
45const LINE_HEIGHT: f32 = 1.5;
47
48pub 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
62pub 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 pub fn id(mut self, id: impl Into<Id>) -> Self {
84 self.id = Some(id.into());
85 self
86 }
87
88 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 pub fn invalid(mut self, invalid: bool) -> Self {
101 self.invalid = invalid;
102 self
103 }
104
105 pub fn width(mut self, width: impl Into<Length>) -> Self {
107 self.width = width.into();
108 self
109 }
110
111 pub fn height(mut self, height: impl Into<Length>) -> Self {
114 self.height = height.into();
115 self
116 }
117
118 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 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 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 touch_clicks(container(editor).width(textarea.width))
187 .scroll(true)
188 .line_height(text_size::SM * LINE_HEIGHT)
189 .into()
190 }
191}
192
193struct 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 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
350pub 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}