Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Selectable text in Labels #3814

Merged
merged 27 commits into from
Jan 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c195b5c
Add setting for selectable labels
emilk Jan 13, 2024
23959bc
Refactor: introduce `TextCursorState`
emilk Jan 13, 2024
301db85
Refactor: break out `mouse_selection` function
emilk Jan 13, 2024
2029f9f
Refactor: extract cursor interaction code
emilk Jan 13, 2024
80b83e8
More refactor
emilk Jan 13, 2024
f6e655c
Remove request of focus when interacting with text selection
emilk Jan 13, 2024
716e3bd
Implement selecting text in labels
emilk Jan 13, 2024
bbceb14
Copy selected text with Cmd-C
emilk Jan 13, 2024
d79390b
Handle all keyboard shortcuts for cursor movement for a label
emilk Jan 13, 2024
013622a
`#[inline]`
emilk Jan 13, 2024
3d0ee85
Refactor: move out text selection logic to own function
emilk Jan 13, 2024
8fbf4a4
Optimize label slightly
emilk Jan 13, 2024
1e1e92d
Selectable hyperlinks
emilk Jan 13, 2024
b7073f7
Update accesskit
emilk Jan 13, 2024
797d1ee
Use the term `galley_pos` for clarity
emilk Jan 13, 2024
40a48dd
Bug fix: interaction with centered labels
emilk Jan 13, 2024
bc30f2d
Don't drag-to-select labels on touch screens
emilk Jan 13, 2024
f76938d
Scroll to follow cursor
emilk Jan 13, 2024
b3c7de0
Allow copying the full non-ellided text of a label
emilk Jan 13, 2024
5dbd2a8
Remove Galley::as_str for now
emilk Jan 13, 2024
b19ee22
Fix check.sh
emilk Jan 13, 2024
edd99bd
Fix doclinks
emilk Jan 13, 2024
02a7646
Compilation fix for when accesskit is off
emilk Jan 13, 2024
31ebc35
Fix sense union
emilk Jan 13, 2024
800fba8
Cleanup
emilk Jan 13, 2024
801e685
Simplify http demo using new selectable labels
emilk Jan 13, 2024
2aae7c5
Give focus to TextEdit on interaction
emilk Jan 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading