Skip to content

Commit

Permalink
Implement blinking text cursor in TextEdit (#4279)
Browse files Browse the repository at this point in the history
On by default. Can be set with `style.text_cursor.blink`.

* Closes #4121
  • Loading branch information
emilk authored Mar 30, 2024
1 parent d3c6895 commit 3b147c0
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 32 deletions.
102 changes: 88 additions & 14 deletions crates/egui/src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -1737,8 +1769,9 @@ impl Visuals {
popup_shadow,

resize_corner_size,

text_cursor,
text_cursor_preview,

clip_rect_margin,
button_frame,
collapsing_header_frame,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down
36 changes: 34 additions & 2 deletions crates/egui/src/text_selection/visuals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
}
44 changes: 28 additions & 16 deletions crates/egui/src/widgets/text_edit/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
*,
};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
});
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions crates/egui/src/widgets/text_edit/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 3b147c0

Please sign in to comment.