diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..f11e9a008 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -181,6 +181,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | +| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -254,6 +255,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | +| 2026-10-11 | minor | UI building blocks; progress log row | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/crates/ui-egui/src/cell_pictures.rs b/crates/ui-egui/src/cell_pictures.rs index 461014022..c28167c4d 100644 --- a/crates/ui-egui/src/cell_pictures.rs +++ b/crates/ui-egui/src/cell_pictures.rs @@ -130,7 +130,12 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc) { painter.image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } } else { - crate::icons::paint(&painter, cell.shrink(2.0), crate::icons::Icon::Picture, Color32::from_gray(130)); + crate::icons::paint_on_sheet( + &painter, + cell.shrink(2.0), + crate::icons::Icon::Picture, + crate::theme::Tokens::get(painter.ctx()).sheet_control_border, + ); } } diff --git a/crates/ui-egui/src/chartview.rs b/crates/ui-egui/src/chartview.rs index fad08ff22..fb3dbd8dd 100644 --- a/crates/ui-egui/src/chartview.rs +++ b/crates/ui-egui/src/chartview.rs @@ -8,7 +8,7 @@ use gridcraft_engine::model::{Anchor, ShapeKind, Sheet, Workbook}; use crate::SheetApp; use crate::grid::Geo; -use crate::theme; +use crate::theme::{self, Tokens}; fn rgba(c: [u8; 4]) -> Color32 { Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]) @@ -310,7 +310,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo } ShapeKind::Icon => { if let Some(icon) = crate::icons::from_name(&sp.text) { - crate::icons::paint(p, r, icon, if fill == Color32::TRANSPARENT { Color32::BLACK } else { fill }); + let t = Tokens::get(p.ctx()); + crate::icons::paint_on_sheet(p, r, icon, if fill == Color32::TRANSPARENT { t.cell_text } else { fill }); } label = false; } @@ -341,7 +342,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo if let Some(tex) = crate::floating_pictures::texture(p.ctx(), doc, im, r) { p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } else { - p.rect_filled(r, 0.0, Color32::from_gray(230)); + p.rect_filled(r, 0.0, Tokens::get(p.ctx()).sheet_track); } if app.selected_chart == Some(im.id) { selection_frame(p, r); @@ -356,7 +357,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo let start = p.add(egui::Shape::Noop); // Soft shadow + white card. p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18)); - p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg); let data = gridcraft_chart::resolve(wb, si, ch); let m = EguiMeasure { painter: p, z: geo.z }; let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m); @@ -373,9 +374,10 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo && rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name)) { let rr = geo.range_rect(sh, rf.range()); + let color = Tokens::get(p.ctx()).ref_color(0, true); for pane in geo.range_panes(rf.range()) { let pp = p.with_clip_rect(pane.intersect(p.clip_rect())); - pp.rect_stroke(rr, 0.0, Stroke::new(1.5, Color32::from_rgb(0x2E, 0x6F, 0xD8)), StrokeKind::Inside); + pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside); } } } @@ -447,7 +449,7 @@ fn paint_slicers(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usiz if !r.contains(tr.left_top()) { continue; // overflowed the card; skip (no scroll yet) } - let (fill, fg) = if *on { (tk.accent, Color32::WHITE) } else { (tk.hover, tk.text_dim) }; + let (fill, fg) = if *on { (tk.accent, tk.on_accent) } else { (tk.hover, tk.text_dim) }; p.rect_filled(*tr, 3.0 * geo.z, fill); let cp = p.with_clip_rect(tr.intersect(p.clip_rect())); cp.text(tr.left_center() + vec2(6.0 * geo.z, 0.0), Align2::LEFT_CENTER, val, theme::ui_font(12.0 * geo.z), fg); @@ -491,10 +493,11 @@ pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> } fn selection_frame(p: &Painter, r: Rect) { - p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(120)), StrokeKind::Outside); + let t = Tokens::get(p.ctx()); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Outside); for c in [r.left_top(), r.center_top(), r.right_top(), r.left_center(), r.right_center(), r.left_bottom(), r.center_bottom(), r.right_bottom()] { - p.circle_filled(c, 4.0, Color32::WHITE); - p.circle_stroke(c, 4.0, Stroke::new(1.0, Color32::from_gray(100))); + p.circle_filled(c, 4.0, t.sheet_control_bg); + p.circle_stroke(c, 4.0, Stroke::new(1.0, t.sheet_control_border)); } let _ = Align2::CENTER_CENTER; } diff --git a/crates/ui-egui/src/code_editor.rs b/crates/ui-egui/src/code_editor.rs new file mode 100644 index 000000000..906ddf3f1 --- /dev/null +++ b/crates/ui-egui/src/code_editor.rs @@ -0,0 +1,938 @@ +//! 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, Token)> { + match lang { + Lang::Python => python(text), + Lang::Latex => latex(text), + Lang::Plain => Vec::new(), + } +} + +fn python(text: &str) -> Vec<(Range, 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 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, + } + } + let j = j.min(chars.len()); + 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, 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 +} + +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, +} + +/// 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, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> 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 holding && is_keyboard(&ev) { + out.held.push(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, +} + +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 { + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx() + .data_mut(|d| d.remove_temp::(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) { + 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 { + let t = Tokens::get(ui.ctx()); + let font = font(); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let max_lines = max_lines.clamp(1, 200); + // 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 = ui.memory(|m| m.has_focus(id)); + let held = take_held(ui, id, focused); + let mut edited = None; + if focused { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let count = text.chars().count(); + 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(&mut i.events, text, &mut sel, lang) + }); + 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); + } + } + let syntax = t.syntax; + let plain = t.text; + let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { + let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width); + ui.fonts_mut(|f| f.layout_job(job)) + }; + let frame_h = row_h * max_lines as f32 + 8.0; + 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| { + egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.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(f32::INFINITY) + .desired_rows(1) + .lock_focus(true) + .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); + } + let mut r = out.response.response; + if edited.is_some() { + r.mark_changed(); + } + response = Some(r); + }); + }); + response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())) +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::Modifiers; + + fn spans(text: &str, lang: Lang) -> Vec<(&str, Token)> { + highlight(text, lang).into_iter().map(|(r, k)| (text.get(r).unwrap_or("?"), k)).collect() + } + + #[test] + fn python_tokens() { + let s = spans("def f(x):\n return len(x) + 1.5 # done\nxl(\"A1:B2\")", Lang::Python); + assert_eq!( + s, + vec![ + ("def", Token::Keyword), + ("return", Token::Keyword), + ("len", Token::Builtin), + ("1.5", Token::Number), + ("# done", Token::Comment), + ("xl", Token::Builtin), + ("\"A1:B2\"", Token::String), + ] + ); + assert_eq!(spans("s = '''a\n'b'\nc'''", Lang::Python), vec![("'''a\n'b'\nc'''", Token::String)]); + assert_eq!(spans("x = 'it\\'s'", Lang::Python), vec![("'it\\'s'", Token::String)]); + assert_eq!(spans("hello", Lang::Plain), vec![]); + } + + #[test] + fn latex_tokens() { + let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex); + assert_eq!( + s, + vec![ + ("\\frac", Token::Keyword), + ("^", Token::Builtin), + ("2", Token::Number), + ("3", Token::Number), + ("% half", Token::Comment), + ("\\{", Token::Keyword), + ] + ); + } + + #[test] + fn hostile_input_never_panics_and_spans_stay_on_char_boundaries() { + let inputs = [ + "", + "'", + "\"", + "'''", + "\"\"\"x", + "#", + "\\", + "%", + "1", + ".", + "é'ü\\", + "θ = 'π' # ∑", + "\u{1F600}'\u{1F600}", + "'\\", + "\\\u{1F600}", + "\u{200B}\n\t'''\n", + "0x_é", + "a.b.c", + "____", + ]; + for text in inputs { + for lang in [Lang::Python, Lang::Latex, Lang::Plain] { + let mut last = 0; + for (r, _) in highlight(text, lang) { + assert!(r.start >= last && r.start <= r.end && r.end <= text.len(), "{text:?} {lang:?} {r:?}"); + assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {lang:?} {r:?}"); + last = r.end; + } + let job = layout_job(text, lang, &FontId::default(), egui::Color32::BLACK, &Tokens::light().syntax, f32::INFINITY); + assert_eq!(job.text, text, "every character is laid out once"); + } + } + } + + #[test] + fn enter_keeps_indentation_and_indents_after_a_colon() { + let mut s = " x = 1".to_string(); + let e = newline(&mut s, (9, 9), Lang::Python); + assert_eq!(s, " x = 1\n "); + assert_eq!(e.caret, 14); + let mut s = "if x:".to_string(); + let e = newline(&mut s, (5, 5), Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(e.caret, 10); + // Plain text keeps the indentation but adds none after a colon. + let mut s = " a:".to_string(); + newline(&mut s, (4, 4), Lang::Plain); + assert_eq!(s, " a:\n "); + // A selection is replaced; out-of-range carets are clamped. + let mut s = "abcdef".to_string(); + let e = newline(&mut s, (4, 2), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("ab\nef", 3)); + let mut s = "é".to_string(); + let e = newline(&mut s, (99, 99), Lang::Plain); + assert_eq!((s.as_str(), e.caret), ("é\n", 2)); + } + + #[test] + fn shift_tab_in_the_editor_dedents_rather_than_indents() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + let mut fonts = egui::FontDefinitions::default(); + let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default(); + fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono); + ui.ctx().set_fonts(fonts); + return; + } + let id = egui::Id::new("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + " x".to_string(), + ); + h.run_steps(2); + let tab = |modifiers| egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers }; + h.input_mut().events.push(tab(Modifiers::SHIFT)); + h.run_steps(1); + assert_eq!(h.state(), " x"); + // A plain Tab still inserts spaces at the caret (the end). + h.input_mut().events.push(tab(Modifiers::NONE)); + h.run_steps(1); + assert_eq!(h.state(), " x "); + } + + /// Gives the context the editor's monospace family (the test has no craft fonts). + fn install_mono(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default(); + fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono); + ctx.set_fonts(fonts); + } + + fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, text: &mut String| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::new("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, text, Lang::Python, 5); + }, + text.to_string(), + ); + h.run_steps(2); + h + } + + fn key(key: Key, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } + } + + #[test] + fn batched_input_applies_in_order_and_keeps_every_key() { + // Events gather between repaints (typing, key repeat). The text typed before an Enter + // comes first, and two Enters make two lines. + let mut h = editor_harness(""); + h.input_mut().events.extend([egui::Event::Text("if x:".into()), key(Key::Enter, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n "); + // Backspace and Tab in the same frame as the typing around them. + h.input_mut().events.extend([ + egui::Event::Text("y = 1".into()), + key(Key::Backspace, Modifiers::NONE), + egui::Event::Text("2".into()), + key(Key::Tab, Modifiers::NONE), + ]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 "); + // An event the field acts on (an arrow) keeps the order too: the text typed before it + // is applied first, the field moves the caret, and the Enter after it waits for the + // next pass, where the editor applies it at the moved caret, with the indentation. + h.input_mut().events.extend([egui::Event::Text("a".into()), key(Key::ArrowLeft, Modifiers::NONE), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 a", "the Enter waits for the arrow"); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \n y = 2 \n a"); + } + + #[test] + fn a_selection_ending_at_a_line_start_leaves_that_line_alone() { + // Selecting "a\n" in "a\nb" ends at the start of the second line, which is not selected. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 2), false); + assert_eq!(s, " a\nb"); + let mut s = " a\n b".to_string(); + indent(&mut s, (0, 6), true); + assert_eq!(s, "a\n b"); + // One character into the second line, both lines are touched. + let mut s = "a\nb".to_string(); + indent(&mut s, (0, 3), false); + assert_eq!(s, " a\n b"); + } + + #[test] + fn typed_text_and_deletions_replace_the_selection() { + let mut s = "abcd".to_string(); + assert_eq!(insert(&mut s, (3, 1), "X"), Edit { anchor: 2, caret: 2 }); + assert_eq!(s, "aXd"); + assert_eq!(delete(&mut s, (2, 2), true), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "ad"); + assert_eq!(delete(&mut s, (1, 1), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + assert_eq!(delete(&mut s, (0, 0), true), Edit { anchor: 0, caret: 0 }); + assert_eq!(delete(&mut s, (9, 9), false), Edit { anchor: 1, caret: 1 }); + assert_eq!(s, "a"); + let mut s = "éa".to_string(); + delete(&mut s, (2, 0), true); + assert_eq!(s, ""); + // The queue keeps what the editor does not handle, and everything after it. + let mut events = vec![ + egui::Event::Text("x".into()), + egui::Event::Text("\n".into()), + key(Key::ArrowLeft, Modifiers::NONE), + key(Key::Enter, Modifiers::NONE), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(out.edited); + assert_eq!((s.as_str(), sel), ("x", (1, 1))); + assert_eq!(events.len(), 2, "the lone newline and the arrow stay for the field: {events:?}"); + assert_eq!(out.held, vec![key(Key::Enter, Modifiers::NONE)], "the Enter after the arrow waits for the next pass"); + let mut events = vec![egui::Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]; + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert!(!out.edited && out.held.is_empty(), "a key release is nothing to apply"); + assert_eq!(events.len(), 1); + } + + #[test] + fn select_all_then_tab_in_one_pass_indents_every_line() { + // Cmd+A is the field's, Tab the editor's: the Tab waits for the pass after the + // selection and indents the selected lines, rather than reaching the field, which would + // replace the selection with a tab character. + let mut h = editor_harness("a\nb"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "a\nb", "the Tab waits for the selection"); + h.run_steps(1); + assert_eq!(h.state(), " a\n b"); + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::SHIFT)]); + h.run_steps(2); + assert_eq!(h.state(), "a\nb"); + } + + #[test] + fn a_keystroke_sequence_as_the_platform_sends_it_keeps_the_editors_enter() { + // egui-winit sends a Key event before each Text event. Those presses are nobody's to + // read, so they do not hand the Enter to the field, which knows no indentation. + let mut h = editor_harness(""); + let typed = |k, c: &str, m| [key(k, m), egui::Event::Text(c.into())]; + let mut events = Vec::new(); + events.extend(typed(Key::I, "i", Modifiers::NONE)); + events.extend(typed(Key::F, "f", Modifiers::NONE)); + events.extend(typed(Key::Space, " ", Modifiers::NONE)); + events.extend(typed(Key::X, "x", Modifiers::NONE)); + events.extend(typed(Key::Colon, ":", Modifiers::SHIFT)); + events.push(key(Key::Enter, Modifiers::NONE)); + h.input_mut().events.extend(events); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n "); + // A character typed with Option (macOS) comes as a key under that modifier and its text; + // the field reads neither the key nor Option, so the Enter after it is still the editor's. + h.input_mut().events.extend([key(Key::E, Modifiers::ALT), egui::Event::Text("\u{e9}".into()), key(Key::Enter, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state(), "if x:\n \u{e9}\n "); + } + + #[test] + fn tab_and_enter_under_modifiers_never_reach_the_field() { + // The field would make a tab character of Ctrl+Tab (in place of the selection) and a + // newline of Shift+Enter; Cmd+Enter is nobody's here and stays for a shortcut. + let mut events = vec![key(Key::Tab, Modifiers::CTRL), key(Key::Enter, Modifiers::SHIFT), key(Key::Enter, Modifiers::COMMAND)]; + let (mut s, mut sel) = ("if x:".to_string(), (5, 5)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!(s, "if x:\n "); + assert_eq!(events, vec![key(Key::Enter, Modifiers::COMMAND)]); + assert!(out.held.is_empty()); + } + + #[test] + fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() { + let mut events = vec![ + key(Key::X, Modifiers::NONE), + egui::Event::Text("x".into()), + key(Key::A, Modifiers::COMMAND), + key(Key::Tab, Modifiers::NONE), + egui::Event::PointerMoved(egui::Pos2::ZERO), + key(Key::ArrowLeft, Modifiers::NONE), + egui::Event::Text("y".into()), + ]; + let (mut s, mut sel) = (String::new(), (0, 0)); + let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python); + assert_eq!((s.as_str(), sel), ("x", (1, 1)), "the typing before the select-all is applied"); + assert_eq!(events, vec![key(Key::X, Modifiers::NONE), key(Key::A, Modifiers::COMMAND), egui::Event::PointerMoved(egui::Pos2::ZERO)]); + assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("y".into())]); + } + + #[test] + fn held_back_events_land_in_the_next_pass_only() { + let mut h = editor_harness(""); + let id = egui::Id::new("code"); + let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; + // What the pass just completed held back comes in with the next one. + let last = h.ctx.cumulative_pass_nr() - 1; + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "a"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + // Anything older is dropped rather than typed out of context, and cleared. + h.ctx.data_mut(|d| d.insert_temp(held_id(id), held(last, "b"))); + h.run_steps(1); + assert_eq!(h.state(), "a"); + assert!(h.ctx.data(|d| d.get_temp::(held_id(id))).is_none()); + } + + #[test] + fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { + // Typed into the editor, they must not land in whatever has the focus next. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let (code, other) = (egui::Id::new("code"), egui::Id::new("other")); + ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); + code_editor(ui, code, &mut s.0, Lang::Python, 5); + ui.add(TextEdit::singleline(&mut s.1).id(other)); + }, + (String::new(), String::new(), true), + ); + h.run_steps(2); + h.input_mut().events.extend([key(Key::ArrowLeft, Modifiers::NONE), egui::Event::Text("x".into())]); + h.run_steps(1); + assert_eq!(h.state().0, "", "the x waits for the arrow"); + h.state_mut().2 = false; + h.run_steps(2); + assert_eq!((h.state().0.as_str(), h.state().1.as_str()), ("", "")); + } + + #[test] + fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { + // When a widget asks egui to discard and repeat the pass, the repeat pass gets the + // held-back keys, with the selection the field made in the first pass. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( + |ui, s: &mut (String, bool)| { + if ui.ctx().cumulative_pass_nr() == 0 { + install_mono(ui.ctx()); + return; + } + let id = egui::Id::new("code"); + ui.memory_mut(|m| m.request_focus(id)); + code_editor(ui, id, &mut s.0, Lang::Python, 5); + if std::mem::take(&mut s.1) { + ui.ctx().request_discard("the test repeats this pass"); + } + }, + ("a\nb".to_string(), false), + ); + h.run_steps(2); + h.state_mut().1 = true; + h.input_mut().events.extend([key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE)]); + h.run_steps(1); + assert_eq!(h.state().0, " a\n b"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e83db2fa1..33b4e8fc7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1592,7 +1592,7 @@ fn paste_special(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: *confirm = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)).fill(t.accent)); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } @@ -1675,10 +1675,8 @@ fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let lang = crate::i18n::current(ui.ctx()); - let ok = ui.add( - egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE)) - .fill(crate::theme::Tokens::get(ui.ctx()).accent), - ); + let t = crate::theme::Tokens::get(ui.ctx()); + let ok = ui.add(egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(t.on_accent)).fill(t.accent)); if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) { *confirm = true; } @@ -3066,8 +3064,8 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool ); ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let solve = - ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent)); + let t = crate::theme::Tokens::get(ui.ctx()); + let solve = ui.add(egui::Button::new(egui::RichText::new(" Solve ").color(t.on_accent)).fill(t.accent)); if solve.clicked() && !text(d, "objective").trim().is_empty() { *confirm = true; } @@ -3142,7 +3140,7 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo let word = it["word"].as_str().unwrap_or("").to_string(); let cell = it["cell"].as_str().unwrap_or("").to_string(); ui.label(format!("Not in Dictionary ({cell}):")); - ui.label(egui::RichText::new(&word).strong().color(Color32::from_rgb(0xC4, 0x2B, 0x1C))); + ui.label(egui::RichText::new(&word).strong().color(crate::theme::Tokens::get(ui.ctx()).danger)); let sugg: Vec = it["suggestions"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(); ui.label("Suggestions:"); diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 8984ad53c..72afcf282 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -1,20 +1,10 @@ //! In-cell / formula-bar editing state: point mode, reference colouring, function //! autocomplete and argument hints. -use egui::Color32; use gridcraft_engine::core::{CellRef, RangeRef}; -/// Colours for references in a formula being edited (our own palette). -pub const REF_COLORS: [Color32; 8] = [ - Color32::from_rgb(0x2E, 0x6F, 0xD8), - Color32::from_rgb(0xD0, 0x3C, 0x3C), - Color32::from_rgb(0x7A, 0x4F, 0xC9), - Color32::from_rgb(0x2B, 0x9A, 0x4A), - Color32::from_rgb(0xC2, 0x5E, 0x1E), - Color32::from_rgb(0x1D, 0x92, 0xA6), - Color32::from_rgb(0xB8, 0x3B, 0x8E), - Color32::from_rgb(0x8A, 0x7A, 0x1F), -]; +/// How many distinct reference colours a formula cycles through (`Tokens::ref_colors`). +pub const REF_COLOR_COUNT: usize = 8; #[derive(Clone, Debug)] pub struct EditState { @@ -293,7 +283,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, seen.len() - 1 } }; - out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len())); + out.push((start + offset, end + offset, range, sheet, idx % REF_COLOR_COUNT)); continue; } i += 1; diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..20c8ea496 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -1,65 +1,149 @@ //! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget. -use egui::{Color32, FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; +use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2}; use serde_json::json; use crate::SheetApp; -use crate::editor::{REF_COLORS, formula_refs}; +use crate::editor::formula_refs; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +/// Lines the formula bar (and the in-cell editor) grows to before it scrolls. +pub const MAX_LINES: usize = 5; +/// The bar's height at one line. +const ONE_LINE: f32 = 30.0; +/// What the bar adds around the field's rows: the panel's margins (3 + 3), the field's (3 + 3) +/// and two pixels for the rounding to the pixel grid. +const PAD: f32 = 14.0; + +/// The bar's height for one line of a font whose rows are `row_h` tall: Excel's 30 px, unless +/// the rows need more (the Windows UI font's row is 17.28 px). Every line counted must fit in +/// the field, whatever the font; `n` lines take `one_line(row_h) + (n - 1) * row_h`. +pub fn one_line(row_h: f32) -> f32 { + ONE_LINE.max(row_h + PAD) +} +/// How long the bar takes to grow or shrink to a new line count. +const EASE_SECONDS: f32 = 0.12; + +/// Where the formula bar put its parts last frame (for tests). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BarLayout { + pub name_box: Rect, + /// ✕ ✓ fx. + pub buttons: Rect, + pub field: Rect, + pub height: f32, + /// Laid-out lines of the content (wrapped to the field), and how many show without scrolling. + pub lines: usize, + pub visible_lines: usize, + /// The caret while editing in the bar. + pub caret: Option, +} + +impl Default for BarLayout { + fn default() -> Self { + BarLayout { name_box: Rect::NOTHING, buttons: Rect::NOTHING, field: Rect::NOTHING, height: 0.0, lines: 1, visible_lines: 1, caret: None } + } +} + +impl BarLayout { + pub fn id() -> egui::Id { + egui::Id::new("formula_bar.layout") + } + + /// Last frame's layout. + pub fn get(ctx: &egui::Context) -> BarLayout { + ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default() + } +} + +/// What the editor widget laid out: its line count and caret. +#[derive(Clone, Copy, Debug, Default)] +pub struct EditorInfo { + pub lines: usize, + pub caret: Option, +} + pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let expanded = app.ui.formula_bar_expanded; - let h = if expanded { 72.0 } else { 30.0 }; + let font = theme::ui_font(13.0); + let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font)); + let last = BarLayout::get(ui.ctx()); + // Height follows the content's line count (last frame's), 1 to MAX_LINES; the chevron pins it + // at MAX_LINES. It eases only when that target changes. + let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) }; + let target = one_line(row_h) + (lines - 1) as f32 * row_h; + let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS); + let mut layout = BarLayout { height: h, ..Default::default() }; egui::Panel::top("formula_bar") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 })) .show(ui, |ui| { - ui.horizontal(|ui| { + // Top-aligned: the Name Box and buttons stay on the first line as the field grows. + ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| { ui.spacing_mut().item_spacing.x = 4.0; - name_box(app, ui, &t); + layout.name_box = name_box(app, ui, &t); ui.add_space(4.0); let editing = app.editor.is_some(); let c = if editing { t.text } else { t.text_disabled }; - if crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 22.0)).clicked() && editing { + let cancel = crate::widgets::icon_button(ui, Icon::Close, c, "Cancel", vec2(22.0, 24.0)); + if cancel.clicked() && editing { app.cancel_edit(); } - if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 22.0)).clicked() && editing { + if crate::widgets::icon_button(ui, Icon::Check, c, "Enter", vec2(22.0, 24.0)).clicked() && editing { app.commit_edit(0, 0, false, false); } - if crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 22.0)).clicked() { + let fx = crate::widgets::icon_button(ui, Icon::Fx, t.text, "Insert Function", vec2(26.0, 24.0)); + if fx.clicked() { app.open_dialog("insertFunction", json!({})); } + layout.buttons = cancel.rect.union(fx.rect); ui.add_space(4.0); - // The formula field. + // The formula field fills the rest, as tall as the bar. let avail = ui.available_width() - 24.0; - let field = Rect::from_min_size(ui.cursor().min, vec2(avail, h - 6.0)); + let field = Rect::from_min_size(ui.cursor().min, vec2(avail, ui.available_height())); ui.painter().rect_filled(field, 4.0, t.input_bg); ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0)))); + let inner = field.shrink2(vec2(6.0, 3.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); let id = egui::Id::new("gridcraft.formula_bar"); + // A spill child shows its anchor's formula muted and read-only. let read_only = active_spill_anchor(app).is_some(); - if app.editor.is_some() { - editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0); + let info = if app.editor.is_some() { + editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height()) } else { // Show the active cell's content; clicking starts editing in the bar. let text = active_input(app); let color = if read_only { t.text_disabled } else { t.text }; - let resp = child - .add(egui::Label::new(egui::RichText::new(text).font(theme::ui_font(13.0)).color(color)).truncate().sense(Sense::click())); - let click = child.interact(field, id.with("click"), Sense::click()); - if !read_only && (resp.clicked() || click.clicked()) { + let galley = child.painter().layout(text, font.clone(), color, inner.width()); + let lines = galley.rows.len(); + // Each cell's view starts at its top: the offset is kept per cell, not per bar. + let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active.row, d.selection.active.col)); + egui::ScrollArea::vertical().id_salt(id.with(("view", shown))).max_height(inner.height()).auto_shrink([false, true]).show( + &mut child, + |ui| { + if ui.add(egui::Label::new(galley).sense(Sense::click())).clicked() && !read_only { + app.begin_edit(None, true); + } + }, + ); + if !read_only && ui.interact(field, id.with("click"), Sense::click()).clicked() { app.begin_edit(None, true); } - } - ui.allocate_space(vec2(avail, h - 6.0)); - let chevron = if expanded { Icon::ChevronUp } else { Icon::Chevron }; - if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 22.0)).clicked() { + EditorInfo { lines, caret: None } + }; + layout.field = field; + layout.lines = info.lines.max(1); + layout.visible_lines = ((inner.height() / row_h).floor() as usize).max(1); + layout.caret = info.caret; + ui.allocate_space(vec2(avail, field.height())); + let chevron = if app.ui.formula_bar_expanded { Icon::ChevronUp } else { Icon::Chevron }; + if crate::widgets::icon_button(ui, chevron, t.text_dim, "Expand formula bar", vec2(20.0, 24.0)).clicked() { app.ui.formula_bar_expanded = !app.ui.formula_bar_expanded; } }); }); + ui.ctx().data_mut(|d| d.insert_temp(BarLayout::id(), layout)); } /// Resolve ownership on every frame; never cache a spill range in presentation state. @@ -101,7 +185,7 @@ fn active_input(app: &SheetApp) -> String { String::new() } -fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { +fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect { let id = egui::Id::new("gridcraft.name_box"); let shown = app.name_box_text(); let mut text = app.name_box.clone().unwrap_or(shown.clone()); @@ -158,6 +242,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { } } }); + rect } /// Backspace and Delete take back the AutoComplete proposal (Excel's selected rest), not a typed character. @@ -172,9 +257,33 @@ fn entry_shrank(chars_before: usize, text: &str) -> bool { text.chars().count() < chars_before } -/// The text editor used in the cell and in the formula bar (they share `app.editor`). -pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) { - let Some(mut ed) = app.editor.take() else { return }; +/// The editor's text field, the same in the cell and in the formula bar. Enter is the editor's +/// own key (it commits, and Alt+Enter breaks the line), so the field itself does nothing with it: +/// a message box over the editor takes Enter to close, and the field must not add a line then. +fn show_edit<'t>( + edit: TextEdit<'t>, + ui: &mut egui::Ui, + id: egui::Id, + font: FontId, + width: f32, + layouter: &'t mut dyn FnMut(&egui::Ui, &dyn egui::TextBuffer, f32) -> std::sync::Arc, +) -> egui::text_edit::TextEditOutput { + edit.id(id) + .frame(egui::Frame::NONE) + .font(font) + .desired_width(width) + .desired_rows(1) + .layouter(layouter) + .lock_focus(true) + .return_key(None) + .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) + .show(ui) +} + +/// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows +/// `max_height` of text and scrolls beyond that, keeping the caret in view. +pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo { + let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar")); // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); @@ -293,8 +402,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: } // The text widget. let refs = formula_refs(&ed.text); - let text_color = Tokens::get(ui.ctx()).text; - let cell_text_color = if id == egui::Id::new("gridcraft.cell_editor") { Color32::BLACK } else { text_color }; + let t = Tokens::get(ui.ctx()); + let on_sheet = id == egui::Id::new("gridcraft.cell_editor"); + let cell_text_color = if on_sheet { t.cell_text } else { t.text }; let font2 = font.clone(); let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { let s = buf.as_str(); @@ -307,7 +417,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: continue; } job.append(s.get(pos..a).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color)); - job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), REF_COLORS[idx])); + job.append(s.get(a..b).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), t.ref_color(idx, on_sheet))); pos = b; } job.append(s.get(pos..).unwrap_or(""), 0.0, egui::TextFormat::simple(font2.clone(), cell_text_color)); @@ -315,24 +425,32 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.fonts_mut(|f| f.layout_job(job)) }; let chars_before = ed.text.chars().count(); - // The in-cell editor is as tall as its lines (a multiline field reserves four rows). - let in_cell = id == egui::Id::new("gridcraft.cell_editor"); - let edit = if multiline || ed.text.contains('\n') { - TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 }) - } else { - TextEdit::singleline(&mut ed.text) - }; - let mut output = edit - .id(id) - .frame(egui::Frame::NONE) - .font(font) - .desired_width(width) - .layouter(&mut layouter) - .lock_focus(true) - .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - .show(ui); - let resp = output.response.clone(); let synced = mine && (ed.request_focus || ed.sync_caret); + let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text) } else { TextEdit::singleline(&mut ed.text) }; + let caret_index = ed.caret; + // The in-cell editor grows with its text and never scrolls, so the field keeps its whole width + // for the text (a scroll area would take the bar's width off it and wrap the text narrower + // than the cell does). The formula bar shows its lines and scrolls past them. + let mut output = if on_sheet { + show_edit(edit, ui, id, font, width, &mut layouter) + } else { + egui::ScrollArea::vertical() + .id_salt(id.with("scroll")) + .max_height(max_height.max(1.0)) + .auto_shrink([false, true]) + .show(ui, |ui| { + let output = show_edit(edit, ui, id, font, width, &mut layouter); + // A caret moved by our own keys (Alt+Enter, F4, point mode) isn't the TextEdit's + // doing, so it doesn't scroll to it itself. + if synced { + let caret = output.galley.pos_from_cursor(egui::text::CCursor::new(caret_index)).translate(output.galley_pos.to_vec2()); + ui.scroll_to_rect(caret.expand(2.0), None); + } + output + }) + .inner + }; + let resp = output.response.clone(); if synced { if ed.request_focus { resp.request_focus(); @@ -388,11 +506,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: let g = ui.painter().layout_no_wrap( rest.to_string(), output.galley.job.sections.first().map(|s| s.format.font_id.clone()).unwrap_or_default(), - Color32::WHITE, + t.on_accent, ); let r = egui::Rect::from_min_size(pos, g.size()); - ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8)); - ui.painter().galley(pos, g, Color32::WHITE); + ui.painter().rect_filled(r, 0.0, t.accent); + ui.painter().galley(pos, g, t.on_accent); } // Autocomplete list and argument hint under the editor. if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { @@ -456,6 +574,8 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }); }); } + let lines = output.galley.rows.len().max(1); + let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2())); app.editor = Some(ed); if cancel { app.cancel_edit(); @@ -466,6 +586,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: app.commit_edit(dr, dc, array, fill); } } + EditorInfo { lines, caret } } fn cycle_anchor(ed: &mut crate::editor::EditState) { @@ -686,4 +807,21 @@ mod tests { assert!(entry_shrank(4, "Som")); assert!(!entry_shrank(4, "Same")); } + + #[test] + fn one_line_fits_a_row_of_any_font() { + // macOS and Linux UI fonts: rows of 15 to 16 px keep Excel's 30 px bar. + assert_eq!(one_line(15.6), 30.0); + assert_eq!(one_line(16.0), 30.0); + // The Windows UI font's 17.28 px rows need more: the panel's and the field's margins take + // 12 px, and rounding to the pixel grid up to another one, so 30 px held only four rows + // of five (a tester's finding). Every counted line must fit in what is left. + for row_h in [17.0_f32, 17.28, 18.0, 20.0] { + for lines in 1..=MAX_LINES { + let bar = one_line(row_h) + (lines - 1) as f32 * row_h; + let inner = bar - 13.0; + assert!((inner / row_h).floor() as usize >= lines, "{lines} lines of {row_h} px in a {bar} px bar"); + } + } + } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index b485104b1..a656d90ef 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -12,7 +12,7 @@ use gridcraft_engine::model::{BorderLine, BorderStyle, Color, HAlign, LineIndex, use serde_json::json; use crate::SheetApp; -use crate::editor::{EditState, REF_COLORS}; +use crate::editor::EditState; use crate::theme::{self, Tokens}; pub const COL_HEADER_H: f32 = 21.0; @@ -1227,7 +1227,7 @@ pub(crate) fn paint_quadrant( StrokeKind::Inside, ); if on { - crate::icons::paint(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); + crate::icons::paint_on_sheet(p, b.shrink(1.5), crate::icons::Icon::Check, Color32::WHITE); } continue; } @@ -1558,9 +1558,15 @@ pub fn table_headings(geo: &Geo, sh: &Sheet, active: CellRef) -> Vec