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