mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 14:49:06 -04:00
Add AtomLayout::selectable for opt-in text selection (#8224)
* Closes https://github.com/emilk/egui/issues/8217 * [x] I have followed the instructions in the PR template ## What `AllocatedAtomLayout::paint` painted text via `ui.painter().galley(...)`, so text in atom-based widgets could never be selected. As discussed in #8217, routing it through `LabelSelectionState::label_text_selection` unconditionally would break `Button` / `Checkbox` / `RadioButton` / `TextEdit` (whose labels should not be selectable, and whose click/drag handling would fight the selection drag). So this adds an **opt-in** `AtomLayout::selectable(bool)` (default `false`). When enabled, the layout also senses clicks and drags (mirroring `Label::layout_in_ui`) and paints its text through the label-selection machinery. The underline is `Stroke::NONE`, so when nothing is selected the painted output is identical to the existing path — the default (`selectable == false`) behaviour, and existing snapshots, are unchanged. ## Tests Two tests in `tests/egui_tests/tests/test_atoms.rs`: - `test_atom_selectable_senses_click_and_drag` — a `selectable(true)` layout senses click+drag; the default stays inert. - `test_atom_selectable_text_can_be_copied` — selecting (drag) the text of a selectable layout and copying yields the text via `OutputCommand::CopyText`, while a non-selectable layout yields nothing. ## Notes - Verified locally: `cargo fmt --all -- --check`, `cargo clippy -p egui` (clean), and the two new tests pass. I haven't run the full `./scripts/check.sh` (wasm/typos) locally, hence opening as a draft. - This only adds the opt-in API; no existing widget is made selectable. Happy to wire it up on a specific widget and/or add an `egui_demo_lib` demo if you'd like — just let me know.
This commit is contained in:
@@ -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<f32>,
|
||||
pub(crate) frame: Frame,
|
||||
pub(crate) sense: Sense,
|
||||
selectable: bool,
|
||||
fallback_text_color: Option<Color32>,
|
||||
fallback_font: Option<FontSelection>,
|
||||
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);
|
||||
|
||||
@@ -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 <https://github.com/emilk/egui/issues/8217>.
|
||||
#[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 <https://github.com/emilk/egui/issues/8217>.
|
||||
#[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<String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user