Skip to main content

zng_wgt_text_input/
text_input.rs

1use colors::{ACCENT_COLOR_VAR, BASE_COLOR_VAR};
2use zng_ext_clipboard::{COPY_CMD, CUT_CMD, PASTE_CMD};
3use zng_wgt::base_color;
4use zng_wgt::{align, is_disabled, margin, prelude::*};
5use zng_wgt_access::{AccessRole, access_role};
6use zng_wgt_button::Button;
7use zng_wgt_data::{DATA, DataNoteLevel, DataNotes};
8use zng_wgt_fill::foreground_highlight;
9use zng_wgt_filter::{child_opacity, saturate};
10use zng_wgt_input::{
11    focus::{focusable, is_return_focus},
12    pointer_capture::capture_pointer,
13};
14use zng_wgt_layer::popup;
15use zng_wgt_menu::{
16    self as menu,
17    context::{ContextMenu, context_menu_fn},
18};
19use zng_wgt_rule_line::hr::Hr;
20use zng_wgt_size_offset::{offset, y};
21use zng_wgt_style::{Style, StyleMix, impl_named_style_fn, impl_style_fn};
22use zng_wgt_text::{self as text, *};
23use zng_wgt_undo::{UndoMix, undo_scope};
24
25/// Simple text editor widget.
26///
27/// If `txt` is set to a variable that can be modified the widget becomes interactive, it implements
28/// the usual *text box* capabilities: keyboard editing of text in a single style, pointer
29/// caret positioning and text selection.
30///
31/// You can also use [`text::cmd`] to edit the text.
32///
33/// [`text::cmd`]: zng_wgt_text::cmd
34///
35/// # Undo/Redo
36///
37/// Undo/redo is enabled by default, the widget is an undo scope and handles undo commands. Note that external
38/// changes to the `txt` variable clears the undo stack, only changes done by the widget can be undone.
39///
40/// # Shorthand
41///
42/// The `TextInput!` macro provides shorthand syntax that sets the `txt` property.
43///
44/// ```
45/// # zng_wgt::enable_widget_macros!();
46/// # use zng_wgt::prelude::*;
47/// # use zng_wgt_text_input::*;
48/// #
49/// # fn main() {
50/// let editable_text = TextInput!(var_from(""));
51/// # }
52/// ```
53#[widget($crate::TextInput { ($txt:expr) => { txt = $txt; }; })]
54pub struct TextInput(StyleMix<UndoMix<Text>>);
55impl TextInput {
56    fn widget_intrinsic(&mut self) {
57        self.style_intrinsic(STYLE_FN_VAR, property_id!(self::style_fn));
58        widget_set! {
59            self;
60
61            access_role = AccessRole::TextInput;
62            txt_editable = true;
63            txt_selectable = true;
64            capture_pointer = true;
65            txt_align = Align::TOP_START;
66            focusable = true;
67            undo_scope = true;
68            undo_limit = 100;
69        }
70    }
71}
72impl_style_fn!(TextInput, DefaultStyle);
73
74/// Context menu set by the [`DefaultStyle!`].
75///
76/// [`DefaultStyle!`]: struct@DefaultStyle
77pub fn default_context_menu(args: menu::context::ContextMenuArgs) -> UiNode {
78    let id = args.anchor_id;
79    ContextMenu!(ui_vec![
80        Button!(CUT_CMD.scoped(id)),
81        Button!(COPY_CMD.scoped(id)),
82        Button!(PASTE_CMD.scoped(id)),
83        Hr!(),
84        Button!(text::cmd::SELECT_ALL_CMD.scoped(id)),
85    ])
86}
87
88/// Selection toolbar set by the [`DefaultStyle!`].
89///
90/// [`DefaultStyle!`]: struct@DefaultStyle
91pub fn default_selection_toolbar(args: text::SelectionToolbarArgs) -> UiNode {
92    if args.is_touch {
93        let id = args.anchor_id;
94        ContextMenu! {
95            style_fn = menu::context::TouchStyle!();
96            children = ui_vec![
97                Button!(CUT_CMD.scoped(id)),
98                Button!(COPY_CMD.scoped(id)),
99                Button!(PASTE_CMD.scoped(id)),
100                Button!(text::cmd::SELECT_ALL_CMD.scoped(id)),
101            ];
102        }
103    } else {
104        UiNode::nil()
105    }
106}
107
108/// Context captured for the context menu, set by the [`DefaultStyle!`].
109///
110/// Captures all context vars, except text style vars.
111///
112/// [`DefaultStyle!`]: struct@DefaultStyle
113pub fn default_popup_context_capture() -> popup::ContextCapture {
114    popup::ContextCapture::context_vars_except(Text::context_vars_set_except_lang)
115}
116
117/// Text input default style.
118#[widget($crate::text_input::DefaultStyle)]
119pub struct DefaultStyle(Style);
120impl DefaultStyle {
121    fn widget_intrinsic(&mut self) {
122        use zng_wgt::border;
123        use zng_wgt_container::*;
124        use zng_wgt_fill::*;
125        use zng_wgt_input::{focus::is_focused, *};
126        use zng_wgt_layer::*;
127
128        widget_set! {
129            self;
130            replace = true;
131            padding = (7, 10);
132            cursor = CursorIcon::Text;
133            base_color = light_dark(rgb(0.88, 0.88, 0.88), rgb(0.12, 0.12, 0.12));
134            background_color = BASE_COLOR_VAR.rgba();
135            border = {
136                widths: 1,
137                sides: BASE_COLOR_VAR.shade_fct_into(0.20),
138            };
139
140            popup::context_capture = default_popup_context_capture();
141            context_menu_fn = WidgetFn::new(default_context_menu);
142            selection_toolbar_fn = WidgetFn::new(default_selection_toolbar);
143            selection_color = ACCENT_COLOR_VAR.rgba_map(|c| c.with_alpha(30.pct()));
144
145            when *#is_cap_hovered || *#is_return_focus {
146                border = {
147                    widths: 1,
148                    sides: BASE_COLOR_VAR.shade_fct_into(0.30),
149                };
150            }
151
152            when *#is_focused {
153                border = {
154                    widths: 1,
155                    sides: ACCENT_COLOR_VAR.rgba_into(),
156                };
157            }
158
159            when *#is_disabled {
160                saturate = false;
161                child_opacity = 50.pct();
162                cursor = CursorIcon::NotAllowed;
163            }
164        }
165    }
166}
167
168/// Text input style for a search field.
169#[widget($crate::SearchStyle)]
170pub struct SearchStyle(DefaultStyle);
171impl_named_style_fn!(search, SearchStyle);
172impl SearchStyle {
173    fn widget_intrinsic(&mut self) {
174        widget_set! {
175            self;
176            named_style_fn = SEARCH_STYLE_FN_VAR;
177            zng_wgt_container::padding = (7, 10, 7, 0);
178            zng_wgt_access::access_role = zng_wgt_access::AccessRole::SearchBox;
179            auto_selection = true;
180
181            zng_wgt_container::child_out_start = zng_wgt_container::Container! {
182                child = zng_wgt::ICONS.req("search");
183                zng_wgt::align = Align::CENTER;
184                zng_wgt::hit_test_mode = false;
185                zng_wgt_size_offset::size = 18;
186                zng_wgt::margin = DIRECTION_VAR.map(|d| {
187                    match d {
188                        LayoutDirection::LTR => (0, 0, 0, 6),
189                        LayoutDirection::RTL => (0, 6, 0, 0),
190                    }
191                    .into()
192                });
193            };
194
195            zng_wgt_container::child_out_end = zng_wgt_button::Button! {
196                zng_wgt::corner_radius = 0;
197                style_fn = zng_wgt_button::LightStyle!();
198                child = zng_wgt::ICONS.req("backspace");
199                focusable = false;
200                zng_wgt::visibility = zng_var::contextual_var(|| {
201                    zng_wgt_text::node::TEXT.resolved().txt.clone().map(|t| match t.is_empty() {
202                        true => Visibility::Collapsed,
203                        false => Visibility::Visible,
204                    })
205                });
206                on_click = hn!(|args| {
207                    args.propagation.stop();
208                    zng_wgt_text::cmd::EDIT_CMD
209                        .scoped(WIDGET.info().parent().unwrap().id())
210                        .notify_param(zng_wgt_text::cmd::TextEditOp::clear());
211                });
212            };
213        }
214    }
215}
216
217/// Text shown when the `txt` is empty.
218///
219/// The placeholder has the same text style as the parent widget, with 50% opacity.
220/// You can use the [`placeholder`](fn@placeholder) to use a custom widget placeholder.
221#[property(CHILD, default(""), widget_impl(TextInput, DefaultStyle))]
222pub fn placeholder_txt(child: impl IntoUiNode, txt: impl IntoVar<Txt>) -> UiNode {
223    placeholder(
224        child,
225        Text! {
226            txt;
227            zng_wgt_filter::opacity = 50.pct();
228            zng_wgt::hit_test_mode = false;
229        },
230    )
231}
232
233/// Widget shown when the `txt` is empty.
234///
235/// The `placeholder` can be any widget, the `Text!` widget is recommended.
236#[property(CHILD, widget_impl(TextInput, DefaultStyle))]
237pub fn placeholder(child: impl IntoUiNode, placeholder: impl IntoUiNode) -> UiNode {
238    let mut txt_is_empty = None;
239    zng_wgt_container::child_under(
240        child,
241        match_widget(placeholder, move |c, op| match op {
242            UiNodeOp::Init => {
243                let is_empty = zng_wgt_text::node::TEXT.resolved().txt.map(|t| t.is_empty());
244                WIDGET.sub_var_render(&is_empty);
245                txt_is_empty = Some(is_empty);
246            }
247            UiNodeOp::Deinit => {
248                txt_is_empty = None;
249            }
250            UiNodeOp::Render { frame } => {
251                c.delegated();
252                if txt_is_empty.as_ref().unwrap().get() {
253                    c.render(frame);
254                } else {
255                    frame.hide(|frame| c.render(frame));
256                }
257            }
258            UiNodeOp::RenderUpdate { update } => {
259                c.delegated();
260                if txt_is_empty.as_ref().unwrap().get() {
261                    c.render_update(update);
262                } else {
263                    update.hidden(|update| c.render_update(update));
264                }
265            }
266            _ => {}
267        }),
268    )
269}
270
271/// Text input style that shows data notes, info, warn and error.
272///
273/// You can also set the [`field_help`] property in text inputs with this style to set a text that
274/// shows in place of the data notes when there are none.
275///
276/// [`field_help`]: fn@field_help
277#[widget($crate::FieldStyle)]
278pub struct FieldStyle(DefaultStyle);
279impl_named_style_fn!(field, FieldStyle);
280impl FieldStyle {
281    fn widget_intrinsic(&mut self) {
282        let top_notes = var(DataNotes::default());
283
284        let top_level_and_txt = top_notes.map(|ns| {
285            if let Some(n) = ns.first() {
286                return (n.level(), formatx!("{ns}"));
287            }
288            (DataNoteLevel::INFO, "".into())
289        });
290        let top_txt = top_level_and_txt.map(|(_, t)| t.clone());
291        let top_color = DATA.note_color(top_level_and_txt.map(|(l, _)| *l));
292
293        let highlight = top_level_and_txt.map(|(l, _)| *l >= DataNoteLevel::WARN);
294        let adorn = merge_var!(top_txt.clone(), FIELD_HELP_VAR, |t, h| (t.is_empty(), h.is_empty()));
295
296        let chars_count = var(0usize);
297        let has_max_count = MAX_CHARS_COUNT_VAR.map(|&c| c > 0);
298
299        widget_set! {
300            self;
301            named_style_fn = FIELD_STYLE_FN_VAR;
302            zng_wgt_data::get_data_notes_top = top_notes.clone();
303            get_chars_count = chars_count.clone();
304            auto_selection = true;
305
306            foreground_highlight = {
307                offsets: -2, // -1 border plus -1 to be outside
308                widths: 1,
309                sides: merge_var!(highlight, top_color.clone(), |&h, &c| if h {
310                    c.into()
311                } else {
312                    BorderSides::hidden()
313                }),
314            };
315            data_notes_adorner_fn = adorn.map(move |&(top_txt_empty, help_empty)| {
316                if !top_txt_empty {
317                    wgt_fn!(top_txt, top_color, |_| Text! {
318                        focusable = false;
319                        txt_editable = false;
320                        txt_selectable = false;
321                        txt = top_txt.clone();
322                        font_color = top_color.clone();
323                        font_size = 0.8.em();
324                        align = Align::BOTTOM_START;
325                        margin = (0, 4);
326                        y = 2.dip() + 100.pct();
327                    })
328                } else if !help_empty {
329                    wgt_fn!(|_| Text! {
330                        focusable = false;
331                        txt_editable = false;
332                        txt_selectable = false;
333                        txt = FIELD_HELP_VAR;
334                        font_size = 0.8.em();
335                        font_color = text::FONT_COLOR_VAR.map(|c| colors::GRAY.with_alpha(10.pct()).mix_normal(*c));
336                        align = Align::BOTTOM_START;
337                        margin = (0, 4);
338                        y = 2.dip() + 100.pct();
339                    })
340                } else {
341                    WidgetFn::nil()
342                }
343            });
344
345            max_chars_count_adorner_fn = has_max_count.map(move |&has| {
346                if has {
347                    wgt_fn!(chars_count, |_| Text! {
348                        focusable = false;
349                        txt_editable = false;
350                        txt_selectable = false;
351                        txt = merge_var!(chars_count.clone(), text::MAX_CHARS_COUNT_VAR, |c, m| formatx!(
352                            "{c}/{m}"
353                        ));
354                        font_color = text::FONT_COLOR_VAR.map(|c| colors::GRAY.with_alpha(10.pct()).mix_normal(*c));
355                        font_size = 0.8.em();
356                        align = Align::BOTTOM_END;
357                        offset = (-4, 2.dip() + 100.pct());
358                    })
359                } else {
360                    WidgetFn::nil()
361                }
362            });
363
364            margin = (0, 0, 1.2.em(), 0);
365        }
366    }
367}
368
369/// Adorner property used by [`FieldStyle`] to show data info, warn and error.
370///
371/// [`FieldStyle`]: struct@FieldStyle
372#[property(FILL, default(WidgetFn::nil()))]
373pub fn data_notes_adorner_fn(child: impl IntoUiNode, adorner_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
374    zng_wgt_layer::adorner_fn(child, adorner_fn)
375}
376
377/// Adorner property used by [`FieldStyle`] to show the count/max indicator.
378///
379/// [`FieldStyle`]: struct@FieldStyle
380#[property(FILL, default(WidgetFn::nil()))]
381pub fn max_chars_count_adorner_fn(child: impl IntoUiNode, adorner_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
382    zng_wgt_layer::adorner_fn(child, adorner_fn)
383}
384
385context_var! {
386    /// Text shown under a [`FieldStyle`] when it has no data notes (no info, warn or error).
387    ///
388    /// [`FieldStyle`]: struct@FieldStyle
389    pub static FIELD_HELP_VAR: Txt = "";
390}
391
392/// Text shown under a [`FieldStyle`] when it has no data notes (no info, warn or error).
393///
394/// [`FieldStyle`]: struct@FieldStyle
395#[property(CONTEXT, default(""))]
396pub fn field_help(child: impl IntoUiNode, help: impl IntoVar<Txt>) -> UiNode {
397    with_context_var(child, FIELD_HELP_VAR, help)
398}