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
This commit is contained in:
parent
ddd52f47c5
commit
c7638ca7f5
11 changed files with 810 additions and 533 deletions
|
@ -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
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
File diff suppressed because it is too large
Load diff
130
egui/src/widgets/text_edit/cursor_range.rs
Normal file
130
egui/src/widgets/text_edit/cursor_range.rs
Normal file
|
@ -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
Normal file
10
egui/src/widgets/text_edit/mod.rs
Normal file
|
@ -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
Normal file
16
egui/src/widgets/text_edit/output.rs
Normal file
|
@ -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
Normal file
85
egui/src/widgets/text_edit/state.rs
Normal file
|
@ -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
Normal file
124
egui/src/widgets/text_edit/text_buffer.rs
Normal file
|
@ -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()
|
||||
}
|
|
@ -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…
Reference in a new issue