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}