M1.5: README with screenshots, display scale matching desktop density, validation dropdowns, note popups, trace arrows, header menus, hyperlink clicks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b6ae96633d
commit
00c246a3e2
|
|
@ -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 |
|
||||
|
|
|
|||
204
README.md
204
README.md
|
|
@ -13,8 +13,210 @@
|
|||
<b>Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
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.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-147a40?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-1f9d55?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-147a40?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-1f9d55?style=flat-square">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
Work in progress — the full README with screenshots lands with the first alpha.
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/apps/sheetcraft"><b>SheetCraft on getartcraft.com</b></a> ·
|
||||
<a href="https://getartcraft.com/">ArtCraft</a> ·
|
||||
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/hero-sales.png" alt="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" width="100%">
|
||||
<br><sub><b>Quarterly Sales Dashboard</b>: a table with a totals row, conditional formatting, sparklines and two live charts — built entirely from SheetCraft commands (<code>File → New → Sample</code>).</sub>
|
||||
</p>
|
||||
|
||||
> [!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).**
|
||||
|
||||
<p align="center">
|
||||
<a href="#a-look-around">A look around</a> ·
|
||||
<a href="#why-sheetcraft">Why SheetCraft</a> ·
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="#for-agents-mcp-cli-and-the-control-channel">For agents</a> ·
|
||||
<a href="#architecture">Architecture</a> ·
|
||||
<a href="#roadmap">Roadmap</a> ·
|
||||
<a href="#the-crafting-apps">The Crafting Apps</a> ·
|
||||
<a href="#license-and-credits">License and credits</a>
|
||||
</p>
|
||||
|
||||
## A look around
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top"><img src="docs/images/budget.png" alt="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" width="100%"><p align="center"><sub><b>Formatting.</b> Accounting formats, cell styles, conditional rules and colour scales, frozen headers and a bar chart.</sub></p></td>
|
||||
<td width="50%" valign="top"><img src="docs/images/grades-formulas.png" alt="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" width="100%"><p align="center"><sub><b>Formulas.</b> 500+ functions, dynamic arrays, LET and LAMBDA, structured table references.</sub></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top"><img src="docs/images/dark-insert.png" alt="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" width="100%"><p align="center"><sub><b>Dark mode</b> and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.</sub></p></td>
|
||||
<td width="50%" valign="top"><img src="docs/images/hero-sales.png" alt="Close-up of the sales dashboard" width="100%"><p align="center"><sub><b>Charts.</b> Column, bar, line, area, pie, doughnut, scatter, bubble, radar, histogram, waterfall, funnel, treemap, sunburst, box & whisker, stock and combo.</sub></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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 |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/sheetcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.sheetcraft.png" alt="" width="32" height="32"> | **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.
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">Come make things with us</h3>
|
||||
|
||||
<p align="center">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">getartcraft.com</a> ·
|
||||
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
|
||||
<a href="https://getartcraft.com/apps/sheetcraft">SheetCraft</a>
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
|
||||
<sub>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.</sub>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value>,
|
||||
pub renaming_tab: Option<usize>,
|
||||
pub rename_text: String,
|
||||
pub list_picker: Option<CellRef>,
|
||||
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<Rect> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 487 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 420 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 538 KiB |
Loading…
Reference in New Issue