1use 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
35pub const MIN_HEIGHT: f32 = 80.0;
37const LINE_HEIGHT: f32 = 1.5;
39
40pub 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
54pub 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 pub fn id(mut self, id: impl Into<Id>) -> Self {
76 self.id = Some(id.into());
77 self
78 }
79
80 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 pub fn invalid(mut self, invalid: bool) -> Self {
93 self.invalid = invalid;
94 self
95 }
96
97 pub fn width(mut self, width: impl Into<Length>) -> Self {
99 self.width = width.into();
100 self
101 }
102
103 pub fn height(mut self, height: impl Into<Length>) -> Self {
106 self.height = height.into();
107 self
108 }
109
110 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 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 touch_clicks(container(editor).width(textarea.width))
166 .scroll(true)
167 .line_height(text_size::SM * LINE_HEIGHT)
168 .into()
169 }
170}
171
172pub 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}