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. +

+ +

+ Written in Rust + Runs on macOS, Windows, Linux, BSD and the web + License: MIT OR Apache-2.0 + Agent-drivable over MCP +

+

Join the ArtCraft community on Discord

-Work in progress — the full README with screenshots lands with the first alpha. +

+ SheetCraft on getartcraft.com · + ArtCraft · + All Crafting Apps +

+ +
+ +

+ SheetCraft showing a quarterly sales dashboard: a styled table with banded rows, filter buttons, a totals row, data bars, arrow icons and sparklines, next to a clustered column chart and a pie chart +
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 + + + + + + + + + + +
A household budget with accounting number formats, red highlights for overspent categories, a green-yellow-red colour scale on the percent-used column, a totals row with a double border, and a horizontal bar chart comparing budget with actual

Formatting. Accounting formats, cell styles, conditional rules and colour scales, frozen headers and a bar chart.

A gradebook table with averages and letter grades computed with IFS, green highlights on A grades and data bars, with the Formulas ribbon tab open showing the function library

Formulas. 500+ functions, dynamic arrays, LET and LAMBDA, structured table references.

The sales dashboard in dark mode with the Insert ribbon tab open, showing PivotTable, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons

Dark mode and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.

Close-up of the sales dashboard

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. + +
+ +

+ Join the ArtCraft community on Discord +

+ +

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. + +
+ +

+ ArtCraft
+ 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