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::CaptureBlend {
115        filter: CaptureFilter::ContextVars {
116            exclude: {
117                let mut exclude = ContextValueSet::new();
118                Text::context_vars_set(&mut exclude);
119
120                let mut allow = ContextValueSet::new();
121                LangMix::<()>::context_vars_set(&mut allow);
122                exclude.remove_all(&allow);
123
124                exclude
125            },
126        },
127        over: false,
128    }
129}
130
131/// Text input default style.
132#[widget($crate::text_input::DefaultStyle)]
133pub struct DefaultStyle(Style);
134impl DefaultStyle {
135    fn widget_intrinsic(&mut self) {
136        use zng_wgt::border;
137        use zng_wgt_container::*;
138        use zng_wgt_fill::*;
139        use zng_wgt_input::{focus::is_focused, *};
140        use zng_wgt_layer::*;
141
142        widget_set! {
143            self;
144            replace = true;
145            padding = (7, 10);
146            cursor = CursorIcon::Text;
147            base_color = light_dark(rgb(0.88, 0.88, 0.88), rgb(0.12, 0.12, 0.12));
148            background_color = BASE_COLOR_VAR.rgba();
149            border = {
150                widths: 1,
151                sides: BASE_COLOR_VAR.shade_fct_into(0.20),
152            };
153
154            popup::context_capture = default_popup_context_capture();
155            context_menu_fn = WidgetFn::new(default_context_menu);
156            selection_toolbar_fn = WidgetFn::new(default_selection_toolbar);
157            selection_color = ACCENT_COLOR_VAR.rgba_map(|c| c.with_alpha(30.pct()));
158
159            when *#is_cap_hovered || *#is_return_focus {
160                border = {
161                    widths: 1,
162                    sides: BASE_COLOR_VAR.shade_fct_into(0.30),
163                };
164            }
165
166            when *#is_focused {
167                border = {
168                    widths: 1,
169                    sides: ACCENT_COLOR_VAR.rgba_into(),
170                };
171            }
172
173            when *#is_disabled {
174                saturate = false;
175                child_opacity = 50.pct();
176                cursor = CursorIcon::NotAllowed;
177            }
178        }
179    }
180}
181
182/// Text input style for a search field.
183#[widget($crate::SearchStyle)]
184pub struct SearchStyle(DefaultStyle);
185impl_named_style_fn!(search, SearchStyle);
186impl SearchStyle {
187    fn widget_intrinsic(&mut self) {
188        widget_set! {
189            self;
190            named_style_fn = SEARCH_STYLE_FN_VAR;
191            zng_wgt_container::padding = (7, 10, 7, 0);
192            zng_wgt_access::access_role = zng_wgt_access::AccessRole::SearchBox;
193            auto_selection = true;
194
195            zng_wgt_container::child_out_start = zng_wgt_container::Container! {
196                child = zng_wgt::ICONS.req("search");
197                zng_wgt::align = Align::CENTER;
198                zng_wgt::hit_test_mode = false;
199                zng_wgt_size_offset::size = 18;
200                zng_wgt::margin = DIRECTION_VAR.map(|d| {
201                    match d {
202                        LayoutDirection::LTR => (0, 0, 0, 6),
203                        LayoutDirection::RTL => (0, 6, 0, 0),
204                    }
205                    .into()
206                });
207            };
208
209            zng_wgt_container::child_out_end = zng_wgt_button::Button! {
210                zng_wgt::corner_radius = 0;
211                style_fn = zng_wgt_button::LightStyle!();
212                child = zng_wgt::ICONS.req("backspace");
213                focusable = false;
214                zng_wgt::visibility = zng_var::contextual_var(|| {
215                    zng_wgt_text::node::TEXT.resolved().txt.clone().map(|t| match t.is_empty() {
216                        true => Visibility::Collapsed,
217                        false => Visibility::Visible,
218                    })
219                });
220                on_click = hn!(|args| {
221                    args.propagation().stop();
222                    zng_wgt_text::cmd::EDIT_CMD
223                        .scoped(WIDGET.info().parent().unwrap().id())
224                        .notify_param(zng_wgt_text::cmd::TextEditOp::clear());
225                });
226            };
227        }
228    }
229}
230
231/// Text shown when the `txt` is empty.
232///
233/// The placeholder has the same text style as the parent widget, with 50% opacity.
234/// You can use the [`placeholder`](fn@placeholder) to use a custom widget placeholder.
235#[property(CHILD, default(""), widget_impl(TextInput, DefaultStyle))]
236pub fn placeholder_txt(child: impl IntoUiNode, txt: impl IntoVar<Txt>) -> UiNode {
237    placeholder(
238        child,
239        Text! {
240            txt;
241            zng_wgt_filter::opacity = 50.pct();
242            zng_wgt::hit_test_mode = false;
243        },
244    )
245}
246
247/// Widget shown when the `txt` is empty.
248///
249/// The `placeholder` can be any widget, the `Text!` widget is recommended.
250#[property(CHILD, widget_impl(TextInput, DefaultStyle))]
251pub fn placeholder(child: impl IntoUiNode, placeholder: impl IntoUiNode) -> UiNode {
252    let mut txt_is_empty = None;
253    zng_wgt_container::child_under(
254        child,
255        match_widget(placeholder, move |c, op| match op {
256            UiNodeOp::Init => {
257                let is_empty = zng_wgt_text::node::TEXT.resolved().txt.map(|t| t.is_empty());
258                WIDGET.sub_var_render(&is_empty);
259                txt_is_empty = Some(is_empty);
260            }
261            UiNodeOp::Deinit => {
262                txt_is_empty = None;
263            }
264            UiNodeOp::Render { frame } => {
265                c.delegated();
266                if txt_is_empty.as_ref().unwrap().get() {
267                    c.render(frame);
268                } else {
269                    frame.hide(|frame| c.render(frame));
270                }
271            }
272            UiNodeOp::RenderUpdate { update } => {
273                c.delegated();
274                if txt_is_empty.as_ref().unwrap().get() {
275                    c.render_update(update);
276                } else {
277                    update.hidden(|update| c.render_update(update));
278                }
279            }
280            _ => {}
281        }),
282    )
283}
284
285/// Text input style that shows data notes, info, warn and error.
286///
287/// You can also set the [`field_help`] property in text inputs with this style to set a text that
288/// shows in place of the data notes when there are none.
289///
290/// [`field_help`]: fn@field_help
291#[widget($crate::FieldStyle)]
292pub struct FieldStyle(DefaultStyle);
293impl_named_style_fn!(field, FieldStyle);
294impl FieldStyle {
295    fn widget_intrinsic(&mut self) {
296        let top_notes = var(DataNotes::default());
297
298        let top_level_and_txt = top_notes.map(|ns| {
299            if let Some(n) = ns.first() {
300                return (n.level(), formatx!("{ns}"));
301            }
302            (DataNoteLevel::INFO, "".into())
303        });
304        let top_txt = top_level_and_txt.map(|(_, t)| t.clone());
305        let top_color = DATA.note_color(top_level_and_txt.map(|(l, _)| *l));
306
307        let highlight = top_level_and_txt.map(|(l, _)| *l >= DataNoteLevel::WARN);
308        let adorn = merge_var!(top_txt.clone(), FIELD_HELP_VAR, |t, h| (t.is_empty(), h.is_empty()));
309
310        let chars_count = var(0usize);
311        let has_max_count = MAX_CHARS_COUNT_VAR.map(|&c| c > 0);
312
313        widget_set! {
314            self;
315            named_style_fn = FIELD_STYLE_FN_VAR;
316            zng_wgt_data::get_data_notes_top = top_notes.clone();
317            get_chars_count = chars_count.clone();
318            auto_selection = true;
319
320            foreground_highlight = {
321                offsets: -2, // -1 border plus -1 to be outside
322                widths: 1,
323                sides: merge_var!(highlight, top_color.clone(), |&h, &c| if h {
324                    c.into()
325                } else {
326                    BorderSides::hidden()
327                }),
328            };
329            data_notes_adorner_fn = adorn.map(move |&(top_txt_empty, help_empty)| {
330                if !top_txt_empty {
331                    wgt_fn!(top_txt, top_color, |_| Text! {
332                        focusable = false;
333                        txt_editable = false;
334                        txt_selectable = false;
335                        txt = top_txt.clone();
336                        font_color = top_color.clone();
337                        font_size = 0.8.em();
338                        align = Align::BOTTOM_START;
339                        margin = (0, 4);
340                        y = 2.dip() + 100.pct();
341                    })
342                } else if !help_empty {
343                    wgt_fn!(|_| Text! {
344                        focusable = false;
345                        txt_editable = false;
346                        txt_selectable = false;
347                        txt = FIELD_HELP_VAR;
348                        font_size = 0.8.em();
349                        font_color = text::FONT_COLOR_VAR.map(|c| colors::GRAY.with_alpha(10.pct()).mix_normal(*c));
350                        align = Align::BOTTOM_START;
351                        margin = (0, 4);
352                        y = 2.dip() + 100.pct();
353                    })
354                } else {
355                    WidgetFn::nil()
356                }
357            });
358
359            max_chars_count_adorner_fn = has_max_count.map(move |&has| {
360                if has {
361                    wgt_fn!(chars_count, |_| Text! {
362                        focusable = false;
363                        txt_editable = false;
364                        txt_selectable = false;
365                        txt = merge_var!(chars_count.clone(), text::MAX_CHARS_COUNT_VAR, |c, m| formatx!(
366                            "{c}/{m}"
367                        ));
368                        font_color = text::FONT_COLOR_VAR.map(|c| colors::GRAY.with_alpha(10.pct()).mix_normal(*c));
369                        font_size = 0.8.em();
370                        align = Align::BOTTOM_END;
371                        offset = (-4, 2.dip() + 100.pct());
372                    })
373                } else {
374                    WidgetFn::nil()
375                }
376            });
377
378            margin = (0, 0, 1.2.em(), 0);
379        }
380    }
381}
382
383/// Adorner property used by [`FieldStyle`] to show data info, warn and error.
384///
385/// [`FieldStyle`]: struct@FieldStyle
386#[property(FILL, default(WidgetFn::nil()))]
387pub fn data_notes_adorner_fn(child: impl IntoUiNode, adorner_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
388    zng_wgt_layer::adorner_fn(child, adorner_fn)
389}
390
391/// Adorner property used by [`FieldStyle`] to show the count/max indicator.
392///
393/// [`FieldStyle`]: struct@FieldStyle
394#[property(FILL, default(WidgetFn::nil()))]
395pub fn max_chars_count_adorner_fn(child: impl IntoUiNode, adorner_fn: impl IntoVar<WidgetFn<()>>) -> UiNode {
396    zng_wgt_layer::adorner_fn(child, adorner_fn)
397}
398
399context_var! {
400    /// Text shown under a [`FieldStyle`] when it has no data notes (no info, warn or error).
401    ///
402    /// [`FieldStyle`]: struct@FieldStyle
403    pub static FIELD_HELP_VAR: Txt = "";
404}
405
406/// Text shown under a [`FieldStyle`] when it has no data notes (no info, warn or error).
407///
408/// [`FieldStyle`]: struct@FieldStyle
409#[property(CONTEXT, default(""))]
410pub fn field_help(child: impl IntoUiNode, help: impl IntoVar<Txt>) -> UiNode {
411    with_context_var(child, FIELD_HELP_VAR, help)
412}