diff --git a/crates/egui/src/atomics/atom_layout.rs b/crates/egui/src/atomics/atom_layout.rs index 1b44c986b..2960a4161 100644 --- a/crates/egui/src/atomics/atom_layout.rs +++ b/crates/egui/src/atomics/atom_layout.rs @@ -1,7 +1,7 @@ use crate::atomics::ATOMS_SMALL_VEC_SIZE; use crate::{ AtomKind, Atoms, FontSelection, Frame, Id, Image, IntoAtoms, Response, Sense, SizedAtom, - SizedAtomKind, Ui, Widget, + SizedAtomKind, Stroke, Ui, Widget, text_selection::LabelSelectionState, }; use emath::{Align2, GuiRounding as _, NumExt as _, Rect, Vec2}; use epaint::text::TextWrapMode; @@ -35,6 +35,7 @@ pub struct AtomLayout<'a> { gap: Option, pub(crate) frame: Frame, pub(crate) sense: Sense, + selectable: bool, fallback_text_color: Option, fallback_font: Option, min_size: Vec2, @@ -57,6 +58,7 @@ impl<'a> AtomLayout<'a> { gap: None, frame: Frame::default(), sense: Sense::hover(), + selectable: false, fallback_text_color: None, fallback_font: None, min_size: Vec2::ZERO, @@ -89,6 +91,18 @@ impl<'a> AtomLayout<'a> { self } + /// Make the text in this layout selectable with the mouse. + /// + /// This is opt-in (default `false`): [`AtomLayout`] backs widgets like + /// [`crate::Button`] and [`crate::Checkbox`] whose labels should not be + /// selectable, so enabling it unconditionally would break them. When enabled, + /// the layout also senses clicks and drags so the selection can be made. + #[inline] + pub fn selectable(mut self, selectable: bool) -> Self { + self.selectable = selectable; + self + } + /// Set the fallback (default) text color. /// /// Default: [`crate::Visuals::text_color`] @@ -187,7 +201,8 @@ impl<'a> AtomLayout<'a> { mut atoms, gap, frame, - sense, + mut sense, + selectable, fallback_text_color, min_size, mut max_size, @@ -198,6 +213,19 @@ impl<'a> AtomLayout<'a> { let fallback_font = fallback_font.unwrap_or_default(); + if selectable { + // Mirror `Label`: sense clicks and drags so the text can be selected, + // but don't take keyboard focus on TAB. + let allow_drag_to_select = ui.input(|i| !i.has_touch_screen()); + let mut select_sense = if allow_drag_to_select { + Sense::click_and_drag() + } else { + Sense::click() + }; + select_sense -= Sense::FOCUSABLE; + sense |= select_sense; + } + let wrap_mode = wrap_mode.unwrap_or_else(|| ui.wrap_mode()); // If the TextWrapMode is not Extend, ensure there is some item marked as `shrink`. @@ -331,6 +359,7 @@ impl<'a> AtomLayout<'a> { desired_size, align2, gap, + selectable, } } } @@ -347,6 +376,7 @@ pub struct AllocatedAtomLayout<'a> { desired_size: Vec2, align2: Align2, gap: f32, + selectable: bool, } impl<'atom> AllocatedAtomLayout<'atom> { @@ -434,6 +464,7 @@ impl<'atom> AllocatedAtomLayout<'atom> { desired_size, align2, gap, + selectable, } = self; let inner_rect = response.rect - self.frame.total_margin(); @@ -476,7 +507,21 @@ impl<'atom> AllocatedAtomLayout<'atom> { match sized.kind { SizedAtomKind::Text(galley) => { - ui.painter().galley(rect.min, galley, fallback_text_color); + if selectable { + // Route through the label selection machinery, which also + // paints the galley. `Stroke::NONE` keeps the rendering + // identical to the non-selectable path (no focus underline). + LabelSelectionState::label_text_selection( + ui, + &response.response, + rect.min, + galley, + fallback_text_color, + Stroke::NONE, + ); + } else { + ui.painter().galley(rect.min, galley, fallback_text_color); + } } SizedAtomKind::Image { image, size: _ } => { image.paint_at(ui, rect); diff --git a/tests/egui_tests/tests/test_atoms.rs b/tests/egui_tests/tests/test_atoms.rs index f7e0a4af1..cb0d89619 100644 --- a/tests/egui_tests/tests/test_atoms.rs +++ b/tests/egui_tests/tests/test_atoms.rs @@ -140,3 +140,94 @@ fn test_atom_letter_spacing() { harness.snapshot("atom_letter_spacing"); } + +/// `AtomLayout::selectable(true)` should opt the layout into click+drag sensing +/// so its text can be selected, while the default layout stays inert. +/// See . +#[test] +fn test_atom_selectable_senses_click_and_drag() { + use egui::{AtomLayout, Sense}; + + let mut captured = (Sense::hover(), Sense::hover()); + { + let mut harness = HarnessBuilder::default().build_ui(|ui| { + let selectable = AtomLayout::new("selectable").selectable(true).show(ui); + let default = AtomLayout::new("default").show(ui); + captured = (selectable.response.sense, default.response.sense); + }); + harness.run(); + } + + let (selectable_sense, default_sense) = captured; + assert!( + selectable_sense.senses_click() && selectable_sense.senses_drag(), + "a selectable AtomLayout should sense clicks and drags" + ); + assert!( + !default_sense.senses_drag(), + "a non-selectable AtomLayout should stay inert" + ); +} + +/// Selecting the text of a `selectable` [`egui::AtomLayout`] and copying it should +/// yield the text, while a non-selectable one yields nothing. +/// See . +#[test] +fn test_atom_selectable_text_can_be_copied() { + use egui::{AtomLayout, Event, Modifiers, OutputCommand, PointerButton, Pos2, Rect}; + use std::cell::Cell; + + fn copied_text(selectable: bool) -> Option { + let rect_cell = Cell::new(Rect::NOTHING); + let mut harness = HarnessBuilder::default() + .with_size(Vec2::new(400.0, 100.0)) + .build_ui(|ui| { + let response = AtomLayout::new("selectable atoms") + .selectable(selectable) + .show(ui); + rect_cell.set(response.response.rect); + }); + harness.run(); + + let rect = rect_cell.get(); + let left = Pos2::new(rect.left() + 1.0, rect.center().y); + let right = Pos2::new(rect.right() - 1.0, rect.center().y); + + // Press at the start of the text and drag to the end to select all of it. + harness.event(Event::PointerMoved(left)); + harness.event(Event::PointerButton { + pos: left, + button: PointerButton::Primary, + pressed: true, + modifiers: Modifiers::NONE, + }); + harness.run(); + harness.event(Event::PointerMoved(right)); + harness.run(); + + // Copy, then read back the clipboard command produced by this frame. + harness.event(Event::Copy); + harness.step(); + + harness + .output() + .platform_output + .commands + .iter() + .find_map(|cmd| match cmd { + OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) + } + + assert_eq!( + copied_text(true).as_deref(), + Some("selectable atoms"), + "selectable atom text should be copyable after selecting it" + ); + assert_eq!( + copied_text(false), + None, + "non-selectable atom text should not be selectable" + ); +}