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
This commit is contained in:
parent
d3c6895443
commit
3b147c066b
|
|
@ -647,6 +647,39 @@ pub struct Interaction {
|
||||||
pub multi_widget_text_select: bool,
|
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.
|
/// Controls the visual style (colors etc) of egui.
|
||||||
///
|
///
|
||||||
/// You can change the visuals of a [`Ui`] with [`Ui::visuals_mut`]
|
/// You can change the visuals of a [`Ui`] with [`Ui::visuals_mut`]
|
||||||
|
|
@ -722,11 +755,8 @@ pub struct Visuals {
|
||||||
|
|
||||||
pub resize_corner_size: f32,
|
pub resize_corner_size: f32,
|
||||||
|
|
||||||
/// The color and width of the text cursor
|
/// How the text cursor acts.
|
||||||
pub text_cursor: Stroke,
|
pub text_cursor: TextCursorStyle,
|
||||||
|
|
||||||
/// show where the text cursor would be if you clicked
|
|
||||||
pub text_cursor_preview: bool,
|
|
||||||
|
|
||||||
/// Allow child widgets to be just on the border and still have a stroke with some thickness
|
/// Allow child widgets to be just on the border and still have a stroke with some thickness
|
||||||
pub clip_rect_margin: f32,
|
pub clip_rect_margin: f32,
|
||||||
|
|
@ -1094,8 +1124,7 @@ impl Visuals {
|
||||||
|
|
||||||
resize_corner_size: 12.0,
|
resize_corner_size: 12.0,
|
||||||
|
|
||||||
text_cursor: Stroke::new(2.0, Color32::from_rgb(192, 222, 255)),
|
text_cursor: Default::default(),
|
||||||
text_cursor_preview: false,
|
|
||||||
|
|
||||||
clip_rect_margin: 3.0, // should be at least half the size of the widest frame stroke + max WidgetVisuals::expansion
|
clip_rect_margin: 3.0, // should be at least half the size of the widest frame stroke + max WidgetVisuals::expansion
|
||||||
button_frame: true,
|
button_frame: true,
|
||||||
|
|
@ -1146,7 +1175,10 @@ impl Visuals {
|
||||||
color: Color32::from_black_alpha(25),
|
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()
|
..Self::dark()
|
||||||
}
|
}
|
||||||
|
|
@ -1737,8 +1769,9 @@ impl Visuals {
|
||||||
popup_shadow,
|
popup_shadow,
|
||||||
|
|
||||||
resize_corner_size,
|
resize_corner_size,
|
||||||
|
|
||||||
text_cursor,
|
text_cursor,
|
||||||
text_cursor_preview,
|
|
||||||
clip_rect_margin,
|
clip_rect_margin,
|
||||||
button_frame,
|
button_frame,
|
||||||
collapsing_header_frame,
|
collapsing_header_frame,
|
||||||
|
|
@ -1834,16 +1867,14 @@ impl Visuals {
|
||||||
);
|
);
|
||||||
|
|
||||||
ui_color(ui, hyperlink_color, "hyperlink_color");
|
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.collapsing("Misc", |ui| {
|
||||||
ui.add(Slider::new(resize_corner_size, 0.0..=20.0).text("resize_corner_size"));
|
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.add(Slider::new(clip_rect_margin, 0.0..=20.0).text("clip_rect_margin"));
|
||||||
|
|
||||||
ui.checkbox(button_frame, "Button has a frame");
|
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)]
|
#[cfg(debug_assertions)]
|
||||||
impl DebugOptions {
|
impl DebugOptions {
|
||||||
pub fn ui(&mut self, ui: &mut crate::Ui) {
|
pub fn ui(&mut self, ui: &mut crate::Ui) {
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,10 @@ pub fn paint_text_selection(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint one end of the selection, e.g. the primary cursor.
|
/// 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 top = cursor_rect.center_top();
|
||||||
let bottom = cursor_rect.center_bottom();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ use crate::{
|
||||||
os::OperatingSystem,
|
os::OperatingSystem,
|
||||||
output::OutputEvent,
|
output::OutputEvent,
|
||||||
text_selection::{
|
text_selection::{
|
||||||
text_cursor_state::cursor_rect,
|
text_cursor_state::cursor_rect, visuals::paint_text_selection, CCursorRange, CursorRange,
|
||||||
visuals::{paint_cursor, paint_text_selection},
|
|
||||||
CCursorRange, CursorRange,
|
|
||||||
},
|
},
|
||||||
*,
|
*,
|
||||||
};
|
};
|
||||||
|
|
@ -544,14 +542,14 @@ impl<'t> TextEdit<'t> {
|
||||||
let cursor_at_pointer =
|
let cursor_at_pointer =
|
||||||
galley.cursor_from_pos(pointer_pos - rect.min + singleline_offset);
|
galley.cursor_from_pos(pointer_pos - rect.min + singleline_offset);
|
||||||
|
|
||||||
if ui.visuals().text_cursor_preview
|
if ui.visuals().text_cursor.preview
|
||||||
&& response.hovered()
|
&& response.hovered()
|
||||||
&& ui.input(|i| i.pointer.is_moving())
|
&& ui.input(|i| i.pointer.is_moving())
|
||||||
{
|
{
|
||||||
// preview:
|
// text cursor preview:
|
||||||
let cursor_rect =
|
let cursor_rect =
|
||||||
cursor_rect(rect.min, &galley, &cursor_at_pointer, row_height);
|
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);
|
let is_being_dragged = ui.ctx().is_being_dragged(response.id);
|
||||||
|
|
@ -683,10 +681,25 @@ impl<'t> TextEdit<'t> {
|
||||||
ui.scroll_to_rect(primary_cursor_rect, None);
|
ui.scroll_to_rect(primary_cursor_rect, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
if text.is_mutable() {
|
if text.is_mutable() && interactive {
|
||||||
paint_cursor(&painter, ui.visuals(), primary_cursor_rect);
|
let now = ui.ctx().input(|i| i.time);
|
||||||
|
if response.changed || selection_changed {
|
||||||
|
state.last_edit_time = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if interactive {
|
|
||||||
// For IME, so only set it when text is editable and visible!
|
// For IME, so only set it when text is editable and visible!
|
||||||
ui.ctx().output_mut(|o| {
|
ui.ctx().output_mut(|o| {
|
||||||
o.ime = Some(crate::output::IMEOutput {
|
o.ime = Some(crate::output::IMEOutput {
|
||||||
|
|
@ -698,7 +711,6 @@ impl<'t> TextEdit<'t> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
state.clone().store(ui.ctx(), id);
|
state.clone().store(ui.ctx(), id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@ pub struct TextEditState {
|
||||||
// Visual offset when editing singleline text bigger than the width.
|
// Visual offset when editing singleline text bigger than the width.
|
||||||
#[cfg_attr(feature = "serde", serde(skip))]
|
#[cfg_attr(feature = "serde", serde(skip))]
|
||||||
pub(crate) singleline_offset: f32,
|
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 {
|
impl TextEditState {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue