mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -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::atomics::ATOMS_SMALL_VEC_SIZE;
|
||||||
use crate::{
|
use crate::{
|
||||||
AtomKind, Atoms, FontSelection, Frame, Id, Image, IntoAtoms, Response, Sense, SizedAtom,
|
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 emath::{Align2, GuiRounding as _, NumExt as _, Rect, Vec2};
|
||||||
use epaint::text::TextWrapMode;
|
use epaint::text::TextWrapMode;
|
||||||
@@ -35,6 +35,7 @@ pub struct AtomLayout<'a> {
|
|||||||
gap: Option<f32>,
|
gap: Option<f32>,
|
||||||
pub(crate) frame: Frame,
|
pub(crate) frame: Frame,
|
||||||
pub(crate) sense: Sense,
|
pub(crate) sense: Sense,
|
||||||
|
selectable: bool,
|
||||||
fallback_text_color: Option<Color32>,
|
fallback_text_color: Option<Color32>,
|
||||||
fallback_font: Option<FontSelection>,
|
fallback_font: Option<FontSelection>,
|
||||||
min_size: Vec2,
|
min_size: Vec2,
|
||||||
@@ -57,6 +58,7 @@ impl<'a> AtomLayout<'a> {
|
|||||||
gap: None,
|
gap: None,
|
||||||
frame: Frame::default(),
|
frame: Frame::default(),
|
||||||
sense: Sense::hover(),
|
sense: Sense::hover(),
|
||||||
|
selectable: false,
|
||||||
fallback_text_color: None,
|
fallback_text_color: None,
|
||||||
fallback_font: None,
|
fallback_font: None,
|
||||||
min_size: Vec2::ZERO,
|
min_size: Vec2::ZERO,
|
||||||
@@ -89,6 +91,18 @@ impl<'a> AtomLayout<'a> {
|
|||||||
self
|
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.
|
/// Set the fallback (default) text color.
|
||||||
///
|
///
|
||||||
/// Default: [`crate::Visuals::text_color`]
|
/// Default: [`crate::Visuals::text_color`]
|
||||||
@@ -187,7 +201,8 @@ impl<'a> AtomLayout<'a> {
|
|||||||
mut atoms,
|
mut atoms,
|
||||||
gap,
|
gap,
|
||||||
frame,
|
frame,
|
||||||
sense,
|
mut sense,
|
||||||
|
selectable,
|
||||||
fallback_text_color,
|
fallback_text_color,
|
||||||
min_size,
|
min_size,
|
||||||
mut max_size,
|
mut max_size,
|
||||||
@@ -198,6 +213,19 @@ impl<'a> AtomLayout<'a> {
|
|||||||
|
|
||||||
let fallback_font = fallback_font.unwrap_or_default();
|
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());
|
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`.
|
// If the TextWrapMode is not Extend, ensure there is some item marked as `shrink`.
|
||||||
@@ -331,6 +359,7 @@ impl<'a> AtomLayout<'a> {
|
|||||||
desired_size,
|
desired_size,
|
||||||
align2,
|
align2,
|
||||||
gap,
|
gap,
|
||||||
|
selectable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +376,7 @@ pub struct AllocatedAtomLayout<'a> {
|
|||||||
desired_size: Vec2,
|
desired_size: Vec2,
|
||||||
align2: Align2,
|
align2: Align2,
|
||||||
gap: f32,
|
gap: f32,
|
||||||
|
selectable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'atom> AllocatedAtomLayout<'atom> {
|
impl<'atom> AllocatedAtomLayout<'atom> {
|
||||||
@@ -434,6 +464,7 @@ impl<'atom> AllocatedAtomLayout<'atom> {
|
|||||||
desired_size,
|
desired_size,
|
||||||
align2,
|
align2,
|
||||||
gap,
|
gap,
|
||||||
|
selectable,
|
||||||
} = self;
|
} = self;
|
||||||
|
|
||||||
let inner_rect = response.rect - self.frame.total_margin();
|
let inner_rect = response.rect - self.frame.total_margin();
|
||||||
@@ -476,7 +507,21 @@ impl<'atom> AllocatedAtomLayout<'atom> {
|
|||||||
|
|
||||||
match sized.kind {
|
match sized.kind {
|
||||||
SizedAtomKind::Text(galley) => {
|
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: _ } => {
|
SizedAtomKind::Image { image, size: _ } => {
|
||||||
image.paint_at(ui, rect);
|
image.paint_at(ui, rect);
|
||||||
|
|||||||
@@ -140,3 +140,94 @@ fn test_atom_letter_spacing() {
|
|||||||
|
|
||||||
harness.snapshot("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