mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
UI: theme tokens with a contrast test, ribbon overflow arrows and split button, multi-line formula bar, code editor widget (#456)
* UI: theme tokens for icon, reference and sheet-control colours; WCAG contrast test
Tokens gains the colours the chrome was hard-coding, each with a light
and a dark value: on_accent (text on an accent fill), an eight-colour
category palette (theme::Cat), the paper colour inside icons, reference
colours for the formula bar and for the sheet, the message box warning
disc, the tab bar scroll thumb, and sheet_control_* colours that follow
the sheet and so stay light in both themes.
What now comes from the tokens:
- icons: paper (white pages glared in dark mode) and the accent
constants; icons placed on the sheet use icons::paint_on_sheet, which
keeps the light colours;
- reference colours (editor::REF_COLORS), which were hard to read on the
dark formula field;
- white text on the accent in OK, Solve, slicer tiles and the AutoSave
switch, 2:1 on the dark accent;
- the comments pane (a white card with light text in dark mode), gallery
hover and swatch outlines, the message box disc, the tab bar thumb,
chart selection frames, and the sheet's filter and validation buttons.
A unit test (theme::contrast_tests) checks every token pair used by the
ribbon, popups, dialogs, panes, formula bar, status bar and sheet in
both themes against WCAG 2: 4.5:1 for text and 3:1 for glyphs. It caught
the dark danger red (3.5:1 on hover) and the selected header text (4.1:1
light), which are adjusted.
* UI: ribbon scroll arrows, large split button for Formulas › AutoSum
- widgets::big_split_button: the icon runs the default action, the
label and its arrow open the menu. It returns both responses, with
accessible names ("AutoSum" and "AutoSum options"). big_button shares
its label layout.
- Formulas › AutoSum uses it: the icon inserts SUM as before, and the
arrow, which used to do the same, opens the Sum / Average / Count /
Max / Min / More Functions menu that Home › AutoSum already had
(shared as autosum_menu). Its keytip is unchanged.
- When a tab's groups are wider than the window, arrows at the card's
edges scroll them; before, only the mouse wheel did and nothing showed
that the tab went on. ribbon::RibbonOverflow records the groups' and
the card's sizes each frame.
- A test checks every tab fits the card's three rows. It found View ›
Show stacking four checkboxes, with Headings cut off; Show is now two
columns (Ruler, Gridlines / Formula Bar, Headings).
Tests: tests/ribbon_layout.rs (the split button's two parts, AutoSum's
icon and menu, the scroll arrows, the card height).
* UI: formula bar grows from one to five lines; code editor widget
The formula bar is a top-aligned row: the Name Box and the cancel,
enter and fx buttons stay on the first line while the field grows. Its
height is the content's laid-out line count (Alt+Enter or wrapped text)
times the font's row height, from one line (30 px, as before) to five,
then the field scrolls with the caret in view. This holds while editing
and while viewing a cell. The height eases over 120 ms when the line
count changes, and the chevron pins it at five lines (it used to give a
fixed 72 px). The in-cell editor likewise stops at five lines and
scrolls. formula_bar::BarLayout records where the parts went, for tests.
widgets::code_editor(ui, id, &mut text, Lang, max_lines) is a small
code editor for features that edit code: the monospace font, syntax
colours from Tokens::syntax (new, with light and dark values in the
contrast test) for Python (keywords, strings including triple-quoted
ones, numbers, comments and builtins) and LaTeX (commands, scripts and
comments), Tab inserting four spaces (or indenting every selected line,
Shift+Tab dedenting), Enter keeping the line's indentation (one level
more after a Python colon), and a scroll area that follows the caret.
The tokenizer and the edits are pure functions with tests, including
hostile input that must keep spans on char boundaries.
kittest (tests/formula_bar.rs) covers Alt+Enter up to eight lines with
the Name Box and buttons fixed and the caret visible, the commit,
viewing the committed cell, the chevron, the ease, the in-cell cap and
the code editor's Tab and Enter.
* UI: Shift+Tab in the code editor dedents instead of indenting
The editor consumed plain Tab before Shift+Tab, and egui's key match ignores Shift
when the pattern lacks it, so a Shift+Tab was taken as Tab and indented the line.
Shift+Tab is now consumed first; a plain Tab still inserts spaces.
* UI: the ribbon card stays inside a narrow window, so its right scroll arrow is on screen
The row above the ribbon can be wider than a narrow window, and the card took that width, so the
right scroll arrow was drawn past the window's edge and couldn't be clicked: below about 640 px on
macOS, and at 700 px with Linux's wider default fonts, where CI's ribbon test failed. The card is
now kept within the window with the same margin it has on the left.
* UI: each ribbon tab keeps a scroll offset of its own
The ribbon's scroll area used one identity for every tab, so scrolling an overflowing tab and
switching to another overflowing one hid that tab's first groups. Each tab now has its own scroll
state: a tab opened after another was scrolled starts at its left edge, and a tab keeps its offset
while others are used.
* UI: the in-cell editor keeps its whole width for the text and shows every line
Merged with main, the grid's own test asked the in-cell editor to wrap its
text at the field's full width and to show every line (the editor never
scrolls); the editor widget wrapped it inside the formula bar's scroll area,
which took the bar's width off the text (168 px tall where 156 was right),
and the grid capped it at five lines. The in-cell editor now shows the text
field on its own, through the one show_edit helper the formula bar also
uses, and grows with every line; the formula bar keeps its five lines and
its caret scroll. The branch's five-line test for the cell editor becomes a
grows-with-every-line test.
* Docs: the UI building blocks' revision rows carry the date they land
The rows said 2026-10-11, older than upstream rows above them, and the
ui-parity row said the in-cell editor is capped at five lines.
* UI: the formula bar's view of a cell starts at the cell's top
The bar's read-only view kept one scroll offset for every cell, so a cell
shown after scrolling another's text started scrolled. The offset is kept
per cell now.
* UI: the editor's field does nothing with Enter on its own
The editor handles Enter (commit) and Alt+Enter (a line break) itself, and a
message box over the editor takes Enter to close. The field under it is a
multi-line TextEdit since the bar grew, whose own return key would add a
line if it ever saw Enter; return_key(None) keeps that from depending on
where focus sits. A test shows Enter over a message box adds nothing to the
entry (focus is off the field then, so it passed before too: this is
hardening, not a repair). The doc comment split by the show_edit helper is
mended.
* Docs: ui-parity says the in-cell editor shows every line, and its revision table is whole
A sentence still said the in-cell editor stops at five lines, and a blank
line between the table's separator and its first row ended the table.
* UI: the split button's tooltip carries the shortcut hint like every ribbon button's
big_split_button built its tooltip with the bare translation, so AutoSum,
which became a split button, lost its shortcut hint. It uses tip_text now;
the split-button test hovers the button and looks for the hint.
* UI: a selection that ends at the start of a line leaves that line alone
Selecting "a\n" in "a\nb" and pressing Tab also indented b, and Shift+Tab
could take its indentation: the line after the selection was counted as
touched. It is touched only when the selection reaches into it.
* UI: the code editor applies its keys in input order, with the typing around them
The editor consumed Enter, Tab and Shift+Tab before the text field saw the
frame's events, so a frame carrying typed text followed by Enter (events
gather between repaints, and under key repeat) put the newline before the
text, and two Enters in one frame made one line. The editor now walks the
frame's events in order and applies typed text, pastes, Enter, Tab,
Shift+Tab, Backspace and Delete itself, taking them from the queue; the
first event it does not handle and everything after it stay for the field,
which runs next, so the order holds either way.
* UI: the code editor keeps the input order past the keys the text field handles
The editor applied its keys up to the first event it did not handle and
left that event and everything after it to the text field, which has
its own Tab and Enter: select all followed by Tab in one frame replaced
the code with a tab character, and since the platform sends a key event
before each text event, an Enter after ordinary typing lost its
indentation.
The editor now applies its keys up to the first event the field acts on
(a caret or selection key, a key under a command modifier, IME, a click);
that event and the run after it stay for the field; from the editor's
next key on, the keyboard events are held back under the editor's id for
its next pass, where it puts them ahead of that pass's input and splits
again. They go only to the editor they were typed into, only while it
still has the focus, and only in the pass right after; otherwise they
are dropped rather than typed into another widget. Printable key presses
do not split the stream. Tab under a command or option modifier is taken
off the queue, and Enter under Shift or Option is the editor's newline,
so the field never gets a Tab or an Enter of a focused editor.
* UI: the ribbon layout and formula bar tests join the crate's integration test binary
Since #2372 the crate's integration tests are one binary built from tests/it/, one module per file; only tests that set process-wide state stay separate. These two set none.
* UI: the formula bar's one-line height follows the font, so five lines fit on Windows
One line was a fixed 30 px and the bar grew by one row height per line.
The panel's and the field's margins take 12 px of that, and rounding to
the pixel grid up to another, so a row taller than 17 px lost the last
counted line: with the Windows UI font (17.28 px rows) five lines showed
four, and bar_grows_to_five_lines_then_scrolls_with_name_box_pinned
failed there.
formula_bar::one_line(row_h) is 30 px unless the rows need more, and
every counted line fits whatever the font. The kittest heights are
computed from it rather than from 30, and a unit test checks the
arithmetic for rows of 15.6 to 20 px.
* UI: Paste Special's OK button uses the on-accent colour
It was the one OK button still drawn white on the accent: 2.14:1 in the dark theme, where every other OK uses on_accent (7.1:1).
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -130,7 +130,12 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<usize>, Token)> {
|
||||
match lang {
|
||||
Lang::Python => python(text),
|
||||
Lang::Latex => latex(text),
|
||||
Lang::Plain => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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 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<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
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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<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 {
|
||||
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>(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");
|
||||
}
|
||||
}
|
||||
@@ -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<String> =
|
||||
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:");
|
||||
|
||||
@@ -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<String>,
|
||||
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;
|
||||
|
||||
@@ -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<Rect>,
|
||||
}
|
||||
|
||||
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<Rect>,
|
||||
}
|
||||
|
||||
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::Galley>,
|
||||
) -> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-11
@@ -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<TableHeadin
|
||||
}
|
||||
|
||||
fn paint_filter_button(p: &Painter, r: Rect, filtered: bool) {
|
||||
p.rect_filled(r, 2.0, Color32::from_gray(250));
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(170)), StrokeKind::Inside);
|
||||
crate::icons::paint(p, r.shrink(1.5), if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron }, Color32::from_gray(70));
|
||||
let t = Tokens::get(p.ctx());
|
||||
p.rect_filled(r, 2.0, t.sheet_control_bg);
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
|
||||
crate::icons::paint_on_sheet(
|
||||
p,
|
||||
r.shrink(1.5),
|
||||
if filtered { crate::icons::Icon::Filter } else { crate::icons::Icon::Chevron },
|
||||
t.sheet_control_text,
|
||||
);
|
||||
}
|
||||
|
||||
/// The outline segments of merged area `m` drawn at `rect`: the top and left edges come from the
|
||||
@@ -1789,7 +1795,7 @@ pub(crate) fn paint_selection(
|
||||
continue;
|
||||
}
|
||||
let rr = geo.range_rect(sh, r);
|
||||
let col = REF_COLORS[idx];
|
||||
let col = Tokens::get(p.ctx()).ref_color(idx, true);
|
||||
p.rect_filled(rr, 0.0, col.gamma_multiply(0.12));
|
||||
p.rect_stroke(rr, 0.0, Stroke::new(1.5, col), StrokeKind::Inside);
|
||||
for corner in [rr.left_top(), rr.right_top(), rr.left_bottom(), rr.right_bottom()] {
|
||||
@@ -3149,6 +3155,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
let size = st.font.size * 96.0 / 72.0 * geo.z;
|
||||
let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));
|
||||
let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };
|
||||
// Grows with the text: the in-cell editor shows every line and never scrolls (the formula bar
|
||||
// is the one that stops at five lines).
|
||||
let shown = lines;
|
||||
// Wrap Text: the editor keeps the column's width and wraps, growing down as Excel's does.
|
||||
// Otherwise it widens to the text's one-line width.
|
||||
let wrap = st.align.wrap;
|
||||
@@ -3156,7 +3165,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
// The field wraps at the width it is given (the editor's less its 2 px inset a side): its
|
||||
// frame is `Frame::NONE`, so no text margin is taken off.
|
||||
let wrapped = ui.painter().layout(ed.text.clone(), font.clone(), Color32::BLACK, (rect.width() - 4.0).max(1.0));
|
||||
(rect.width(), (wrapped.size().y.max(size * 1.3 * lines) + 4.0).max(rect.height()))
|
||||
(rect.width(), (wrapped.size().y.max(size * 1.3 * shown) + 4.0).max(rect.height()))
|
||||
} else {
|
||||
let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x;
|
||||
// Excel keeps the cell's height for one line and only grows for extra lines.
|
||||
@@ -3184,7 +3193,8 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
|
||||
// theme, on the white paper): pick black or white against the fill, like Excel.
|
||||
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0);
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("gridcraft.cell_editor.rect"), erect));
|
||||
}
|
||||
|
||||
/// Test the popup itself, not whether the pointer is over any part of the app.
|
||||
@@ -3662,9 +3672,10 @@ pub(crate) fn paint_overlays(
|
||||
if app.editor.is_none()
|
||||
&& let Some(r) = validation_arrow(sh, geo, sel.active)
|
||||
{
|
||||
p.rect_filled(r, 2.0, Color32::from_gray(245));
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_gray(160)), StrokeKind::Inside);
|
||||
crate::icons::paint(p, r.shrink(3.0), crate::icons::Icon::Chevron, Color32::from_gray(60));
|
||||
let t = Tokens::get(p.ctx());
|
||||
p.rect_filled(r, 2.0, t.sheet_control_bg);
|
||||
p.rect_stroke(r, 2.0, Stroke::new(1.0, t.sheet_control_border), StrokeKind::Inside);
|
||||
crate::icons::paint_on_sheet(p, r.shrink(3.0), crate::icons::Icon::Chevron, t.sheet_control_text);
|
||||
}
|
||||
// Input message of the active cell's validation, hidden while its list is open: it would cover the items.
|
||||
if app.editor.is_none()
|
||||
|
||||
+154
-121
@@ -1,9 +1,13 @@
|
||||
//! GridCraft's icon set, drawn in code on a 16×16 design grid (original artwork; see
|
||||
//! ATTRIBUTION.md). Large ribbon icons use the same drawings at 32×32 with accent colours.
|
||||
//! Accent and paper colours come from the theme tokens, so icons follow light and dark mode;
|
||||
//! icons placed on the sheet ([`paint_on_sheet`]) keep the light colours, as the sheet does.
|
||||
|
||||
use egui::epaint::{CubicBezierShape, PathShape, PathStroke};
|
||||
use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2};
|
||||
|
||||
use crate::theme::{Cat, Tokens};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Icon {
|
||||
Paste,
|
||||
@@ -184,25 +188,57 @@ impl G<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accent palette for icons (our own).
|
||||
pub const GREEN: Color32 = Color32::from_rgb(0x10, 0x7C, 0x41);
|
||||
pub const BLUE: Color32 = Color32::from_rgb(0x2B, 0x7C, 0xD3);
|
||||
pub const RED: Color32 = Color32::from_rgb(0xD1, 0x3B, 0x2F);
|
||||
pub const YELLOW: Color32 = Color32::from_rgb(0xF2, 0xC3, 0x2E);
|
||||
pub const ORANGE: Color32 = Color32::from_rgb(0xE8, 0x7D, 0x2B);
|
||||
pub const PURPLE: Color32 = Color32::from_rgb(0x88, 0x55, 0xC8);
|
||||
/// The colours an icon draws with besides its main colour: the paper inside documents and cells,
|
||||
/// and the category palette for its coloured parts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Palette {
|
||||
paper: Color32,
|
||||
cat: [Color32; 8],
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`.
|
||||
impl Palette {
|
||||
fn of(t: &Tokens) -> Palette {
|
||||
Palette { paper: t.paper, cat: t.cat }
|
||||
}
|
||||
|
||||
fn cat(&self, c: Cat) -> Color32 {
|
||||
self.cat.get(c as usize).copied().unwrap_or(Color32::GRAY)
|
||||
}
|
||||
|
||||
/// A pale wash of a category colour over the paper (cell fills inside icons).
|
||||
fn wash(&self, c: Cat) -> Color32 {
|
||||
self.cat(c).gamma_multiply(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette.
|
||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx())));
|
||||
}
|
||||
|
||||
/// Draws `icon` on the sheet, which stays white in both themes, so with the light palette.
|
||||
pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::light()));
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with palette `pal`.
|
||||
fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
||||
let side = rect.width().min(rect.height());
|
||||
let r = Rect::from_center_size(rect.center(), vec2(side, side));
|
||||
let g = G { p, r, s: side / 16.0 };
|
||||
let light = c.gamma_multiply(0.45);
|
||||
let paper = pal.paper;
|
||||
let blue = pal.cat(Cat::Blue);
|
||||
let green = pal.cat(Cat::Green);
|
||||
let red = pal.cat(Cat::Red);
|
||||
let orange = pal.cat(Cat::Orange);
|
||||
let purple = pal.cat(Cat::Purple);
|
||||
let yellow = pal.cat(Cat::Amber);
|
||||
match icon {
|
||||
Icon::Paste => {
|
||||
g.rect(3.0, 2.5, 9.0, 12.0, Some(Color32::from_rgb(0xC8, 0x9B, 0x5C)), None);
|
||||
g.rect(5.0, 1.5, 5.0, 2.5, Some(Color32::from_rgb(0x8A, 0x8A, 0x8A)), None);
|
||||
g.rect(6.5, 6.0, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(3.0, 2.5, 9.0, 12.0, Some(yellow), None);
|
||||
g.rect(5.0, 1.5, 5.0, 2.5, Some(pal.cat(Cat::Slate)), None);
|
||||
g.rect(6.5, 6.0, 7.5, 9.0, Some(paper), Some(c));
|
||||
for y in [8.5, 10.5, 12.5] {
|
||||
g.stroke(&[(8.0, y), (12.5, y)], light);
|
||||
}
|
||||
@@ -214,11 +250,11 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.stroke(&[(9.7, 10.6), (4.5, 2.0)], c);
|
||||
}
|
||||
Icon::Copy => {
|
||||
g.rect(2.5, 2.5, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(6.0, 5.5, 7.5, 9.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.5, 2.5, 7.5, 9.0, Some(paper), Some(c));
|
||||
g.rect(6.0, 5.5, 7.5, 9.0, Some(paper), Some(c));
|
||||
}
|
||||
Icon::Brush => {
|
||||
g.rect(3.0, 2.0, 10.0, 4.0, Some(YELLOW), Some(c));
|
||||
g.rect(3.0, 2.0, 10.0, 4.0, Some(yellow), Some(c));
|
||||
g.stroke(&[(8.0, 6.0), (8.0, 8.5), (5.0, 8.5), (5.0, 10.0)], c);
|
||||
g.rect(4.0, 10.0, 2.0, 4.5, Some(c), None);
|
||||
}
|
||||
@@ -259,7 +295,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.line(&[(2.5, 2.5), (13.5, 2.5), (13.5, 13.5), (2.5, 13.5), (2.5, 2.5)], c, if icon == Icon::BorderThick { 2.2 } else { 1.2 });
|
||||
}
|
||||
Icon::Fill => {
|
||||
g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], Color32::WHITE);
|
||||
g.poly(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0)], paper);
|
||||
g.line(&[(3.0, 7.0), (8.0, 2.0), (13.0, 7.0), (8.0, 12.0), (3.0, 7.0)], c, 1.1);
|
||||
g.circle(13.5, 10.0, 1.2, Some(c), None);
|
||||
}
|
||||
@@ -297,16 +333,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.stroke(&[(x0, *y), (x1, *y)], c);
|
||||
}
|
||||
if icon == Icon::IndentInc {
|
||||
g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], BLUE);
|
||||
g.poly(&[(2.0, 6.0), (5.0, 8.0), (2.0, 10.0)], blue);
|
||||
}
|
||||
if icon == Icon::IndentDec {
|
||||
g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], BLUE);
|
||||
g.poly(&[(5.0, 6.0), (2.0, 8.0), (5.0, 10.0)], blue);
|
||||
}
|
||||
}
|
||||
Icon::Orientation => {
|
||||
g.text(6.0, 9.0, "ab", 8.5, c, false, false);
|
||||
g.stroke(&[(9.0, 13.0), (14.0, 3.0)], BLUE);
|
||||
g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], BLUE);
|
||||
g.stroke(&[(9.0, 13.0), (14.0, 3.0)], blue);
|
||||
g.poly(&[(14.5, 2.0), (12.0, 4.0), (14.6, 5.2)], blue);
|
||||
}
|
||||
Icon::Wrap => {
|
||||
g.stroke(&[(2.0, 4.0), (14.0, 4.0)], c);
|
||||
@@ -315,16 +351,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
[g.pt(12.0, 8.0), g.pt(15.0, 8.0), g.pt(15.0, 12.0), g.pt(11.0, 12.0)],
|
||||
false,
|
||||
Color32::TRANSPARENT,
|
||||
Stroke::new(1.1, BLUE),
|
||||
Stroke::new(1.1, blue),
|
||||
));
|
||||
g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], BLUE);
|
||||
g.poly(&[(9.0, 12.0), (11.5, 10.5), (11.5, 13.5)], blue);
|
||||
g.stroke(&[(2.0, 12.0), (7.0, 12.0)], c);
|
||||
}
|
||||
Icon::Merge => {
|
||||
g.rect(1.5, 3.5, 13.0, 9.0, None, Some(c));
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], BLUE);
|
||||
g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], BLUE);
|
||||
g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], BLUE);
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], blue);
|
||||
g.poly(&[(3.0, 8.0), (5.5, 6.5), (5.5, 9.5)], blue);
|
||||
g.poly(&[(13.0, 8.0), (10.5, 6.5), (10.5, 9.5)], blue);
|
||||
}
|
||||
Icon::Currency => g.text(8.0, 8.5, "$", 12.0, c, false, false),
|
||||
Icon::Percent => g.text(8.0, 8.5, "%", 11.0, c, false, false),
|
||||
@@ -332,69 +368,69 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
Icon::DecInc => {
|
||||
g.text(5.0, 5.0, ".0", 6.5, c, false, false);
|
||||
g.text(10.0, 11.0, ".00", 6.5, c, false, false);
|
||||
g.stroke(&[(2.0, 12.0), (5.0, 12.0)], BLUE);
|
||||
g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], BLUE);
|
||||
g.stroke(&[(2.0, 12.0), (5.0, 12.0)], blue);
|
||||
g.poly(&[(1.0, 12.0), (2.8, 10.6), (2.8, 13.4)], blue);
|
||||
}
|
||||
Icon::DecDec => {
|
||||
g.text(6.0, 5.0, ".00", 6.5, c, false, false);
|
||||
g.text(11.0, 11.0, ".0", 6.5, c, false, false);
|
||||
g.stroke(&[(2.0, 12.0), (5.5, 12.0)], BLUE);
|
||||
g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], BLUE);
|
||||
g.stroke(&[(2.0, 12.0), (5.5, 12.0)], blue);
|
||||
g.poly(&[(7.0, 12.0), (5.2, 10.6), (5.2, 13.4)], blue);
|
||||
}
|
||||
Icon::CondFormat => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(Color32::WHITE), Some(light));
|
||||
g.rect(3.0, 3.5, 4.0, 2.5, Some(RED), None);
|
||||
g.rect(3.0, 7.0, 7.0, 2.5, Some(YELLOW), None);
|
||||
g.rect(3.0, 10.5, 10.0, 2.5, Some(BLUE), None);
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(paper), Some(light));
|
||||
g.rect(3.0, 3.5, 4.0, 2.5, Some(red), None);
|
||||
g.rect(3.0, 7.0, 7.0, 2.5, Some(yellow), None);
|
||||
g.rect(3.0, 10.5, 10.0, 2.5, Some(blue), None);
|
||||
}
|
||||
Icon::FormatTable => {
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(light));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(BLUE), None);
|
||||
g.rect(2.0, 8.0, 12.0, 2.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(light));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(blue), None);
|
||||
g.rect(2.0, 8.0, 12.0, 2.5, Some(pal.wash(Cat::Blue)), None);
|
||||
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
||||
g.stroke(&[(10.0, 2.5), (10.0, 13.5)], light);
|
||||
}
|
||||
Icon::CellStyles => {
|
||||
g.rect(2.0, 3.0, 12.0, 4.5, Some(Color32::from_rgb(0xC6, 0xEF, 0xCE)), Some(light));
|
||||
g.rect(2.0, 8.5, 12.0, 4.5, Some(Color32::from_rgb(0xFF, 0xC7, 0xCE)), Some(light));
|
||||
g.stroke(&[(4.0, 5.2), (9.0, 5.2)], GREEN);
|
||||
g.stroke(&[(4.0, 10.7), (9.0, 10.7)], RED);
|
||||
g.rect(2.0, 3.0, 12.0, 4.5, Some(pal.wash(Cat::Green)), Some(light));
|
||||
g.rect(2.0, 8.5, 12.0, 4.5, Some(pal.wash(Cat::Red)), Some(light));
|
||||
g.stroke(&[(4.0, 5.2), (9.0, 5.2)], green);
|
||||
g.stroke(&[(4.0, 10.7), (9.0, 10.7)], red);
|
||||
}
|
||||
Icon::Insert | Icon::Delete | Icon::Format => {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::WHITE), Some(light));
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(paper), Some(light));
|
||||
g.stroke(&[(2.0, 6.5), (14.0, 6.5)], light);
|
||||
g.stroke(&[(2.0, 10.0), (14.0, 10.0)], light);
|
||||
g.stroke(&[(6.0, 3.0), (6.0, 13.0)], light);
|
||||
match icon {
|
||||
Icon::Insert => {
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(BLUE));
|
||||
g.line(&[(1.0, 8.2), (4.5, 8.2)], GREEN, 1.6);
|
||||
g.line(&[(2.75, 6.5), (2.75, 10.0)], GREEN, 1.6);
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), Some(blue));
|
||||
g.line(&[(1.0, 8.2), (4.5, 8.2)], green, 1.6);
|
||||
g.line(&[(2.75, 6.5), (2.75, 10.0)], green, 1.6);
|
||||
}
|
||||
Icon::Delete => {
|
||||
g.line(&[(8.0, 6.0), (13.0, 11.0)], RED, 1.6);
|
||||
g.line(&[(13.0, 6.0), (8.0, 11.0)], RED, 1.6);
|
||||
g.line(&[(8.0, 6.0), (13.0, 11.0)], red, 1.6);
|
||||
g.line(&[(13.0, 6.0), (8.0, 11.0)], red, 1.6);
|
||||
}
|
||||
_ => {
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.rect(6.0, 6.5, 8.0, 3.5, Some(pal.wash(Cat::Blue)), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon::Sum => g.text(8.0, 8.0, "Σ", 13.0, c, false, false),
|
||||
Icon::FillDown => {
|
||||
g.rect(4.0, 2.0, 8.0, 12.0, Some(Color32::WHITE), Some(light));
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 11.0)], BLUE);
|
||||
g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], BLUE);
|
||||
g.rect(4.0, 2.0, 8.0, 12.0, Some(paper), Some(light));
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 11.0)], blue);
|
||||
g.poly(&[(5.5, 9.5), (10.5, 9.5), (8.0, 13.0)], blue);
|
||||
}
|
||||
Icon::Eraser => {
|
||||
g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], Color32::from_rgb(0xE9, 0x8A, 0xB8));
|
||||
g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], Color32::from_rgb(0xB6, 0x4E, 0x86));
|
||||
g.poly(&[(2.0, 10.0), (8.0, 4.0), (12.0, 8.0), (6.0, 14.0)], red.gamma_multiply(0.55));
|
||||
g.poly(&[(8.0, 4.0), (10.5, 1.5), (14.5, 5.5), (12.0, 8.0)], red);
|
||||
}
|
||||
Icon::SortFilter => {
|
||||
g.text(4.5, 4.5, "A", 6.0, c, false, false);
|
||||
g.text(4.5, 11.5, "Z", 6.0, c, false, false);
|
||||
g.stroke(&[(8.0, 3.0), (8.0, 13.0)], BLUE);
|
||||
g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], BLUE);
|
||||
g.stroke(&[(8.0, 3.0), (8.0, 13.0)], blue);
|
||||
g.poly(&[(6.5, 11.0), (9.5, 11.0), (8.0, 13.5)], blue);
|
||||
g.poly(&[(10.0, 3.0), (15.0, 3.0), (13.0, 7.5), (13.0, 12.0), (12.0, 11.0), (12.0, 7.5)], light);
|
||||
}
|
||||
Icon::Find | Icon::Search | Icon::Zoom => {
|
||||
@@ -466,45 +502,45 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
}
|
||||
Icon::PageBreak => {
|
||||
g.rect(2.5, 2.0, 11.0, 12.0, None, Some(c));
|
||||
g.line(&[(2.5, 8.0), (13.5, 8.0)], BLUE, 1.6);
|
||||
g.line(&[(2.5, 8.0), (13.5, 8.0)], blue, 1.6);
|
||||
}
|
||||
Icon::Table | Icon::PivotTable => {
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { BLUE } else { GREEN }), None);
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, Some(paper), Some(c));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(if icon == Icon::Table { blue } else { green }), None);
|
||||
g.stroke(&[(6.0, 2.5), (6.0, 13.5)], light);
|
||||
g.stroke(&[(2.0, 9.5), (14.0, 9.5)], light);
|
||||
if icon == Icon::PivotTable {
|
||||
g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], ORANGE);
|
||||
g.poly(&[(9.0, 7.0), (12.5, 7.0), (12.5, 10.5)], orange);
|
||||
}
|
||||
}
|
||||
Icon::Chart | Icon::ChartBar => {
|
||||
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], c);
|
||||
g.rect(3.0, 8.0, 2.5, 6.0, Some(BLUE), None);
|
||||
g.rect(6.8, 4.0, 2.5, 10.0, Some(GREEN), None);
|
||||
g.rect(10.6, 6.0, 2.5, 8.0, Some(ORANGE), None);
|
||||
g.rect(3.0, 8.0, 2.5, 6.0, Some(blue), None);
|
||||
g.rect(6.8, 4.0, 2.5, 10.0, Some(green), None);
|
||||
g.rect(10.6, 6.0, 2.5, 8.0, Some(orange), None);
|
||||
}
|
||||
Icon::ChartLine | Icon::Sparkline => {
|
||||
g.stroke(&[(2.0, 14.0), (14.0, 14.0)], light);
|
||||
g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], BLUE, 1.5);
|
||||
g.line(&[(2.0, 11.0), (5.5, 7.0), (8.5, 9.5), (13.5, 3.5)], blue, 1.5);
|
||||
}
|
||||
Icon::ChartPie => {
|
||||
g.circle(8.0, 8.0, 6.0, Some(BLUE), None);
|
||||
g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], ORANGE);
|
||||
g.circle(8.0, 8.0, 6.0, Some(blue), None);
|
||||
g.poly(&[(8.0, 8.0), (8.0, 2.0), (12.0, 3.4), (14.0, 8.0)], orange);
|
||||
}
|
||||
Icon::ChartScatter => {
|
||||
g.stroke(&[(2.0, 2.0), (2.0, 14.0), (14.0, 14.0)], c);
|
||||
for (x, y) in [(5.0, 11.0), (7.0, 8.0), (9.5, 9.5), (11.0, 5.0), (13.0, 4.0)] {
|
||||
g.circle(x, y, 1.1, Some(BLUE), None);
|
||||
g.circle(x, y, 1.1, Some(blue), None);
|
||||
}
|
||||
}
|
||||
Icon::Picture => {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), Some(c));
|
||||
g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], GREEN);
|
||||
g.circle(11.0, 5.5, 1.2, Some(YELLOW), None);
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, Some(pal.wash(Cat::Blue)), Some(c));
|
||||
g.poly(&[(3.0, 12.0), (7.0, 7.0), (10.0, 10.0), (11.5, 8.5), (13.0, 12.0)], green);
|
||||
g.circle(11.0, 5.5, 1.2, Some(yellow), None);
|
||||
}
|
||||
Icon::Shapes => {
|
||||
g.circle(5.5, 5.5, 3.5, Some(BLUE), None);
|
||||
g.rect(7.0, 7.0, 7.0, 7.0, Some(ORANGE), None);
|
||||
g.circle(5.5, 5.5, 3.5, Some(blue), None);
|
||||
g.rect(7.0, 7.0, 7.0, 7.0, Some(orange), None);
|
||||
}
|
||||
Icon::Link => {
|
||||
g.line(&[(6.0, 10.0), (10.0, 6.0)], c, 1.2);
|
||||
@@ -512,10 +548,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.rect(8.0, 4.0, 6.0, 4.0, None, Some(c));
|
||||
}
|
||||
Icon::Comment | Icon::Note => {
|
||||
g.poly(
|
||||
&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)],
|
||||
if icon == Icon::Note { Color32::from_rgb(0xFF, 0xF2, 0xA8) } else { Color32::WHITE },
|
||||
);
|
||||
g.poly(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (2.0, 11.0)], if icon == Icon::Note { pal.wash(Cat::Amber) } else { paper });
|
||||
g.line(&[(2.0, 3.0), (14.0, 3.0), (14.0, 11.0), (7.0, 11.0), (4.0, 14.0), (4.0, 11.0), (2.0, 11.0), (2.0, 3.0)], c, 1.1);
|
||||
}
|
||||
Icon::TextBox => {
|
||||
@@ -529,10 +562,10 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.text(8.0, 8.0, "ab", 8.0, c, false, false);
|
||||
}
|
||||
Icon::Trace => {
|
||||
g.circle(4.0, 12.0, 1.6, Some(BLUE), None);
|
||||
g.circle(12.0, 4.0, 1.6, Some(BLUE), None);
|
||||
g.line(&[(5.0, 11.0), (11.0, 5.0)], BLUE, 1.2);
|
||||
g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], BLUE);
|
||||
g.circle(4.0, 12.0, 1.6, Some(blue), None);
|
||||
g.circle(12.0, 4.0, 1.6, Some(blue), None);
|
||||
g.line(&[(5.0, 11.0), (11.0, 5.0)], blue, 1.2);
|
||||
g.poly(&[(12.5, 3.5), (9.0, 4.5), (11.5, 7.0)], blue);
|
||||
}
|
||||
Icon::Calc => {
|
||||
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
||||
@@ -546,23 +579,23 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
let (a, b) = if icon == Icon::SortAsc { ("A", "Z") } else { ("Z", "A") };
|
||||
g.text(5.0, 4.5, a, 6.5, c, false, false);
|
||||
g.text(5.0, 11.5, b, 6.5, c, false, false);
|
||||
g.stroke(&[(11.0, 2.5), (11.0, 13.0)], BLUE);
|
||||
g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], BLUE);
|
||||
g.stroke(&[(11.0, 2.5), (11.0, 13.0)], blue);
|
||||
g.poly(&[(9.0, 11.0), (13.0, 11.0), (11.0, 14.0)], blue);
|
||||
}
|
||||
Icon::Duplicates => {
|
||||
g.rect(2.0, 2.0, 9.0, 4.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(5.0, 7.0, 9.0, 4.0, Some(Color32::WHITE), Some(c));
|
||||
g.line(&[(9.0, 12.0), (14.0, 15.0)], RED, 1.4);
|
||||
g.line(&[(14.0, 12.0), (9.0, 15.0)], RED, 1.4);
|
||||
g.rect(2.0, 2.0, 9.0, 4.0, Some(paper), Some(c));
|
||||
g.rect(5.0, 7.0, 9.0, 4.0, Some(paper), Some(c));
|
||||
g.line(&[(9.0, 12.0), (14.0, 15.0)], red, 1.4);
|
||||
g.line(&[(14.0, 12.0), (9.0, 15.0)], red, 1.4);
|
||||
}
|
||||
Icon::TextColumns => {
|
||||
g.rect(1.5, 3.0, 5.5, 10.0, None, Some(c));
|
||||
g.rect(9.0, 3.0, 5.5, 10.0, None, Some(c));
|
||||
g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], BLUE);
|
||||
g.poly(&[(7.0, 6.0), (9.0, 8.0), (7.0, 10.0)], blue);
|
||||
}
|
||||
Icon::Validation => {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c));
|
||||
g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], GREEN, 1.4);
|
||||
g.line(&[(4.0, 8.0), (6.5, 10.5), (11.5, 5.5)], green, 1.4);
|
||||
}
|
||||
Icon::Group | Icon::Ungroup | Icon::Subtotal => {
|
||||
g.stroke(&[(3.0, 3.0), (3.0, 13.0), (6.0, 13.0)], c);
|
||||
@@ -570,59 +603,59 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.stroke(&[(7.0, y), (14.0, y)], light);
|
||||
}
|
||||
match icon {
|
||||
Icon::Group => g.text(3.0, 2.5, "+", 6.0, BLUE, true, false),
|
||||
Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, BLUE, true, false),
|
||||
_ => g.text(11.0, 13.0, "Σ", 6.0, BLUE, false, false),
|
||||
Icon::Group => g.text(3.0, 2.5, "+", 6.0, blue, true, false),
|
||||
Icon::Ungroup => g.text(3.0, 2.5, "−", 6.0, blue, true, false),
|
||||
_ => g.text(11.0, 13.0, "Σ", 6.0, blue, false, false),
|
||||
}
|
||||
}
|
||||
Icon::Lock => {
|
||||
g.rect(3.5, 7.0, 9.0, 7.0, Some(YELLOW), Some(c));
|
||||
g.rect(3.5, 7.0, 9.0, 7.0, Some(yellow), Some(c));
|
||||
g.line(&[(5.5, 7.0), (5.5, 4.5), (8.0, 2.5), (10.5, 4.5), (10.5, 7.0)], c, 1.2);
|
||||
}
|
||||
Icon::Spell => {
|
||||
g.text(7.0, 6.0, "abc", 7.0, c, false, false);
|
||||
g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], GREEN, 1.4);
|
||||
g.line(&[(4.0, 11.0), (6.5, 13.5), (12.0, 8.5)], green, 1.4);
|
||||
}
|
||||
Icon::Freeze => {
|
||||
g.rect(2.0, 2.5, 12.0, 11.0, None, Some(c));
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.rect(2.0, 2.5, 3.5, 11.0, Some(Color32::from_rgb(0xD6, 0xE6, 0xF7)), None);
|
||||
g.line(&[(2.0, 5.5), (14.0, 5.5)], BLUE, 1.2);
|
||||
g.line(&[(5.5, 2.5), (5.5, 13.5)], BLUE, 1.2);
|
||||
g.rect(2.0, 2.5, 12.0, 3.0, Some(pal.wash(Cat::Blue)), None);
|
||||
g.rect(2.0, 2.5, 3.5, 11.0, Some(pal.wash(Cat::Blue)), None);
|
||||
g.line(&[(2.0, 5.5), (14.0, 5.5)], blue, 1.2);
|
||||
g.line(&[(5.5, 2.5), (5.5, 13.5)], blue, 1.2);
|
||||
}
|
||||
Icon::Theme => {
|
||||
g.rect(2.0, 2.0, 5.5, 5.5, Some(BLUE), None);
|
||||
g.rect(8.5, 2.0, 5.5, 5.5, Some(ORANGE), None);
|
||||
g.rect(2.0, 8.5, 5.5, 5.5, Some(GREEN), None);
|
||||
g.rect(8.5, 8.5, 5.5, 5.5, Some(PURPLE), None);
|
||||
g.rect(2.0, 2.0, 5.5, 5.5, Some(blue), None);
|
||||
g.rect(8.5, 2.0, 5.5, 5.5, Some(orange), None);
|
||||
g.rect(2.0, 8.5, 5.5, 5.5, Some(green), None);
|
||||
g.rect(8.5, 8.5, 5.5, 5.5, Some(purple), None);
|
||||
}
|
||||
Icon::Margins => {
|
||||
g.rect(3.0, 1.5, 10.0, 13.0, None, Some(c));
|
||||
g.rect(5.0, 3.5, 6.0, 9.0, None, Some(BLUE));
|
||||
g.rect(5.0, 3.5, 6.0, 9.0, None, Some(blue));
|
||||
}
|
||||
Icon::Orient => {
|
||||
g.rect(2.0, 4.0, 8.0, 10.0, None, Some(c));
|
||||
g.rect(6.0, 2.0, 8.0, 6.0, Some(Color32::WHITE), Some(BLUE));
|
||||
g.rect(6.0, 2.0, 8.0, 6.0, Some(paper), Some(blue));
|
||||
}
|
||||
Icon::SheetRtl => {
|
||||
// A sheet with an arrow running right to left.
|
||||
g.rect(2.0, 2.5, 12.0, 8.0, None, Some(c));
|
||||
g.stroke(&[(6.0, 2.5), (6.0, 10.5)], light);
|
||||
g.stroke(&[(10.0, 2.5), (10.0, 10.5)], light);
|
||||
g.line(&[(13.5, 13.0), (4.5, 13.0)], BLUE, 1.4);
|
||||
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], BLUE);
|
||||
g.line(&[(13.5, 13.0), (4.5, 13.0)], blue, 1.4);
|
||||
g.poly(&[(2.0, 13.0), (5.0, 10.5), (5.0, 15.5)], blue);
|
||||
}
|
||||
Icon::PrintArea => {
|
||||
g.rect(2.5, 2.5, 11.0, 11.0, None, Some(light));
|
||||
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], BLUE, 1.4);
|
||||
g.line(&[(4.0, 4.0), (11.0, 4.0), (11.0, 10.0), (4.0, 10.0), (4.0, 4.0)], blue, 1.4);
|
||||
}
|
||||
Icon::Script => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c));
|
||||
g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], GREEN, 1.3);
|
||||
g.line(&[(4.5, 6.0), (7.0, 8.0), (4.5, 10.0)], green, 1.3);
|
||||
g.stroke(&[(8.0, 10.5), (11.5, 10.5)], c);
|
||||
}
|
||||
Icon::Pen => {
|
||||
g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], BLUE);
|
||||
g.poly(&[(3.0, 13.0), (4.0, 10.0), (11.0, 3.0), (13.0, 5.0), (6.0, 12.0)], blue);
|
||||
g.stroke(&[(3.0, 13.0), (4.0, 10.0)], c);
|
||||
}
|
||||
Icon::Share => {
|
||||
@@ -645,18 +678,18 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
g.poly(&half, c);
|
||||
}
|
||||
Icon::Book => {
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(GREEN), None);
|
||||
g.rect(4.0, 4.0, 8.0, 8.0, Some(Color32::WHITE), None);
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 12.0)], GREEN);
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], GREEN);
|
||||
g.rect(2.0, 2.0, 12.0, 12.0, Some(green), None);
|
||||
g.rect(4.0, 4.0, 8.0, 8.0, Some(paper), None);
|
||||
g.stroke(&[(8.0, 4.0), (8.0, 12.0)], green);
|
||||
g.stroke(&[(4.0, 8.0), (12.0, 8.0)], green);
|
||||
}
|
||||
Icon::Folder => {
|
||||
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW);
|
||||
g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], yellow);
|
||||
}
|
||||
Icon::Header | Icon::Footer => {
|
||||
g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c));
|
||||
let y = if icon == Icon::Header { 2.5 } else { 11.0 };
|
||||
g.rect(4.5, y, 7.0, 2.5, Some(BLUE), None);
|
||||
g.rect(4.5, y, 7.0, 2.5, Some(blue), None);
|
||||
for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } {
|
||||
g.stroke(&[(5.5, yy), (10.5, yy)], light);
|
||||
}
|
||||
@@ -665,16 +698,16 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
if icon == Icon::PageCount {
|
||||
g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light));
|
||||
}
|
||||
g.rect(2.5, 3.0, 8.5, 12.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.5, 3.0, 8.5, 12.0, Some(paper), Some(c));
|
||||
// A "#" mark.
|
||||
g.stroke(&[(5.3, 6.5), (4.8, 12.5)], BLUE);
|
||||
g.stroke(&[(8.3, 6.5), (7.8, 12.5)], BLUE);
|
||||
g.stroke(&[(4.0, 8.4), (9.6, 8.4)], BLUE);
|
||||
g.stroke(&[(3.8, 10.8), (9.4, 10.8)], BLUE);
|
||||
g.stroke(&[(5.3, 6.5), (4.8, 12.5)], blue);
|
||||
g.stroke(&[(8.3, 6.5), (7.8, 12.5)], blue);
|
||||
g.stroke(&[(4.0, 8.4), (9.6, 8.4)], blue);
|
||||
g.stroke(&[(3.8, 10.8), (9.4, 10.8)], blue);
|
||||
}
|
||||
Icon::Calendar => {
|
||||
g.rect(2.0, 3.0, 12.0, 11.0, Some(Color32::WHITE), Some(c));
|
||||
g.rect(2.0, 3.0, 12.0, 3.0, Some(BLUE), None);
|
||||
g.rect(2.0, 3.0, 12.0, 11.0, Some(paper), Some(c));
|
||||
g.rect(2.0, 3.0, 12.0, 3.0, Some(blue), None);
|
||||
for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] {
|
||||
g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None);
|
||||
}
|
||||
@@ -682,13 +715,13 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
Icon::Clock => {
|
||||
let center = g.pt(8.0, 8.0);
|
||||
g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c));
|
||||
g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], BLUE);
|
||||
g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], blue);
|
||||
}
|
||||
Icon::FileName => {
|
||||
g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], Color32::WHITE);
|
||||
g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], paper);
|
||||
g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15);
|
||||
g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c);
|
||||
g.line(&[(5.0, 9.0), (11.0, 9.0)], BLUE, 1.4);
|
||||
g.line(&[(5.0, 9.0), (11.0, 9.0)], blue, 1.4);
|
||||
g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light);
|
||||
}
|
||||
Icon::Back => {
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod backstage;
|
||||
mod border_preview;
|
||||
mod cell_pictures;
|
||||
pub mod chartview;
|
||||
pub mod code_editor;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
|
||||
@@ -57,12 +57,13 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if list.is_empty() {
|
||||
ui.label(egui::RichText::new("No comments on this sheet yet. Select a cell and choose New Comment.").italics());
|
||||
}
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
for (cell, c) in list {
|
||||
egui::Frame::NONE
|
||||
.fill(if c.resolved { Color32::from_gray(240) } else { Color32::WHITE })
|
||||
.fill(if c.resolved { t.window } else { t.menu_bg })
|
||||
.corner_radius(6.0)
|
||||
.inner_margin(8.0)
|
||||
.stroke(egui::Stroke::new(1.0, Color32::from_gray(215)))
|
||||
.stroke(egui::Stroke::new(1.0, t.separator))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
@@ -70,14 +71,14 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let _ = app.run("selection.set", json!({"cell": cell}));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
ui.label(egui::RichText::new(&c.author).color(Color32::from_gray(90)));
|
||||
ui.label(egui::RichText::new(&c.author).color(t.text_dim));
|
||||
if c.resolved {
|
||||
ui.label(egui::RichText::new("Resolved").small().color(Color32::from_rgb(0x10, 0x7C, 0x41)));
|
||||
ui.label(egui::RichText::new("Resolved").small().color(t.accent));
|
||||
}
|
||||
});
|
||||
ui.label(&c.text);
|
||||
for (a, txt) in &c.replies {
|
||||
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(Color32::from_gray(60)));
|
||||
ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(t.text));
|
||||
}
|
||||
let key = egui::Id::new(("reply", &cell));
|
||||
let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default();
|
||||
@@ -239,7 +240,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, col);
|
||||
ui.label(&name);
|
||||
ui.label(egui::RichText::new(&s.values).small().color(Color32::from_gray(110)));
|
||||
ui.label(egui::RichText::new(&s.values).small().color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||||
ui.menu_button("Fill…", |ui| {
|
||||
if let Some(c) = crate::widgets::color_palette_fixed(ui, &theme.colors, "Automatic") {
|
||||
app.run_or_alert(
|
||||
|
||||
+139
-51
@@ -7,8 +7,8 @@ use serde_json::json;
|
||||
use crate::SheetApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::keytips::keytip_table;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button};
|
||||
use crate::theme::{self, Cat, Tokens};
|
||||
use crate::widgets::{big_button, big_split_button, color_palette, icon_button, small_button, split_button, toggle_button};
|
||||
|
||||
pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"];
|
||||
|
||||
@@ -58,7 +58,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(r, 8.0, if on { t.accent } else { Color32::TRANSPARENT });
|
||||
ui.painter().rect_stroke(r, 8.0, Stroke::new(1.0, if on { t.accent } else { t.text_dim }), StrokeKind::Inside);
|
||||
let knob = if on { pos2(r.right() - 8.0, r.center().y) } else { pos2(r.left() + 8.0, r.center().y) };
|
||||
ui.painter().circle_filled(knob, 5.0, if on { Color32::WHITE } else { t.text_dim });
|
||||
ui.painter().circle_filled(knob, 5.0, if on { t.on_accent } else { t.text_dim });
|
||||
if resp.on_hover_text(app.ui.language.tr("AutoSave: save after every change (workbooks saved to a file)")).clicked() {
|
||||
app.toggle_autosave();
|
||||
}
|
||||
@@ -279,36 +279,122 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if collapsed {
|
||||
return;
|
||||
}
|
||||
// Ribbon card.
|
||||
let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 88.0));
|
||||
// Ribbon card. The row above it can be wider than a narrow window, so the card is kept
|
||||
// within the window (with the margin it has on the left), or its right scroll arrow
|
||||
// would be drawn off screen.
|
||||
let left = ui.cursor().min.x;
|
||||
let screen = ui.ctx().content_rect();
|
||||
let fits = (screen.right() - left - (left - screen.left()).max(0.0)).max(0.0);
|
||||
let card = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width().min(fits), 88.0));
|
||||
ui.painter().rect_filled(card, 10.0, t.ribbon);
|
||||
ui.painter().rect_stroke(card, 10.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
||||
let inner = card.shrink2(vec2(10.0, 6.0));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
|
||||
egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match app.ui.ribbon_tab.as_str() {
|
||||
"Home" => home(app, ui),
|
||||
"Insert" => insert(app, ui),
|
||||
"Draw" => draw(app, ui),
|
||||
"Page Layout" => page_layout(app, ui),
|
||||
"Formulas" => formulas(app, ui),
|
||||
"Data" => data(app, ui),
|
||||
"Review" => review(app, ui),
|
||||
"View" => view(app, ui),
|
||||
"Automate" => automate(app, ui),
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
// One scroll state per tab: a tab opened after scrolling another starts at its
|
||||
// left edge, and a tab keeps its own offset while others are used.
|
||||
let scroll = egui::ScrollArea::horizontal()
|
||||
.id_salt(("ribbon_scroll", app.ui.ribbon_tab.as_str()))
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match app.ui.ribbon_tab.as_str() {
|
||||
"Home" => home(app, ui),
|
||||
"Insert" => insert(app, ui),
|
||||
"Draw" => draw(app, ui),
|
||||
"Page Layout" => page_layout(app, ui),
|
||||
"Formulas" => formulas(app, ui),
|
||||
"Data" => data(app, ui),
|
||||
"Review" => review(app, ui),
|
||||
"View" => view(app, ui),
|
||||
"Automate" => automate(app, ui),
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Chart Design" => chart_design(app, ui),
|
||||
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
scroll_arrows(ui, &t, scroll);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// How much of the active tab's groups the ribbon card shows, recorded each frame for the scroll
|
||||
/// arrows and for tests.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RibbonOverflow {
|
||||
/// Width of all the tab's groups.
|
||||
pub content: f32,
|
||||
/// Width of the card's content area.
|
||||
pub visible: f32,
|
||||
/// How far the groups are scrolled.
|
||||
pub offset: f32,
|
||||
/// Height of the tallest group (a column of small buttons) and of the content area.
|
||||
pub content_height: f32,
|
||||
pub visible_height: f32,
|
||||
}
|
||||
|
||||
impl RibbonOverflow {
|
||||
fn id() -> egui::Id {
|
||||
egui::Id::new("ribbon_overflow")
|
||||
}
|
||||
|
||||
/// Last frame's overflow.
|
||||
pub fn get(ctx: &egui::Context) -> RibbonOverflow {
|
||||
ctx.data(|d| d.get_temp(Self::id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether the groups are wider than the card.
|
||||
pub fn overflows(&self) -> bool {
|
||||
self.content > self.visible + 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// When the tab's groups are wider than the card, arrows at its edges scroll them (the mouse wheel
|
||||
/// over the card still does too).
|
||||
fn scroll_arrows(ui: &mut Ui, t: &Tokens, mut scroll: egui::scroll_area::ScrollAreaOutput<()>) {
|
||||
let inner = scroll.inner_rect;
|
||||
let max = (scroll.content_size.x - inner.width()).max(0.0);
|
||||
let overflow = RibbonOverflow {
|
||||
content: scroll.content_size.x,
|
||||
visible: inner.width(),
|
||||
offset: scroll.state.offset.x,
|
||||
content_height: scroll.content_size.y,
|
||||
visible_height: inner.height(),
|
||||
};
|
||||
ui.ctx().data_mut(|d| d.insert_temp(RibbonOverflow::id(), overflow));
|
||||
if !overflow.overflows() {
|
||||
return;
|
||||
}
|
||||
let step = (inner.width() * 0.6).max(60.0);
|
||||
let mut target = None;
|
||||
for (left, show) in [(true, overflow.offset > 0.5), (false, overflow.offset < max - 0.5)] {
|
||||
if !show {
|
||||
continue;
|
||||
}
|
||||
let r = if left {
|
||||
Rect::from_min_size(inner.left_top(), vec2(18.0, inner.height()))
|
||||
} else {
|
||||
Rect::from_min_size(pos2(inner.right() - 18.0, inner.top()), vec2(18.0, inner.height()))
|
||||
};
|
||||
let resp = ui.interact(r, ui.id().with(("ribbon_scroll_arrow", left)), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.ribbon });
|
||||
ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if left { Icon::Left } else { Icon::Right }, t.text);
|
||||
let name = if left { "Scroll ribbon left" } else { "Scroll ribbon right" };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name));
|
||||
if resp.on_hover_text(crate::i18n::current(ui.ctx()).tr(name)).clicked() {
|
||||
target = Some(if left { overflow.offset - step } else { overflow.offset + step });
|
||||
}
|
||||
}
|
||||
if let Some(x) = target {
|
||||
scroll.state.offset.x = x.clamp(0.0, max);
|
||||
scroll.state.store(ui.ctx(), scroll.id);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like
|
||||
/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes.
|
||||
fn file_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||
@@ -1064,17 +1150,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if s {
|
||||
act(app, "formulas.autoSum", json!({}));
|
||||
}
|
||||
egui::Popup::menu(&sa).show(|ui| {
|
||||
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
|
||||
if ui.button(f).clicked() {
|
||||
act(app, "formulas.autoSum", json!({"function": f}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("More Functions…").clicked() {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
});
|
||||
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
|
||||
});
|
||||
let fill = small_button(ui, Icon::FillDown, "", "Fill", true);
|
||||
for sequence in keytip_table::group_extension(
|
||||
@@ -1167,6 +1243,20 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The AutoSum menu (Home › Editing and Formulas › Function Library): a function, or the Insert
|
||||
/// Function dialog.
|
||||
fn autosum_menu(app: &mut SheetApp, ui: &mut Ui) {
|
||||
for f in ["SUM", "AVERAGE", "COUNT", "MAX", "MIN"] {
|
||||
if ui.button(f).clicked() {
|
||||
act(app, "formulas.autoSum", json!({"function": f}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("More Functions…").clicked() {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn font_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
||||
let name = st.font.name.clone();
|
||||
let resp = ui.add_sized([150.0, 24.0], egui::Button::new(egui::RichText::new(&name).font(theme::ui_font(12.5))));
|
||||
@@ -1389,7 +1479,7 @@ fn table_gallery(app: &mut SheetApp, ui: &mut Ui, cmd: &str) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(54.0, 38.0), Sense::click());
|
||||
paint_table_swatch(ui, r, &wb, &name);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.on_hover_text(&name).clicked() {
|
||||
act(app, cmd, json!({"style": name}));
|
||||
@@ -1407,7 +1497,8 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook,
|
||||
let lk = gridcraft_engine::tables::look(style);
|
||||
let res = |c: Option<gridcraft_engine::model::Color>| c.and_then(|c| c.resolve(&wb.theme)).map(theme::color32);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
p.rect_filled(r, 0.0, t.grid_bg);
|
||||
let rows = 5;
|
||||
let rh = r.height() / rows as f32;
|
||||
for i in 0..rows {
|
||||
@@ -1427,14 +1518,15 @@ fn paint_table_swatch(ui: &Ui, r: Rect, wb: &gridcraft_engine::model::Workbook,
|
||||
}
|
||||
for k in 0..3 {
|
||||
let x = rr.left() + 4.0 + k as f32 * (r.width() - 8.0) / 3.0;
|
||||
let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(Color32::BLACK) } else { Color32::from_gray(120) };
|
||||
let col = if i == 0 { res(Some(lk.header_font)).unwrap_or(t.cell_text) } else { t.sheet_control_border };
|
||||
p.line_segment([pos2(x, rr.center().y), pos2(x + 10.0, rr.center().y)], Stroke::new(1.0, col));
|
||||
}
|
||||
}
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
}
|
||||
|
||||
fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.set_width(520.0);
|
||||
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
|
||||
let groups: &[(&str, &[&str])] = &[
|
||||
@@ -1484,17 +1576,17 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
.resolve(&wb.theme)
|
||||
.filter(|_| st.fill.pattern != gridcraft_engine::model::PatternType::None)
|
||||
.map(theme::color32)
|
||||
.unwrap_or(Color32::WHITE);
|
||||
.unwrap_or(t.grid_bg);
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_gray(210)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
if !st.borders.bottom.is_none() {
|
||||
let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||
let c = st.borders.bottom.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
|
||||
ui.painter().line_segment(
|
||||
[r.left_bottom() + vec2(0.0, -1.5), r.right_bottom() + vec2(0.0, -1.5)],
|
||||
Stroke::new(st.borders.bottom.style.width(), c),
|
||||
);
|
||||
}
|
||||
let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||
let col = st.font.color.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
|
||||
let fam = theme::cell_family(&st.font.name, st.font.bold, st.font.italic);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
@@ -1504,7 +1596,7 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
col,
|
||||
);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.5), 2.0, Stroke::new(2.0, Tokens::get(ui.ctx()).cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
act(app, "home.cellStyle", json!({"name": n}));
|
||||
@@ -1921,11 +2013,12 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if __k.clicked() {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
let s = big_button(ui, Icon::Sum, "AutoSum", "AutoSum", true);
|
||||
control_keytip(app, ui, &s, "formulas.autoSum");
|
||||
let (s, sa) = big_split_button(ui, Icon::Sum, "AutoSum", "AutoSum");
|
||||
control_keytip(app, ui, &sa, "formulas.autoSum");
|
||||
if s.clicked() {
|
||||
act(app, "formulas.autoSum", json!({}));
|
||||
}
|
||||
egui::Popup::menu(&sa).show(|ui| autosum_menu(app, ui));
|
||||
for (label, cat, cid) in [
|
||||
("Financial", "Financial", "formulas.financial"),
|
||||
("Logical", "Logical", "formulas.logical"),
|
||||
@@ -2704,11 +2797,6 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: &Tokens, _: Icon) {
|
||||
let _ = icons::GREEN;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use super::*;
|
||||
|
||||
+222
-3
@@ -4,6 +4,11 @@
|
||||
//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the
|
||||
//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in
|
||||
//! fonts as the last resort.
|
||||
//!
|
||||
//! Chrome colours belong in [`Tokens`], each with a light and a dark value; colours
|
||||
//! drawn on the sheet keep their light value in both themes, because the sheet stays white. The
|
||||
//! foreground/background pairs are listed in `contrast_tests`, which holds them to WCAG 2 (4.5:1
|
||||
//! for text, 3:1 for glyphs) in both themes: a new token goes there too.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -54,8 +59,74 @@ pub struct Tokens {
|
||||
pub notice_warn_border: Color32,
|
||||
pub notice_info_bg: Color32,
|
||||
pub notice_info_border: Color32,
|
||||
/// Text and glyphs drawn on an `accent` fill (primary buttons, switched-on toggles).
|
||||
pub on_accent: Color32,
|
||||
/// The category palette, indexed by [`Cat`]: the coloured parts of icons, gallery highlights.
|
||||
pub cat: [Color32; 8],
|
||||
/// The page colour inside icons (documents, cells, clipboards).
|
||||
pub paper: Color32,
|
||||
/// Reference colours for formulas edited in the window-coloured formula bar.
|
||||
pub ref_colors: [Color32; 8],
|
||||
/// Reference colours on the sheet (outlines and the in-cell editor). The sheet stays white.
|
||||
pub sheet_ref_colors: [Color32; 8],
|
||||
/// Code colours in the code editor (and the window-coloured fields it sits in).
|
||||
pub syntax: Syntax,
|
||||
/// The message box warning disc and its glyph.
|
||||
pub warning: Color32,
|
||||
pub on_warning: Color32,
|
||||
/// Controls drawn on the sheet (filter and validation buttons, object handles). They follow
|
||||
/// the sheet, so they are light in both themes.
|
||||
pub sheet_control_bg: Color32,
|
||||
pub sheet_control_border: Color32,
|
||||
pub sheet_control_text: Color32,
|
||||
/// A placeholder fill on the sheet (a picture that has not loaded).
|
||||
pub sheet_track: Color32,
|
||||
}
|
||||
|
||||
/// The eight category colours, in the order of [`Tokens::cat`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Cat {
|
||||
Blue,
|
||||
Green,
|
||||
Orange,
|
||||
Red,
|
||||
Purple,
|
||||
Teal,
|
||||
Amber,
|
||||
Slate,
|
||||
}
|
||||
|
||||
impl Cat {
|
||||
pub const ALL: [Cat; 8] = [Cat::Blue, Cat::Green, Cat::Orange, Cat::Red, Cat::Purple, Cat::Teal, Cat::Amber, Cat::Slate];
|
||||
}
|
||||
|
||||
const fn rgb(hex: u32) -> Color32 {
|
||||
Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
|
||||
}
|
||||
|
||||
/// Reference colours on the white sheet, the same in both themes (the sheet does not follow the
|
||||
/// theme). The light theme's formula bar is white too, so it uses them as well.
|
||||
const SHEET_REF_COLORS: [Color32; 8] =
|
||||
[rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)];
|
||||
/// Code colours for one background; plain text uses the surface's own text colour.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Syntax {
|
||||
pub keyword: Color32,
|
||||
pub string: Color32,
|
||||
pub number: Color32,
|
||||
pub comment: Color32,
|
||||
pub builtin: Color32,
|
||||
}
|
||||
|
||||
const LIGHT_SYNTAX: Syntax =
|
||||
Syntax { keyword: rgb(0x0B57B3), string: rgb(0xA3361B), number: rgb(0x157A5A), comment: rgb(0x6B7280), builtin: rgb(0x7B3FB5) };
|
||||
const DARK_SYNTAX: Syntax =
|
||||
Syntax { keyword: rgb(0x6FAEFF), string: rgb(0xF09A7A), number: rgb(0x7FD1A8), comment: rgb(0x9AA3AE), builtin: rgb(0xC69BF2) };
|
||||
const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3);
|
||||
const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A);
|
||||
const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F);
|
||||
const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
|
||||
|
||||
impl Tokens {
|
||||
pub fn light() -> Tokens {
|
||||
Tokens {
|
||||
@@ -79,7 +150,7 @@ impl Tokens {
|
||||
header_text: Color32::from_rgb(0x42, 0x42, 0x42),
|
||||
header_line: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
||||
header_sel_bg: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
||||
header_sel_text: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||
header_sel_text: Color32::from_rgb(0x0B, 0x5E, 0x31),
|
||||
header_all_bg: Color32::from_rgb(0xC8, 0xE6, 0xD3),
|
||||
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C),
|
||||
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||
@@ -99,6 +170,18 @@ impl Tokens {
|
||||
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
|
||||
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
|
||||
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
|
||||
on_accent: Color32::WHITE,
|
||||
cat: [rgb(0x2F6FCF), rgb(0x1E8A4C), rgb(0xC8601A), rgb(0xC8372D), rgb(0x8350C8), rgb(0x13808A), rgb(0x9C6C00), rgb(0x5A6B7D)],
|
||||
paper: Color32::WHITE,
|
||||
ref_colors: SHEET_REF_COLORS,
|
||||
sheet_ref_colors: SHEET_REF_COLORS,
|
||||
syntax: LIGHT_SYNTAX,
|
||||
warning: rgb(0xF2C32E),
|
||||
on_warning: rgb(0x1F1F1F),
|
||||
sheet_control_bg: SHEET_CONTROL_BG,
|
||||
sheet_control_border: SHEET_CONTROL_BORDER,
|
||||
sheet_control_text: SHEET_CONTROL_TEXT,
|
||||
sheet_track: SHEET_TRACK,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +207,7 @@ impl Tokens {
|
||||
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
|
||||
header_line: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||
header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||
header_sel_text: Color32::from_rgb(0x5C, 0xC5, 0x87),
|
||||
header_sel_text: Color32::from_rgb(0x6F, 0xD3, 0x9A),
|
||||
header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E),
|
||||
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24),
|
||||
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
|
||||
@@ -136,7 +219,7 @@ impl Tokens {
|
||||
cell_text: Color32::BLACK,
|
||||
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
|
||||
shadow: Color32::from_black_alpha(90),
|
||||
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
|
||||
danger: Color32::from_rgb(0xFF, 0x95, 0x85),
|
||||
error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B),
|
||||
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
|
||||
warning_ink: Color32::BLACK,
|
||||
@@ -144,12 +227,50 @@ impl Tokens {
|
||||
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
|
||||
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
|
||||
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
|
||||
on_accent: rgb(0x0F2A1A),
|
||||
cat: [rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)],
|
||||
paper: rgb(0x454545),
|
||||
ref_colors: [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)],
|
||||
sheet_ref_colors: SHEET_REF_COLORS,
|
||||
syntax: DARK_SYNTAX,
|
||||
warning: rgb(0xF2C32E),
|
||||
on_warning: rgb(0x1F1F1F),
|
||||
sheet_control_bg: SHEET_CONTROL_BG,
|
||||
sheet_control_border: SHEET_CONTROL_BORDER,
|
||||
sheet_control_text: SHEET_CONTROL_TEXT,
|
||||
sheet_track: SHEET_TRACK,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(ctx: &egui::Context) -> Tokens {
|
||||
if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() }
|
||||
}
|
||||
|
||||
/// One colour of the category palette.
|
||||
pub fn cat(&self, c: Cat) -> Color32 {
|
||||
self.cat.get(c as usize).copied().unwrap_or(self.text)
|
||||
}
|
||||
|
||||
/// The reference colour for a formula's `index`-th reference, on the bar or on the sheet.
|
||||
pub fn ref_color(&self, index: usize, on_sheet: bool) -> Color32 {
|
||||
let set = if on_sheet { &self.sheet_ref_colors } else { &self.ref_colors };
|
||||
set.get(index % set.len()).copied().unwrap_or(self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// WCAG 2 relative luminance of an opaque colour.
|
||||
pub fn luminance(c: Color32) -> f32 {
|
||||
let lin = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
|
||||
}
|
||||
|
||||
/// WCAG 2 contrast ratio between two opaque colours (1 to 21).
|
||||
pub fn contrast(a: Color32, b: Color32) -> f32 {
|
||||
let (la, lb) = (luminance(a), luminance(b));
|
||||
(la.max(lb) + 0.05) / (la.min(lb) + 0.05)
|
||||
}
|
||||
|
||||
pub const UI: &str = "ui";
|
||||
@@ -891,6 +1012,104 @@ mod font_binding_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod contrast_tests {
|
||||
use super::*;
|
||||
|
||||
/// Body text needs 4.5:1 (WCAG AA); glyphs, icons and other graphics need 3:1.
|
||||
const TEXT: f32 = 4.5;
|
||||
const GLYPH: f32 = 3.0;
|
||||
|
||||
/// Every foreground/background pair the chrome draws: ribbon, popups, dialogs, panes, formula
|
||||
/// bar, status bar, plus the colours that sit on the white sheet.
|
||||
fn pairs(t: &Tokens) -> Vec<(String, Color32, Color32, f32)> {
|
||||
let mut v: Vec<(String, Color32, Color32, f32)> = Vec::new();
|
||||
let mut add = |name: &str, fg: Color32, bg: Color32, min: f32| v.push((name.to_string(), fg, bg, min));
|
||||
let surfaces = [
|
||||
("window", t.window),
|
||||
("ribbon", t.ribbon),
|
||||
("menu", t.menu_bg),
|
||||
("input", t.input_bg),
|
||||
("status bar", t.status_bar),
|
||||
("tab bar", t.tab_bar),
|
||||
("active tab", t.tab_active),
|
||||
("hover", t.hover),
|
||||
];
|
||||
for (name, bg) in surfaces {
|
||||
add(&format!("text on {name}"), t.text, bg, TEXT);
|
||||
add(&format!("dim text on {name}"), t.text_dim, bg, TEXT);
|
||||
add(&format!("accent glyph on {name}"), t.accent, bg, GLYPH);
|
||||
add(&format!("danger on {name}"), t.danger, bg, TEXT);
|
||||
}
|
||||
add("text on pressed", t.text, t.pressed, TEXT);
|
||||
add("accent text (File, contextual tabs) on window", t.accent, t.window, TEXT);
|
||||
for (name, bg) in [("ribbon", t.ribbon), ("menu", t.menu_bg), ("hover", t.hover), ("pressed", t.pressed)] {
|
||||
for c in Cat::ALL {
|
||||
add(&format!("{c:?} icon colour on {name}"), t.cat(c), bg, GLYPH);
|
||||
}
|
||||
}
|
||||
add("icon stroke on paper", t.text, t.paper, GLYPH);
|
||||
add("on_accent on accent", t.on_accent, t.accent, TEXT);
|
||||
add("warning glyph", t.on_warning, t.warning, TEXT);
|
||||
add("notice text (warning)", t.text, t.notice_warn_bg, TEXT);
|
||||
add("notice text (info)", t.text, t.notice_info_bg, TEXT);
|
||||
add("header text", t.header_text, t.header_bg, TEXT);
|
||||
add("selected header text", t.header_sel_text, t.header_sel_bg, TEXT);
|
||||
for (i, c) in t.ref_colors.iter().enumerate() {
|
||||
add(&format!("formula bar reference {i}"), *c, t.input_bg, TEXT);
|
||||
}
|
||||
let s = t.syntax;
|
||||
for (name, c) in [("keyword", s.keyword), ("string", s.string), ("number", s.number), ("comment", s.comment), ("builtin", s.builtin)] {
|
||||
add(&format!("code {name} on input"), c, t.input_bg, TEXT);
|
||||
}
|
||||
// On the sheet.
|
||||
add("cell text", t.cell_text, t.grid_bg, TEXT);
|
||||
for (i, c) in t.sheet_ref_colors.iter().enumerate() {
|
||||
add(&format!("sheet reference {i}"), *c, t.grid_bg, TEXT);
|
||||
}
|
||||
add("selection border on sheet", t.sel_border, t.grid_bg, GLYPH);
|
||||
add("sheet control text", t.sheet_control_text, t.sheet_control_bg, TEXT);
|
||||
add("sheet control border on sheet", t.sheet_control_border, t.grid_bg, GLYPH);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_pairs_meet_wcag_contrast_in_both_themes() {
|
||||
let mut failures = Vec::new();
|
||||
for t in [Tokens::light(), Tokens::dark()] {
|
||||
for (name, fg, bg, min) in pairs(&t) {
|
||||
let ratio = contrast(fg, bg);
|
||||
if ratio < min {
|
||||
failures.push(format!("{} theme: {name} is {ratio:.2}:1, needs {min}:1", if t.dark { "dark" } else { "light" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "contrast failures:\n{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_matches_wcag_reference_values() {
|
||||
assert!((contrast(Color32::BLACK, Color32::WHITE) - 21.0).abs() < 0.01);
|
||||
assert!((contrast(Color32::WHITE, Color32::WHITE) - 1.0).abs() < 0.001);
|
||||
// #767676 on white is the classic 4.54:1 grey.
|
||||
assert!((contrast(rgb(0x767676), Color32::WHITE) - 4.54).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_tokens_follow_the_sheet_not_the_theme() {
|
||||
let (l, d) = (Tokens::light(), Tokens::dark());
|
||||
assert_eq!(l.grid_bg, d.grid_bg);
|
||||
assert_eq!(l.sheet_ref_colors, d.sheet_ref_colors);
|
||||
assert_eq!(
|
||||
(l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),
|
||||
(d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)
|
||||
);
|
||||
for c in Cat::ALL {
|
||||
assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
use crate::SheetApp;
|
||||
pub use crate::code_editor::{Lang, code_editor};
|
||||
use crate::i18n;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::theme::{self, Cat, Tokens};
|
||||
|
||||
/// Translates a label or tooltip for the language of the frame being drawn (English if untranslated).
|
||||
/// Ribbon chrome all flows through these widgets, so translating here localises it in one place.
|
||||
@@ -79,13 +80,8 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response {
|
||||
/// Large ribbon button: 32px icon over a one- or two-line label.
|
||||
pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::ui_font(11.5);
|
||||
let label = tr(ui, label);
|
||||
let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect();
|
||||
let arrow_size = 10.0;
|
||||
let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 };
|
||||
let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max);
|
||||
let w = (text_w + 12.0).max(44.0);
|
||||
let (lines, w) = big_label(ui, label, dropdown);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::click());
|
||||
if resp.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(rect, 5.0, t.pressed);
|
||||
@@ -93,6 +89,59 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
|
||||
ui.painter().rect_filled(rect, 5.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
||||
paint_big_label(ui, rect, &lines, dropdown);
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
/// Large split button (Formulas › AutoSum): the icon runs the default action, the label and its
|
||||
/// arrow open the menu. Returns the (main, arrow) responses; their accessible names are the label
|
||||
/// and "<label> options".
|
||||
pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Response, Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = tr(ui, label);
|
||||
let (lines, w) = big_label(ui, label, true);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
|
||||
let split = rect.top() + 38.0;
|
||||
let main_rect = Rect::from_min_max(rect.min, pos2(rect.right(), split));
|
||||
let arrow_rect = Rect::from_min_max(pos2(rect.left(), split), rect.max);
|
||||
let main = ui.interact(main_rect, ui.id().with((label, "big-split-main")), Sense::click());
|
||||
let arrow = ui.interact(arrow_rect, ui.id().with((label, "big-split-arrow")), Sense::click());
|
||||
if main.hovered() || arrow.hovered() {
|
||||
ui.painter().rect_filled(rect, 5.0, t.hover);
|
||||
ui.painter().line_segment([pos2(rect.left() + 4.0, split), pos2(rect.right() - 4.0, split)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
for (part, r) in [(&main, main_rect), (&arrow, arrow_rect)] {
|
||||
if part.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(r, 5.0, t.pressed);
|
||||
}
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text);
|
||||
paint_big_label(ui, rect, &lines, true);
|
||||
let name = label.replace('\n', " ");
|
||||
let enabled = ui.is_enabled();
|
||||
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name));
|
||||
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, format!("{name} options")));
|
||||
let tip = tip_text(ui, tip);
|
||||
(main.on_hover_text(tip.clone()), arrow.on_hover_text(tip))
|
||||
}
|
||||
|
||||
/// Size of the dropdown arrow beside a big button's last label line.
|
||||
const BIG_ARROW: f32 = 10.0;
|
||||
|
||||
/// A big button's label lines, laid out, and the width the button needs for them.
|
||||
fn big_label(ui: &Ui, label: &str, dropdown: bool) -> (Vec<std::sync::Arc<egui::Galley>>, f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::ui_font(11.5);
|
||||
let lines: Vec<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect();
|
||||
let arrow_space = if dropdown { 4.0 + BIG_ARROW } else { 0.0 };
|
||||
let text_w = lines.iter().enumerate().map(|(i, line)| line.size().x + if i + 1 == lines.len() { arrow_space } else { 0.0 }).fold(0.0, f32::max);
|
||||
(lines, (text_w + 12.0).max(44.0))
|
||||
}
|
||||
|
||||
/// Paints a big button's label lines in `rect`, with the dropdown arrow beside the last one.
|
||||
fn paint_big_label(ui: &Ui, rect: Rect, lines: &[std::sync::Arc<egui::Galley>], dropdown: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let arrow_space = if dropdown { 4.0 + BIG_ARROW } else { 0.0 };
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let has_arrow = dropdown && i + 1 == lines.len();
|
||||
let line_w = line.size().x + if has_arrow { arrow_space } else { 0.0 };
|
||||
@@ -102,13 +151,12 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo
|
||||
if has_arrow {
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(left + line_w - arrow_size / 2.0, y + 1.0), vec2(arrow_size, arrow_size)),
|
||||
Rect::from_center_size(pos2(left + line_w - BIG_ARROW / 2.0, y + 1.0), vec2(BIG_ARROW, BIG_ARROW)),
|
||||
Icon::Chevron,
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
}
|
||||
resp.on_hover_text(tip_text(ui, tip))
|
||||
}
|
||||
|
||||
/// Small ribbon button: 16px icon with optional label to the right.
|
||||
@@ -240,6 +288,7 @@ pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &s
|
||||
}
|
||||
|
||||
fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut picked = None;
|
||||
const ROWS: usize = 7; // 6 tint rows + 1 standard row
|
||||
const COLS: usize = 10;
|
||||
@@ -333,9 +382,9 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
||||
let col32 = Color32::from_rgb(c[0], c[1], c[2]);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 1.0, col32);
|
||||
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(r, 1.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
if resp.hovered() || cur == Some((row, col)) {
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
picked = Some(theme_hex(base, *tint));
|
||||
@@ -355,7 +404,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors:
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 1.0, c);
|
||||
if resp.hovered() || cur == Some((ROWS - 1, col)) {
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, Color32::from_rgb(0xE8, 0x7D, 0x2B)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(r.expand(1.0), 1.0, Stroke::new(1.5, t.cat(Cat::Orange)), StrokeKind::Outside);
|
||||
}
|
||||
if resp.clicked() {
|
||||
picked = Some(hex.to_string());
|
||||
@@ -442,8 +491,9 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
ui.set_width(380.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover());
|
||||
ui.painter().circle_filled(r.center(), 16.0, Color32::from_rgb(0xF2, 0xC3, 0x2E));
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().circle_filled(r.center(), 16.0, t.warning);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "!", theme::ui_bold(20.0), t.on_warning);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(title).strong());
|
||||
ui.add(egui::Label::new(msg).wrap());
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! The auto-growing formula bar, the 5-line in-cell editor and the code editor widget.
|
||||
|
||||
use egui::{Event, Key, Modifiers, Rect, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::formula_bar::{BarLayout, MAX_LINES, one_line};
|
||||
use gridcraft_ui_egui::widgets::{Lang, code_editor};
|
||||
use gridcraft_ui_egui::{SheetApp, theme};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
settle(&mut h);
|
||||
h
|
||||
}
|
||||
|
||||
fn blank() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
/// Long enough for the 120 ms height ease to finish.
|
||||
fn settle(h: &mut Harness) {
|
||||
h.run_steps(16);
|
||||
}
|
||||
|
||||
fn key(key: Key, modifiers: Modifiers) -> [Event; 2] {
|
||||
[
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness, text: &str) {
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn alt_enter(h: &mut Harness) {
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::ALT));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn layout(h: &Harness) -> BarLayout {
|
||||
BarLayout::get(&h.ctx)
|
||||
}
|
||||
|
||||
fn row_height(h: &Harness) -> f32 {
|
||||
h.ctx.fonts_mut(|f| f.row_height(&theme::ui_font(13.0)))
|
||||
}
|
||||
|
||||
fn same(a: Rect, b: Rect) -> bool {
|
||||
(a.min - b.min).length() < 0.5 && (a.max - b.max).length() < 0.5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_grows_to_five_lines_then_scrolls_with_name_box_pinned() {
|
||||
let mut h = harness(blank());
|
||||
// The one-line height is 30 px unless the font's rows need more (Windows' do).
|
||||
let rh = row_height(&h);
|
||||
let base = one_line(rh);
|
||||
let one = layout(&h);
|
||||
assert!((one.height - base).abs() < 0.5, "one line is {base} px, got {}", one.height);
|
||||
assert_eq!(one.lines, 1);
|
||||
h.state_mut().begin_edit(None, true);
|
||||
h.run_steps(2);
|
||||
type_text(&mut h, "line 1");
|
||||
for n in 2..=8 {
|
||||
alt_enter(&mut h);
|
||||
type_text(&mut h, &format!("line {n}"));
|
||||
settle(&mut h);
|
||||
let l = layout(&h);
|
||||
assert_eq!(l.lines, n, "the editor holds {n} lines");
|
||||
let expect = base + (n.min(MAX_LINES) - 1) as f32 * rh;
|
||||
assert!((l.height - expect).abs() < 0.5, "{n} lines: height {} expected {expect}", l.height);
|
||||
assert!(same(l.name_box, one.name_box), "the Name Box stays on the first line ({n} lines)");
|
||||
assert!(same(l.buttons, one.buttons), "the buttons stay on the first line ({n} lines)");
|
||||
let caret = l.caret.expect("a caret while editing");
|
||||
assert!(l.field.contains_rect(caret.shrink(0.5)), "the caret stays visible at {n} lines: {caret:?} in {:?}", l.field);
|
||||
}
|
||||
assert_eq!(layout(&h).visible_lines, MAX_LINES);
|
||||
// Enter commits the multi-line text; viewing the cell keeps the bar at five lines.
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
settle(&mut h);
|
||||
let text = (1..=8).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
let d = h.state().session.active().unwrap();
|
||||
assert_eq!(d.wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::from(text.as_str()));
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
settle(&mut h);
|
||||
let viewing = layout(&h);
|
||||
assert_eq!(viewing.lines, 8);
|
||||
assert!((viewing.height - (base + 4.0 * rh)).abs() < 0.5);
|
||||
// An empty cell shrinks it back to one line.
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
settle(&mut h);
|
||||
assert!((layout(&h).height - base).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_that_closes_a_message_box_adds_no_line_to_the_entry() {
|
||||
// A message box over the editor (a data validation alert) takes Enter to close. The bar's
|
||||
// field is multi-line, and a field of its own would add a line on that Enter; the editor
|
||||
// handles Enter itself, so the field does nothing with it.
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().begin_edit(None, true);
|
||||
h.run_steps(2);
|
||||
type_text(&mut h, "abc");
|
||||
h.state_mut().message = Some(("Stop".into(), "Whole numbers only.".into()));
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().message.is_none(), "Enter closed the message box");
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.text.as_str()), Some("abc"), "and added nothing to the entry");
|
||||
assert_eq!(layout(&h).lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chevron_pins_the_bar_at_five_lines() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().ui.formula_bar_expanded = true;
|
||||
settle(&mut h);
|
||||
let rh = row_height(&h);
|
||||
let base = one_line(rh);
|
||||
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
|
||||
h.state_mut().ui.formula_bar_expanded = false;
|
||||
settle(&mut h);
|
||||
assert!((layout(&h).height - base).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_eases_instead_of_jumping() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().ui.formula_bar_expanded = true;
|
||||
h.run_steps(2);
|
||||
let mid = layout(&h).height;
|
||||
let rh = row_height(&h);
|
||||
let base = one_line(rh);
|
||||
assert!(mid > base + 0.5 && mid < base + 4.0 * rh - 0.5, "mid-ease height {mid}");
|
||||
settle(&mut h);
|
||||
assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5);
|
||||
}
|
||||
|
||||
fn in_cell_height(lines: usize) -> f32 {
|
||||
let mut s = blank();
|
||||
s.execute("selection.set", json!({"cell": "B3"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
let text = (1..=lines).map(|n| format!("row {n}")).collect::<Vec<_>>().join("\n");
|
||||
h.state_mut().begin_edit(Some(text.clone()), false);
|
||||
settle(&mut h);
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().text, text, "the text is kept whole");
|
||||
let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::new("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted");
|
||||
rect.height()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_cell_editor_grows_with_every_line() {
|
||||
// The formula bar stops at five lines and scrolls; the in-cell editor shows every line, as
|
||||
// the grid's own tests ask (`an_unwrapped_multiline_edit_shows_every_line`).
|
||||
let (four, five, nine) = (in_cell_height(4), in_cell_height(5), in_cell_height(9));
|
||||
assert!(five > four + 1.0, "grows with the lines ({four} then {five})");
|
||||
assert!(nine > five + 1.0, "and past five ({five} then {nine})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_editor_indents_and_keeps_indentation() {
|
||||
struct State {
|
||||
text: String,
|
||||
focused: bool,
|
||||
}
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
// egui's own fonts stand in for the system ones under the theme's family names.
|
||||
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");
|
||||
if !s.focused {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
s.focused = true;
|
||||
}
|
||||
code_editor(ui, id, &mut s.text, Lang::Python, 5);
|
||||
},
|
||||
State { text: String::new(), focused: false },
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Text("if x:".into()));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::Text("y = 1".into()));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.extend(key(Key::Tab, Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::Text("z".into()));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().text, "if x:\n y = 1\n z");
|
||||
}
|
||||
@@ -34,6 +34,7 @@ mod find_dialog_focus;
|
||||
mod floating_pictures;
|
||||
mod font_size_box;
|
||||
mod format_painter;
|
||||
mod formula_bar;
|
||||
mod formula_locale;
|
||||
mod formula_point_headers;
|
||||
mod formula_popup;
|
||||
@@ -64,6 +65,7 @@ mod pivot_pane;
|
||||
mod pivot_show_details;
|
||||
mod popup_dismissal;
|
||||
mod protect_password;
|
||||
mod ribbon_layout;
|
||||
mod right_to_left;
|
||||
mod scroll_position;
|
||||
mod scrollbar_column_fill;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Ribbon layout: the big split button, the scroll arrows of an overflowing tab, and every tab
|
||||
//! fitting the card's height.
|
||||
|
||||
use egui::{FontDefinitions, FontFamily, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::icons::Icon;
|
||||
use gridcraft_ui_egui::ribbon::{RibbonOverflow, TABS};
|
||||
use gridcraft_ui_egui::{SheetApp, theme, widgets};
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn app_at(width: f32, tab: &str) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
app.ui.ribbon_tab = tab.into();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(width, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tab_fits_the_card_height() {
|
||||
// Columns of small buttons hold three rows; a fourth would be cut off at the card's bottom.
|
||||
for tab in TABS {
|
||||
let h = app_at(1440.0, tab);
|
||||
let o = RibbonOverflow::get(&h.ctx);
|
||||
assert!(o.visible_height > 0.0, "{tab}: the card was laid out");
|
||||
assert!(o.content_height <= o.visible_height + 0.5, "{tab}: {} px of groups in {} px", o.content_height, o.visible_height);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overflowing_tab_shows_scroll_arrows_that_scroll() {
|
||||
let mut h = app_at(700.0, "Home");
|
||||
let o = RibbonOverflow::get(&h.ctx);
|
||||
assert!(o.overflows(), "Home is wider than a 700 px window");
|
||||
assert_eq!(o.offset, 0.0);
|
||||
assert!(h.query_by_label("Scroll ribbon left").is_none(), "nothing to the left yet");
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
let scrolled = RibbonOverflow::get(&h.ctx).offset;
|
||||
assert!(scrolled > 0.0);
|
||||
h.get_by_label("Scroll ribbon left").click();
|
||||
h.run_steps(3);
|
||||
assert!(RibbonOverflow::get(&h.ctx).offset < scrolled);
|
||||
// A tab that fits shows no arrows.
|
||||
let h = app_at(1440.0, "Automate");
|
||||
assert!(!RibbonOverflow::get(&h.ctx).overflows());
|
||||
assert!(h.query_by_label("Scroll ribbon right").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_tab_keeps_a_scroll_offset_of_its_own() {
|
||||
// Scrolling Home must not hide the first groups of another overflowing tab, and Home keeps
|
||||
// its offset while the other tab is used.
|
||||
let mut h = app_at(700.0, "Home");
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
let home_offset = RibbonOverflow::get(&h.ctx).offset;
|
||||
assert!(home_offset > 0.0);
|
||||
let other = TABS.iter().copied().find(|t| *t != "Home" && RibbonOverflow::get(&app_at(700.0, t).ctx).overflows());
|
||||
let other = other.expect("another tab overflows a 700 px window");
|
||||
h.state_mut().ui.ribbon_tab = other.into();
|
||||
h.run_steps(3);
|
||||
let o = RibbonOverflow::get(&h.ctx);
|
||||
assert!(o.overflows(), "{other} overflows");
|
||||
assert_eq!(o.offset, 0.0, "{other} starts at its left edge");
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
assert!(RibbonOverflow::get(&h.ctx).offset > 0.0);
|
||||
h.state_mut().ui.ribbon_tab = "Home".into();
|
||||
h.run_steps(3);
|
||||
assert_eq!(RibbonOverflow::get(&h.ctx).offset, home_offset, "Home kept its offset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scroll_arrows_stay_inside_a_narrow_window() {
|
||||
// The tab row can be wider than a narrow window (more so with wider system fonts); the card
|
||||
// and its arrows stay inside the window, and the right arrow still scrolls.
|
||||
for width in [600.0, 480.0] {
|
||||
let mut h = app_at(width, "Home");
|
||||
let arrow = h.get_by_label("Scroll ribbon right").rect();
|
||||
assert!(arrow.max.x <= width, "{width}: the arrow ends at {}", arrow.max.x);
|
||||
h.get_by_label("Scroll ribbon right").click();
|
||||
h.run_steps(3);
|
||||
assert!(RibbonOverflow::get(&h.ctx).offset > 0.0, "{width}: the right arrow scrolls");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Clicks {
|
||||
main: u32,
|
||||
arrow: u32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_split_button_separates_the_default_action_from_the_menu() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state(
|
||||
|ui, c: &mut Clicks| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
// egui's own font stands in for the system UI font; it applies from the next pass.
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let proportional = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
fonts.families.insert(FontFamily::Name(theme::UI.into()), proportional.clone());
|
||||
fonts.families.insert(FontFamily::Name(theme::CELL.into()), proportional);
|
||||
ui.ctx().set_fonts(fonts);
|
||||
return;
|
||||
}
|
||||
let (main, arrow) = widgets::big_split_button(ui, Icon::Sum, "Auto\nSum", "AutoSum");
|
||||
c.main += u32::from(main.clicked());
|
||||
c.arrow += u32::from(arrow.clicked());
|
||||
},
|
||||
Clicks::default(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto Sum").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto Sum options").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Auto Sum options").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().main, h.state().arrow), (1, 2));
|
||||
// The tooltip carries the shortcut hint, as every ribbon button's does.
|
||||
h.get_by_label("Auto Sum").hover();
|
||||
h.run_steps(3);
|
||||
let hint = if cfg!(target_os = "macos") { "AutoSum (\u{2318}" } else { "AutoSum (Alt+=" };
|
||||
assert!(h.query(egui_kittest::kittest::By::new().label_contains(hint)).is_some(), "tooltip with the shortcut");
|
||||
// The icon part sits above the label part, across the full width.
|
||||
let main = h.get_by_label("Auto Sum").rect();
|
||||
let arrow = h.get_by_label("Auto Sum options").rect();
|
||||
assert!(main.max.y <= arrow.min.y + 0.5);
|
||||
assert!((main.min.x - arrow.min.x).abs() < 0.5 && (main.max.x - arrow.max.x).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_autosum_runs_on_the_icon_and_opens_its_menu_on_the_label() {
|
||||
let mut h = app_at(1600.0, "Formulas");
|
||||
h.get_by_label("AutoSum").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "formulas.autoSum"), "the icon runs AutoSum");
|
||||
assert!(h.query_by_label("AVERAGE").is_none());
|
||||
h.get_by_label("AutoSum options").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("AVERAGE").click();
|
||||
h.run_steps(2);
|
||||
let last = h.state().session.journal.iter().rev().find(|(id, _)| id == "formulas.autoSum").map(|(_, p)| p.clone());
|
||||
assert_eq!(last.and_then(|p| p.get("function").and_then(|f| f.as_str().map(str::to_string))).as_deref(), Some("AVERAGE"));
|
||||
}
|
||||
+34
-1
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · ** Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md).
|
||||
|
||||
@@ -49,6 +49,39 @@ including tests, 2026-10-10.
|
||||
5. **Files**: `engine/io.rs` sniffs content (XLSX, XLSB, ODS) before trusting the extension and
|
||||
dispatches to `crates/xlsx`; imports that lose data return warnings shown to the user.
|
||||
|
||||
## UI building blocks
|
||||
|
||||
The panels in `crates/ui-egui` share a few pieces, so a new feature reuses them instead of
|
||||
drawing its own:
|
||||
|
||||
- **Colours**: `theme::Tokens`, each with a light and a dark value. The sheet stays white in both
|
||||
themes, so colours drawn on it (`sheet_ref_colors`, `sheet_control_*`, icons through
|
||||
`icons::paint_on_sheet`) keep their light values. `theme::contrast_tests` holds every
|
||||
foreground/background pair to WCAG 2 (4.5:1 for text, 3:1 for glyphs) in both themes; a new
|
||||
token pair is added there. Icons (`icons.rs`, drawn in code) take their paper and category
|
||||
colours (`theme::Cat`) from the tokens.
|
||||
- **Ribbon widgets**: `widgets.rs` has the buttons every tab is built from (`big_button`,
|
||||
`small_button`, `split_button`, `big_split_button`, `toggle_button`), translated through
|
||||
`i18n` in one place. Split buttons return the main and arrow responses separately, with
|
||||
accessible names (`AutoSum`, `AutoSum options`). The ribbon card scrolls a tab wider than the
|
||||
window, with arrows at its edges; `ribbon::RibbonOverflow` records the groups' size each frame,
|
||||
and a test keeps every tab within the card's three rows.
|
||||
- **Code editor**: `widgets::code_editor(ui, id, &mut text, Lang, max_lines)` (`code_editor.rs`)
|
||||
edits code in the monospace font with `Tokens::syntax` colours: Python (keywords, strings
|
||||
including triple-quoted ones, numbers, comments, builtins) and LaTeX (commands, scripts,
|
||||
comments). Tab indents (every selected line, Shift+Tab dedents), Enter keeps the line's
|
||||
indentation (one level more after a Python colon), both applied in input order with the typing
|
||||
around them: a pass can carry several events, and the editor's keys that follow an event the
|
||||
text field acts on (select all, an arrow) wait for the editor's next pass, which puts them
|
||||
ahead of its input, so the field never gets a Tab or an Enter of a focused editor; and it
|
||||
scrolls past `max_lines` with the caret in view. The tokenizer and the key edits are pure functions with tests, including
|
||||
hostile input.
|
||||
- **Formula bar**: grows with its content from one line to `formula_bar::MAX_LINES` (five), then
|
||||
scrolls; one line is Excel's 30 px unless the font's rows need more (`formula_bar::one_line`;
|
||||
the Windows UI font's row is 17.28 px), so every counted line fits whatever the font;
|
||||
`formula_bar::BarLayout` records where its parts went each frame, for tests.
|
||||
|
||||
|
||||
## Engine building blocks for multi-command actions
|
||||
|
||||
Actions made of many commands (macros, a debugger that runs one step per call) use these parts of
|
||||
|
||||
+7
-4
@@ -16,12 +16,12 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|
||||
|---|---|---|---|
|
||||
| 2026-10-14 | minor | Dialogs (Find and Replace, Go To, Format Cells, Sort and the rest) move by their title bar instead of staying pinned to the centre (#1250) |
|
||||
| Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) |
|
||||
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928) | done; missing contextual tabs for pictures, shapes, sparklines, slicers |
|
||||
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs); Fill Color, Font Color and the Format Cells colour pickers end with Recent Colors and More Colors… (#928); large split buttons (Formulas › AutoSum); arrows at the card's edges scroll a tab wider than the window | done; missing contextual tabs for pictures, shapes, sparklines, slicers; groups do not collapse when narrow |
|
||||
| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) |
|
||||
| Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers |
|
||||
| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing |
|
||||
| Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h |
|
||||
| Formula bar with Name Box, expand, function hints | yes | yes | done |
|
||||
| Formula bar with Name Box, expand, function hints | yes | yes; grows with its content from one to five lines (Alt+Enter, wrapped text, or viewing a multi-line cell), then scrolls with the caret in view, the Name Box and buttons staying on the first line; the chevron pins it at five; the in-cell editor shows every line | done; no drag-to-resize handle |
|
||||
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | Ctrl-drag (Cmd or Option on macOS) drops a copy (#971); active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
|
||||
| Formula bar with Name Box, expand, function hints | yes | yes | done; dynamic-array spill children show the source formula muted and read-only, using current spill ownership |
|
||||
| Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | active tab revealed on navigation and resize (#681); manual wheel/trackpad browsing preserved; rename focus bug on macOS (#145) |
|
||||
@@ -29,7 +29,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|
||||
| Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial |
|
||||
| Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes; PivotTable source-field list scrollbar sits at the right edge of its pane | element-level Format pane for shapes and pictures missing |
|
||||
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing |
|
||||
| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) |
|
||||
| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239); icons, reference colours, primary buttons and the comments pane follow the theme, and a unit test holds every token pair to WCAG 2 contrast in both themes |
|
||||
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has |
|
||||
| Dark mode, system appearance | yes | yes | done (10-09) |
|
||||
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) |
|
||||
@@ -160,7 +160,8 @@ applies to as it is (#2348). Other dialogs' reference fields don't take clicks y
|
||||
|
||||
AccessKit is enabled, but the grid, ribbon and dialogs are not exposed as an accessible tree with
|
||||
cell addresses and values, so screen readers can't navigate a sheet; there is no high-contrast
|
||||
theme or accessibility checker. Excel is strong here. 12–18 h.
|
||||
theme or accessibility checker. Excel is strong here. 12–18 h. The light and dark themes' colour
|
||||
pairs (text 4.5:1, glyphs 3:1) are checked by `theme::contrast_tests`.
|
||||
|
||||
## Feel
|
||||
|
||||
@@ -217,6 +218,8 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| 2026-10-12 | minor | Page Layout view: left/right/top/bottom margins are dragged on the rulers (#792) |
|
||||
| 2026-10-12 | minor | Alt+Down opens the active filter header's existing popup, with validation priority and mirrored anchoring on right-to-left sheets (part of #441) |
|
||||
| 2026-10-12 | minor | Paste from the ribbon (button and options menu), the cell and header context menus and the macOS menu bar's Edit menu pastes what is on the system clipboard, as Cmd/Ctrl+V does, instead of only GridCraft's own copy (the web build, which can't read the clipboard on request, still pastes GridCraft's copy). Copy and Cut by keytip (H,C / H,X) publish to the system clipboard like the ribbon buttons. After a control-channel or MCP copy, which leaves the system clipboard alone, Paste pastes that copy until other text is copied. Once a cut has been pasted, Paste (ribbon, menus or Cmd/Ctrl+V) doesn't paste the cut's clipboard text again over the moved cells (Excel pastes a cut once), and the Paste keytip (H,V,P) pastes the system clipboard like the button. Regression test for the Paste button click freeze in 0.5.0 (#273, fixed by #272); the Paste button gets an accessible "Paste" name for screen readers |
|
||||
| 2026-10-11 | minor | Ribbon: scroll arrows on a tab wider than the window, Formulas › AutoSum as a split button with its function menu, View › Show in two columns so Headings is no longer cut off |
|
||||
| 2026-10-11 | minor | Theme tokens for icon, reference, on-accent and sheet-control colours; WCAG 2 contrast test over both themes (dark danger red and selected header text adjusted) |
|
||||
| 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area |
|
||||
| 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) |
|
||||
| 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |
|
||||
|
||||
Reference in New Issue
Block a user