diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 73e36793f..affdadaa5 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -33,3 +33,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.sheetcraft.svg` | SheetCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | Placeholder icon, copy of sheetcraft.svg |
| `assets/app-icon/README.md` | SheetCraft contributors | original | MIT OR Apache-2.0 | Icon documentation |
| `assets/app-icon/LICENSE.txt` | (licence text) | original | — | Licence of the app icon files |
+| `docs/images/hero-sales.png` | SheetCraft contributors | screenshot of SheetCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
+| `docs/images/budget.png` | SheetCraft contributors | screenshot of SheetCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
+| `docs/images/grades-formulas.png` | SheetCraft contributors | screenshot of SheetCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
+| `docs/images/dark-insert.png` | SheetCraft contributors | screenshot of SheetCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
diff --git a/README.md b/README.md
index 5651357cb..c324e27ba 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,210 @@
Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust.
+
+ A fast, open-source, clean-room take on the Excel workflow. It runs natively on macOS,
+ Windows, Linux and BSD, and in the browser via WebAssembly.
+ By the ArtCraft team.
+
+
+
+
+
+
+
+
+
-Work in progress — the full README with screenshots lands with the first alpha.
+
+ SheetCraft on getartcraft.com ·
+ ArtCraft ·
+ All Crafting Apps
+
+
+
+
+
+
+
Quarterly Sales Dashboard: a table with a totals row, conditional formatting, sparklines and two live charts — built entirely from SheetCraft commands (File → New → Sample).
+
+
+> [!NOTE]
+> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
+> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
+> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
+
+
+ A look around ·
+ Why SheetCraft ·
+ Quick start ·
+ For agents ·
+ Architecture ·
+ Roadmap ·
+ The Crafting Apps ·
+ License and credits
+
+
+## A look around
+
+
+
+ Formatting. Accounting formats, cell styles, conditional rules and colour scales, frozen headers and a bar chart. |
+ Formulas. 500+ functions, dynamic arrays, LET and LAMBDA, structured table references. |
+
+
+ Dark mode and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments. |
+ Charts. Column, bar, line, area, pie, doughnut, scatter, bubble, radar, histogram, waterfall, funnel, treemap, sunburst, box & whisker, stock and combo. |
+
+
+
+## Why SheetCraft
+
+- **Familiar.** Excel's ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill
+ handle, point-mode formula entry with coloured references, Format Cells, Paste Special,
+ conditional formatting, tables, charts and more. If you know Excel, you already know SheetCraft.
+- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
+ tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines and
+ print settings round-trip. CSV and TSV too.
+- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
+ 478 worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
+ structured table references, Excel's full number-format language, sort and AutoFilter,
+ Flash Fill, data validation, outlining and subtotals.
+- **Fast.** Copy-on-write workbooks (undo snapshots are nearly free), incremental recalculation,
+ and a virtualised grid that only lays out what's on screen.
+- **Agent-native.** Every ribbon button, menu item, dialog, keystroke and mouse gesture is a
+ command that agents can run over a JSON control channel, an **MCP server**, or the CLI.
+- **Everywhere.** One Rust codebase for desktop and the web. No subscription, no licence server,
+ no telemetry.
+
+## Quick start
+
+```sh
+cargo run --release -p sheetcraft # desktop app
+cargo run --release -p sheetcraft -- --sample sales # open a sample (sales, budget, grades)
+cargo run --release -p sheetcraft -- book.xlsx # open a workbook
+cargo run --release -p sheetcraft -- --sample sales --control 7979 # + JSON control channel
+cargo xtask ci # fmt, clippy, tests, assets, layering, wasm
+```
+
+### Web
+
+```sh
+cd apps/sheetcraft-web && trunk build --release # → dist/web (serve it with any static server)
+cd apps/sheetcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sample=sales opens a sample)
+```
+
+You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
+browser's file picker (dropping files works too) and Save downloads the workbook.
+
+## For agents: MCP, CLI and the control channel
+
+```sh
+claude mcp add sheetcraft -- sheetcraft-cli mcp # headless MCP server
+sheetcraft-cli mcp --connect 7979 # MCP bridged to a running window
+sheetcraft-cli eval '=XLOOKUP("b",{"a","b"},{1,2})' # → 2
+sheetcraft-cli run --sample budget --cmd 'home.bold={"range":"B4:F4"}' --out budget.xlsx
+sheetcraft-cli cat budget.xlsx --range B4:F15 # aligned text table
+sheetcraft-cli commands --search chart # every command and its params
+```
+
+The MCP server offers tools such as `read_range`, `write_range`, `set_cell`, `evaluate_formula`,
+`format_range`, `insert_chart`, `create_table`, `sort_range`, `filter` and `execute_command`
+(which reaches every one of SheetCraft's commands). The desktop app's control channel adds real
+pointer and keyboard input, dialogs and screenshots. See [`docs/mcp.md`](docs/mcp.md),
+[`docs/cli.md`](docs/cli.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
+
+## Architecture
+
+SheetCraft is an engine-first Cargo workspace with enforced layering (`cargo xtask layers`). The
+egui frontend is a separate crate, so the UI can be swapped without touching the engine.
+
+| Layer | Crates |
+|---|---|
+| L0 | `core` (addresses, values, errors, dates, input parsing) · `numfmt` (number format codes) |
+| L1 | `formula` (lexer, parser, printer, reference adjustment) · `functions` (worksheet function library) |
+| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX and CSV) |
+| L3 | `chart` (toolkit-free chart layout and rendering) |
+| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
+| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
+| L7 | `apps/sheetcraft`, `apps/sheetcraft-cli`, `apps/sheetcraft-web` |
+
+- **Contributor and agent rules** (clean-room, asset policy, never-crash, quality gates): [`AGENTS.md`](AGENTS.md)
+- **Bundled assets:** each one is listed with its licence in [`ATTRIBUTION.md`](ATTRIBUTION.md)
+- **Releases:** signed macOS, Windows, Linux, BSD and web builds; see [`docs/releasing.md`](docs/releasing.md)
+
+## Roadmap
+
+SheetCraft is pre-alpha and moving fast. Today it covers 60% of Excel's ribbon and menu
+commands ([`docs/parity.md`](docs/parity.md)) and about 93% of its worksheet functions. PivotTables,
+printing and PDF, page layout view, deeper chart formatting and scripting come next. The plan
+and estimates are in [ROADMAP.md](ROADMAP.md).
+
+## The Crafting Apps
+
+SheetCraft is one of the **Crafting Apps**: free, open-source creative tools from the
+[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
+stand on its own.
+
+| | App | What it's for | Code | Learn more |
+|:-:|---|---|---|---|
+|
| **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
+|
| **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
+|
| **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
+|
| **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
+|
| **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
+|
| **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
+|
| **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
+|
| **SheetCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/sheetcraft) | [Website](https://getartcraft.com/apps/sheetcraft) |
+
+And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+
+
+
+
+
+
+
+Come make things with us
+
+
+ Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
+ set type, and people still figuring out what they like to make. Share what you're working on,
+ ask for help, tell us what's broken, or tell us what you wish these tools could do.
+ Whatever your medium and however long you've been at it, you're welcome here.
+
+
+
+ discord.gg/artcraft ·
+ getartcraft.com ·
+ The Crafting Apps ·
+ SheetCraft
+
+
+## License and credits
+
+SheetCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
+Copyright (c) 2026 ArtCraft Team and the SheetCraft contributors. Required notices are in [NOTICE](NOTICE).
+
+Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
+with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
+
+All UI icons are drawn in code and are original; the sample workbooks and every screenshot in
+this README were made with SheetCraft itself. SheetCraft bundles no fonts: it uses the fonts
+installed on your system.
+
+The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
+ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
+part of this repository and SheetCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
+Forks and modified versions must remove them.
+
+Microsoft, Excel and Microsoft 365 are trademarks or registered trademarks of Microsoft Corporation in the United States and/or other countries. SheetCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation; these names are used only to describe the workflows it is compatible with.
+
+
+
+
+ 
+ Made by the ArtCraft team and community.
+
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
index 87b4134fa..4e3b92feb 100644
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -15,7 +15,10 @@ use crate::SheetApp;
use crate::editor::{EditState, REF_COLORS};
use crate::theme::{self, Tokens};
-pub const COL_HEADER_H: f32 = 20.0;
+pub const COL_HEADER_H: f32 = 21.0;
+/// Sheet units are 96-dpi pixels (as in XLSX); on screen one unit is drawn at this many points,
+/// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall).
+pub const DISPLAY_SCALE: f32 = 0.8;
#[derive(Clone, Debug, Default, PartialEq)]
pub enum Drag {
@@ -73,6 +76,8 @@ pub struct GridState {
pub trace: Option,
pub renaming_tab: Option,
pub rename_text: String,
+ pub list_picker: Option,
+ pub header_menu: Option<(Pos2, bool)>,
}
impl GridState {
@@ -101,11 +106,12 @@ pub struct Geo {
impl Geo {
pub fn new(sh: &Sheet, rect: Rect, scroll: egui::Vec2) -> Geo {
- let z = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0);
- let header_h = if sh.show_headings { (COL_HEADER_H * z.max(0.75)).round() } else { 0.0 };
+ let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0);
+ let z = zoom * DISPLAY_SCALE;
+ let header_h = if sh.show_headings { (COL_HEADER_H * zoom.max(0.75)).round() } else { 0.0 };
let last_row = sh.row_at((scroll.y + rect.height() / z) as f64 + 200.0) + 1;
let digits = (last_row as f32).log10().floor() as i32 + 1;
- let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.5 + 12.0) * z.max(0.75)).round() } else { 0.0 };
+ let header_w = if sh.show_headings { ((digits.max(2) as f32 * 7.0 + 14.0) * zoom.max(0.75)).round() } else { 0.0 };
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
let frozen_w = (sh.col_left(fc) as f32) * z;
let frozen_h = (sh.row_top(fr) as f32) * z;
@@ -308,7 +314,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round();
let _ = app.session.run("view.zoom", json!({"percent": z}));
} else if scroll_delta != egui::Vec2::ZERO {
- let z = sh.zoom as f32 / 100.0;
+ let z = sh.zoom as f32 / 100.0 * DISPLAY_SCALE;
let mut d = scroll_delta;
if modifiers.shift && d.x == 0.0 {
d = vec2(d.y, 0.0);
@@ -387,6 +393,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
// Objects above cells.
crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
+ paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel);
// Headers.
if sh.show_headings {
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
@@ -397,6 +404,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
in_cell_editor(app, ui, &geo, sh, &wb);
}
filter_menu(app, ui, &geo);
+ list_picker(app, ui, &geo);
+ header_menu(app, ui);
context_menu(app, ui);
if app.session.clipboard.is_some() {
app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0;
@@ -866,7 +875,7 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &sheetcraft_engine::
}
fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &sheetcraft_engine::Selection, t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)]) {
- let font = theme::ui_font((11.0 * geo.z.max(0.75)).min(15.0));
+ let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0));
let r = geo.rect;
let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top()));
let left = Rect::from_min_max(pos2(r.left(), geo.cells.top()), pos2(geo.cells.left(), r.bottom()));
@@ -1027,6 +1036,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
return;
}
let c = geo.cell_at(sh, p);
+ // Data validation list arrow next to the active cell.
+ if let Some(r) = validation_arrow(sh, geo, sel.active)
+ && r.contains(p)
+ {
+ app.grid.list_picker = Some(sel.active);
+ return;
+ }
// Filter dropdown buttons.
if is_filter_button(sh, geo, p) {
app.grid.filter_menu = Some((c.col, p));
@@ -1083,11 +1099,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let _ = app.session.run("selection.set", json!({"range": ranges.join(","), "active": c.a1()}));
} else {
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
- // Ctrl/Cmd-click a hyperlink opens it.
- if let Some(h) = sh.hyperlinks.get(&c)
- && mods.alt
+ // A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
+ if let Some(h) = sh.hyperlinks.get(&c).cloned()
+ && resp.clicked()
+ && !mods.any()
{
- follow_link(app, &h.target);
+ let r = geo.cell_rect(sh, c);
+ if p.x < r.left() + r.width() * 0.8 {
+ follow_link(app, &h.target);
+ }
}
}
app.grid.drag = Drag::Select;
@@ -1268,7 +1288,17 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if in_cells && !sel.contains(geo.cell_at(sh, p)) {
let _ = app.session.run("selection.set", json!({"cell": geo.cell_at(sh, p).a1()}));
}
- app.grid.context_menu = Some(p);
+ if in_col_header || in_row_header {
+ let (rows, idx) = if in_row_header { (true, geo.row_at(sh, p.y)) } else { (false, geo.col_at(sh, p.x)) };
+ let inside = sel.ranges.iter().any(|r| if rows { r.is_full_rows() && idx >= r.start.row && idx <= r.end.row } else { r.is_full_cols() && idx >= r.start.col && idx <= r.end.col });
+ if !inside {
+ let range = if rows { RangeRef::rows(idx, idx) } else { RangeRef::cols(idx, idx) };
+ let _ = app.session.run("selection.set", json!({"range": range.a1()}));
+ }
+ app.grid.header_menu = Some((p, rows));
+ } else {
+ app.grid.context_menu = Some(p);
+ }
}
if resp.clicked() || resp.drag_started() {
@@ -1704,3 +1734,173 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
ed.point_cell = Some(to);
app.grid.ensure_visible = true;
}
+
+/// The dropdown arrow beside a cell with an in-cell list validation.
+pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option {
+ let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(c)))?;
+ if dv.kind != sheetcraft_engine::model::ValidationKind::List || !dv.in_cell_dropdown {
+ return None;
+ }
+ let r = geo.cell_rect(sh, c);
+ let s = r.height().clamp(14.0, 22.0);
+ Some(Rect::from_min_size(pos2(r.right() + 1.0, r.bottom() - s), vec2(s, s)))
+}
+
+#[allow(clippy::too_many_arguments)]
+fn paint_overlays(app: &mut SheetApp, ui: &egui::Ui, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, sel: &sheetcraft_engine::Selection) {
+ // Validation dropdown arrow for the active cell.
+ 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));
+ }
+ // Input message of the active cell's validation.
+ if app.editor.is_none()
+ && let Some(dv) = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(sel.active)))
+ && dv.show_input
+ && !(dv.input_title.is_empty() && dv.input_message.is_empty())
+ {
+ let r = geo.cell_rect(sh, sel.active);
+ let at = pos2(r.left() + 8.0, r.bottom() + 6.0);
+ egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
+ egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
+ ui.set_max_width(220.0);
+ if !dv.input_title.is_empty() {
+ ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
+ }
+ ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
+ });
+ });
+ }
+ // Notes: shown on hover (or always when marked visible).
+ let hover = app.grid.hover_cell;
+ for (c, cm) in &sh.comments {
+ let show = cm.visible || hover == Some(*c);
+ if !show || app.grid.drag != Drag::None {
+ continue;
+ }
+ let r = geo.cell_rect(sh, *c);
+ if !p.clip_rect().intersects(r) {
+ continue;
+ }
+ let at = pos2(r.right() + 10.0, r.top());
+ p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90)));
+ egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
+ egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
+ ui.set_max_width(240.0);
+ ui.label(egui::RichText::new(format!("{}:", cm.author)).strong().color(Color32::BLACK));
+ ui.label(egui::RichText::new(&cm.text).color(Color32::BLACK));
+ for (a, t) in &cm.replies {
+ ui.separator();
+ ui.label(egui::RichText::new(format!("{a}: {t}")).color(Color32::BLACK));
+ }
+ });
+ });
+ }
+ // Hyperlink tooltip.
+ if let Some(h) = hover.and_then(|c| sh.hyperlinks.get(&c))
+ && app.editor.is_none()
+ {
+ let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target));
+ egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
+ ui.label(tip);
+ });
+ }
+ // Trace precedents / dependents arrows.
+ if let Some(trace) = app.grid.trace.clone()
+ && let Some(items) = trace.as_array()
+ {
+ let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
+ let active = geo.cell_rect(sh, sel.active).center();
+ for it in items {
+ if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
+ continue;
+ }
+ let target = it["range"].as_str().or(it["cell"].as_str()).and_then(RangeRef::parse);
+ let Some(t) = target else { continue };
+ let tr = geo.range_rect(sh, t);
+ if !t.is_single() {
+ p.rect_stroke(tr, 0.0, Stroke::new(1.5, blue), StrokeKind::Inside);
+ }
+ let (from, to) = if it.get("range").is_some() { (tr.center(), active) } else { (active, tr.center()) };
+ p.line_segment([from, to], Stroke::new(1.5, blue));
+ p.circle_filled(from, 3.0, blue);
+ let d = (to - from).normalized();
+ let n = vec2(-d.y, d.x);
+ p.add(egui::Shape::convex_polygon(vec![to, to - d * 9.0 + n * 4.0, to - d * 9.0 - n * 4.0], blue, Stroke::NONE));
+ }
+ }
+ let _ = (wb, si);
+}
+
+fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) {
+ let Some(c) = app.grid.list_picker else { return };
+ let Some(d) = app.session.active() else { return };
+ let wb = d.wb.clone();
+ let si = wb.active_sheet;
+ let Some(sh) = wb.sheet(si) else { return };
+ let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else {
+ app.grid.list_picker = None;
+ return;
+ };
+ let items = sheetcraft_engine::cmd::data::list_items(&wb, si, &dv);
+ let r = geo.cell_rect(sh, c);
+ let mut close = false;
+ egui::Area::new(egui::Id::new("dv_list")).fixed_pos(r.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
+ egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| {
+ ui.set_min_width(r.width().max(100.0));
+ egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
+ for it in &items {
+ if ui.add(egui::Button::new(it.as_str()).frame(false).min_size(vec2(r.width().max(100.0), 18.0))).clicked() {
+ app.run_or_alert("cell.set", json!({"cell": c.a1(), "input": it}));
+ close = true;
+ }
+ }
+ });
+ });
+ });
+ if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
+ app.grid.list_picker = None;
+ }
+}
+
+fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
+ let Some((at, rows)) = app.grid.header_menu else { return };
+ let mut close = false;
+ egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
+ egui::Frame::popup(ui.style()).show(ui, |ui| {
+ ui.set_min_width(190.0);
+ let items: Vec<(&str, &str)> = if rows {
+ vec![("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste"), ("-", ""), ("Insert", "home.insertRows"), ("Delete", "home.deleteRows"), ("Clear Contents", "edit.clearContents"), ("-", ""), ("Row Height…", "ui:rowHeight"), ("AutoFit Row Height", "home.autofitRowHeight"), ("Hide", "home.hideRows"), ("Unhide", "home.unhideRows"), ("Group", "data.group")]
+ } else {
+ vec![("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste"), ("-", ""), ("Insert", "home.insertColumns"), ("Delete", "home.deleteColumns"), ("Clear Contents", "edit.clearContents"), ("-", ""), ("Column Width…", "ui:columnWidth"), ("AutoFit Column Width", "home.autofitColumnWidth"), ("Hide", "home.hideColumns"), ("Unhide", "home.unhideColumns"), ("Sort A to Z", "data.sortAscending"), ("Group", "data.group")]
+ };
+ for (label, id) in items {
+ if label == "-" {
+ ui.separator();
+ continue;
+ }
+ if ui.add(egui::Button::new(label).frame(false).min_size(vec2(180.0, 20.0))).clicked() {
+ close = true;
+ match id {
+ "ui:rowHeight" => app.open_dialog("rowHeight", json!({})),
+ "ui:columnWidth" => app.open_dialog("columnWidth", json!({})),
+ "edit.copy" | "edit.cut" => {
+ if let Ok(r) = app.run(id, json!({}))
+ && let Some(t) = r.get("text").and_then(|t| t.as_str())
+ {
+ ui.ctx().copy_text(t.to_string());
+ }
+ }
+ other => app.run_or_alert(other, json!({})),
+ }
+ }
+ }
+ });
+ });
+ if close || ui.input(|i| i.key_pressed(egui::Key::Escape)) || (ui.input(|i| i.pointer.any_pressed()) && !ui.ctx().is_pointer_over_egui()) {
+ app.grid.header_menu = None;
+ }
+}
diff --git a/docs/images/budget.png b/docs/images/budget.png
new file mode 100644
index 000000000..6ddb26679
Binary files /dev/null and b/docs/images/budget.png differ
diff --git a/docs/images/dark-insert.png b/docs/images/dark-insert.png
new file mode 100644
index 000000000..179b97b21
Binary files /dev/null and b/docs/images/dark-insert.png differ
diff --git a/docs/images/grades-formulas.png b/docs/images/grades-formulas.png
new file mode 100644
index 000000000..0d5e1c02f
Binary files /dev/null and b/docs/images/grades-formulas.png differ
diff --git a/docs/images/hero-sales.png b/docs/images/hero-sales.png
new file mode 100644
index 000000000..e611d9ccb
Binary files /dev/null and b/docs/images/hero-sales.png differ