mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 00:41:56 -06:00
The merge of main brought #471's code_editor_marked, whose mark was drawn by the layouter inside the code editor; on this branch that layouter lives in code_field, which had no mark, so the merge did not compile. FieldOptions gains mark (None by default), code_editor_marked passes it, and code_field underlines it as before. Test: the_marked_editor_underlines_its_mark (fails when the field ignores the mark).
734 lines
29 KiB
Rust
734 lines
29 KiB
Rust
//! A small code editor: monospace text with syntax colours, Tab to indent, Enter that keeps the
|
|
//! indentation, and a scroll area that follows the caret. It colours Python (for code in cells,
|
|
//! where `xl()` reads the sheet) and LaTeX (for equation source); reached as
|
|
//! `widgets::code_editor`. The tokenizer and the key edits are pure functions, tested below.
|
|
|
|
use std::ops::Range;
|
|
|
|
use egui::text::{CCursor, CCursorRange};
|
|
use egui::{FontFamily, FontId, Key, Response, TextEdit, Ui};
|
|
|
|
use crate::theme::{self, Syntax, Tokens};
|
|
|
|
/// The language a code editor colours.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Lang {
|
|
Python,
|
|
Latex,
|
|
Plain,
|
|
}
|
|
|
|
/// A coloured span's kind.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Token {
|
|
Keyword,
|
|
String,
|
|
Number,
|
|
Comment,
|
|
Builtin,
|
|
}
|
|
|
|
/// Spaces one Tab inserts.
|
|
pub const INDENT: &str = " ";
|
|
|
|
const PY_KEYWORDS: &[&str] = &[
|
|
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except",
|
|
"finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "match", "case", "nonlocal", "not", "or", "pass", "raise", "return",
|
|
"try", "while", "with", "yield",
|
|
];
|
|
|
|
const PY_BUILTINS: &[&str] = &[
|
|
"abs",
|
|
"all",
|
|
"any",
|
|
"bool",
|
|
"dict",
|
|
"enumerate",
|
|
"filter",
|
|
"float",
|
|
"format",
|
|
"int",
|
|
"isinstance",
|
|
"len",
|
|
"list",
|
|
"map",
|
|
"max",
|
|
"min",
|
|
"print",
|
|
"range",
|
|
"reversed",
|
|
"round",
|
|
"set",
|
|
"sorted",
|
|
"str",
|
|
"sum",
|
|
"tuple",
|
|
"type",
|
|
"zip",
|
|
"xl",
|
|
];
|
|
|
|
/// The coloured spans of `text` (byte ranges on char boundaries, in order, not overlapping).
|
|
/// Anything not covered is plain text. Never panics, whatever the input.
|
|
pub fn highlight(text: &str, lang: Lang) -> Vec<(Range<usize>, Token)> {
|
|
match lang {
|
|
Lang::Python => python(text),
|
|
Lang::Latex => latex(text),
|
|
Lang::Plain => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// End (exclusive, in chars) of the Python string literal opening with quote `c` at char `i`.
|
|
fn python_string_end(chars: &[(usize, char)], i: usize, c: char) -> usize {
|
|
let triple = chars.get(i + 1).is_some_and(|(_, d)| *d == c) && chars.get(i + 2).is_some_and(|(_, d)| *d == c);
|
|
let mut j = if triple { i + 3 } else { i + 1 };
|
|
loop {
|
|
match chars.get(j) {
|
|
None => break,
|
|
Some((_, '\\')) => j += 2,
|
|
Some((_, '\n')) if !triple => break,
|
|
Some((_, d)) if *d == c => {
|
|
if !triple {
|
|
j += 1;
|
|
break;
|
|
}
|
|
if chars.get(j + 1).is_some_and(|(_, d)| *d == c) && chars.get(j + 2).is_some_and(|(_, d)| *d == c) {
|
|
j += 3;
|
|
break;
|
|
}
|
|
j += 1;
|
|
}
|
|
Some(_) => j += 1,
|
|
}
|
|
}
|
|
j.min(chars.len())
|
|
}
|
|
|
|
fn python(text: &str) -> Vec<(Range<usize>, Token)> {
|
|
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
|
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
|
|
let mut out = Vec::new();
|
|
let mut i = 0;
|
|
while let Some(&(start, c)) = chars.get(i) {
|
|
if c == '#' {
|
|
let mut j = i;
|
|
while chars.get(j).is_some_and(|(_, c)| *c != '\n') {
|
|
j += 1;
|
|
}
|
|
out.push((start..end_of(j), Token::Comment));
|
|
i = j;
|
|
} else if c == '"' || c == '\'' {
|
|
let j = python_string_end(&chars, i, c);
|
|
out.push((start..end_of(j), Token::String));
|
|
i = j;
|
|
} else if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|(_, d)| d.is_ascii_digit())) {
|
|
let mut j = i + 1;
|
|
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphanumeric() || *d == '_' || *d == '.') {
|
|
j += 1;
|
|
}
|
|
out.push((start..end_of(j), Token::Number));
|
|
i = j;
|
|
} else if c.is_alphabetic() || c == '_' {
|
|
let mut j = i + 1;
|
|
while chars.get(j).is_some_and(|(_, d)| d.is_alphanumeric() || *d == '_') {
|
|
j += 1;
|
|
}
|
|
let word = text.get(start..end_of(j)).unwrap_or("");
|
|
if PY_KEYWORDS.contains(&word) {
|
|
out.push((start..end_of(j), Token::Keyword));
|
|
} else if PY_BUILTINS.contains(&word) {
|
|
out.push((start..end_of(j), Token::Builtin));
|
|
}
|
|
i = j;
|
|
} else {
|
|
i += 1;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn latex(text: &str) -> Vec<(Range<usize>, Token)> {
|
|
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
|
let end_of = |i: usize| chars.get(i).map(|(b, _)| *b).unwrap_or(text.len());
|
|
let mut out = Vec::new();
|
|
let mut i = 0;
|
|
while let Some(&(start, c)) = chars.get(i) {
|
|
match c {
|
|
'%' => {
|
|
let mut j = i;
|
|
while chars.get(j).is_some_and(|(_, c)| *c != '\n') {
|
|
j += 1;
|
|
}
|
|
out.push((start..end_of(j), Token::Comment));
|
|
i = j;
|
|
}
|
|
'\\' => {
|
|
let mut j = i + 1;
|
|
if chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) {
|
|
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_alphabetic()) {
|
|
j += 1;
|
|
}
|
|
} else if chars.get(j).is_some() {
|
|
j += 1; // a control symbol such as \{ or \%
|
|
}
|
|
out.push((start..end_of(j), Token::Keyword));
|
|
i = j;
|
|
}
|
|
'^' | '_' | '&' => {
|
|
out.push((start..end_of(i + 1), Token::Builtin));
|
|
i += 1;
|
|
}
|
|
d if d.is_ascii_digit() => {
|
|
let mut j = i + 1;
|
|
while chars.get(j).is_some_and(|(_, d)| d.is_ascii_digit() || *d == '.') {
|
|
j += 1;
|
|
}
|
|
out.push((start..end_of(j), Token::Number));
|
|
i = j;
|
|
}
|
|
_ => i += 1,
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn color(s: &Syntax, token: Token) -> egui::Color32 {
|
|
match token {
|
|
Token::Keyword => s.keyword,
|
|
Token::String => s.string,
|
|
Token::Number => s.number,
|
|
Token::Comment => s.comment,
|
|
Token::Builtin => s.builtin,
|
|
}
|
|
}
|
|
|
|
/// The code editor's font: the monospace face at 12.5 px.
|
|
pub fn font() -> FontId {
|
|
FontId::new(12.5, FontFamily::Name(theme::MONO.into()))
|
|
}
|
|
|
|
/// A layout job for `text` with syntax colours.
|
|
pub fn layout_job(text: &str, lang: Lang, font: &FontId, plain: egui::Color32, syntax: &Syntax, wrap_width: f32) -> egui::text::LayoutJob {
|
|
let mut job = egui::text::LayoutJob::default();
|
|
let mut pos = 0usize;
|
|
for (range, token) in highlight(text, lang) {
|
|
if range.start < pos {
|
|
continue;
|
|
}
|
|
if let (Some(before), Some(span)) = (text.get(pos..range.start), text.get(range.clone())) {
|
|
job.append(before, 0.0, egui::TextFormat::simple(font.clone(), plain));
|
|
job.append(span, 0.0, egui::TextFormat::simple(font.clone(), color(syntax, token)));
|
|
pos = range.end;
|
|
}
|
|
}
|
|
job.append(text.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font.clone(), plain));
|
|
job.wrap.max_width = wrap_width;
|
|
job
|
|
}
|
|
|
|
/// Underlines the byte range `mark` of `job` in `color` (an error's position), splitting the
|
|
/// sections it crosses. An empty range marks the character before it (the end of the text, for
|
|
/// an error about missing input). A range past the end of the text is clamped to it, and its
|
|
/// ends are moved out to char boundaries: the mark may come from an earlier version of the text
|
|
/// (the equation editor computes it before this frame's edit), and egui slices the text at every
|
|
/// section boundary.
|
|
pub fn underline(job: &mut egui::text::LayoutJob, mark: Range<usize>, color: egui::Color32) {
|
|
let text = job.text.as_str();
|
|
let floor = |i: usize| (0..=i.min(text.len())).rev().find(|&b| text.is_char_boundary(b)).unwrap_or(0);
|
|
let ceil = |i: usize| (i.min(text.len())..=text.len()).find(|&b| text.is_char_boundary(b)).unwrap_or(text.len());
|
|
let start = floor(mark.start);
|
|
let mut mark = start..ceil(mark.end).max(start);
|
|
if mark.is_empty() {
|
|
let start = text.get(..mark.start).and_then(|t| t.char_indices().next_back()).map_or(0, |(i, _)| i);
|
|
mark = start..mark.start.max(start + text.get(start..).and_then(|t| t.chars().next()).map_or(0, char::len_utf8));
|
|
}
|
|
let mut out = Vec::with_capacity(job.sections.len() + 2);
|
|
for sec in job.sections.drain(..) {
|
|
let r = sec.byte_range.start.0..sec.byte_range.end.0;
|
|
let (a, b) = (mark.start.clamp(r.start, r.end), mark.end.clamp(r.start, r.end));
|
|
for (piece, marked) in [(r.start..a, false), (a..b, true), (b..r.end, false)] {
|
|
if piece.is_empty() {
|
|
continue;
|
|
}
|
|
let mut s = sec.clone();
|
|
s.byte_range = egui::text::ByteIndex(piece.start)..egui::text::ByteIndex(piece.end);
|
|
if marked {
|
|
s.format.underline = egui::Stroke::new(1.5, color);
|
|
s.format.color = color;
|
|
}
|
|
if s.byte_range.start.0 != r.start {
|
|
s.leading_space = 0.0;
|
|
}
|
|
out.push(s);
|
|
}
|
|
}
|
|
job.sections = out;
|
|
}
|
|
|
|
fn byte_of(text: &str, char_index: usize) -> usize {
|
|
text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len())
|
|
}
|
|
|
|
/// Start (char index) of the line holding char `at`.
|
|
fn line_start(text: &str, at: usize) -> usize {
|
|
let b = byte_of(text, at);
|
|
text.get(..b).and_then(|s| s.rfind('\n')).map(|nl| text.get(..nl + 1).map(|s| s.chars().count()).unwrap_or(0)).unwrap_or(0)
|
|
}
|
|
|
|
/// What a key did to the text: the new caret (and selection anchor), as char indices.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Edit {
|
|
pub anchor: usize,
|
|
pub caret: usize,
|
|
}
|
|
|
|
/// Enter: a newline that keeps the current line's indentation (one level more after a Python `:`).
|
|
/// `sel` is the selection as char indices (anchor, caret); it is replaced.
|
|
pub fn newline(text: &mut String, sel: (usize, usize), lang: Lang) -> Edit {
|
|
let count = text.chars().count();
|
|
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
let ls = line_start(text, a);
|
|
let line_before: String = text.chars().skip(ls).take(a - ls.min(a)).collect();
|
|
let mut indent: String = line_before.chars().take_while(|c| *c == ' ' || *c == '\t').collect();
|
|
if lang == Lang::Python && line_before.trim_end().ends_with(':') {
|
|
indent.push_str(INDENT);
|
|
}
|
|
let insert = format!("\n{indent}");
|
|
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
text.replace_range(ba..bb, &insert);
|
|
let caret = a + insert.chars().count();
|
|
Edit { anchor: caret, caret }
|
|
}
|
|
|
|
/// Typed text (or a paste) replacing the selection.
|
|
pub fn insert(text: &mut String, sel: (usize, usize), s: &str) -> Edit {
|
|
let count = text.chars().count();
|
|
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
text.replace_range(ba..bb, s);
|
|
let caret = a + s.chars().count();
|
|
Edit { anchor: caret, caret }
|
|
}
|
|
|
|
/// Backspace (`back`) or Delete: the selection goes, or the character before or after the caret.
|
|
pub fn delete(text: &mut String, sel: (usize, usize), back: bool) -> Edit {
|
|
let count = text.chars().count();
|
|
let (mut a, mut b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
if a == b {
|
|
if back {
|
|
a = a.saturating_sub(1);
|
|
} else {
|
|
b = (b + 1).min(count);
|
|
}
|
|
}
|
|
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
text.replace_range(ba..bb, "");
|
|
Edit { anchor: a, caret: a }
|
|
}
|
|
|
|
/// An edit the editor makes itself, read off an input event by `classify`.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum EditKey<'a> {
|
|
Insert(&'a str),
|
|
Enter,
|
|
Tab { dedent: bool },
|
|
Backspace,
|
|
Delete,
|
|
}
|
|
|
|
/// What one input event means to the editor. See `apply_in_order`.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Class<'a> {
|
|
/// The editor applies it: typed text, a paste, Enter, Tab, Shift+Tab, Backspace, Delete.
|
|
Edit(EditKey<'a>),
|
|
/// The editor takes it off the queue and does nothing: Tab under a command or option
|
|
/// modifier, which the field would turn into a tab character in place of the selection.
|
|
Dropped,
|
|
/// The text field acts on it: a caret or selection key, a command shortcut (select all,
|
|
/// undo, word deletion), IME, Copy, Cut, or a click, which the field reads from the pointer
|
|
/// state.
|
|
Field,
|
|
/// Neither reads it from the queue: a key release, a printable key press (its character
|
|
/// arrives as the `Text` event after it, also under Shift or Option), a pointer move, Escape
|
|
/// (the focus system acts on it when the pass begins).
|
|
Other,
|
|
}
|
|
|
|
fn classify(ev: &egui::Event) -> Class<'_> {
|
|
use egui::Event;
|
|
match ev {
|
|
// The field treats a lone newline as the Enter key's business, and ignores it.
|
|
Event::Text(t) if t != "\n" && t != "\r" => Class::Edit(EditKey::Insert(t)),
|
|
Event::Text(_) => Class::Other,
|
|
Event::Paste(t) => Class::Edit(EditKey::Insert(t)),
|
|
Event::Key { key, pressed: true, modifiers, .. } => {
|
|
let command = modifiers.command || modifiers.ctrl || modifiers.mac_cmd;
|
|
match key {
|
|
// The field makes a newline of Enter under any modifier but a command one, and
|
|
// a tab character of Tab under every modifier; the editor takes all of those.
|
|
Key::Enter if !command => Class::Edit(EditKey::Enter),
|
|
Key::Tab if !command && !modifiers.alt => Class::Edit(EditKey::Tab { dedent: modifiers.shift }),
|
|
Key::Tab => Class::Dropped,
|
|
Key::Backspace if modifiers.is_none() => Class::Edit(EditKey::Backspace),
|
|
Key::Delete if modifiers.is_none() => Class::Edit(EditKey::Delete),
|
|
// The keys egui's text field reads: caret and selection moves, word and line
|
|
// deletion, and its command shortcuts (select all, undo, redo, the Emacs keys).
|
|
Key::Enter | Key::Backspace | Key::Delete => Class::Field,
|
|
Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End => Class::Field,
|
|
_ if command => Class::Field,
|
|
_ => Class::Other,
|
|
}
|
|
}
|
|
Event::Copy | Event::Cut | Event::Ime(_) | Event::PointerButton { pressed: true, .. } => Class::Field,
|
|
_ => Class::Other,
|
|
}
|
|
}
|
|
|
|
/// Whether an event is part of the keyboard stream, whose order the editor keeps. Pointer,
|
|
/// touch, scroll and window events are not: egui folds them into its input state when the
|
|
/// pass begins, so they act in the pass they arrive in whatever their place among the keys.
|
|
fn is_keyboard(ev: &egui::Event) -> bool {
|
|
use egui::Event;
|
|
matches!(ev, Event::Key { .. } | Event::Text(_) | Event::Paste(_) | Event::Copy | Event::Cut | Event::Ime(_))
|
|
}
|
|
|
|
fn apply(key: EditKey<'_>, text: &mut String, sel: (usize, usize), lang: Lang) -> Edit {
|
|
match key {
|
|
EditKey::Insert(s) => insert(text, sel, s),
|
|
EditKey::Enter => newline(text, sel, lang),
|
|
EditKey::Tab { dedent } => indent(text, sel, dedent),
|
|
EditKey::Backspace => delete(text, sel, true),
|
|
EditKey::Delete => delete(text, sel, false),
|
|
}
|
|
}
|
|
|
|
/// What `apply_in_order` did with a pass's events.
|
|
#[derive(Debug, Default, PartialEq)]
|
|
pub struct Applied {
|
|
/// Whether the text or the selection changed.
|
|
pub edited: bool,
|
|
/// The keyboard events held back for the next pass, in order: the editor's first key after
|
|
/// an event the text field acts on, and every keyboard event after that key.
|
|
pub held: Vec<egui::Event>,
|
|
/// The caller's key the pass stopped at (see [`apply_in_order_until`]).
|
|
pub stop: Option<egui::Event>,
|
|
}
|
|
|
|
/// Applies the pass's input to `text` in the order it came. A pass can carry several events
|
|
/// (typing between repaints, key repeat), and two readers take them: this editor, for typed
|
|
/// text, pastes, Enter, Tab, Shift+Tab, Backspace and Delete, and then egui's text field, for
|
|
/// the caret and selection keys, command shortcuts, IME and clicks. The editor runs first, so
|
|
/// it can only apply its keys up to the first event the field acts on: its edits before that
|
|
/// event are applied and taken from the queue, the field's event and the run after it stay in
|
|
/// the queue for the field, and from the editor's next key on the keyboard events are held
|
|
/// back (`Applied::held`) for the next pass, where `code_editor` puts them ahead of that
|
|
/// pass's input and they go through this again. So select all, Tab in one pass indents every
|
|
/// line in two passes, and the field never gets an Enter or a Tab of a focused editor.
|
|
/// Printable key presses do not split the stream: the field reads the `Text` event that
|
|
/// follows each one, not the key. `sel` is the selection as char indices (anchor, caret),
|
|
/// updated.
|
|
pub fn apply_in_order(events: &mut Vec<egui::Event>, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied {
|
|
apply_in_order_until(events, text, sel, lang, &|_| false)
|
|
}
|
|
|
|
/// [`apply_in_order`], stopping at the first event `stop` picks: a key the caller acts on itself
|
|
/// (commit, cancel, a mode switch). It counts as one of the editor's keys for the order: the
|
|
/// typing before it is applied first, and after an event the text field acts on it is held back
|
|
/// for the next pass with the rest. Once the pass stops (`Applied::stop`), the keyboard events
|
|
/// after the stop key are dropped: the caller acts on the text as it was when the key was
|
|
/// pressed, and what followed it was typed after it.
|
|
pub fn apply_in_order_until(
|
|
events: &mut Vec<egui::Event>,
|
|
text: &mut String,
|
|
sel: &mut (usize, usize),
|
|
lang: Lang,
|
|
stop: &dyn Fn(&egui::Event) -> bool,
|
|
) -> Applied {
|
|
let mut out = Applied::default();
|
|
// An event the field acts on has gone by: the editor's keys after it wait for the next pass.
|
|
let mut field_seen = false;
|
|
// A key is waiting: every keyboard event after it waits too, so the order holds.
|
|
let mut holding = false;
|
|
let mut keep = Vec::with_capacity(events.len());
|
|
for ev in std::mem::take(events) {
|
|
if out.stop.is_some() {
|
|
if !is_keyboard(&ev) {
|
|
keep.push(ev);
|
|
}
|
|
continue;
|
|
}
|
|
if holding && is_keyboard(&ev) {
|
|
out.held.push(ev);
|
|
continue;
|
|
}
|
|
if is_keyboard(&ev) && stop(&ev) {
|
|
if field_seen {
|
|
holding = true;
|
|
out.held.push(ev);
|
|
} else {
|
|
out.stop = Some(ev);
|
|
}
|
|
continue;
|
|
}
|
|
match classify(&ev) {
|
|
Class::Edit(_) if field_seen => {
|
|
holding = true;
|
|
out.held.push(ev);
|
|
}
|
|
Class::Edit(key) => {
|
|
let e = apply(key, text, *sel, lang);
|
|
*sel = (e.anchor, e.caret);
|
|
out.edited = true;
|
|
}
|
|
Class::Dropped => {}
|
|
Class::Field => {
|
|
field_seen = true;
|
|
keep.push(ev);
|
|
}
|
|
Class::Other => keep.push(ev),
|
|
}
|
|
}
|
|
*events = keep;
|
|
out
|
|
}
|
|
|
|
/// The keyboard events a code editor held back, with the pass that held them.
|
|
#[derive(Clone, Debug, Default)]
|
|
struct Held {
|
|
pass: u64,
|
|
events: Vec<egui::Event>,
|
|
}
|
|
|
|
fn held_id(id: egui::Id) -> egui::Id {
|
|
id.with("held")
|
|
}
|
|
|
|
/// The events the editor `id` held back in the previous pass, to go ahead of this pass's input
|
|
/// (see `apply_in_order`). Only the editor they were typed into gets them, only while it still
|
|
/// has the focus, and only in the pass right after: once the focus has moved on, or the editor
|
|
/// was not shown in between, they are dropped rather than typed into another widget.
|
|
fn take_held(ui: &Ui, id: egui::Id, focused: bool) -> Vec<egui::Event> {
|
|
let pass = ui.ctx().cumulative_pass_nr();
|
|
ui.ctx()
|
|
.data_mut(|d| d.remove_temp::<Held>(held_id(id)))
|
|
.filter(|h| focused && h.pass.checked_add(1) == Some(pass))
|
|
.map(|h| h.events)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Holds `events` back for the editor's next pass, and asks for that pass.
|
|
fn hold(ui: &Ui, id: egui::Id, events: Vec<egui::Event>) {
|
|
let pass = ui.ctx().cumulative_pass_nr();
|
|
ui.ctx().data_mut(|d| d.insert_temp(held_id(id), Held { pass, events }));
|
|
ui.ctx().request_repaint();
|
|
}
|
|
|
|
/// Tab (indent) or Shift+Tab (dedent). A selection over several lines indents or dedents each
|
|
/// line; otherwise Tab inserts four spaces at the caret.
|
|
pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit {
|
|
let count = text.chars().count();
|
|
let (a, b) = (sel.0.min(sel.1).min(count), sel.0.max(sel.1).min(count));
|
|
let multi = text.chars().skip(a).take(b - a).any(|c| c == '\n');
|
|
if !dedent && !multi {
|
|
let (ba, bb) = (byte_of(text, a), byte_of(text, b));
|
|
text.replace_range(ba..bb, INDENT);
|
|
let caret = a + INDENT.len();
|
|
return Edit { anchor: caret, caret };
|
|
}
|
|
// Line starts (char indices) of every line the selection touches. A selection that ends at
|
|
// the start of the next line (`"a\n"` in `"a\nb"`) does not touch that line.
|
|
let mut starts = vec![line_start(text, a)];
|
|
for (i, c) in text.chars().enumerate().skip(a).take(b - a) {
|
|
if c == '\n' && i + 1 < b {
|
|
starts.push(i + 1);
|
|
}
|
|
}
|
|
let (mut na, mut nb) = (a, b);
|
|
// Work from the last line back so earlier indices stay valid.
|
|
for &s in starts.iter().rev() {
|
|
let bs = byte_of(text, s);
|
|
let n = if dedent {
|
|
let n = text.get(bs..).map(|rest| rest.chars().take(INDENT.len()).take_while(|c| *c == ' ').count()).unwrap_or(0);
|
|
text.replace_range(bs..bs + n, "");
|
|
n
|
|
} else {
|
|
text.insert_str(bs, INDENT);
|
|
0
|
|
};
|
|
let shift = |p: usize| {
|
|
if dedent {
|
|
if p > s { p - n.min(p - s) } else { p }
|
|
} else if p >= s {
|
|
p + INDENT.len()
|
|
} else {
|
|
p
|
|
}
|
|
};
|
|
na = shift(na);
|
|
nb = shift(nb);
|
|
}
|
|
if sel.1 >= sel.0 { Edit { anchor: na, caret: nb } } else { Edit { anchor: nb, caret: na } }
|
|
}
|
|
|
|
/// The code editor. `max_lines` rows are shown before it scrolls; the scroll area follows the
|
|
/// caret.
|
|
pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response {
|
|
code_editor_marked(ui, id, text, lang, max_lines, None)
|
|
}
|
|
|
|
/// [`code_editor`] with the byte range `mark` underlined in the error colour (see [`underline`]).
|
|
pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize, mark: Option<Range<usize>>) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font()));
|
|
let max_lines = max_lines.clamp(1, 200);
|
|
let mut response = None;
|
|
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
|
let options = FieldOptions { max_height: row_h * max_lines as f32, mark: mark.clone(), ..FieldOptions::new(lang) };
|
|
response = Some(code_field(ui, id, text, &options).response);
|
|
});
|
|
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()))
|
|
}
|
|
|
|
/// How a [`code_field`] is laid out, and the keys its caller takes.
|
|
pub struct FieldOptions<'a> {
|
|
pub lang: Lang,
|
|
/// The field's width (`f32::INFINITY`: all there is).
|
|
pub width: f32,
|
|
/// The height shown before it scrolls; the scroll area follows the caret.
|
|
pub max_height: f32,
|
|
/// Whether it takes input. A dialog or message box over the field has the keyboard.
|
|
pub interactive: bool,
|
|
/// Keys the caller acts on itself, in input order (see [`apply_in_order_until`]).
|
|
pub stop: &'a dyn Fn(&egui::Event) -> bool,
|
|
/// Esc stays with the field (for a caller that stops at it) instead of releasing the focus.
|
|
pub keep_escape: bool,
|
|
/// Puts the caret here (a char index) before the pass's input: the caller changed the text.
|
|
pub caret: Option<usize>,
|
|
/// The field's name for screen readers.
|
|
pub name: &'a str,
|
|
/// A byte range underlined in the error colour (see [`underline`]).
|
|
pub mark: Option<Range<usize>>,
|
|
}
|
|
|
|
impl FieldOptions<'_> {
|
|
pub fn new(lang: Lang) -> FieldOptions<'static> {
|
|
FieldOptions {
|
|
lang,
|
|
width: f32::INFINITY,
|
|
max_height: 100.0,
|
|
interactive: true,
|
|
stop: &|_| false,
|
|
keep_escape: false,
|
|
caret: None,
|
|
// The editor is a generic code field, so the name is not part of the UI's translated
|
|
// strings unless the caller gives one.
|
|
name: "Code Editor",
|
|
mark: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What a [`code_field`] laid out and did.
|
|
pub struct FieldOutput {
|
|
pub response: Response,
|
|
/// The caller's key the pass stopped at, if any.
|
|
pub stop: Option<egui::Event>,
|
|
/// Laid-out rows of the text.
|
|
pub lines: usize,
|
|
/// The caret on screen, while the field has the focus.
|
|
pub caret: Option<egui::Rect>,
|
|
/// The caret and the other end of the selection (char indices).
|
|
pub selection: (usize, usize),
|
|
}
|
|
|
|
/// The code editor's text field without a frame: monospace text with syntax colours, Enter that
|
|
/// keeps the indentation, Tab and Shift+Tab, applied in input order (see [`apply_in_order`]).
|
|
pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldOptions<'_>) -> FieldOutput {
|
|
let t = Tokens::get(ui.ctx());
|
|
let font = font();
|
|
let lang = options.lang;
|
|
// The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in
|
|
// input order with the typing around it, before the text field sees the rest; what had to
|
|
// wait for the field in the previous pass comes first.
|
|
let focused = options.interactive && ui.memory(|m| m.has_focus(id));
|
|
let held = take_held(ui, id, focused);
|
|
let mut edited = None;
|
|
let mut stop = None;
|
|
let count = text.chars().count();
|
|
if let Some(caret) = options.caret {
|
|
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
|
let caret = caret.min(count);
|
|
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(caret))));
|
|
state.store(ui.ctx(), id);
|
|
edited = Some(Edit { anchor: caret, caret });
|
|
}
|
|
if focused {
|
|
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
|
let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count));
|
|
let applied = ui.input_mut(|i| {
|
|
if !held.is_empty() {
|
|
let mut events = held;
|
|
events.append(&mut i.events);
|
|
i.events = events;
|
|
}
|
|
apply_in_order_until(&mut i.events, text, &mut sel, lang, options.stop)
|
|
});
|
|
if applied.edited {
|
|
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1))));
|
|
state.store(ui.ctx(), id);
|
|
edited = Some(Edit { anchor: sel.0, caret: sel.1 });
|
|
}
|
|
if !applied.held.is_empty() {
|
|
hold(ui, id, applied.held);
|
|
}
|
|
stop = applied.stop;
|
|
}
|
|
let syntax = t.syntax;
|
|
let plain = t.text;
|
|
let danger = t.danger;
|
|
let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
|
let mut job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width);
|
|
if let Some(m) = options.mark.clone() {
|
|
underline(&mut job, m, danger);
|
|
}
|
|
ui.fonts_mut(|f| f.layout_job(job))
|
|
};
|
|
let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: options.keep_escape };
|
|
let scrolled =
|
|
egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(options.max_height.max(1.0)).auto_shrink([false, true]).show(ui, |ui| {
|
|
let out = TextEdit::multiline(text)
|
|
.id(id)
|
|
.font(font.clone())
|
|
.code_editor()
|
|
.frame(egui::Frame::NONE)
|
|
.desired_width(options.width)
|
|
.desired_rows(1)
|
|
.interactive(options.interactive)
|
|
.event_filter(filter)
|
|
.layouter(&mut layouter)
|
|
.show(ui);
|
|
if let Some(e) = edited {
|
|
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
|
|
ui.scroll_to_rect(caret, None);
|
|
}
|
|
out
|
|
});
|
|
let out = scrolled.inner;
|
|
// The field has no visible label; name it for screen readers and the strict kittest
|
|
// accessibility check.
|
|
let mut response = out.response.response.accessible_name(options.name);
|
|
if edited.is_some() {
|
|
response.mark_changed();
|
|
}
|
|
let selection = out.cursor_range.map(|r| (r.primary.index.into(), r.secondary.index.into())).unwrap_or((count, count));
|
|
let caret = out
|
|
.cursor_range
|
|
.map(|r| out.galley.pos_from_cursor(r.primary).translate(out.galley_pos.to_vec2()))
|
|
.filter(|_| response.has_focus())
|
|
.map(|r| r.intersect(scrolled.inner_rect.expand(1.0)));
|
|
FieldOutput { response, stop, lines: out.galley.rows.len().max(1), caret, selection }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|