From 1e63bfd65798efda95d88b874586f0f514242a9c Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Fri, 7 Nov 2025 13:34:18 +0100 Subject: [PATCH 1/6] Improve accessibility and testability of `ComboBox` (#7658) Changed it to use labeled_by to avoid kittest finding the label when searching for the ComboBox and also set the value so a screen reader will know what's selected. --- crates/egui/src/containers/combo_box.rs | 18 ++++++++++-------- tests/egui_tests/tests/regression_tests.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/egui/src/containers/combo_box.rs b/crates/egui/src/containers/combo_box.rs index 8195024fb..c4097f803 100644 --- a/crates/egui/src/containers/combo_box.rs +++ b/crates/egui/src/containers/combo_box.rs @@ -239,7 +239,7 @@ impl ComboBox { let mut ir = combo_box_dyn( ui, button_id, - selected_text, + selected_text.clone(), menu_contents, icon, wrap_mode, @@ -247,14 +247,16 @@ impl ComboBox { popup_style, (width, height), ); + ir.response.widget_info(|| { + let mut info = WidgetInfo::new(WidgetType::ComboBox); + info.enabled = ui.is_enabled(); + info.current_text_value = Some(selected_text.text().to_owned()); + info + }); if let Some(label) = label { - ir.response.widget_info(|| { - WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), label.text()) - }); - ir.response |= ui.label(label); - } else { - ir.response - .widget_info(|| WidgetInfo::labeled(WidgetType::ComboBox, ui.is_enabled(), "")); + let label_response = ui.label(label); + ir.response = ir.response.labelled_by(label_response.id); + ir.response |= label_response; } ir }) diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index a407864e7..1ee197cb5 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -61,3 +61,17 @@ fn text_edit_rtl() { harness.snapshot(format!("text_edit_rtl_{i}")); } } + +#[test] +fn combobox_should_have_value() { + let harness = Harness::new_ui(|ui| { + egui::ComboBox::from_label("Select an option") + .selected_text("Option 1") + .show_ui(ui, |_ui| {}); + }); + + assert_eq!( + harness.get_by_label("Select an option").value().as_deref(), + Some("Option 1") + ); +} From 04913ed651203906622c15588166bb648ab4f1fb Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Fri, 7 Nov 2025 13:34:25 +0100 Subject: [PATCH 2/6] Add some more text edit tests (#7608) Adds tests to text the clip option in text edits and how it behaves with a placeholder --------- Co-authored-by: lucasmerlin <8009393+lucasmerlin@users.noreply.github.com> --- .../tests/snapshots/layout/text_edit_clip.png | 3 ++ .../snapshots/layout/text_edit_no_clip.png | 3 ++ .../layout/text_edit_placeholder_clip.png | 3 ++ .../snapshots/visuals/text_edit_clip.png | 3 ++ .../snapshots/visuals/text_edit_no_clip.png | 3 ++ .../visuals/text_edit_placeholder_clip.png | 3 ++ tests/egui_tests/tests/test_widgets.rs | 33 ++++++++++++++++++- 7 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/egui_tests/tests/snapshots/layout/text_edit_clip.png create mode 100644 tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png create mode 100644 tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png create mode 100644 tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png create mode 100644 tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png create mode 100644 tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_clip.png b/tests/egui_tests/tests/snapshots/layout/text_edit_clip.png new file mode 100644 index 000000000..0c4327b58 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f107d95fee9a5fb5fbfd2422452e1820738a84c81774587dbfa8153e91e4c73 +size 414552 diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png b/tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png new file mode 100644 index 000000000..ecc6efa8b --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_no_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c1aebada9349f8cb4046469b0a6f9796a21f88b6724bd85cd832a40b8007409 +size 540527 diff --git a/tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png b/tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png new file mode 100644 index 000000000..780fec82f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/layout/text_edit_placeholder_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:685de2e33ff26aafa87426bcda18bb9963c2deb2a811cd0aae4450af0e245a06 +size 390735 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png new file mode 100644 index 000000000..f44900fa5 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf4236b1a8f63d184cd780c334d9f996e4d47817a96a29f0d81658d2d897597f +size 10529 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png new file mode 100644 index 000000000..7329c49cf --- /dev/null +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_no_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7a63953853f526b83f80d63335b03e60258ea9a3416d19f8ed57d746b5c551d +size 21557 diff --git a/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png b/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png new file mode 100644 index 000000000..e1a15cf7d --- /dev/null +++ b/tests/egui_tests/tests/snapshots/visuals/text_edit_placeholder_clip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f7d802a4de7e30f8d254cab6d9ca127866c104c1738103bc4a579917e8f42d3 +size 9850 diff --git a/tests/egui_tests/tests/test_widgets.rs b/tests/egui_tests/tests/test_widgets.rs index 6a75e36a3..440b1939b 100644 --- a/tests/egui_tests/tests/test_widgets.rs +++ b/tests/egui_tests/tests/test_widgets.rs @@ -2,7 +2,7 @@ use egui::accesskit::Role; use egui::load::SizedTexture; use egui::{ Align, AtomExt as _, AtomLayout, Button, Color32, ColorImage, Direction, DragValue, Event, - Grid, IntoAtoms as _, Layout, PointerButton, Response, Slider, Stroke, StrokeKind, + Grid, IntoAtoms as _, Layout, PointerButton, Response, Slider, Stroke, StrokeKind, TextEdit, TextWrapMode, TextureHandle, TextureOptions, Ui, UiBuilder, Vec2, Widget as _, include_image, }; use egui_kittest::kittest::{Queryable as _, by}; @@ -84,6 +84,37 @@ fn widget_tests() { }, &mut results, ); + test_widget( + "text_edit_clip", + |ui| { + ui.spacing_mut().text_edit_width = 45.0; + TextEdit::singleline(&mut "This is a very very long text".to_owned()) + .clip_text(true) + .ui(ui) + }, + &mut results, + ); + test_widget( + "text_edit_no_clip", + |ui| { + ui.spacing_mut().text_edit_width = 45.0; + TextEdit::singleline(&mut "This is a very very long text".to_owned()) + .clip_text(false) + .ui(ui) + }, + &mut results, + ); + test_widget( + "text_edit_placeholder_clip", + |ui| { + ui.spacing_mut().text_edit_width = 45.0; + TextEdit::singleline(&mut String::new()) + .hint_text("This is a very very long placeholder") + .clip_text(true) + .ui(ui) + }, + &mut results, + ); test_widget( "slider", From 1d4d14f18ee402e6eb333cf9c2ddaf2453aaed69 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 7 Nov 2025 14:43:49 +0100 Subject: [PATCH 3/6] Hide scroll bars when dragging other things (#7689) This closes a small visual glitch where scroll bars would show up when dragging something unrelated, like a slider or a panel side. --- crates/egui/src/containers/scroll_area.rs | 131 ++++++++++++---------- 1 file changed, 74 insertions(+), 57 deletions(-) diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index db64ba03b..d63a2ab59 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -3,8 +3,8 @@ use std::ops::{Add, AddAssign, BitOr, BitOrAssign}; use crate::{ - Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Sense, Ui, UiBuilder, UiKind, - UiStackInfo, Vec2, Vec2b, emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, + Context, CursorIcon, Id, NumExt as _, Pos2, Rangef, Rect, Response, Sense, Ui, UiBuilder, + UiKind, UiStackInfo, Vec2, Vec2b, emath, epaint, lerp, pass_state, pos2, remap, remap_clamp, }; #[derive(Clone, Copy, Debug)] @@ -659,6 +659,9 @@ struct Prepared { /// not for us to handle so we save it and restore it after this [`ScrollArea`] is done. saved_scroll_target: [Option; 2], + /// The response from dragging the background (if enabled) + background_drag_response: Option, + animated: bool, } @@ -772,70 +775,72 @@ impl ScrollArea { let viewport = Rect::from_min_size(Pos2::ZERO + state.offset, inner_size); let dt = ui.input(|i| i.stable_dt).at_most(0.1); - if scroll_source.drag - && ui.is_enabled() - && (state.content_is_too_large[0] || state.content_is_too_large[1]) - { - // Drag contents to scroll (for touch screens mostly). - // We must do this BEFORE adding content to the `ScrollArea`, - // or we will steal input from the widgets we contain. - let content_response_option = state - .interact_rect - .map(|rect| ui.interact(rect, id.with("area"), Sense::drag())); + let background_drag_response = + if scroll_source.drag && ui.is_enabled() && state.content_is_too_large.any() { + // Drag contents to scroll (for touch screens mostly). + // We must do this BEFORE adding content to the `ScrollArea`, + // or we will steal input from the widgets we contain. + let content_response_option = state + .interact_rect + .map(|rect| ui.interact(rect, id.with("area"), Sense::drag())); - if content_response_option - .as_ref() - .is_some_and(|response| response.dragged()) - { - for d in 0..2 { - if direction_enabled[d] { - ui.input(|input| { - state.offset[d] -= input.pointer.delta()[d]; - }); - state.scroll_stuck_to_end[d] = false; - state.offset_target[d] = None; - } - } - } else { - // Apply the cursor velocity to the scroll area when the user releases the drag. if content_response_option .as_ref() - .is_some_and(|response| response.drag_stopped()) + .is_some_and(|response| response.dragged()) { - state.vel = - direction_enabled.to_vec2() * ui.input(|input| input.pointer.velocity()); - } - for d in 0..2 { - // Kinetic scrolling - let stop_speed = 20.0; // Pixels per second. - let friction_coeff = 1000.0; // Pixels per second squared. + for d in 0..2 { + if direction_enabled[d] { + ui.input(|input| { + state.offset[d] -= input.pointer.delta()[d]; + }); + state.scroll_stuck_to_end[d] = false; + state.offset_target[d] = None; + } + } + } else { + // Apply the cursor velocity to the scroll area when the user releases the drag. + if content_response_option + .as_ref() + .is_some_and(|response| response.drag_stopped()) + { + state.vel = direction_enabled.to_vec2() + * ui.input(|input| input.pointer.velocity()); + } + for d in 0..2 { + // Kinetic scrolling + let stop_speed = 20.0; // Pixels per second. + let friction_coeff = 1000.0; // Pixels per second squared. - let friction = friction_coeff * dt; - if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed { - state.vel[d] = 0.0; - } else { - state.vel[d] -= friction * state.vel[d].signum(); - // Offset has an inverted coordinate system compared to - // the velocity, so we subtract it instead of adding it - state.offset[d] -= state.vel[d] * dt; - ctx.request_repaint(); + let friction = friction_coeff * dt; + if friction > state.vel[d].abs() || state.vel[d].abs() < stop_speed { + state.vel[d] = 0.0; + } else { + state.vel[d] -= friction * state.vel[d].signum(); + // Offset has an inverted coordinate system compared to + // the velocity, so we subtract it instead of adding it + state.offset[d] -= state.vel[d] * dt; + ctx.request_repaint(); + } } } - } - // Set the desired mouse cursors. - if let Some(response) = content_response_option { - if response.dragged() { - if let Some(cursor) = on_drag_cursor { - response.on_hover_cursor(cursor); + // Set the desired mouse cursors. + if let Some(response) = &content_response_option { + if response.dragged() + && let Some(cursor) = on_drag_cursor + { + ui.ctx().set_cursor_icon(cursor); + } else if response.hovered() + && let Some(cursor) = on_hover_cursor + { + ui.ctx().set_cursor_icon(cursor); } - } else if response.hovered() - && let Some(cursor) = on_hover_cursor - { - response.on_hover_cursor(cursor); } - } - } + + content_response_option + } else { + None + }; // Scroll with an animation if we have a target offset (that hasn't been cleared by the code // above). @@ -888,6 +893,7 @@ impl ScrollArea { wheel_scroll_multiplier, stick_to_end, saved_scroll_target, + background_drag_response, animated, } } @@ -1003,6 +1009,7 @@ impl Prepared { wheel_scroll_multiplier, stick_to_end, saved_scroll_target, + background_drag_response, animated, } = self; @@ -1118,7 +1125,16 @@ impl Prepared { ); let max_offset = content_size - inner_rect.size(); - let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect); + + // Drag-to-scroll? + let is_dragging_background = background_drag_response + .as_ref() + .is_some_and(|r| r.dragged()); + + let is_hovering_outer_rect = ui.rect_contains_pointer(outer_rect) + && ui.ctx().dragged_id().is_none() + || is_dragging_background; + if scroll_source.mouse_wheel && ui.is_enabled() && is_hovering_outer_rect { let always_scroll_enabled_direction = ui.style().always_scroll_the_only_direction && direction_enabled[0] != direction_enabled[1]; @@ -1204,6 +1220,7 @@ impl Prepared { let is_hovering_bar_area = is_hovering_outer_rect && ui.rect_contains_pointer(max_bar_rect) + && !is_dragging_background || state.scroll_bar_interaction[d]; let is_hovering_bar_area_t = ui From d8dcb316739dbaa520d0b8ed11788d37ee888fa3 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 7 Nov 2025 14:46:09 +0100 Subject: [PATCH 4/6] `kittest`: add drag-and-drop helpers (#7690) --- crates/egui_kittest/src/lib.rs | 57 ++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index c8112f47b..33a188ea4 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -8,9 +8,7 @@ mod builder; mod snapshot; #[cfg(feature = "snapshot")] -pub use snapshot::*; -use std::fmt::{Debug, Display, Formatter}; -use std::time::Duration; +pub use crate::snapshot::*; mod app_kind; mod node; @@ -20,19 +18,26 @@ mod texture_to_image; #[cfg(feature = "wgpu")] pub mod wgpu; -pub use kittest; +// re-exports: +pub use { + self::{builder::*, node::*, renderer::*}, + kittest, +}; + +use std::{ + fmt::{Debug, Display, Formatter}, + time::Duration, +}; + +use egui::{ + Color32, Key, Modifiers, PointerButton, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId, + epaint::{ClippedShape, RectShape}, + style::ScrollAnimation, +}; +use kittest::Queryable; use crate::app_kind::AppKind; -pub use builder::*; -pub use node::*; -pub use renderer::*; - -use egui::epaint::{ClippedShape, RectShape}; -use egui::style::ScrollAnimation; -use egui::{Color32, Key, Modifiers, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId}; -use kittest::Queryable; - #[derive(Debug, Clone)] pub struct ExceededMaxStepsError { pub max_steps: u64, @@ -598,6 +603,32 @@ impl<'a, State> Harness<'a, State> { self.key_combination_modifiers(modifiers, &[key]); } + /// Move mouse cursor to this position. + pub fn hover_at(&self, pos: egui::Pos2) { + self.event(egui::Event::PointerMoved(pos)); + } + + /// Start dragging from a position. + pub fn drag_at(&self, pos: egui::Pos2) { + self.event(egui::Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + } + + /// Stop dragging and remove cursor. + pub fn drop_at(&self, pos: egui::Pos2) { + self.event(egui::Event::PointerButton { + pos, + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + self.remove_cursor(); + } + /// Remove the cursor from the screen. /// /// Will fire a [`egui::Event::PointerGone`] event. From fa4cfec777c9ea21b8643f77b6a26561da44aad0 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Fri, 7 Nov 2025 15:34:36 +0100 Subject: [PATCH 5/6] Change text color of selected text (#7691) Selected text now gets the color of `visuals.selection.stroke.color`. This means you can have inverted colors for selected text, like in the new test: image It also means the color of selected text in labels matches that of the text color of selected buttons. --- crates/egui/src/style.rs | 3 ++ crates/egui/src/text_selection/visuals.rs | 31 +++++++++++++++++-- crates/egui_demo_lib/tests/misc.rs | 26 ++++++++++++++-- .../image_dark_x1.00.png | 0 .../image_dark_x1.41.png | 0 .../image_dark_x2.00.png | 0 .../image_light_x1.00.png | 0 .../image_light_x1.41.png | 0 .../image_light_x2.00.png | 0 .../tests/snapshots/text_selection.png | 3 ++ crates/emath/src/rect.rs | 3 +- crates/epaint/src/text/text_layout.rs | 9 ++++-- crates/epaint/src/text/text_layout_types.rs | 3 ++ 13 files changed, 70 insertions(+), 8 deletions(-) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_dark_x1.00.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_dark_x1.41.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_dark_x2.00.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_light_x1.00.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_light_x1.41.png (100%) rename crates/egui_demo_lib/tests/snapshots/{image_blending => italics}/image_light_x2.00.png (100%) create mode 100644 crates/egui_demo_lib/tests/snapshots/text_selection.png diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 454fc6d89..9982c05bb 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -1129,7 +1129,10 @@ impl Visuals { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct Selection { + /// Background color behind selected text and other selectable buttons. pub bg_fill: Color32, + + /// Color of selected text. pub stroke: Stroke, } diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index 0f6d54abd..50bb1a34d 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -25,7 +25,9 @@ pub fn paint_text_selection( // and so we need to clone it if it is shared: let galley: &mut Galley = Arc::make_mut(galley); - let color = visuals.selection.bg_fill; + let background_color = visuals.selection.bg_fill; + let text_color = visuals.selection.stroke.color; + let [min, max] = cursor_range.sorted_cursors(); let min = galley.layout_from_cursor(min); let max = galley.layout_from_cursor(max); @@ -53,6 +55,31 @@ pub fn paint_text_selection( let rect = Rect::from_min_max(pos2(left, 0.0), pos2(right, row.size.y)); let mesh = &mut row.visuals.mesh; + if !row.glyphs.is_empty() { + // Change color of the selected text: + let first_glyph_index = if ri == min.row { min.column } else { 0 }; + let last_glyph_index = if ri == max.row { + max.column + } else { + row.glyphs.len() - 1 + }; + + let first_vertex_index = row + .glyphs + .get(first_glyph_index) + .map_or(row.visuals.glyph_vertex_range.start, |g| { + g.first_vertex as _ + }); + let last_vertex_index = row + .glyphs + .get(last_glyph_index) + .map_or(row.visuals.glyph_vertex_range.end, |g| g.first_vertex as _); + + for vi in first_vertex_index..last_vertex_index { + mesh.vertices[vi].color = text_color; + } + } + // Time to insert the selection rectangle into the row mesh. // It should be on top (after) of any background in the galley, // but behind (before) any glyphs. The row visuals has this information: @@ -60,7 +87,7 @@ pub fn paint_text_selection( // Start by appending the selection rectangle to end of the mesh, as two triangles (= 6 indices): let num_indices_before = mesh.indices.len(); - mesh.add_colored_rect(rect, color); + mesh.add_colored_rect(rect, background_color); assert_eq!( num_indices_before + 6, mesh.indices.len(), diff --git a/crates/egui_demo_lib/tests/misc.rs b/crates/egui_demo_lib/tests/misc.rs index 395baceb6..af8858bca 100644 --- a/crates/egui_demo_lib/tests/misc.rs +++ b/crates/egui_demo_lib/tests/misc.rs @@ -1,4 +1,5 @@ -use egui_kittest::Harness; +use egui::{Color32, accesskit::Role}; +use egui_kittest::{Harness, kittest::Queryable as _}; #[test] fn test_kerning() { @@ -42,7 +43,7 @@ fn test_italics() { harness.run(); harness.fit_contents(); harness.snapshot(format!( - "image_blending/image_{theme}_x{pixels_per_point:.2}", + "italics/image_{theme}_x{pixels_per_point:.2}", theme = match theme { egui::Theme::Dark => "dark", egui::Theme::Light => "light", @@ -51,3 +52,24 @@ fn test_italics() { } } } + +#[test] +fn test_text_selection() { + let mut harness = Harness::builder().build_ui(|ui| { + let visuals = ui.visuals_mut(); + visuals.selection.bg_fill = Color32::LIGHT_GREEN; + visuals.selection.stroke.color = Color32::DARK_BLUE; + + ui.label("Some varied ☺ text :)\nAnd it has a second line!"); + }); + harness.run(); + harness.fit_contents(); + + // Drag to select text: + let label = harness.get_by_role(Role::Label); + harness.drag_at(label.rect().lerp_inside([0.2, 0.25])); + harness.drop_at(label.rect().lerp_inside([0.6, 0.75])); + harness.run(); + + harness.snapshot("text_selection"); +} diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.41.png b/crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.41.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x1.41.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_dark_x1.41.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x2.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_dark_x2.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_dark_x2.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_dark_x2.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.41.png b/crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.41.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x1.41.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_light_x1.41.png diff --git a/crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x2.00.png b/crates/egui_demo_lib/tests/snapshots/italics/image_light_x2.00.png similarity index 100% rename from crates/egui_demo_lib/tests/snapshots/image_blending/image_light_x2.00.png rename to crates/egui_demo_lib/tests/snapshots/italics/image_light_x2.00.png diff --git a/crates/egui_demo_lib/tests/snapshots/text_selection.png b/crates/egui_demo_lib/tests/snapshots/text_selection.png new file mode 100644 index 000000000..78ebc0dbf --- /dev/null +++ b/crates/egui_demo_lib/tests/snapshots/text_selection.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14f253fedc94985ff1431f1016d901d747e1f9948531cc6350f6615649f29056 +size 4862 diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index b46fc43ca..81729713b 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -449,7 +449,8 @@ impl Rect { /// Linearly interpolate so that `[0, 0]` is [`Self::min`] and /// `[1, 1]` is [`Self::max`]. #[inline] - pub fn lerp_inside(&self, t: Vec2) -> Pos2 { + pub fn lerp_inside(&self, t: impl Into) -> Pos2 { + let t = t.into(); Pos2 { x: lerp(self.min.x..=self.max.x, t.x), y: lerp(self.min.y..=self.max.y, t.y), diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index b1fe895da..1db56731d 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -230,6 +230,7 @@ fn layout_section( font_ascent: font_metrics.ascent, uv_rect: glyph_alloc.uv_rect, section_index, + first_vertex: 0, // filled in later }); paragraph.cursor_x_px += glyph_alloc.advance_width_px; @@ -531,6 +532,7 @@ fn replace_last_glyph_with_overflow_character( font_ascent: font_metrics.ascent, uv_rect: replacement_glyph_alloc.uv_rect, section_index, + first_vertex: 0, // filled in later }); return; } @@ -748,7 +750,7 @@ fn tessellate_row( point_scale: PointScale, job: &LayoutJob, format_summary: &FormatSummary, - row: &Row, + row: &mut Row, ) -> RowVisuals { if row.glyphs.is_empty() { return Default::default(); @@ -843,8 +845,9 @@ fn add_row_backgrounds(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh end_run(run_start.take(), last_rect.right()); } -fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &Row, mesh: &mut Mesh) { - for glyph in &row.glyphs { +fn tessellate_glyphs(point_scale: PointScale, job: &LayoutJob, row: &mut Row, mesh: &mut Mesh) { + for glyph in &mut row.glyphs { + glyph.first_vertex = mesh.vertices.len() as u32; let uv_rect = glyph.uv_rect; if !uv_rect.is_nothing() { let mut left_top = glyph.pos + uv_rect.offset; diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index d87f9a579..f3963394a 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -701,6 +701,9 @@ pub struct Glyph { /// enable the paragraph-concat optimization path without having to /// adjust `section_index` when concatting. pub(crate) section_index: u32, + + /// Which is our first vertex in [`RowVisuals::mesh`]. + pub first_vertex: u32, } impl Glyph { From 93425ae06b8ea3c4f8f8cc9dc01851c37876495b Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 10 Nov 2025 16:34:58 +0100 Subject: [PATCH 6/6] Allow multiple atoms in `Button::shortcut_text` and `right_text` (#7696) Useful when intermixing text and icons (e.g. for modifiers) --- crates/egui/src/widgets/button.rs | 25 ++++++++++++------- .../tests/snapshots/button_shortcut.png | 3 +++ tests/egui_tests/tests/test_atoms.rs | 11 ++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 tests/egui_tests/tests/snapshots/button_shortcut.png diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index af31b40af..ccb1db69f 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -223,22 +223,29 @@ impl<'a> Button<'a> { /// /// See also [`Self::right_text`]. #[inline] - pub fn shortcut_text(mut self, shortcut_text: impl Into>) -> Self { - let mut atom = shortcut_text.into(); - atom.kind = match atom.kind { - AtomKind::Text(text) => AtomKind::Text(text.weak()), - other => other, - }; + pub fn shortcut_text(mut self, shortcut_text: impl IntoAtoms<'a>) -> Self { self.layout.push_right(Atom::grow()); - self.layout.push_right(atom); + + for mut atom in shortcut_text.into_atoms() { + atom.kind = match atom.kind { + AtomKind::Text(text) => AtomKind::Text(text.weak()), + other => other, + }; + self.layout.push_right(atom); + } + self } /// Show some text on the right side of the button. #[inline] - pub fn right_text(mut self, right_text: impl Into>) -> Self { + pub fn right_text(mut self, right_text: impl IntoAtoms<'a>) -> Self { self.layout.push_right(Atom::grow()); - self.layout.push_right(right_text.into()); + + for atom in right_text.into_atoms() { + self.layout.push_right(atom); + } + self } diff --git a/tests/egui_tests/tests/snapshots/button_shortcut.png b/tests/egui_tests/tests/snapshots/button_shortcut.png new file mode 100644 index 000000000..7f39196b8 --- /dev/null +++ b/tests/egui_tests/tests/snapshots/button_shortcut.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5befd84158b582c79a968f36e43c7017187b364824eb4470b048d133e62f9360 +size 1600 diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index cf2abbe1a..6f4b694e6 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -108,3 +108,14 @@ fn test_intrinsic_size() { } } } + +#[test] +fn test_button_shortcut_text() { + let mut harness = HarnessBuilder::default().build_ui(|ui| { + ui.add(egui::Button::new("Click me").shortcut_text(("1", "2", "3"))); + }); + harness.run(); + harness.fit_contents(); + + harness.snapshot("button_shortcut"); +}