Skip to content

Commit

Permalink
Selectable text in Labels (#3814)
Browse files Browse the repository at this point in the history
* Closes #3804

Add ability to select the text in labels with mouse-drag, double-click,
and keyboard (once clicked).
Hit Cmd+C to copy the text. If everything of a label with elided text is
selected, the copy command will copy the full non-elided text. IME and
accesskit _should_ work, but is untested.

You can control wether or not text in labels is selected globally in
`style.interaction.selectable_labels` or on a per-label basis in
`Label::selectable`. The default is ON.

This also cleans up the `TextEdit` code somewhat, fixing a couple
smaller bugs along the way.

This does _not_ implement selecting text across multiple widgets. Text
selection is only supported within a single `Label`, `TextEdit`, `Link`
or `Hyperlink`.


![label-text-selection](https://github.com/emilk/egui/assets/1148717/c161e819-50da-4b97-9686-042e6abf3564)


## TODO
* [x] Test
  • Loading branch information
emilk authored Jan 14, 2024
1 parent 301c72b commit 5d2e192
Show file tree
Hide file tree
Showing 18 changed files with 1,031 additions and 632 deletions.
2 changes: 1 addition & 1 deletion crates/egui/src/data/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ pub struct IMEOutput {
/// Where the [`crate::TextEdit`] is located on screen.
pub rect: crate::Rect,

/// Where the cursor is.
/// Where the primary cursor is.
///
/// This is a very thin rectangle.
pub cursor_rect: crate::Rect,
Expand Down
7 changes: 7 additions & 0 deletions crates/egui/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,9 @@ pub struct Interaction {

/// Delay in seconds before showing tooltips after the mouse stops moving
pub tooltip_delay: f64,

/// Can you select the text on a [`crate::Label`] by default?
pub selectable_labels: bool,
}

/// Controls the visual style (colors etc) of egui.
Expand Down Expand Up @@ -846,6 +849,7 @@ impl Visuals {
&self.widgets.noninteractive
}

// Non-interactive text color.
pub fn text_color(&self) -> Color32 {
self.override_text_color
.unwrap_or_else(|| self.widgets.noninteractive.text_color())
Expand Down Expand Up @@ -1114,6 +1118,7 @@ impl Default for Interaction {
resize_grab_radius_corner: 10.0,
show_tooltips_only_when_still: true,
tooltip_delay: 0.0,
selectable_labels: true,
}
}
}
Expand Down Expand Up @@ -1573,6 +1578,7 @@ impl Interaction {
resize_grab_radius_corner,
show_tooltips_only_when_still,
tooltip_delay,
selectable_labels,
} = self;
ui.add(Slider::new(resize_grab_radius_side, 0.0..=20.0).text("resize_grab_radius_side"));
ui.add(
Expand All @@ -1583,6 +1589,7 @@ impl Interaction {
"Only show tooltips if mouse is still",
);
ui.add(Slider::new(tooltip_delay, 0.0..=1.0).text("tooltip_delay"));
ui.checkbox(selectable_labels, "Selectable text in labels");

ui.vertical_centered(|ui| reset_button(ui, self));
}
Expand Down
2 changes: 1 addition & 1 deletion crates/egui/src/widgets/drag_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ impl<'a> Widget for DragValue<'a> {
ui.data_mut(|data| data.remove::<String>(id));
ui.memory_mut(|mem| mem.request_focus(id));
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
state.set_ccursor_range(Some(text::CCursorRange::two(
state.cursor.set_char_range(Some(text::CCursorRange::two(
text::CCursor::default(),
text::CCursor::new(value_text.chars().count()),
)));
Expand Down
20 changes: 13 additions & 7 deletions crates/egui/src/widgets/hyperlink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,9 @@ impl Widget for Link {
let Self { text } = self;
let label = Label::new(text).sense(Sense::click());

let (pos, galley, response) = label.layout_in_ui(ui);
let (galley_pos, galley, response) = label.layout_in_ui(ui);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Link, galley.text()));

if response.hovered() {
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
}

if ui.is_rect_visible(response.rect) {
let color = ui.visuals().hyperlink_color;
let visuals = ui.style().interact(&response);
Expand All @@ -51,8 +47,18 @@ impl Widget for Link {
Stroke::NONE
};

ui.painter()
.add(epaint::TextShape::new(pos, galley, color).with_underline(underline));
ui.painter().add(
epaint::TextShape::new(galley_pos, galley.clone(), color).with_underline(underline),
);

let selectable = ui.style().interaction.selectable_labels;
if selectable {
crate::widgets::label::text_selection(ui, &response, galley_pos, &galley);
}

if response.hovered() {
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
}
}

response
Expand Down
233 changes: 219 additions & 14 deletions crates/egui/src/widgets/label.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
use std::sync::Arc;

use crate::*;
use crate::{
text_edit::{
cursor_interaction::cursor_rect, paint_cursor_selection, CursorRange, TextCursorState,
},
*,
};

/// Static text.
///
Expand All @@ -23,6 +28,7 @@ pub struct Label {
wrap: Option<bool>,
truncate: bool,
sense: Option<Sense>,
selectable: Option<bool>,
}

impl Label {
Expand All @@ -32,6 +38,7 @@ impl Label {
wrap: None,
truncate: false,
sense: None,
selectable: None,
}
}

Expand Down Expand Up @@ -73,6 +80,15 @@ impl Label {
self
}

/// Can the user select the text with the mouse?
///
/// Overrides [`crate::style::Interaction::selectable_labels`].
#[inline]
pub fn selectable(mut self, selectable: bool) -> Self {
self.selectable = Some(selectable);
self
}

/// Make the label respond to clicks and/or drags.
///
/// By default, a label is inert and does not respond to click or drags.
Expand All @@ -97,14 +113,35 @@ impl Label {
impl Label {
/// Do layout and position the galley in the ui, without painting it or adding widget info.
pub fn layout_in_ui(self, ui: &mut Ui) -> (Pos2, Arc<Galley>, Response) {
let sense = self.sense.unwrap_or_else(|| {
// We only want to focus labels if the screen reader is on.
let selectable = self
.selectable
.unwrap_or_else(|| ui.style().interaction.selectable_labels);

let mut sense = self.sense.unwrap_or_else(|| {
if ui.memory(|mem| mem.options.screen_reader) {
// We only want to focus labels if the screen reader is on.
Sense::focusable_noninteractive()
} else {
Sense::hover()
}
});

if selectable {
// On touch screens (e.g. mobile in `eframe` web), should
// dragging select text, or scroll the enclosing [`ScrollArea`] (if any)?
// Since currently copying selected text in not supported on `eframe` web,
// we prioritize touch-scrolling:
let allow_drag_to_select = ui.input(|i| !i.any_touches());

let select_sense = if allow_drag_to_select {
Sense::click_and_drag()
} else {
Sense::click()
};

sense = sense.union(select_sense);
}

if let WidgetText::Galley(galley) = self.text {
// If the user said "use this specific galley", then just use it:
let (rect, response) = ui.allocate_exact_size(galley.size(), sense);
Expand Down Expand Up @@ -178,39 +215,207 @@ impl Label {

let galley = ui.fonts(|fonts| fonts.layout_job(layout_job));
let (rect, response) = ui.allocate_exact_size(galley.size(), sense);
let pos = match galley.job.halign {
let galley_pos = match galley.job.halign {
Align::LEFT => rect.left_top(),
Align::Center => rect.center_top(),
Align::RIGHT => rect.right_top(),
};
(pos, galley, response)
(galley_pos, galley, response)
}
}
}

impl Widget for Label {
fn ui(self, ui: &mut Ui) -> Response {
let (pos, galley, mut response) = self.layout_in_ui(ui);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, galley.text()));
// Interactive = the uses asked to sense interaction.
// We DON'T want to have the color respond just because the text is selectable;
// the cursor is enough to communicate that.
let interactive = self.sense.map_or(false, |sense| sense != Sense::hover());

if galley.elided {
// Show the full (non-elided) text on hover:
response = response.on_hover_text(galley.text());
}
let selectable = self.selectable;

let (galley_pos, galley, mut response) = self.layout_in_ui(ui);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, galley.text()));

if ui.is_rect_visible(response.rect) {
let response_color = ui.style().interact(&response).text_color();
if galley.elided {
// Show the full (non-elided) text on hover:
response = response.on_hover_text(galley.text());
}

let response_color = if interactive {
ui.style().interact(&response).text_color()
} else {
ui.style().visuals.text_color()
};

let underline = if response.has_focus() || response.highlighted() {
Stroke::new(1.0, response_color)
} else {
Stroke::NONE
};

ui.painter()
.add(epaint::TextShape::new(pos, galley, response_color).with_underline(underline));
ui.painter().add(
epaint::TextShape::new(galley_pos, galley.clone(), response_color)
.with_underline(underline),
);

let selectable = selectable.unwrap_or_else(|| ui.style().interaction.selectable_labels);
if selectable {
text_selection(ui, &response, galley_pos, &galley);
}
}

response
}
}

/// Handle text selection state for a label or similar widget.
///
/// Make sure the widget senses to clicks and drags.
///
/// This should be called after painting the text, because this will also
/// paint the text cursor/selection on top.
pub fn text_selection(ui: &Ui, response: &Response, galley_pos: Pos2, galley: &Galley) {
let mut cursor_state = LabelSelectionState::load(ui.ctx(), response.id);
let original_cursor = cursor_state.range(galley);

if response.hovered {
ui.ctx().set_cursor_icon(CursorIcon::Text);
} else if !cursor_state.is_empty() && ui.input(|i| i.pointer.any_pressed()) {
// We clicked somewhere else - deselect this label.
cursor_state = Default::default();
LabelSelectionState::store(ui.ctx(), response.id, cursor_state);
}

if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - galley_pos);
cursor_state.pointer_interaction(ui, response, cursor_at_pointer, galley);
}

if let Some(mut cursor_range) = cursor_state.range(galley) {
process_selection_key_events(ui, galley, response.id, &mut cursor_range);
cursor_state.set_range(Some(cursor_range));
}

let cursor_range = cursor_state.range(galley);

if let Some(cursor_range) = cursor_range {
// We paint the cursor on top of the text, in case
// the text galley has backgrounds (as e.g. `code` snippets in markup do).
paint_cursor_selection(
ui.visuals(),
ui.painter(),
galley_pos,
galley,
&cursor_range,
);

let selection_changed = original_cursor != Some(cursor_range);

let is_fully_visible = ui.clip_rect().contains_rect(response.rect); // TODO: remove this HACK workaround for https://github.com/emilk/egui/issues/1531

if selection_changed && !is_fully_visible {
// Scroll to keep primary cursor in view:
let row_height = estimate_row_height(galley);
let primary_cursor_rect =
cursor_rect(galley_pos, galley, &cursor_range.primary, row_height);
ui.scroll_to_rect(primary_cursor_rect, None);
}
}

#[cfg(feature = "accesskit")]
text_edit::accesskit_text::update_accesskit_for_text_widget(
ui.ctx(),
response.id,
cursor_range,
accesskit::Role::StaticText,
galley_pos,
galley,
);

if !cursor_state.is_empty() {
LabelSelectionState::store(ui.ctx(), response.id, cursor_state);
}
}

fn estimate_row_height(galley: &Galley) -> f32 {
if let Some(row) = galley.rows.first() {
row.rect.height()
} else {
galley.size().y
}
}

fn process_selection_key_events(
ui: &Ui,
galley: &Galley,
widget_id: Id,
cursor_range: &mut CursorRange,
) {
let mut copy_text = None;

ui.input(|i| {
// NOTE: we have a lock on ui/ctx here,
// so be careful to not call into `ui` or `ctx` again.

for event in &i.events {
match event {
Event::Copy | Event::Cut => {
// This logic means we can select everything in an ellided label (including the `…`)
// and still copy the entire un-ellided text!
let everything_is_selected =
cursor_range.contains(&CursorRange::select_all(galley));

let copy_everything = cursor_range.is_empty() || everything_is_selected;

if copy_everything {
copy_text = Some(galley.text().to_owned());
} else {
copy_text = Some(cursor_range.slice_str(galley).to_owned());
}
}

event => {
cursor_range.on_event(event, galley, widget_id);
}
}
}
});

if let Some(copy_text) = copy_text {
ui.ctx().copy_text(copy_text);
}
}

// ----------------------------------------------------------------------------

/// One state for all labels, because we only support text selection in one label at a time.
#[derive(Clone, Copy, Debug, Default)]
struct LabelSelectionState {
/// Id of the (only) label with a selection, if any
id: Option<Id>,

/// The current selection, if any.
selection: TextCursorState,
}

impl LabelSelectionState {
fn load(ctx: &Context, id: Id) -> TextCursorState {
ctx.data(|data| data.get_temp::<Self>(Id::NULL))
.and_then(|state| (state.id == Some(id)).then_some(state.selection))
.unwrap_or_default()
}

fn store(ctx: &Context, id: Id, selection: TextCursorState) {
ctx.data_mut(|data| {
data.insert_temp(
Id::NULL,
Self {
id: Some(id),
selection,
},
);
});
}
}
Loading

0 comments on commit 5d2e192

Please sign in to comment.