Skip to main content

multiline_text_input/
multiline_text_input.rs

1//! Demonstrates a single, minimal multiline [`EditableText`] widget.
2
3use bevy::color::palettes::css::DARK_SLATE_GRAY;
4use bevy::color::palettes::tailwind::SLATE_300;
5use bevy::input::keyboard::{Key, KeyboardInput};
6use bevy::input_focus::tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin};
7use bevy::input_focus::{AutoFocus, FocusCause, FocusedInput, InputFocus};
8use bevy::prelude::*;
9use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle, TextLayoutInfo};
10use bevy::ui_widgets::TextInput;
11use bevy::ui_widgets::{
12    popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide},
13    Activate, Button, MenuAction, MenuButton, MenuEvent, MenuFocusState, MenuItem, MenuPopup,
14    SelectAllOnFocus,
15};
16
17fn main() {
18    App::new()
19        .add_plugins((DefaultPlugins, TabNavigationPlugin))
20        .add_systems(Startup, setup)
21        .add_systems(Update, update_input_scrollbar)
22        .run();
23}
24
25#[derive(Component)]
26struct MultilineInput;
27
28#[derive(Component)]
29struct VisibleLinesInput;
30
31#[derive(Component)]
32struct FontSizeInput;
33
34#[derive(Component)]
35struct SelectionRadiusInput;
36
37#[derive(Component)]
38struct JustifyLabel;
39
40/// Marks the draggable thumb of the multiline input's vertical scrollbar.
41///
42/// This is a small, self-contained scrollbar built directly against
43/// [`EditableText::viewport`] and [`TextLayoutInfo::size`], since the input's scroll
44/// state isn't a [`ScrollPosition`](bevy::ui::ScrollPosition) and so can't drive the headless
45/// `Scrollbar` widget from `bevy_ui_widgets`. It has no `TabIndex` and isn't `InputFocus`able.
46#[derive(Component)]
47struct InputScrollThumb;
48
49#[derive(Component, Default)]
50struct InputScrollDragState {
51    dragging: bool,
52    drag_origin: f32,
53}
54
55fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
56    commands.spawn(Camera2d);
57
58    commands
59        .spawn(Node {
60            width: percent(100.),
61            height: percent(100.),
62            justify_content: JustifyContent::Center,
63            align_items: AlignItems::Center,
64            ..default()
65        })
66        .with_children(|parent| {
67            parent
68                .spawn((
69                    Node {
70                        flex_direction: FlexDirection::Column,
71                        align_items: AlignItems::End,
72                        row_gap: px(10.),
73                        ..default()
74                    },
75                    TabGroup::default(),
76                ))
77                .with_children(|parent| {
78                    parent
79                        .spawn(Node {
80                            display: Display::Grid,
81                            grid_template_columns: vec![
82                                RepeatedGridTrack::auto(1),
83                                RepeatedGridTrack::auto(1),
84                            ],
85                            column_gap: px(4.),
86                            ..default()
87                        })
88                        .with_children(|parent| {
89                            parent
90                                .spawn((
91                                    Node {
92                                        width: px(450.),
93                                        border: px(2.).all(),
94                                        padding: px(8.).all(),
95                                        ..default()
96                                    },
97                                    TextInput,
98                                    EditableText {
99                                        visible_lines: Some(8.),
100                                        allow_newlines: true,
101                                        ..default()
102                                    },
103                                    TextLayout {
104                                        linebreak: LineBreak::WordOrCharacter,
105                                        ..default()
106                                    },
107                                    TextCursorStyle {
108                                        color: Color::WHITE,
109                                        selected_text_color: Some(Color::BLACK),
110                                        ..default()
111                                    },
112                                    TextFont {
113                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
114                                        font_size: FontSize::Px(30.),
115                                        ..default()
116                                    },
117                                    BackgroundColor(DARK_SLATE_GRAY.into()),
118                                    BorderColor::all(SLATE_300),
119                                    MultilineInput,
120                                    TabIndex(0),
121                                    AutoFocus,
122                                ))
123                                .observe(
124                                    |on: On<FocusedInput<KeyboardInput>>,
125                                     keys: Res<ButtonInput<Key>>,
126                                     input_query: Query<&EditableText, With<MultilineInput>>| {
127                                        if !(on.input.state.is_pressed()
128                                            && on.input.logical_key == Key::Enter
129                                            && keys.pressed(Key::Control))
130                                        {
131                                            return;
132                                        }
133                                        let Ok(input) = input_query.get(on.focused_entity) else {
134                                            return;
135                                        };
136
137                                        let mut output = String::new();
138                                        output
139                                            .reserve(input.value().into_iter().map(str::len).sum());
140                                        for sub_str in input.value() {
141                                            output.push_str(sub_str);
142                                        }
143
144                                        info!("{output}");
145                                    },
146                                );
147
148                            // Vertical scrollbar for the multiline input above. Not part of the
149                            // input's tab group and has no `TabIndex`, so it's unreachable via
150                            // Tab navigation and can't take input focus.
151                            parent
152                                .spawn((
153                                    Node {
154                                        min_width: px(10.),
155                                        ..default()
156                                    },
157                                    BackgroundColor(DARK_SLATE_GRAY.into()),
158                                ))
159                                .with_children(|parent| {
160                                    parent
161                                        .spawn((
162                                            Node {
163                                                position_type: PositionType::Absolute,
164                                                width: percent(100.),
165                                                left: px(0.),
166                                                border_radius: BorderRadius::all(px(4.)),
167                                                ..default()
168                                            },
169                                            BackgroundColor(SLATE_300.into()),
170                                            Visibility::Hidden,
171                                            InputScrollThumb,
172                                            InputScrollDragState::default(),
173                                        ))
174                                        .observe(on_thumb_drag_start)
175                                        .observe(on_thumb_drag)
176                                        .observe(on_thumb_drag_end);
177                                });
178                        });
179
180                    parent
181                        .spawn((
182                            Node {
183                                flex_direction: FlexDirection::Row,
184                                column_gap: px(10.),
185                                ..default()
186                            },
187                            children![
188                                (
189                                    Text::new("visible lines:"),
190                                    TextFont {
191                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
192                                        font_size: FontSize::Px(30.),
193                                        ..default()
194                                    },
195                                ),
196                                (
197                                    Node {
198                                        width: px(100.),
199                                        border: px(2.).all(),
200                                        ..default()
201                                    },
202                                    TextFont {
203                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
204                                        font_size: FontSize::Px(30.),
205                                        ..default()
206                                    },
207                                    TextLayout {
208                                        justify: Justify::End,
209                                        ..default()
210                                    },
211                                    BackgroundColor(DARK_SLATE_GRAY.into()),
212                                    BorderColor::all(SLATE_300),
213                                    TextInput,
214                                    EditableText::new("8"),
215                                    EditableTextFilter::new(|c| c.is_ascii_digit() || c == '.'),
216                                    TextCursorStyle {
217                                        color: Color::WHITE,
218                                        selected_text_color: Some(Color::BLACK),
219                                        unfocused_selection_color: Color::NONE,
220                                        ..default()
221                                    },
222                                    SelectAllOnFocus,
223                                    VisibleLinesInput,
224                                    TabIndex(1),
225                                )
226                            ],
227                        ))
228                        .observe(
229                            |on: On<FocusedInput<KeyboardInput>>,
230                             mut query_set: ParamSet<(
231                                Query<&EditableText, With<VisibleLinesInput>>,
232                                Query<&mut EditableText, With<MultilineInput>>,
233                            )>| {
234                                if !(on.input.state.is_pressed()
235                                    && on.input.logical_key == Key::Enter)
236                                {
237                                    return;
238                                }
239
240                                let visible_lines_query = query_set.p0();
241                                let Ok(input) = visible_lines_query.get(on.original_event_target())
242                                else {
243                                    return;
244                                };
245
246                                let mut output = String::new();
247                                output.reserve(input.value().into_iter().map(str::len).sum());
248                                for sub_str in input.value() {
249                                    output.push_str(sub_str);
250                                }
251
252                                let Ok(lines) = output.parse::<f32>() else {
253                                    return;
254                                };
255
256                                let mut multiline_query = query_set.p1();
257                                let Ok(mut multiline_input) = multiline_query.single_mut() else {
258                                    return;
259                                };
260
261                                multiline_input.visible_lines = Some(lines.clamp(1., 10.));
262                            },
263                        );
264
265                    parent
266                        .spawn((
267                            Node {
268                                flex_direction: FlexDirection::Row,
269                                column_gap: px(10.),
270                                ..default()
271                            },
272                            children![
273                                (
274                                    Text::new("font size:"),
275                                    TextFont {
276                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
277                                        font_size: FontSize::Px(30.),
278                                        ..default()
279                                    },
280                                ),
281                                (
282                                    Node {
283                                        width: px(100.),
284                                        border: px(2.).all(),
285                                        ..default()
286                                    },
287                                    TextFont {
288                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
289                                        font_size: FontSize::Px(30.),
290                                        ..default()
291                                    },
292                                    TextLayout {
293                                        justify: Justify::End,
294                                        ..default()
295                                    },
296                                    BackgroundColor(DARK_SLATE_GRAY.into()),
297                                    BorderColor::all(SLATE_300),
298                                    TextInput,
299                                    EditableText::new("30"),
300                                    EditableTextFilter::new(|c| c.is_ascii_digit()),
301                                    TextCursorStyle {
302                                        color: Color::WHITE,
303                                        selected_text_color: Some(Color::BLACK),
304                                        unfocused_selection_color: Color::NONE,
305                                        ..default()
306                                    },
307                                    SelectAllOnFocus,
308                                    FontSizeInput,
309                                    TabIndex(2),
310                                )
311                            ],
312                        ))
313                        .observe(
314                            |on: On<FocusedInput<KeyboardInput>>,
315                             font_size_input_query: Query<&EditableText, With<FontSizeInput>>,
316                             mut multiline_input_font: Single<
317                                &mut TextFont,
318                                With<MultilineInput>,
319                            >| {
320                                if !(on.input.state.is_pressed()
321                                    && on.input.logical_key == Key::Enter)
322                                {
323                                    return;
324                                }
325
326                                let Ok(input) =
327                                    font_size_input_query.get(on.original_event_target())
328                                else {
329                                    return;
330                                };
331
332                                let mut output = String::new();
333                                output.reserve(input.value().into_iter().map(str::len).sum());
334                                for sub_str in input.value() {
335                                    output.push_str(sub_str);
336                                }
337
338                                let Ok(font_size) = output.parse::<f32>() else {
339                                    return;
340                                };
341
342                                multiline_input_font.font_size =
343                                    FontSize::Px(font_size.clamp(5., 50.));
344                            },
345                        );
346
347                    parent
348                        .spawn((
349                            Node {
350                                flex_direction: FlexDirection::Row,
351                                column_gap: px(10.),
352                                ..default()
353                            },
354                            children![
355                                (
356                                    Text::new("corner radius:"),
357                                    TextFont {
358                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
359                                        font_size: FontSize::Px(30.),
360                                        ..default()
361                                    },
362                                ),
363                                (
364                                    Node {
365                                        width: px(100.),
366                                        border: px(2.).all(),
367                                        ..default()
368                                    },
369                                    TextFont {
370                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
371                                        font_size: FontSize::Px(30.),
372                                        ..default()
373                                    },
374                                    TextLayout {
375                                        justify: Justify::End,
376                                        ..default()
377                                    },
378                                    BackgroundColor(DARK_SLATE_GRAY.into()),
379                                    BorderColor::all(SLATE_300),
380                                    TextInput,
381                                    EditableText::new("0"),
382                                    EditableTextFilter::new(|c| c.is_ascii_digit() || c == '.'),
383                                    TextCursorStyle {
384                                        color: Color::WHITE,
385                                        selected_text_color: Some(Color::BLACK),
386                                        ..default()
387                                    },
388                                    SelectionRadiusInput,
389                                    TabIndex(2),
390                                )
391                            ],
392                        ))
393                        .observe(
394                            |on: On<FocusedInput<KeyboardInput>>,
395                             radius_input_query: Query<
396                                &EditableText,
397                                With<SelectionRadiusInput>,
398                            >,
399                             mut cursor_style: Single<
400                                &mut TextCursorStyle,
401                                With<MultilineInput>,
402                            >| {
403                                if !(on.input.state.is_pressed()
404                                    && on.input.logical_key == Key::Enter)
405                                {
406                                    return;
407                                }
408
409                                let Ok(input) = radius_input_query.get(on.original_event_target())
410                                else {
411                                    return;
412                                };
413
414                                let mut output = String::new();
415                                output.reserve(input.value().into_iter().map(str::len).sum());
416                                for sub_str in input.value() {
417                                    output.push_str(sub_str);
418                                }
419
420                                let Ok(radius) = output.parse::<f32>() else {
421                                    return;
422                                };
423
424                                cursor_style.selection_radius = radius.clamp(0., 0.5);
425                            },
426                        );
427
428                    parent
429                        .spawn(Node::default())
430                        .observe(
431                            |on: On<MenuEvent>,
432                             mut popup: Single<
433                                (&mut Node, &mut MenuFocusState),
434                                With<MenuPopup>,
435                            >,
436                             button: Single<Entity, With<MenuButton>>,
437                             mut focus: ResMut<InputFocus>| {
438                                match on.action {
439                                    MenuAction::Open(direction) => {
440                                        popup.0.display = Display::Flex;
441                                        *popup.1 = MenuFocusState::Opening(direction);
442                                    }
443                                    MenuAction::Toggle => {
444                                        if popup.0.display == Display::None {
445                                            popup.0.display = Display::Flex;
446                                            *popup.1 = MenuFocusState::Opening(
447                                                bevy::input_focus::tab_navigation::NavAction::First,
448                                            );
449                                        } else {
450                                            popup.0.display = Display::None;
451                                        }
452                                    }
453                                    MenuAction::CloseAll => {
454                                        popup.0.display = Display::None;
455                                    }
456                                    MenuAction::FocusRoot => {
457                                        focus.set(*button, FocusCause::Navigated);
458                                    }
459                                }
460                            },
461                        )
462                        .with_children(|parent| {
463                            parent.spawn((
464                                Node {
465                                    border: px(2.).all(),
466                                    padding: px(8.).horizontal(),
467                                    ..default()
468                                },
469                                Button,
470                                MenuButton,
471                                TabIndex(4),
472                                BackgroundColor(DARK_SLATE_GRAY.into()),
473                                BorderColor::all(SLATE_300),
474                                children![(
475                                    Text::new("Justify::Left"),
476                                    TextFont {
477                                        font: asset_server.load("fonts/FiraMono-Medium.ttf").into(),
478                                        font_size: FontSize::Px(24.),
479                                        ..default()
480                                    },
481                                    JustifyLabel,
482                                )],
483                            ));
484
485                            parent
486                                .spawn((
487                                    Node {
488                                        display: Display::None,
489                                        flex_direction: FlexDirection::Column,
490                                        min_width: percent(100.),
491                                        border: px(2.).all(),
492                                        position_type: PositionType::Absolute,
493                                        ..default()
494                                    },
495                                    MenuPopup::default(),
496                                    Popover {
497                                        positions: vec![PopoverPlacement {
498                                            side: PopoverSide::Top,
499                                            align: PopoverAlign::End,
500                                            gap: 2.,
501                                        }],
502                                        ..default()
503                                    },
504                                    GlobalZIndex(1),
505                                    BackgroundColor(DARK_SLATE_GRAY.into()),
506                                    BorderColor::all(SLATE_300),
507                                ))
508                                .with_children(|parent| {
509                                    for (label, justify) in [
510                                        ("Justify::Left", Justify::Left),
511                                        ("Justify::Center", Justify::Center),
512                                        ("Justify::Right", Justify::Right),
513                                        ("Justify::Justified", Justify::Justified),
514                                        ("Justify::Start", Justify::Start),
515                                        ("Justify::End", Justify::End),
516                                    ] {
517                                        parent
518                                            .spawn((
519                                                Node {
520                                                    padding: px(8.).horizontal(),
521                                                    ..default()
522                                                },
523                                                MenuItem,
524                                                TabIndex(0),
525                                                children![(
526                                                    Text::new(label),
527                                                    TextFont {
528                                                        font: asset_server
529                                                            .load("fonts/FiraMono-Medium.ttf",)
530                                                            .into(),
531                                                        font_size: FontSize::Px(24.),
532                                                        ..default()
533                                                    },
534                                                )],
535                                            ))
536                                            .observe(
537                                                move |_: On<Activate>,
538                                                      mut layout: Single<
539                                                    &mut TextLayout,
540                                                    With<MultilineInput>,
541                                                >,
542                                                      mut selected: Single<
543                                                    &mut Text,
544                                                    With<JustifyLabel>,
545                                                >| {
546                                                    layout.justify = justify;
547                                                    selected.0 = label.into();
548                                                },
549                                            );
550                                    }
551                                });
552                        });
553                });
554        });
555}
556
557/// Sizes and positions the scrollbar thumb from the multiline input's viewport, hiding it
558/// entirely when the full text layout already fits inside the viewport.
559fn update_input_scrollbar(
560    input: Single<(&EditableText, &TextLayoutInfo), With<MultilineInput>>,
561    mut thumb: Single<(&mut Node, &mut Visibility), With<InputScrollThumb>>,
562) {
563    let viewport = &input.0.viewport;
564    let content_height = input.1.size.y;
565
566    if viewport.size.y <= 0. || content_height <= viewport.size.y {
567        thumb.1.set_if_neq(Visibility::Hidden);
568        return;
569    }
570    thumb.1.set_if_neq(Visibility::Visible);
571
572    let thumb_fraction = (viewport.size.y / content_height).clamp(0.05, 1.);
573    let max_offset = content_height - viewport.size.y;
574    let scroll_fraction = (viewport.offset.y / max_offset).clamp(0., 1.);
575
576    let height = percent(thumb_fraction * 100.);
577    let top = percent(scroll_fraction * (1. - thumb_fraction) * 100.);
578    if thumb.0.height != height {
579        thumb.0.height = height;
580    }
581    if thumb.0.top != top {
582        thumb.0.top = top;
583    }
584}
585
586fn on_thumb_drag_start(
587    mut on: On<PointerDragStart>,
588    mut thumb_query: Query<&mut InputScrollDragState, With<InputScrollThumb>>,
589    input: Single<&EditableText, With<MultilineInput>>,
590) {
591    on.propagate(false);
592    let Ok(mut drag) = thumb_query.get_mut(on.entity) else {
593        return;
594    };
595    drag.dragging = true;
596    drag.drag_origin = input.viewport.offset.y;
597}
598
599fn on_thumb_drag(
600    mut on: On<PointerDrag>,
601    thumb_query: Query<(&InputScrollDragState, &ChildOf), With<InputScrollThumb>>,
602    track_query: Query<&ComputedNode>,
603    mut input: Single<(&mut EditableText, &TextLayoutInfo), With<MultilineInput>>,
604) {
605    on.propagate(false);
606    let Ok((drag, ChildOf(track))) = thumb_query.get(on.entity) else {
607        return;
608    };
609    if !drag.dragging {
610        return;
611    }
612    let Ok(track_node) = track_query.get(*track) else {
613        return;
614    };
615    let track_height = track_node.size.y * track_node.inverse_scale_factor;
616    if track_height <= 0. {
617        return;
618    }
619
620    let content_height = input.1.size.y;
621    let max_offset = (content_height - input.0.viewport.size.y).max(0.);
622    let delta = on.distance.y / track_height * content_height;
623    input.0.viewport.offset.y = (drag.drag_origin + delta).clamp(0., max_offset);
624}
625
626fn on_thumb_drag_end(
627    mut on: On<PointerDragEnd>,
628    mut thumb_query: Query<&mut InputScrollDragState, With<InputScrollThumb>>,
629) {
630    on.propagate(false);
631    let Ok(mut drag) = thumb_query.get_mut(on.entity) else {
632        return;
633    };
634    drag.dragging = false;
635}