1use 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
31pub const MIN_HEIGHT: f32 = 80.0;
33
34pub 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
48pub 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 pub fn id(mut self, id: impl Into<Id>) -> Self {
70 self.id = Some(id.into());
71 self
72 }
73
74 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 pub fn invalid(mut self, invalid: bool) -> Self {
87 self.invalid = invalid;
88 self
89 }
90
91 pub fn width(mut self, width: impl Into<Length>) -> Self {
93 self.width = width.into();
94 self
95 }
96
97 pub fn height(mut self, height: impl Into<Length>) -> Self {
100 self.height = height.into();
101 self
102 }
103
104 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 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
161pub 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}