1
0
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:
冷凍アカギ
2026-06-14 03:25:00 +09:00
committed by GitHub
parent 27a61934b1
commit 172fb54f7f
2 changed files with 139 additions and 3 deletions

View File

@@ -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);

View File

@@ -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"
);
}