From 3b147c066b006431fe57a5259b5a9f6c2453b475 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sat, 30 Mar 2024 16:22:28 +0100 Subject: [PATCH] Implement blinking text cursor in `TextEdit` (#4279) On by default. Can be set with `style.text_cursor.blink`. * Closes https://github.com/emilk/egui/pull/4121 --- crates/egui/src/style.rs | 102 ++++++++++++++++--- crates/egui/src/text_selection/visuals.rs | 36 ++++++- crates/egui/src/widgets/text_edit/builder.rs | 44 +++++--- crates/egui/src/widgets/text_edit/state.rs | 5 + 4 files changed, 155 insertions(+), 32 deletions(-) diff --git a/crates/egui/src/style.rs b/crates/egui/src/style.rs index 1523345f9b8..df6ae55c4c5 100644 --- a/crates/egui/src/style.rs +++ b/crates/egui/src/style.rs @@ -647,6 +647,39 @@ pub struct Interaction { pub multi_widget_text_select: bool, } +/// Look and feel of the text cursor. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +pub struct TextCursorStyle { + /// The color and width of the text cursor + pub stroke: Stroke, + + /// Show where the text cursor would be if you clicked? + pub preview: bool, + + /// Should the cursor blink? + pub blink: bool, + + /// When blinking, this is how long the cursor is visible. + pub on_duration: f32, + + /// When blinking, this is how long the cursor is invisible. + pub off_duration: f32, +} + +impl Default for TextCursorStyle { + fn default() -> Self { + Self { + stroke: Stroke::new(2.0, Color32::from_rgb(192, 222, 255)), // Dark mode + preview: false, + blink: true, + on_duration: 0.5, + off_duration: 0.5, + } + } +} + /// Controls the visual style (colors etc) of egui. /// /// You can change the visuals of a [`Ui`] with [`Ui::visuals_mut`] @@ -722,11 +755,8 @@ pub struct Visuals { pub resize_corner_size: f32, - /// The color and width of the text cursor - pub text_cursor: Stroke, - - /// show where the text cursor would be if you clicked - pub text_cursor_preview: bool, + /// How the text cursor acts. + pub text_cursor: TextCursorStyle, /// Allow child widgets to be just on the border and still have a stroke with some thickness pub clip_rect_margin: f32, @@ -1094,8 +1124,7 @@ impl Visuals { resize_corner_size: 12.0, - text_cursor: Stroke::new(2.0, Color32::from_rgb(192, 222, 255)), - text_cursor_preview: false, + text_cursor: Default::default(), clip_rect_margin: 3.0, // should be at least half the size of the widest frame stroke + max WidgetVisuals::expansion button_frame: true, @@ -1146,7 +1175,10 @@ impl Visuals { color: Color32::from_black_alpha(25), }, - text_cursor: Stroke::new(2.0, Color32::from_rgb(0, 83, 125)), + text_cursor: TextCursorStyle { + stroke: Stroke::new(2.0, Color32::from_rgb(0, 83, 125)), + ..Default::default() + }, ..Self::dark() } @@ -1737,8 +1769,9 @@ impl Visuals { popup_shadow, resize_corner_size, + text_cursor, - text_cursor_preview, + clip_rect_margin, button_frame, collapsing_header_frame, @@ -1834,16 +1867,14 @@ impl Visuals { ); ui_color(ui, hyperlink_color, "hyperlink_color"); + }); - ui.horizontal(|ui| { - ui.label("Text cursor"); - ui.add(text_cursor); - }); + ui.collapsing("Text cursor", |ui| { + text_cursor.ui(ui); }); ui.collapsing("Misc", |ui| { ui.add(Slider::new(resize_corner_size, 0.0..=20.0).text("resize_corner_size")); - ui.checkbox(text_cursor_preview, "Preview text cursor on hover"); ui.add(Slider::new(clip_rect_margin, 0.0..=20.0).text("clip_rect_margin")); ui.checkbox(button_frame, "Button has a frame"); @@ -1887,6 +1918,49 @@ impl Visuals { } } +impl TextCursorStyle { + fn ui(&mut self, ui: &mut Ui) { + let Self { + stroke, + preview, + blink, + on_duration, + off_duration, + } = self; + + ui.horizontal(|ui| { + ui.label("Stroke"); + ui.add(stroke); + }); + + ui.checkbox(preview, "Preview text cursor on hover"); + + ui.checkbox(blink, "Blink"); + + if *blink { + Grid::new("cursor_blink").show(ui, |ui| { + ui.label("On time"); + ui.add( + DragValue::new(on_duration) + .speed(0.1) + .clamp_range(0.0..=2.0) + .suffix(" s"), + ); + ui.end_row(); + + ui.label("Off time"); + ui.add( + DragValue::new(off_duration) + .speed(0.1) + .clamp_range(0.0..=2.0) + .suffix(" s"), + ); + ui.end_row(); + }); + } + } +} + #[cfg(debug_assertions)] impl DebugOptions { pub fn ui(&mut self, ui: &mut crate::Ui) { diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index ca85e59ef66..4fc8af0abd4 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -51,8 +51,10 @@ pub fn paint_text_selection( } /// Paint one end of the selection, e.g. the primary cursor. -pub fn paint_cursor(painter: &Painter, visuals: &Visuals, cursor_rect: Rect) { - let stroke = visuals.text_cursor; +/// +/// This will never blink. +pub fn paint_cursor_end(painter: &Painter, visuals: &Visuals, cursor_rect: Rect) { + let stroke = visuals.text_cursor.stroke; let top = cursor_rect.center_top(); let bottom = cursor_rect.center_bottom(); @@ -73,3 +75,33 @@ pub fn paint_cursor(painter: &Painter, visuals: &Visuals, cursor_rect: Rect) { ); } } + +/// Paint one end of the selection, e.g. the primary cursor, with blinking (if enabled). +pub fn paint_text_cursor( + ui: &mut Ui, + painter: &Painter, + primary_cursor_rect: Rect, + time_since_last_edit: f64, +) { + if ui.visuals().text_cursor.blink { + let on_duration = ui.visuals().text_cursor.on_duration; + let off_duration = ui.visuals().text_cursor.off_duration; + let total_duration = on_duration + off_duration; + + let time_in_cycle = (time_since_last_edit % (total_duration as f64)) as f32; + + let wake_in = if time_in_cycle < on_duration { + // Cursor is visible + paint_cursor_end(painter, ui.visuals(), primary_cursor_rect); + on_duration - time_in_cycle + } else { + // Cursor is not visible + total_duration - time_in_cycle + }; + + ui.ctx() + .request_repaint_after(std::time::Duration::from_secs_f32(wake_in)); + } else { + paint_cursor_end(painter, ui.visuals(), primary_cursor_rect); + } +} diff --git a/crates/egui/src/widgets/text_edit/builder.rs b/crates/egui/src/widgets/text_edit/builder.rs index e320a1ae843..e0cf2d012f1 100644 --- a/crates/egui/src/widgets/text_edit/builder.rs +++ b/crates/egui/src/widgets/text_edit/builder.rs @@ -6,9 +6,7 @@ use crate::{ os::OperatingSystem, output::OutputEvent, text_selection::{ - text_cursor_state::cursor_rect, - visuals::{paint_cursor, paint_text_selection}, - CCursorRange, CursorRange, + text_cursor_state::cursor_rect, visuals::paint_text_selection, CCursorRange, CursorRange, }, *, }; @@ -544,14 +542,14 @@ impl<'t> TextEdit<'t> { let cursor_at_pointer = galley.cursor_from_pos(pointer_pos - rect.min + singleline_offset); - if ui.visuals().text_cursor_preview + if ui.visuals().text_cursor.preview && response.hovered() && ui.input(|i| i.pointer.is_moving()) { - // preview: + // text cursor preview: let cursor_rect = cursor_rect(rect.min, &galley, &cursor_at_pointer, row_height); - paint_cursor(&painter, ui.visuals(), cursor_rect); + text_selection::visuals::paint_cursor_end(&painter, ui.visuals(), cursor_rect); } let is_being_dragged = ui.ctx().is_being_dragged(response.id); @@ -683,18 +681,32 @@ impl<'t> TextEdit<'t> { ui.scroll_to_rect(primary_cursor_rect, None); } - if text.is_mutable() { - paint_cursor(&painter, ui.visuals(), primary_cursor_rect); + if text.is_mutable() && interactive { + let now = ui.ctx().input(|i| i.time); + if response.changed || selection_changed { + state.last_edit_time = now; + } - if interactive { - // For IME, so only set it when text is editable and visible! - ui.ctx().output_mut(|o| { - o.ime = Some(crate::output::IMEOutput { - rect, - cursor_rect: primary_cursor_rect, - }); - }); + // Only show (and blink) cursor if the egui viewport has focus. + // This is for two reasons: + // * Don't give the impression that the user can type into a window without focus + // * Don't repaint the ui because of a blinking cursor in an app that is not in focus + if ui.ctx().input(|i| i.focused) { + text_selection::visuals::paint_text_cursor( + ui, + &painter, + primary_cursor_rect, + now - state.last_edit_time, + ); } + + // For IME, so only set it when text is editable and visible! + ui.ctx().output_mut(|o| { + o.ime = Some(crate::output::IMEOutput { + rect, + cursor_rect: primary_cursor_rect, + }); + }); } } } diff --git a/crates/egui/src/widgets/text_edit/state.rs b/crates/egui/src/widgets/text_edit/state.rs index 8901fd56f27..d0334da3213 100644 --- a/crates/egui/src/widgets/text_edit/state.rs +++ b/crates/egui/src/widgets/text_edit/state.rs @@ -51,6 +51,11 @@ pub struct TextEditState { // Visual offset when editing singleline text bigger than the width. #[cfg_attr(feature = "serde", serde(skip))] pub(crate) singleline_offset: f32, + + /// When did the user last press a key? + /// Used to pause the cursor animation when typing. + #[cfg_attr(feature = "serde", serde(skip))] + pub(crate) last_edit_time: f64, } impl TextEditState {