Browse Source

Add read/write of TextEdit cursor state (#848)

* Rename `CursorPair` to `CursorRange`
* Easymark editor: add keyboard shortcuts to toggle bold, italics etc
* Split up TextEdit into separate files
* Add TextEdit::show that returns a rich TextEditOutput object with response, galley and cursor
* Rename text_edit::State to TextEditState
pull/857/head
Emil Ernerfeldt 3 years ago
committed by GitHub
parent
commit
c7638ca7f5
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 2
      egui/src/containers/mod.rs
  3. 6
      egui/src/data/input.rs
  4. 9
      egui/src/widgets/mod.rs
  5. 848
      egui/src/widgets/text_edit/builder.rs
  6. 130
      egui/src/widgets/text_edit/cursor_range.rs
  7. 10
      egui/src/widgets/text_edit/mod.rs
  8. 16
      egui/src/widgets/text_edit/output.rs
  9. 85
      egui/src/widgets/text_edit/state.rs
  10. 124
      egui/src/widgets/text_edit/text_buffer.rs
  11. 105
      egui_demo_lib/src/easy_mark/easy_mark_editor.rs

6
CHANGELOG.md

@ -9,9 +9,13 @@ NOTE: [`epaint`](epaint/CHANGELOG.md), [`eframe`](eframe/CHANGELOG.md), [`egui_w
### Added ⭐
* Add context menus: See `Ui::menu_button` and `Response::context_menu` ([#543](https://github.com/emilk/egui/pull/543)).
* You can now read and write the cursor of a `TextEdit` ([#848](https://github.com/emilk/egui/pull/848)).
### Changed 🔧
* Unified the four `Memory` data buckts (`data`, `data_temp`, `id_data` and `id_data_temp`) into a single `Memory::data`, with a new interface ([#836](https://github.com/emilk/egui/pull/836)).
* Unifiy the four `Memory` data buckets (`data`, `data_temp`, `id_data` and `id_data_temp`) into a single `Memory::data`, with a new interface ([#836](https://github.com/emilk/egui/pull/836)).
### Contributors 🙏
* [mankinskin](https://github.com/mankinskin) ([#543](https://github.com/emilk/egui/pull/543))
## 0.15.0 - 2021-10-24 - Syntax highlighting and hscroll

2
egui/src/containers/mod.rs

@ -14,7 +14,7 @@ pub(crate) mod window;
pub use {
area::Area,
collapsing_header::*,
collapsing_header::{CollapsingHeader, CollapsingResponse},
combo_box::*,
frame::Frame,
panel::{CentralPanel, SidePanel, TopBottomPanel},

6
egui/src/data/input.rs

@ -269,6 +269,12 @@ impl Modifiers {
pub fn shift_only(&self) -> bool {
self.shift && !(self.alt || self.command)
}
/// true if only [`Self::ctrl`] or only [`Self::mac_cmd`] is pressed.
#[inline(always)]
pub fn command_only(&self) -> bool {
!self.alt && !self.shift && self.command
}
}
/// Keyboard keys.

9
egui/src/widgets/mod.rs

@ -17,7 +17,7 @@ mod progress_bar;
mod selected_label;
mod separator;
mod slider;
pub(crate) mod text_edit;
pub mod text_edit;
pub use button::*;
pub use drag_value::DragValue;
@ -28,7 +28,7 @@ pub use progress_bar::ProgressBar;
pub use selected_label::SelectableLabel;
pub use separator::Separator;
pub use slider::*;
pub use text_edit::*;
pub use text_edit::{TextBuffer, TextEdit};
// ----------------------------------------------------------------------------
@ -80,6 +80,11 @@ where
}
}
/// Helper so that you can do `TextEdit::State::read…`
pub trait WidgetWithState {
type State;
}
// ----------------------------------------------------------------------------
/// Show a button to reset a value to its default.

848
egui/src/widgets/text_edit.rs → egui/src/widgets/text_edit/builder.rs

File diff suppressed because it is too large

130
egui/src/widgets/text_edit/cursor_range.rs

@ -0,0 +1,130 @@
use epaint::text::cursor::*;
/// A selected text range (could be a range of length zero).
#[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct CursorRange {
/// When selecting with a mouse, this is where the mouse was released.
/// When moving with e.g. shift+arrows, this is what moves.
/// Note that the two ends can come in any order, and also be equal (no selection).
pub primary: Cursor,
/// When selecting with a mouse, this is where the mouse was first pressed.
/// This part of the cursor does not move when shift is down.
pub secondary: Cursor,
}
impl CursorRange {
/// The empty range.
pub fn one(cursor: Cursor) -> Self {
Self {
primary: cursor,
secondary: cursor,
}
}
pub fn two(min: Cursor, max: Cursor) -> Self {
Self {
primary: max,
secondary: min,
}
}
pub fn as_ccursor_range(&self) -> CCursorRange {
CCursorRange {
primary: self.primary.ccursor,
secondary: self.secondary.ccursor,
}
}
/// True if the selected range contains no characters.
pub fn is_empty(&self) -> bool {
self.primary.ccursor == self.secondary.ccursor
}
/// If there is a selection, None is returned.
/// If the two ends is the same, that is returned.
pub fn single(&self) -> Option<Cursor> {
if self.is_empty() {
Some(self.primary)
} else {
None
}
}
pub fn is_sorted(&self) -> bool {
let p = self.primary.ccursor;
let s = self.secondary.ccursor;
(p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row)
}
/// returns the two ends ordered
pub fn sorted(&self) -> [Cursor; 2] {
if self.is_sorted() {
[self.primary, self.secondary]
} else {
[self.secondary, self.primary]
}
}
}
/// A selected text range (could be a range of length zero).
///
/// The selection is based on character count (NOT byte count!).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct CCursorRange {
/// When selecting with a mouse, this is where the mouse was released.
/// When moving with e.g. shift+arrows, this is what moves.
/// Note that the two ends can come in any order, and also be equal (no selection).
pub primary: CCursor,
/// When selecting with a mouse, this is where the mouse was first pressed.
/// This part of the cursor does not move when shift is down.
pub secondary: CCursor,
}
impl CCursorRange {
/// The empty range.
pub fn one(ccursor: CCursor) -> Self {
Self {
primary: ccursor,
secondary: ccursor,
}
}
pub fn two(min: CCursor, max: CCursor) -> Self {
Self {
primary: max,
secondary: min,
}
}
pub fn is_sorted(&self) -> bool {
let p = self.primary;
let s = self.secondary;
(p.index, p.prefer_next_row) <= (s.index, s.prefer_next_row)
}
/// returns the two ends ordered
pub fn sorted(&self) -> [CCursor; 2] {
if self.is_sorted() {
[self.primary, self.secondary]
} else {
[self.secondary, self.primary]
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct PCursorRange {
/// When selecting with a mouse, this is where the mouse was released.
/// When moving with e.g. shift+arrows, this is what moves.
/// Note that the two ends can come in any order, and also be equal (no selection).
pub primary: PCursor,
/// When selecting with a mouse, this is where the mouse was first pressed.
/// This part of the cursor does not move when shift is down.
pub secondary: PCursor,
}

10
egui/src/widgets/text_edit/mod.rs

@ -0,0 +1,10 @@
mod builder;
mod cursor_range;
mod output;
mod state;
mod text_buffer;
pub use {
builder::TextEdit, cursor_range::*, output::TextEditOutput, state::TextEditState,
text_buffer::TextBuffer,
};

16
egui/src/widgets/text_edit/output.rs

@ -0,0 +1,16 @@
use std::sync::Arc;
/// The output from a `TextEdit`.
pub struct TextEditOutput {
/// The interaction response.
pub response: crate::Response,
/// How the text was displayed.
pub galley: Arc<crate::Galley>,
/// The state we stored after the run/
pub state: super::TextEditState,
/// Where the text cursor is.
pub cursor_range: Option<super::CursorRange>,
}

85
egui/src/widgets/text_edit/state.rs

@ -0,0 +1,85 @@
use std::sync::Arc;
use crate::mutex::Mutex;
use crate::*;
use super::{CCursorRange, CursorRange};
type Undoer = crate::util::undoer::Undoer<(CCursorRange, String)>;
/// The text edit state stored between frames.
#[derive(Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct TextEditState {
cursor_range: Option<CursorRange>,
/// This is what is easiest to work with when editing text,
/// so users are more likely to read/write this.
ccursor_range: Option<CCursorRange>,
/// Wrapped in Arc for cheaper clones.
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) undoer: Arc<Mutex<Undoer>>,
// If IME candidate window is shown on this text edit.
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) has_ime: bool,
// Visual offset when editing singleline text bigger than the width.
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) singleline_offset: f32,
}
impl TextEditState {
pub fn load(ctx: &Context, id: Id) -> Option<Self> {
ctx.memory().data.get_persisted(id)
}
pub fn store(self, ctx: &Context, id: Id) {
ctx.memory().data.insert_persisted(id, self);
}
/// The the currently selected range of characters.
pub fn ccursor_range(&self) -> Option<CCursorRange> {
self.ccursor_range.or_else(|| {
self.cursor_range
.map(|cursor_range| cursor_range.as_ccursor_range())
})
}
/// Sets the currently selected range of characters.
pub fn set_ccursor_range(&mut self, ccursor_range: Option<CCursorRange>) {
self.cursor_range = None;
self.ccursor_range = ccursor_range;
}
pub fn set_cursor_range(&mut self, cursor_range: Option<CursorRange>) {
self.cursor_range = cursor_range;
self.ccursor_range = None;
}
pub fn cursor_range(&mut self, galley: &Galley) -> Option<CursorRange> {
self.cursor_range
.map(|cursor_range| {
// We only use the PCursor (paragraph number, and character offset within that paragraph).
// This is so that if we resize the `TextEdit` region, and text wrapping changes,
// we keep the same byte character offset from the beginning of the text,
// even though the number of rows changes
// (each paragraph can be several rows, due to word wrapping).
// The column (character offset) should be able to extend beyond the last word so that we can
// go down and still end up on the same column when we return.
CursorRange {
primary: galley.from_pcursor(cursor_range.primary.pcursor),
secondary: galley.from_pcursor(cursor_range.secondary.pcursor),
}
})
.or_else(|| {
self.ccursor_range.map(|ccursor_range| CursorRange {
primary: galley.from_ccursor(ccursor_range.primary),
secondary: galley.from_ccursor(ccursor_range.secondary),
})
})
}
}

124
egui/src/widgets/text_edit/text_buffer.rs

@ -0,0 +1,124 @@
use std::ops::Range;
/// Trait constraining what types [`TextEdit`] may use as
/// an underlying buffer.
///
/// Most likely you will use a `String` which implements `TextBuffer`.
pub trait TextBuffer: AsRef<str> {
/// Can this text be edited?
fn is_mutable(&self) -> bool;
/// Returns this buffer as a `str`.
///
/// This is an utility method, as it simply relies on the `AsRef<str>`
/// implementation.
fn as_str(&self) -> &str {
self.as_ref()
}
/// Reads the given character range.
fn char_range(&self, char_range: Range<usize>) -> &str {
assert!(char_range.start <= char_range.end);
let start_byte = self.byte_index_from_char_index(char_range.start);
let end_byte = self.byte_index_from_char_index(char_range.end);
&self.as_str()[start_byte..end_byte]
}
fn byte_index_from_char_index(&self, char_index: usize) -> usize {
byte_index_from_char_index(self.as_str(), char_index)
}
/// Inserts text `text` into this buffer at character index `char_index`.
///
/// # Notes
/// `char_index` is a *character index*, not a byte index.
///
/// # Return
/// Returns how many *characters* were successfully inserted
fn insert_text(&mut self, text: &str, char_index: usize) -> usize;
/// Deletes a range of text `char_range` from this buffer.
///
/// # Notes
/// `char_range` is a *character range*, not a byte range.
fn delete_char_range(&mut self, char_range: Range<usize>);
/// Clears all characters in this buffer
fn clear(&mut self) {
self.delete_char_range(0..self.as_ref().len());
}
/// Replaces all contents of this string with `text`
fn replace(&mut self, text: &str) {
self.clear();
self.insert_text(text, 0);
}
/// Clears all characters in this buffer and returns a string of the contents.
fn take(&mut self) -> String {
let s = self.as_ref().to_owned();
self.clear();
s
}
}
impl TextBuffer for String {
fn is_mutable(&self) -> bool {
true
}
fn insert_text(&mut self, text: &str, char_index: usize) -> usize {
// Get the byte index from the character index
let byte_idx = self.byte_index_from_char_index(char_index);
// Then insert the string
self.insert_str(byte_idx, text);
text.chars().count()
}
fn delete_char_range(&mut self, char_range: Range<usize>) {
assert!(char_range.start <= char_range.end);
// Get both byte indices
let byte_start = self.byte_index_from_char_index(char_range.start);
let byte_end = self.byte_index_from_char_index(char_range.end);
// Then drain all characters within this range
self.drain(byte_start..byte_end);
}
fn clear(&mut self) {
self.clear();
}
fn replace(&mut self, text: &str) {
*self = text.to_owned();
}
fn take(&mut self) -> String {
std::mem::take(self)
}
}
/// Immutable view of a `&str`!
impl<'a> TextBuffer for &'a str {
fn is_mutable(&self) -> bool {
false
}
fn insert_text(&mut self, _text: &str, _ch_idx: usize) -> usize {
0
}
fn delete_char_range(&mut self, _ch_range: Range<usize>) {}
}
fn byte_index_from_char_index(s: &str, char_index: usize) -> usize {
for (ci, (bi, _)) in s.char_indices().enumerate() {
if ci == char_index {
return bi;
}
}
s.len()
}

105
egui_demo_lib/src/easy_mark/easy_mark_editor.rs

@ -1,4 +1,4 @@
use egui::*;
use egui::{text_edit::CCursorRange, *};
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))]
@ -58,6 +58,8 @@ impl EasyMarkEditor {
ui.checkbox(&mut self.show_rendered, "Show rendered");
});
ui.label("Use ctrl/cmd + key to toggle: B: *strong* C: `code` I: /italics/ L: $lowered$ R: ^raised^ S: ~strikethrough~ U: _underline_");
ui.separator();
if self.show_rendered {
@ -84,7 +86,7 @@ impl EasyMarkEditor {
code, highlighter, ..
} = self;
if self.highlight_editor {
let response = if self.highlight_editor {
let mut layouter = |ui: &egui::Ui, easymark: &str, wrap_width: f32| {
let mut layout_job = highlighter.highlight(ui.visuals(), easymark);
layout_job.wrap_width = wrap_width;
@ -96,12 +98,103 @@ impl EasyMarkEditor {
.desired_width(f32::INFINITY)
.text_style(egui::TextStyle::Monospace) // for cursor height
.layouter(&mut layouter),
);
)
} else {
ui.add(egui::TextEdit::multiline(code).desired_width(f32::INFINITY));
ui.add(egui::TextEdit::multiline(code).desired_width(f32::INFINITY))
};
if let Some(mut state) = TextEdit::load_state(ui.ctx(), response.id) {
if let Some(mut ccursor_range) = state.ccursor_range() {
let any_change = shortcuts(ui, code, &mut ccursor_range);
if any_change {
state.set_ccursor_range(Some(ccursor_range));
state.store(ui.ctx(), response.id);
}
}
}
// let cursor = TextEdit::cursor(response.id);
// TODO: cmd-i, cmd-b, etc for italics, bold, ....
}
}
fn shortcuts(ui: &Ui, code: &mut dyn TextBuffer, ccursor_range: &mut CCursorRange) -> bool {
let mut any_change = false;
for event in &ui.input().events {
if let Event::Key {
key,
pressed: true,
modifiers,
} = event
{
if modifiers.command_only() {
match &key {
// toggle *bold*
Key::B => {
toggle_surrounding(code, ccursor_range, "*");
any_change = true;
}
// toggle `code`
Key::C => {
toggle_surrounding(code, ccursor_range, "`");
any_change = true;
}
// toggle /italics/
Key::I => {
toggle_surrounding(code, ccursor_range, "/");
any_change = true;
}
// toggle $lowered$
Key::L => {
toggle_surrounding(code, ccursor_range, "$");
any_change = true;
}
// toggle ^raised^
Key::R => {
toggle_surrounding(code, ccursor_range, "^");
any_change = true;
}
// toggle ~strikethrough~
Key::S => {
toggle_surrounding(code, ccursor_range, "~");
any_change = true;
}
// toggle _underline_
Key::U => {
toggle_surrounding(code, ccursor_range, "_");
any_change = true;
}
_ => {}
}
}
}
}
any_change
}
/// E.g. toggle *strong* with `toggle(&mut text, &mut cursor, "*")`
fn toggle_surrounding(
code: &mut dyn TextBuffer,
ccursor_range: &mut CCursorRange,
surrounding: &str,
) {
let [primary, secondary] = ccursor_range.sorted();
let surrounding_ccount = surrounding.chars().count();
let prefix_crange = primary.index.saturating_sub(surrounding_ccount)..primary.index;
let suffix_crange = secondary.index..secondary.index.saturating_add(surrounding_ccount);
let already_surrounded = code.char_range(prefix_crange.clone()) == surrounding
&& code.char_range(suffix_crange.clone()) == surrounding;
if already_surrounded {
code.delete_char_range(suffix_crange);
code.delete_char_range(prefix_crange);
ccursor_range.primary.index -= surrounding_ccount;
ccursor_range.secondary.index -= surrounding_ccount;
} else {
code.insert_text(surrounding, secondary.index);
let advance = code.insert_text(surrounding, primary.index);
ccursor_range.primary.index += advance;
ccursor_range.secondary.index += advance;
}
}

Loading…
Cancel
Save