diff --git a/AGENTS.md b/AGENTS.md index d85635b..92a95be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off) for the grid, its parser and the pseudo-terminal; `futures` carries its events. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. - Markdown: `pulldown-cmark` 0.13 (MIT, default features off) for CommonMark with tables, tasks, strikethrough, footnotes and math. -- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames. +- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. +- Maps: the gallery's tiles are Natural Earth's 110m countries (public domain), cut z0 to z3 by `scripts/tiles.py` with Pillow. - Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components. ## Commands @@ -23,6 +24,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `cargo test --lib --features test-support`, `cargo clippy --all-targets --features test-support -- -D warnings`, `cargo fmt --check`. `scripts/check.sh` runs them with the house rules. - `rm -rf target/debug/incremental` and `cargo clean -p ely-gpui-component` after each task item keep the disk lean. Cargo keeps every old build of the crate and its tests under a new hash; by T25 they had filled the disk. - `scripts/icons.sh ...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`. +- `python3 scripts/tiles.py ` writes the gallery's map tiles. ## Layout @@ -193,6 +195,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Messages go through `i18n::I18n`, a global the app sets: `cx.global::().text(key, args)` fills each `{name}`. Every catalog holds the first one's keys; a missing key, argument or catalog fails. A right-to-left locale mirrors a stack with `Direction::row` and sets its text with `Direction::align`; CoreText orders mixed Hebrew and Latin runs on macOS, but gpui sets no paragraph direction, so a line takes its first strong character's: `text` leads a right-to-left message with U+200F, so its words suit labels, not a field's value, and `Locale::number` a right-to-left minus with U+200E, as CLDR does. gpui 0.2.2 wraps shaped glyphs in the order they are drawn, so a right-to-left line that wraps comes out garbled: right-to-left text stays on one line, an `Ellipsis`, which cuts by characters and sits by its text's alignment. - gpui orders Tab stops by `tab_index` before paint order, and registers none for an `invisible()` element. A stop that must come first where it sits, yet draw over what paints after it, stays in flow at zero size and raises what it shows (`i18n::SkipLink`): a lower `tab_index` would sort it before the root scope's own handle, so Tab from the root would pass it. - Color-blind safe charts swap Ely's chart hues for a set fitted to stay apart under Machado's protanopia, deuteranopia and tritanopia; a test holds every pair 0.10 apart in Oklab and each hue 3:1 on its page (`Theme::set_color_blind_safe`, in Ely's own palettes alone). +- gpui 0.2.2 packs pictures in its atlas without padding and samples them with a linear filter, so a scaled picture's edge takes color from its atlas neighbor, and pictures laid edge to edge show seams. A map tile carries a one-pixel rim copied from its own edge and draws one of its pixels larger each side, clipped to its box (`maps::tile`). +- A map keeps places in `f64`: at zoom 19 the world is 134 million pixels across. Its wheel pans and Command or Control with the wheel zooms about the pointer, as the canvas's does. ## Decisions diff --git a/examples/gallery/assets/tiles/0/0/0.png b/examples/gallery/assets/tiles/0/0/0.png new file mode 100644 index 0000000..67ef145 Binary files /dev/null and b/examples/gallery/assets/tiles/0/0/0.png differ diff --git a/examples/gallery/assets/tiles/1/0/0.png b/examples/gallery/assets/tiles/1/0/0.png new file mode 100644 index 0000000..74845d1 Binary files /dev/null and b/examples/gallery/assets/tiles/1/0/0.png differ diff --git a/examples/gallery/assets/tiles/1/0/1.png b/examples/gallery/assets/tiles/1/0/1.png new file mode 100644 index 0000000..71e5b6c Binary files /dev/null and b/examples/gallery/assets/tiles/1/0/1.png differ diff --git a/examples/gallery/assets/tiles/1/1/0.png b/examples/gallery/assets/tiles/1/1/0.png new file mode 100644 index 0000000..5008493 Binary files /dev/null and b/examples/gallery/assets/tiles/1/1/0.png differ diff --git a/examples/gallery/assets/tiles/1/1/1.png b/examples/gallery/assets/tiles/1/1/1.png new file mode 100644 index 0000000..93fa8ef Binary files /dev/null and b/examples/gallery/assets/tiles/1/1/1.png differ diff --git a/examples/gallery/assets/tiles/2/0/0.png b/examples/gallery/assets/tiles/2/0/0.png new file mode 100644 index 0000000..f4d4509 Binary files /dev/null and b/examples/gallery/assets/tiles/2/0/0.png differ diff --git a/examples/gallery/assets/tiles/2/0/1.png b/examples/gallery/assets/tiles/2/0/1.png new file mode 100644 index 0000000..261bb6b Binary files /dev/null and b/examples/gallery/assets/tiles/2/0/1.png differ diff --git a/examples/gallery/assets/tiles/2/0/2.png b/examples/gallery/assets/tiles/2/0/2.png new file mode 100644 index 0000000..2d723ca Binary files /dev/null and b/examples/gallery/assets/tiles/2/0/2.png differ diff --git a/examples/gallery/assets/tiles/2/0/3.png b/examples/gallery/assets/tiles/2/0/3.png new file mode 100644 index 0000000..e949cfc Binary files /dev/null and b/examples/gallery/assets/tiles/2/0/3.png differ diff --git a/examples/gallery/assets/tiles/2/1/0.png b/examples/gallery/assets/tiles/2/1/0.png new file mode 100644 index 0000000..cefbe51 Binary files /dev/null and b/examples/gallery/assets/tiles/2/1/0.png differ diff --git a/examples/gallery/assets/tiles/2/1/1.png b/examples/gallery/assets/tiles/2/1/1.png new file mode 100644 index 0000000..7e74b36 Binary files /dev/null and b/examples/gallery/assets/tiles/2/1/1.png differ diff --git a/examples/gallery/assets/tiles/2/1/2.png b/examples/gallery/assets/tiles/2/1/2.png new file mode 100644 index 0000000..bc97d5f Binary files /dev/null and b/examples/gallery/assets/tiles/2/1/2.png differ diff --git a/examples/gallery/assets/tiles/2/1/3.png b/examples/gallery/assets/tiles/2/1/3.png new file mode 100644 index 0000000..6289542 Binary files /dev/null and b/examples/gallery/assets/tiles/2/1/3.png differ diff --git a/examples/gallery/assets/tiles/2/2/0.png b/examples/gallery/assets/tiles/2/2/0.png new file mode 100644 index 0000000..07a3dbd Binary files /dev/null and b/examples/gallery/assets/tiles/2/2/0.png differ diff --git a/examples/gallery/assets/tiles/2/2/1.png b/examples/gallery/assets/tiles/2/2/1.png new file mode 100644 index 0000000..5bb3b25 Binary files /dev/null and b/examples/gallery/assets/tiles/2/2/1.png differ diff --git a/examples/gallery/assets/tiles/2/2/2.png b/examples/gallery/assets/tiles/2/2/2.png new file mode 100644 index 0000000..d404c74 Binary files /dev/null and b/examples/gallery/assets/tiles/2/2/2.png differ diff --git a/examples/gallery/assets/tiles/2/2/3.png b/examples/gallery/assets/tiles/2/2/3.png new file mode 100644 index 0000000..4fae528 Binary files /dev/null and b/examples/gallery/assets/tiles/2/2/3.png differ diff --git a/examples/gallery/assets/tiles/2/3/0.png b/examples/gallery/assets/tiles/2/3/0.png new file mode 100644 index 0000000..a41f710 Binary files /dev/null and b/examples/gallery/assets/tiles/2/3/0.png differ diff --git a/examples/gallery/assets/tiles/2/3/1.png b/examples/gallery/assets/tiles/2/3/1.png new file mode 100644 index 0000000..a157c0e Binary files /dev/null and b/examples/gallery/assets/tiles/2/3/1.png differ diff --git a/examples/gallery/assets/tiles/2/3/2.png b/examples/gallery/assets/tiles/2/3/2.png new file mode 100644 index 0000000..7eea51f Binary files /dev/null and b/examples/gallery/assets/tiles/2/3/2.png differ diff --git a/examples/gallery/assets/tiles/2/3/3.png b/examples/gallery/assets/tiles/2/3/3.png new file mode 100644 index 0000000..6d8c926 Binary files /dev/null and b/examples/gallery/assets/tiles/2/3/3.png differ diff --git a/examples/gallery/assets/tiles/3/0/0.png b/examples/gallery/assets/tiles/3/0/0.png new file mode 100644 index 0000000..befd842 Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/0.png differ diff --git a/examples/gallery/assets/tiles/3/0/1.png b/examples/gallery/assets/tiles/3/0/1.png new file mode 100644 index 0000000..ed9b680 Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/1.png differ diff --git a/examples/gallery/assets/tiles/3/0/2.png b/examples/gallery/assets/tiles/3/0/2.png new file mode 100644 index 0000000..a47eea5 Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/2.png differ diff --git a/examples/gallery/assets/tiles/3/0/3.png b/examples/gallery/assets/tiles/3/0/3.png new file mode 100644 index 0000000..68ac50b Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/3.png differ diff --git a/examples/gallery/assets/tiles/3/0/4.png b/examples/gallery/assets/tiles/3/0/4.png new file mode 100644 index 0000000..24d360e Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/4.png differ diff --git a/examples/gallery/assets/tiles/3/0/5.png b/examples/gallery/assets/tiles/3/0/5.png new file mode 100644 index 0000000..befd842 Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/5.png differ diff --git a/examples/gallery/assets/tiles/3/0/6.png b/examples/gallery/assets/tiles/3/0/6.png new file mode 100644 index 0000000..27dfd78 Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/6.png differ diff --git a/examples/gallery/assets/tiles/3/0/7.png b/examples/gallery/assets/tiles/3/0/7.png new file mode 100644 index 0000000..f0210ee Binary files /dev/null and b/examples/gallery/assets/tiles/3/0/7.png differ diff --git a/examples/gallery/assets/tiles/3/1/0.png b/examples/gallery/assets/tiles/3/1/0.png new file mode 100644 index 0000000..e81a8b5 Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/0.png differ diff --git a/examples/gallery/assets/tiles/3/1/1.png b/examples/gallery/assets/tiles/3/1/1.png new file mode 100644 index 0000000..6718a0a Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/1.png differ diff --git a/examples/gallery/assets/tiles/3/1/2.png b/examples/gallery/assets/tiles/3/1/2.png new file mode 100644 index 0000000..edfe946 Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/2.png differ diff --git a/examples/gallery/assets/tiles/3/1/3.png b/examples/gallery/assets/tiles/3/1/3.png new file mode 100644 index 0000000..c9764d6 Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/3.png differ diff --git a/examples/gallery/assets/tiles/3/1/4.png b/examples/gallery/assets/tiles/3/1/4.png new file mode 100644 index 0000000..befd842 Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/4.png differ diff --git a/examples/gallery/assets/tiles/3/1/5.png b/examples/gallery/assets/tiles/3/1/5.png new file mode 100644 index 0000000..befd842 Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/5.png differ diff --git a/examples/gallery/assets/tiles/3/1/6.png b/examples/gallery/assets/tiles/3/1/6.png new file mode 100644 index 0000000..0cc02b8 Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/6.png differ diff --git a/examples/gallery/assets/tiles/3/1/7.png b/examples/gallery/assets/tiles/3/1/7.png new file mode 100644 index 0000000..144fc8b Binary files /dev/null and b/examples/gallery/assets/tiles/3/1/7.png differ diff --git a/examples/gallery/assets/tiles/3/2/0.png b/examples/gallery/assets/tiles/3/2/0.png new file mode 100644 index 0000000..bc40308 Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/0.png differ diff --git a/examples/gallery/assets/tiles/3/2/1.png b/examples/gallery/assets/tiles/3/2/1.png new file mode 100644 index 0000000..474966d Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/1.png differ diff --git a/examples/gallery/assets/tiles/3/2/2.png b/examples/gallery/assets/tiles/3/2/2.png new file mode 100644 index 0000000..d13e94e Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/2.png differ diff --git a/examples/gallery/assets/tiles/3/2/3.png b/examples/gallery/assets/tiles/3/2/3.png new file mode 100644 index 0000000..5344710 Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/3.png differ diff --git a/examples/gallery/assets/tiles/3/2/4.png b/examples/gallery/assets/tiles/3/2/4.png new file mode 100644 index 0000000..94e78d0 Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/4.png differ diff --git a/examples/gallery/assets/tiles/3/2/5.png b/examples/gallery/assets/tiles/3/2/5.png new file mode 100644 index 0000000..cfe57d1 Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/5.png differ diff --git a/examples/gallery/assets/tiles/3/2/6.png b/examples/gallery/assets/tiles/3/2/6.png new file mode 100644 index 0000000..04788aa Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/6.png differ diff --git a/examples/gallery/assets/tiles/3/2/7.png b/examples/gallery/assets/tiles/3/2/7.png new file mode 100644 index 0000000..aafe27c Binary files /dev/null and b/examples/gallery/assets/tiles/3/2/7.png differ diff --git a/examples/gallery/assets/tiles/3/3/0.png b/examples/gallery/assets/tiles/3/3/0.png new file mode 100644 index 0000000..48fe48b Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/0.png differ diff --git a/examples/gallery/assets/tiles/3/3/1.png b/examples/gallery/assets/tiles/3/3/1.png new file mode 100644 index 0000000..c3600a3 Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/1.png differ diff --git a/examples/gallery/assets/tiles/3/3/2.png b/examples/gallery/assets/tiles/3/3/2.png new file mode 100644 index 0000000..8ced7ac Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/2.png differ diff --git a/examples/gallery/assets/tiles/3/3/3.png b/examples/gallery/assets/tiles/3/3/3.png new file mode 100644 index 0000000..4b02bc3 Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/3.png differ diff --git a/examples/gallery/assets/tiles/3/3/4.png b/examples/gallery/assets/tiles/3/3/4.png new file mode 100644 index 0000000..d983d98 Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/4.png differ diff --git a/examples/gallery/assets/tiles/3/3/5.png b/examples/gallery/assets/tiles/3/3/5.png new file mode 100644 index 0000000..befd842 Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/5.png differ diff --git a/examples/gallery/assets/tiles/3/3/6.png b/examples/gallery/assets/tiles/3/3/6.png new file mode 100644 index 0000000..3ad28c5 Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/6.png differ diff --git a/examples/gallery/assets/tiles/3/3/7.png b/examples/gallery/assets/tiles/3/3/7.png new file mode 100644 index 0000000..da4936d Binary files /dev/null and b/examples/gallery/assets/tiles/3/3/7.png differ diff --git a/examples/gallery/assets/tiles/3/4/0.png b/examples/gallery/assets/tiles/3/4/0.png new file mode 100644 index 0000000..7d64401 Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/0.png differ diff --git a/examples/gallery/assets/tiles/3/4/1.png b/examples/gallery/assets/tiles/3/4/1.png new file mode 100644 index 0000000..6eaada2 Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/1.png differ diff --git a/examples/gallery/assets/tiles/3/4/2.png b/examples/gallery/assets/tiles/3/4/2.png new file mode 100644 index 0000000..09620d7 Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/2.png differ diff --git a/examples/gallery/assets/tiles/3/4/3.png b/examples/gallery/assets/tiles/3/4/3.png new file mode 100644 index 0000000..63de687 Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/3.png differ diff --git a/examples/gallery/assets/tiles/3/4/4.png b/examples/gallery/assets/tiles/3/4/4.png new file mode 100644 index 0000000..97aac62 Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/4.png differ diff --git a/examples/gallery/assets/tiles/3/4/5.png b/examples/gallery/assets/tiles/3/4/5.png new file mode 100644 index 0000000..befd842 Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/5.png differ diff --git a/examples/gallery/assets/tiles/3/4/6.png b/examples/gallery/assets/tiles/3/4/6.png new file mode 100644 index 0000000..9453715 Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/6.png differ diff --git a/examples/gallery/assets/tiles/3/4/7.png b/examples/gallery/assets/tiles/3/4/7.png new file mode 100644 index 0000000..144fc8b Binary files /dev/null and b/examples/gallery/assets/tiles/3/4/7.png differ diff --git a/examples/gallery/assets/tiles/3/5/0.png b/examples/gallery/assets/tiles/3/5/0.png new file mode 100644 index 0000000..50afbfd Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/0.png differ diff --git a/examples/gallery/assets/tiles/3/5/1.png b/examples/gallery/assets/tiles/3/5/1.png new file mode 100644 index 0000000..6885c32 Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/1.png differ diff --git a/examples/gallery/assets/tiles/3/5/2.png b/examples/gallery/assets/tiles/3/5/2.png new file mode 100644 index 0000000..2b3e9de Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/2.png differ diff --git a/examples/gallery/assets/tiles/3/5/3.png b/examples/gallery/assets/tiles/3/5/3.png new file mode 100644 index 0000000..735879a Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/3.png differ diff --git a/examples/gallery/assets/tiles/3/5/4.png b/examples/gallery/assets/tiles/3/5/4.png new file mode 100644 index 0000000..b3cccfd Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/4.png differ diff --git a/examples/gallery/assets/tiles/3/5/5.png b/examples/gallery/assets/tiles/3/5/5.png new file mode 100644 index 0000000..c5553a5 Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/5.png differ diff --git a/examples/gallery/assets/tiles/3/5/6.png b/examples/gallery/assets/tiles/3/5/6.png new file mode 100644 index 0000000..f331ed6 Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/6.png differ diff --git a/examples/gallery/assets/tiles/3/5/7.png b/examples/gallery/assets/tiles/3/5/7.png new file mode 100644 index 0000000..144fc8b Binary files /dev/null and b/examples/gallery/assets/tiles/3/5/7.png differ diff --git a/examples/gallery/assets/tiles/3/6/0.png b/examples/gallery/assets/tiles/3/6/0.png new file mode 100644 index 0000000..977550a Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/0.png differ diff --git a/examples/gallery/assets/tiles/3/6/1.png b/examples/gallery/assets/tiles/3/6/1.png new file mode 100644 index 0000000..12db1e5 Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/1.png differ diff --git a/examples/gallery/assets/tiles/3/6/2.png b/examples/gallery/assets/tiles/3/6/2.png new file mode 100644 index 0000000..6cf2c7f Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/2.png differ diff --git a/examples/gallery/assets/tiles/3/6/3.png b/examples/gallery/assets/tiles/3/6/3.png new file mode 100644 index 0000000..db1dac3 Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/3.png differ diff --git a/examples/gallery/assets/tiles/3/6/4.png b/examples/gallery/assets/tiles/3/6/4.png new file mode 100644 index 0000000..d2ceb27 Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/4.png differ diff --git a/examples/gallery/assets/tiles/3/6/5.png b/examples/gallery/assets/tiles/3/6/5.png new file mode 100644 index 0000000..a100114 Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/5.png differ diff --git a/examples/gallery/assets/tiles/3/6/6.png b/examples/gallery/assets/tiles/3/6/6.png new file mode 100644 index 0000000..e3df3ec Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/6.png differ diff --git a/examples/gallery/assets/tiles/3/6/7.png b/examples/gallery/assets/tiles/3/6/7.png new file mode 100644 index 0000000..144fc8b Binary files /dev/null and b/examples/gallery/assets/tiles/3/6/7.png differ diff --git a/examples/gallery/assets/tiles/3/7/0.png b/examples/gallery/assets/tiles/3/7/0.png new file mode 100644 index 0000000..befd842 Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/0.png differ diff --git a/examples/gallery/assets/tiles/3/7/1.png b/examples/gallery/assets/tiles/3/7/1.png new file mode 100644 index 0000000..ff87395 Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/1.png differ diff --git a/examples/gallery/assets/tiles/3/7/2.png b/examples/gallery/assets/tiles/3/7/2.png new file mode 100644 index 0000000..542214d Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/2.png differ diff --git a/examples/gallery/assets/tiles/3/7/3.png b/examples/gallery/assets/tiles/3/7/3.png new file mode 100644 index 0000000..c80a9e1 Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/3.png differ diff --git a/examples/gallery/assets/tiles/3/7/4.png b/examples/gallery/assets/tiles/3/7/4.png new file mode 100644 index 0000000..aa4755d Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/4.png differ diff --git a/examples/gallery/assets/tiles/3/7/5.png b/examples/gallery/assets/tiles/3/7/5.png new file mode 100644 index 0000000..680e312 Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/5.png differ diff --git a/examples/gallery/assets/tiles/3/7/6.png b/examples/gallery/assets/tiles/3/7/6.png new file mode 100644 index 0000000..d7eebf8 Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/6.png differ diff --git a/examples/gallery/assets/tiles/3/7/7.png b/examples/gallery/assets/tiles/3/7/7.png new file mode 100644 index 0000000..ac5dbd7 Binary files /dev/null and b/examples/gallery/assets/tiles/3/7/7.png differ diff --git a/examples/gallery/pages/maps/mod.rs b/examples/gallery/pages/maps/mod.rs new file mode 100644 index 0000000..2a2f2cb --- /dev/null +++ b/examples/gallery/pages/maps/mod.rs @@ -0,0 +1,150 @@ +use std::path::PathBuf; + +use ely_gpui_component::{ + maps::{ + CoordFormat, CoordinateDisplay, LatLon, LocationPicker, MapMarker, MapPopup, MapView, + MapViewport, Tile, + }, + theme::ActiveTheme, +}; +use gpui::{ + AnyElement, App, ImageSource, IntoElement, ParentElement, SharedString, Styled, Window, div, px, +}; + +use super::Page; +use crate::probe::probe; +use crate::script::Step; +use crate::ui::{keep, section, set, specimen, specimens}; + +pub const PAGE: Page = Page { + number: 41, + slug: "maps", + title: "Maps", + summary: "Tiles the host supplies, pins with a popup, a picker's crosshair. The land is Natural Earth's.", + render, + script: &[ + Step::Click("world-map"), + Step::Key("tab"), + Step::Key("enter"), + Step::Wait(300), + Step::Shot("popup"), + Step::Key("escape"), + Step::Key("="), + Step::Wait(300), + Step::Shot("zoomed"), + Step::Click("picker"), + Step::Key("right"), + Step::Key("down"), + Step::Wait(300), + Step::Shot("picked"), + ], +}; + +const TILES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/gallery/assets/tiles"); +const ATTRIBUTION: &str = "Land: Natural Earth"; + +const CITIES: [(&str, &str, f64, f64, usize); 6] = [ + ("london", "London", 51.5072, -0.1276, 0), + ("new-york", "New York", 40.7128, -74.006, 3), + ("sao-paulo", "São Paulo", -23.5505, -46.6333, 5), + ("cape-town", "Cape Town", -33.9249, 18.4241, 2), + ("tokyo", "Tokyo", 35.6762, 139.6503, 4), + ("sydney", "Sydney", -33.8688, 151.2093, 1), +]; + +/// The gallery's tiles, cut from Natural Earth by `scripts/tiles.py`. +fn tile(tile: Tile) -> ImageSource { + PathBuf::from(format!("{TILES}/{}/{}/{}.png", tile.z, tile.x, tile.y)).into() +} + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(world(window, cx)) + .child(coordinates(cx)) + .child(picker(cx)) + .into_any_element() +} + +fn world(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = keep("map-open", || None::, window, cx); + let shown = open.read(cx).clone(); + let markers = CITIES.map(|(key, name, lat, lon, hue)| { + MapMarker::new(key, LatLon::new(lat, lon)) + .label(name) + .hue(hue) + }); + let popup = shown.as_ref().map(|key| { + let (_, name, lat, lon, _) = *CITIES + .iter() + .find(|(each, ..)| *each == key.as_ref()) + .expect("a listed city"); + let closed = open.clone(); + MapPopup::new(LatLon::new(lat, lon), name) + .child( + CoordinateDisplay::new("map-popup-place", LatLon::new(lat, lon)) + .format(CoordFormat::Dms), + ) + .on_close(move |_, cx| set(&closed, None, cx)) + }); + let map = MapView::new("world-map", MapViewport::new(LatLon::new(20.0, 10.0), 1.2)) + .tiles(tile) + .tile_zooms(0, 3) + .attribution(ATTRIBUTION) + .markers(markers) + .on_marker(move |key, _, cx| set(&open, Some(key.clone()), cx)); + let map = match popup { + Some(popup) => map.popup(popup), + None => map, + }; + section( + "MapView · MapMarker · MapPopup", + "Tiles come from the host by zoom, column and row; past the zooms it cuts, the nearest scales. Drag or scroll to pan, Command-scroll or the buttons to zoom; focused, the arrows pan and + and - zoom. A pin opens its popup; Escape closes it.", + cx, + ) + .child(probe("world-map", div().w(px(720.0)).h(px(360.0)).child(map))) +} + +fn coordinates(cx: &App) -> impl IntoElement + use<> { + let place = LatLon::new(-33.9249, 18.4241); + section( + "CoordinateDisplay", + "A place in degrees or in degrees, minutes and seconds, with its hemispheres; figures keep one width, and the button copies it.", + cx, + ) + .child( + specimens() + .child(specimen("decimal", CoordinateDisplay::new("coords-decimal", place), cx)) + .child(specimen( + "degrees, minutes, seconds", + CoordinateDisplay::new("coords-dms", place).format(CoordFormat::Dms), + cx, + )), + ) +} + +fn picker(cx: &App) -> impl IntoElement + use<> { + let muted = cx.theme().colors.fg_muted; + let map = MapView::new( + "picker-map", + MapViewport::new(LatLon::new(48.8566, 2.3522), 3.0), + ) + .tiles(tile) + .tile_zooms(0, 3) + .attribution(ATTRIBUTION); + section( + "LocationPicker", + "The place under the crosshair is the pick: pan by pointer or keys, and the owner hears each move.", + cx, + ) + .child( + probe( + "picker", + div().w(px(480.0)).h(px(320.0)).child( + LocationPicker::new("picker", map) + .format(CoordFormat::Dms) + .on_pick(|at, _, _| log::info!("maps demo: picked {:.4}, {:.4}", at.lat, at.lon)), + ), + ), + ) + .child(div().text_color(muted).child("The pick reaches the owner as it moves; the log shows it.")) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 612ff6a..d7e0fe1 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -23,6 +23,7 @@ mod interaction; mod layout; mod lists; mod mail; +mod maps; mod media; mod menus; mod messaging; @@ -94,6 +95,7 @@ pub const ALL: &[Page] = &[ interaction::PAGE, theme::PAGE, i18n::PAGE, + maps::PAGE, ]; pub use i18n::install as install_i18n; diff --git a/scripts/tiles.py b/scripts/tiles.py new file mode 100644 index 0000000..7dfda89 --- /dev/null +++ b/scripts/tiles.py @@ -0,0 +1,55 @@ +"""Writes the gallery's map tiles, z0 to z3, from Natural Earth's 110m countries. + +Usage: python3 scripts/tiles.py +Land is gray with alpha, so one set reads on light and dark maps. +""" + +import json +import math +import sys +from pathlib import Path + +from PIL import Image, ImageDraw + +TILE, GRAIN, ZOOMS = 256, 4, range(4) +LAND, BORDER = (128, 72), (128, 150) +OUT = Path(__file__).resolve().parent.parent / "examples/gallery/assets/tiles" + + +def project(lon, lat): + lat = max(-85.05112878, min(85.05112878, lat)) + phi = math.radians(lat) + return (lon + 180) / 360, (1 - math.log(math.tan(phi) + 1 / math.cos(phi)) / math.pi) / 2 + + +def rings(geometry): + kind, coordinates = geometry["type"], geometry["coordinates"] + polygons = [coordinates] if kind == "Polygon" else coordinates + assert kind in ("Polygon", "MultiPolygon"), kind + return [ring for polygon in polygons for ring in polygon] + + +def main(source): + shapes = [rings(feature["geometry"]) for feature in json.load(open(source))["features"]] + for z in ZOOMS: + count = 2**z + side = TILE * GRAIN + for x in range(count): + for y in range(count): + image = Image.new("LA", (side, side), (0, 0)) + draw = ImageDraw.Draw(image) + for shape in shapes: + for ring in shape: + points = [ + ((px * count - x) * side, (py * count - y) * side) + for px, py in (project(lon, lat) for lon, lat in ring) + ] + draw.polygon(points, fill=LAND, outline=BORDER, width=GRAIN) + path = OUT / str(z) / str(x) / f"{y}.png" + path.parent.mkdir(parents=True, exist_ok=True) + image.resize((TILE, TILE), Image.LANCZOS).save(path, optimize=True) + print(f"wrote {sum(4**z for z in ZOOMS)} tiles to {OUT}") + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/src/lib.rs b/src/lib.rs index f55e24e..9a7ae7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ pub mod interaction; pub mod layout; pub mod lists; pub mod mail; +pub mod maps; pub mod media; pub mod menus; pub mod messaging; diff --git a/src/maps/coords.rs b/src/maps/coords.rs new file mode 100644 index 0000000..f6ac57f --- /dev/null +++ b/src/maps/coords.rs @@ -0,0 +1,98 @@ +use gpui::{App, ElementId, IntoElement, RenderOnce, Window}; + +use super::LatLon; +use crate::typography::CopyableText; + +/// How a place is written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CoordFormat { + /// 51.5072° N, 0.1276° W + #[default] + Decimal, + /// 51°30′26″ N, 0°07′39″ W + Dms, +} + +/// `at` in `format`; a value that rounds to zero reads north or east. +pub(crate) fn written(at: LatLon, format: CoordFormat) -> String { + let part = |value: f64, (plus, minus): (char, char)| { + let body = match format { + CoordFormat::Decimal => format!("{:.4}°", value.abs()), + CoordFormat::Dms => { + let seconds = (value.abs() * 3600.0).round() as u64; + format!( + "{}°{:02}′{:02}″", + seconds / 3600, + seconds / 60 % 60, + seconds % 60 + ) + } + }; + let zero = body.chars().all(|ch| !ch.is_ascii_digit() || ch == '0'); + let side = if value < 0.0 && !zero { minus } else { plus }; + format!("{body} {side}") + }; + format!("{}, {}", part(at.lat, ('N', 'S')), part(at.lon, ('E', 'W'))) +} + +/// A place in words, with tabular figures so it holds still as it changes, and a button that copies it. +#[derive(IntoElement)] +pub struct CoordinateDisplay { + id: ElementId, + at: LatLon, + format: CoordFormat, +} + +impl CoordinateDisplay { + pub fn new(id: impl Into, at: LatLon) -> Self { + Self { + id: id.into(), + at, + format: CoordFormat::default(), + } + } + + pub fn format(mut self, format: CoordFormat) -> Self { + self.format = format; + self + } +} + +impl RenderOnce for CoordinateDisplay { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + CopyableText::new(self.id, written(self.at, self.format)).tabular() + } +} + +#[cfg(test)] +mod tests { + use super::{CoordFormat, written}; + use crate::maps::LatLon; + + #[test] + fn places_read_with_their_hemispheres() { + let london = LatLon::new(51.507222, -0.1275); + assert_eq!( + written(london, CoordFormat::Decimal), + "51.5072° N, 0.1275° W" + ); + assert_eq!(written(london, CoordFormat::Dms), "51°30′26″ N, 0°07′39″ W"); + let rio = LatLon::new(-22.9068, -43.1729); + assert_eq!(written(rio, CoordFormat::Dms), "22°54′24″ S, 43°10′22″ W"); + } + + #[test] + fn seconds_carry_into_minutes_and_degrees() { + let edge = LatLon::new(10.999_999_9, 20.5); + assert_eq!(written(edge, CoordFormat::Dms), "11°00′00″ N, 20°30′00″ E"); + } + + #[test] + fn a_value_that_rounds_to_zero_reads_north_and_east() { + let near = LatLon::new(-0.000_01, -0.000_01); + assert_eq!(written(near, CoordFormat::Decimal), "0.0000° N, 0.0000° E"); + assert_eq!(written(near, CoordFormat::Dms), "0°00′00″ N, 0°00′00″ E"); + let south = LatLon::new(-0.0001, 0.0); + assert_eq!(written(south, CoordFormat::Decimal), "0.0001° S, 0.0000° E"); + } +} diff --git a/src/maps/geo.rs b/src/maps/geo.rs new file mode 100644 index 0000000..95fb12e --- /dev/null +++ b/src/maps/geo.rs @@ -0,0 +1,347 @@ +use std::f64::consts::PI; + +/// A place on the globe in degrees: latitude north, longitude east. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LatLon { + pub lat: f64, + pub lon: f64, +} + +impl LatLon { + /// Fails past 90° of latitude or 180° of longitude. + pub fn new(lat: f64, lon: f64) -> Self { + assert!( + (-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon), + "no place at {lat}, {lon}" + ); + Self { lat, lon } + } +} + +/// Web Mercator's last latitude, where its square world ends. +pub const MAX_LAT: f64 = 85.051_128_78; + +/// A place on Web Mercator's unit square: x east from 180° west, y south from the top. +pub(crate) fn project(at: LatLon) -> (f64, f64) { + let phi = at.lat.clamp(-MAX_LAT, MAX_LAT).to_radians(); + let y = (1.0 - (phi.tan() + 1.0 / phi.cos()).ln() / PI) / 2.0; + ((at.lon + 180.0) / 360.0, y) +} + +/// The place at a point of the unit square; x wraps around the globe. +pub(crate) fn unproject((x, y): (f64, f64)) -> LatLon { + let lat = (PI * (1.0 - 2.0 * y.clamp(0.0, 1.0))).sinh().atan(); + LatLon::new(lat.to_degrees(), x.rem_euclid(1.0) * 360.0 - 180.0) +} + +/// A tile of the world at zoom `z`: `x` east and `y` south, each from 0 to 2^z − 1. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Tile { + pub z: u8, + pub x: u32, + pub y: u32, +} + +/// A tile laid in a view: its column before it wraps around the globe, and its box in whole view pixels, so neighbors meet without a seam. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Laid { + pub tile: Tile, + pub column: i64, + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +/// Where a map looks: the place at its center, and its zoom, 0 for the world in one tile. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MapViewport { + pub center: LatLon, + pub zoom: f64, +} + +impl MapViewport { + /// The farthest out and the nearest in a map zooms. + pub const ZOOMS: (f64, f64) = (0.0, 19.0); + + pub fn new(center: LatLon, zoom: f64) -> Self { + let (least, most) = Self::ZOOMS; + assert!( + (least..=most).contains(&zoom), + "zoom {zoom} past {least} to {most}" + ); + Self { center, zoom } + } + + /// The world's side in view pixels. + fn world(&self, tile: f32) -> f64 { + f64::from(tile) * self.zoom.exp2() + } + + /// The center in world pixels. + fn middle(&self, tile: f32) -> (f64, f64) { + let (x, y) = project(self.center); + let side = self.world(tile); + (x * side, y * side) + } + + /// A place in view pixels from the view's top left: of the globe's copies, the one nearest the center. + pub(crate) fn to_view(self, at: LatLon, (w, h): (f32, f32), tile: f32) -> (f32, f32) { + let side = self.world(tile); + let (cx, cy) = self.middle(tile); + let (x, y) = project(at); + let across = (x * side - cx + side / 2.0).rem_euclid(side) - side / 2.0; + ( + (across + f64::from(w) / 2.0) as f32, + (y * side - cy + f64::from(h) / 2.0) as f32, + ) + } + + /// The place at a view point. + pub(crate) fn to_geo(self, (x, y): (f32, f32), (w, h): (f32, f32), tile: f32) -> LatLon { + let side = self.world(tile); + let (cx, cy) = self.middle(tile); + unproject(( + (cx + f64::from(x) - f64::from(w) / 2.0) / side, + (cy + f64::from(y) - f64::from(h) / 2.0) / side, + )) + } + + /// Moved so the map slides by a view distance. + pub(crate) fn panned(self, (dx, dy): (f32, f32), size: (f32, f32), tile: f32) -> Self { + let (cx, cy) = self.middle(tile); + self.centered((cx - f64::from(dx), cy - f64::from(dy)), size, tile) + } + + /// Zoomed by `levels` about a view point, whose place stays under it; held inside the zooms. + pub(crate) fn zoomed( + self, + levels: f64, + about: (f32, f32), + size: (f32, f32), + tile: f32, + ) -> Self { + let (least, most) = Self::ZOOMS; + let (x, y) = project(self.to_geo(about, size, tile)); + let next = Self { + zoom: (self.zoom + levels).clamp(least, most), + ..self + }; + let side = next.world(tile); + let (w, h) = size; + next.centered( + ( + x * side - (f64::from(about.0) - f64::from(w) / 2.0), + y * side - (f64::from(about.1) - f64::from(h) / 2.0), + ), + size, + tile, + ) + } + + /// Centered on a world pixel; a world as tall as the view or taller keeps the view filled. + fn centered(self, (x, y): (f64, f64), (_, h): (f32, f32), tile: f32) -> Self { + let side = self.world(tile); + let half = f64::from(h) / 2.0; + let y = if side >= f64::from(h) { + y.clamp(half, side - half) + } else { + side / 2.0 + }; + Self { + center: unproject((x / side, y / side)), + ..self + } + } + + /// The tiles that cover a view, cut at the nearest zoom within `zooms` and scaled to the view's. + pub(crate) fn tiles(&self, (w, h): (f32, f32), tile: f32, zooms: (u8, u8)) -> Vec { + if w <= 0.0 || h <= 0.0 { + return Vec::new(); + } + let z = (self.zoom.round() as u8).clamp(zooms.0, zooms.1); + let count = 1i64 << z; + let side = f64::from(tile) * (self.zoom - f64::from(z)).exp2(); + let (cx, cy) = self.middle(tile); + let (left, top) = (f64::from(w) / 2.0 - cx, f64::from(h) / 2.0 - cy); + let span = + |from: f64, to: f64| (from / side).floor() as i64..=(to / side).ceil() as i64 - 1; + let edge = |start: f64, ix: i64| (start + ix as f64 * side).round() as f32; + let rows = span(-top, f64::from(h) - top); + let (first, last) = ((*rows.start()).max(0), (*rows.end()).min(count - 1)); + span(-left, f64::from(w) - left) + .flat_map(|column| { + (first..=last).map(move |row| { + let (x, y) = (edge(left, column), edge(top, row)); + Laid { + tile: Tile { + z, + x: column.rem_euclid(count) as u32, + y: row as u32, + }, + column, + x, + y, + w: edge(left, column + 1) - x, + h: edge(top, row + 1) - y, + } + }) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::{LatLon, MAX_LAT, MapViewport, Tile, project, unproject}; + + const TILE: f32 = 256.0; + const VIEW: (f32, f32) = (800.0, 600.0); + + fn near(a: (f32, f32), b: (f32, f32)) -> bool { + (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 + } + + #[test] + fn the_square_holds_the_globe_up_to_its_last_latitude() { + assert_eq!(project(LatLon::new(0.0, 0.0)), (0.5, 0.5)); + let (_, top) = project(LatLon::new(MAX_LAT, -180.0)); + assert!(top.abs() < 1e-9, "the last latitude is the top: {top}"); + assert_eq!( + project(LatLon::new(90.0, 0.0)), + project(LatLon::new(MAX_LAT, 0.0)) + ); + let london = LatLon::new(51.5072, -0.1276); + let back = unproject(project(london)); + assert!((back.lat - london.lat).abs() < 1e-9 && (back.lon - london.lon).abs() < 1e-9); + assert_eq!( + unproject((1.25, 0.5)).lon, + -90.0, + "x wraps around the globe" + ); + } + + #[test] + #[should_panic(expected = "no place at 91, 0")] + fn a_place_past_the_pole_fails() { + LatLon::new(91.0, 0.0); + } + + #[test] + #[should_panic(expected = "zoom 20 past 0 to 19")] + fn a_zoom_past_the_nearest_fails() { + MapViewport::new(LatLon::new(0.0, 0.0), 20.0); + } + + #[test] + fn a_place_nearest_the_center_across_the_date_line() { + let view = MapViewport::new(LatLon::new(0.0, 179.0), 2.0); + let (x, _) = view.to_view(LatLon::new(0.0, -179.0), VIEW, TILE); + let degree = TILE * 4.0 / 360.0; + assert!( + (x - (400.0 + 2.0 * degree)).abs() < 1e-3, + "east of the center, not a world away: {x}" + ); + } + + #[test] + fn a_pan_slides_the_map_with_the_pointer() { + let view = MapViewport::new(LatLon::new(20.0, 10.0), 3.0); + let place = view.to_geo((300.0, 200.0), VIEW, TILE); + let moved = view.panned((50.0, -30.0), VIEW, TILE); + assert!(near(moved.to_view(place, VIEW, TILE), (350.0, 170.0))); + } + + #[test] + fn a_zoom_keeps_the_place_under_the_pointer() { + let view = MapViewport::new(LatLon::new(40.0, -74.0), 4.0); + let about = (620.0, 150.0); + let place = view.to_geo(about, VIEW, TILE); + let nearer = view.zoomed(1.5, about, VIEW, TILE); + assert_eq!(nearer.zoom, 5.5); + assert!(near(nearer.to_view(place, VIEW, TILE), about)); + assert_eq!( + view.zoomed(99.0, about, VIEW, TILE).zoom, + MapViewport::ZOOMS.1 + ); + } + + #[test] + fn the_world_fills_the_view_it_is_tall_enough_for() { + let north = MapViewport::new(LatLon::new(80.0, 0.0), 2.0).panned((0.0, 0.0), VIEW, TILE); + let (_, top) = north.to_view(LatLon::new(MAX_LAT, 0.0), VIEW, TILE); + assert!( + top.abs() < 1e-3, + "the top edge stays at the view's top: {top}" + ); + let whole = MapViewport::new(LatLon::new(60.0, 0.0), 0.0).panned((0.0, 40.0), VIEW, TILE); + assert!( + whole.center.lat.abs() < 1e-9, + "a world shorter than the view sits in its middle" + ); + } + + #[test] + fn tiles_cover_the_view_and_wrap_around_the_globe() { + let view = MapViewport::new(LatLon::new(0.0, 0.0), 1.0); + let laid = view.tiles(VIEW, TILE, (0, 19)); + let columns: Vec = laid.iter().map(|laid| laid.column).collect(); + assert_eq!(laid.len(), 8, "two rows of four columns"); + assert_eq!(columns.iter().min(), Some(&-1)); + assert_eq!(columns.iter().max(), Some(&2)); + let west = laid + .iter() + .find(|laid| laid.column == -1) + .expect("a copy past the west edge"); + assert_eq!(west.tile, Tile { z: 1, x: 1, y: 0 }); + assert_eq!( + (west.x, west.y, west.w, west.h), + (-112.0, 44.0, 256.0, 256.0) + ); + assert!( + laid.iter() + .all(|laid| laid.x < VIEW.0 && laid.x + laid.w > 0.0) + ); + } + + #[test] + fn tiles_meet_on_whole_pixels_at_a_fractional_zoom() { + let laid = MapViewport::new(LatLon::new(12.3, 45.6), 2.37).tiles(VIEW, TILE, (0, 19)); + for tile in &laid { + assert_eq!( + (tile.x.fract(), tile.y.fract(), tile.w.fract()), + (0.0, 0.0, 0.0) + ); + let next = |column: i64, row: u32| { + laid.iter() + .find(|each| each.column == column && each.tile.y == row) + }; + if let Some(east) = next(tile.column + 1, tile.tile.y) { + assert_eq!( + tile.x + tile.w, + east.x, + "no seam between {tile:?} and {east:?}" + ); + } + if let Some(south) = next(tile.column, tile.tile.y + 1) { + assert_eq!(tile.y + tile.h, south.y); + } + } + } + + #[test] + fn tiles_past_the_hosts_zooms_scale() { + let view = MapViewport::new(LatLon::new(0.0, 0.0), 5.0); + let laid = view.tiles(VIEW, TILE, (0, 3)); + assert!( + laid.iter() + .all(|laid| laid.tile.z == 3 && laid.w == 1024.0 && laid.h == 1024.0) + ); + let far = MapViewport::new(LatLon::new(0.0, 0.0), 2.4).tiles(VIEW, TILE, (0, 19)); + assert!( + far.iter().all(|laid| laid.tile.z == 2), + "the nearest whole zoom" + ); + assert!(view.tiles((0.0, 600.0), TILE, (0, 19)).is_empty()); + } +} diff --git a/src/maps/marker.rs b/src/maps/marker.rs new file mode 100644 index 0000000..423fb90 --- /dev/null +++ b/src/maps/marker.rs @@ -0,0 +1,293 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::LatLon; +use crate::{ + buttons::{ButtonVariant, IconButton}, + primitives::{FocusRing, Icon, IconName}, + theme::{ActiveTheme, Elevation, IconSize, Radius, TextSize}, + typography::Ellipsis, +}; + +pub(crate) type OnMarker = Rc; +type OnClose = Rc; + +/// A pin at a place, keyed so it keeps its focus as the map moves; a label beside it, and a chart hue. +#[derive(Clone)] +pub struct MapMarker { + pub(crate) key: SharedString, + pub(crate) at: LatLon, + label: Option, + hue: Option, +} + +impl MapMarker { + pub fn new(key: impl Into, at: LatLon) -> Self { + Self { + key: key.into(), + at, + label: None, + hue: None, + } + } + + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// The chart hue the pin wears; the accent without one. + pub fn hue(mut self, hue: usize) -> Self { + self.hue = Some(hue); + self + } + + /// The pin with its tip at a view point; pressable when the map has a handler. + pub(crate) fn place( + self, + owner: &ElementId, + (x, y): (f32, f32), + on_marker: Option, + cx: &App, + ) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + let ink = match self.hue { + Some(hue) => colors.hue(hue, format_args!("marker {}", self.key)), + None => colors.accent, + }; + let pin = div() + .id((owner.clone(), format!("marker-{}", self.key))) + .flex_none() + .rounded(theme.radius(Radius::Sm)) + .border_1() + .border_color(gpui::transparent_black()) + .child(Icon::new(IconName::MapPin).size(IconSize::Xl).color(ink)) + .when_some(on_marker, |pin, on_marker| { + let key = self.key.clone(); + pin.tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_click(move |_, window, cx| on_marker(&key, window, cx)) + }); + let point = || { + div() + .absolute() + .left(Pixels::from(x)) + .top(Pixels::from(y)) + .size_0() + .flex() + .items_end() + }; + div() + .absolute() + .top_0() + .left_0() + .child(point().justify_center().child(pin)) + .when_some(self.label, |marker, label| { + marker.child( + point().child( + div() + .flex_none() + .ml_3() + .mb_3() + .px_1p5() + .rounded(theme.radius(Radius::Sm)) + .bg(colors.overlay) + .border_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg) + .whitespace_nowrap() + .child(label), + ), + ) + }) + .into_any_element() + } +} + +/// A card that points at a place, over the map; its close button hands the owner `on_close`. +pub struct MapPopup { + pub(crate) at: LatLon, + title: SharedString, + content: Option, + on_close: Option, +} + +impl MapPopup { + pub fn new(at: LatLon, title: impl Into) -> Self { + Self { + at, + title: title.into(), + content: None, + on_close: None, + } + } + + pub fn child(mut self, content: impl IntoElement) -> Self { + self.content = Some(content.into_any_element()); + self + } + + pub fn on_close(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_close = Some(Rc::new(handler)); + self + } + + pub(crate) fn closer(&self) -> Option { + self.on_close.clone() + } + + /// The card at its point in a view of `size`; none when the point lies outside. + pub(crate) fn place( + self, + owner: &ElementId, + point: (f32, f32), + size: (f32, f32), + window: &Window, + cx: &App, + ) -> Option { + let theme = cx.theme(); + let colors = &theme.colors; + let rem = window.rem_size(); + let sizes = theme.maps(); + let margin = f32::from(sizes.margin.to_pixels(rem)); + let lift = f32::from(theme.icon_size(IconSize::Xl).to_pixels(rem)) + margin; + let spot = spot( + point, + size, + f32::from(sizes.popup.to_pixels(rem)), + margin, + lift, + )?; + let card = div() + .id((owner.clone(), "popup")) + .absolute() + .left(Pixels::from(spot.left)) + .w(Pixels::from(spot.width)) + .occlude() + .flex() + .items_start() + .gap_2() + .p_3() + .rounded(theme.radius(Radius::Lg)) + .bg(colors.overlay) + .border_1() + .border_color(colors.border) + .shadow(theme.elevation(Elevation::Floating)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child(Ellipsis::new(self.title)), + ) + .children(self.content), + ) + .when_some(self.on_close, |card, on_close| { + card.child( + IconButton::new((owner.clone(), "popup-close"), IconName::X) + .variant(ButtonVariant::Ghost) + .tooltip("Close") + .on_click(move |_, window, cx| on_close(window, cx)), + ) + }); + Some( + match spot.edge { + Edge::Bottom(bottom) => card.bottom(Pixels::from(bottom)), + Edge::Top(top) => card.top(Pixels::from(top)), + } + .into_any_element(), + ) + } +} + +/// Which edge of a popup holds it: its bottom above the point, or its top below. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Edge { + Bottom(f32), + Top(f32), +} + +/// Where a popup lies in a view. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Spot { + pub left: f32, + pub width: f32, + pub edge: Edge, +} + +/// A popup `width` wide at most, centered on its point and held `margin` inside the view, opening toward the view's larger half: above clears a pin by `lift`. None when the point lies outside. +pub(crate) fn spot( + (x, y): (f32, f32), + (w, h): (f32, f32), + width: f32, + margin: f32, + lift: f32, +) -> Option { + if !(0.0..=w).contains(&x) || !(0.0..=h).contains(&y) { + return None; + } + let width = width.min(w - margin * 2.0).max(0.0); + let left = (x - width / 2.0).clamp(margin, (w - margin - width).max(margin)); + let edge = if y > h / 2.0 { + Edge::Bottom(h - y + lift) + } else { + Edge::Top(y + margin) + }; + Some(Spot { left, width, edge }) +} + +#[cfg(test)] +mod tests { + use super::{Edge, Spot, spot}; + + const VIEW: (f32, f32) = (400.0, 300.0); + + #[test] + fn a_popup_centers_on_its_point_and_stays_inside() { + let at = |x: f32, y: f32| spot((x, y), VIEW, 240.0, 8.0, 32.0); + assert_eq!( + at(200.0, 250.0), + Some(Spot { + left: 80.0, + width: 240.0, + edge: Edge::Bottom(82.0) + }) + ); + assert_eq!( + at(10.0, 250.0).map(|spot| spot.left), + Some(8.0), + "held off the left edge" + ); + assert_eq!( + at(395.0, 250.0).map(|spot| spot.left), + Some(152.0), + "held off the right edge" + ); + assert_eq!( + at(200.0, 40.0).map(|spot| spot.edge), + Some(Edge::Top(48.0)), + "a point up top opens below" + ); + assert_eq!(at(-1.0, 100.0), None, "a point outside draws none"); + assert_eq!(at(200.0, 301.0), None); + } + + #[test] + fn a_narrow_map_narrows_its_popup() { + let narrow = spot((100.0, 100.0), (200.0, 300.0), 240.0, 8.0, 32.0).expect("inside"); + assert_eq!((narrow.left, narrow.width), (8.0, 184.0)); + } +} diff --git a/src/maps/mod.rs b/src/maps/mod.rs new file mode 100644 index 0000000..eb2b303 --- /dev/null +++ b/src/maps/mod.rs @@ -0,0 +1,14 @@ +mod coords; +mod geo; +mod marker; +mod picker; +#[cfg(all(test, feature = "test-support"))] +mod tests; +mod tile; +mod view; + +pub use coords::{CoordFormat, CoordinateDisplay}; +pub use geo::{LatLon, MAX_LAT, MapViewport, Tile}; +pub use marker::{MapMarker, MapPopup}; +pub use picker::LocationPicker; +pub use view::MapView; diff --git a/src/maps/picker.rs b/src/maps/picker.rs new file mode 100644 index 0000000..7539868 --- /dev/null +++ b/src/maps/picker.rs @@ -0,0 +1,85 @@ +use std::rc::Rc; + +use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; + +use super::{CoordFormat, CoordinateDisplay, LatLon, MapView}; +use crate::{ + layout::seeded::use_seeded, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize}, +}; + +type OnPick = Rc; + +/// A map whose center is the pick: a crosshair marks it, the place shows below with a copy button, and the owner hears each move, by pointer or keys. It fills its box; the host gives it a height. +#[derive(IntoElement)] +pub struct LocationPicker { + id: ElementId, + map: MapView, + format: CoordFormat, + on_pick: Option, +} + +impl LocationPicker { + /// Takes the map's moves; a map that has its own `on_viewport` fails at render. + pub fn new(id: impl Into, map: MapView) -> Self { + Self { + id: id.into(), + map, + format: CoordFormat::default(), + on_pick: None, + } + } + + pub fn format(mut self, format: CoordFormat) -> Self { + self.format = format; + self + } + + pub fn on_pick(mut self, handler: impl Fn(LatLon, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for LocationPicker { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + assert!( + self.map.on_viewport.is_none(), + "location picker {:?}: it hears the map's moves; pass on_pick", + self.id + ); + let local = use_seeded((self.id.clone(), "pick"), self.map.viewport, window, cx); + let view = local.read(cx).value; + let on_pick = self.on_pick; + let mut map = self.map; + map.viewport = view; + map.on_viewport = Some(Rc::new(move |next, window, cx| { + local.update(cx, |local, cx| { + local.value = next; + cx.notify(); + }); + if let Some(on_pick) = &on_pick { + on_pick(next.center, window, cx); + } + })); + let fg = cx.theme().colors.fg; + div() + .h_full() + .flex() + .flex_col() + .gap_2() + .child( + div().relative().flex_1().min_h_0().child(map).child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::Crosshair).size(IconSize::Lg).color(fg)), + ), + ) + .child(CoordinateDisplay::new((self.id, "place"), view.center).format(self.format)) + } +} diff --git a/src/maps/tests/mod.rs b/src/maps/tests/mod.rs new file mode 100644 index 0000000..7e52032 --- /dev/null +++ b/src/maps/tests/mod.rs @@ -0,0 +1,414 @@ +use std::{cell::RefCell, collections::HashSet, f64::consts::LOG2_E, rc::Rc, sync::Arc}; + +use gpui::{ + AnyElement, Context, Entity, FocusHandle, ImageSource, IntoElement, KeyUpEvent, Keystroke, + Modifiers, MouseButton, ParentElement, Render, RenderImage, ScrollDelta, ScrollWheelEvent, + SharedString, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, div, point, px, +}; + +use super::{LatLon, LocationPicker, MapMarker, MapPopup, MapView, MapViewport, Tile}; +use crate::{ + primitives::{FocusNext, FocusScope}, + theme::Theme, +}; + +/// The view inside the map's 1px border, in pixels. +const SIZE: (f32, f32) = (398.0, 298.0); +const TILE: f32 = 256.0; + +fn start() -> MapViewport { + MapViewport::new(LatLon::new(0.0, 0.0), 2.0) +} + +/// A view that shows one map part and keeps what it heard. +struct Stage { + root: FocusHandle, + part: fn(&Stage, Entity) -> AnyElement, + views: Vec, + said: Vec, + tiles: Rc>>, +} + +impl Render for Stage { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + FocusScope::new(&self.root).root().size_full().child( + div() + .w(px(400.0)) + .h(px(300.0)) + .child((self.part)(self, cx.entity())), + ) + } +} + +fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +fn stage( + part: fn(&Stage, Entity) -> AnyElement, + cx: &mut TestAppContext, +) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + cx.bind_keys([gpui::KeyBinding::new("tab", FocusNext, None)]); + }); + let (host, cx) = cx.add_window_view(|_, cx| Stage { + root: cx.focus_handle(), + part, + views: Vec::new(), + said: Vec::new(), + tiles: Rc::default(), + }); + settle(cx); + (host, cx) +} + +fn hear(owner: &Entity, view: MapViewport, cx: &mut gpui::App) { + owner.update(cx, |stage, cx| { + stage.views.push(view); + cx.notify(); + }); +} + +fn say(owner: &Entity, words: &str, cx: &mut gpui::App) { + owner.update(cx, |stage, cx| { + stage.said.push(words.to_string().into()); + cx.notify(); + }); +} + +fn last(host: &Entity, cx: &mut VisualTestContext) -> MapViewport { + *host + .read_with(cx, |stage, _| stage.views.clone()) + .last() + .expect("the map moved") +} + +fn said(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |stage, _| stage.said.clone()) +} + +fn picture() -> ImageSource { + let frame = image::Frame::new(image::RgbaImage::new(1, 1)); + ImageSource::Render(Arc::new(RenderImage::new(vec![frame]))) +} + +fn map(stage: &Stage, owner: Entity) -> AnyElement { + let tiles = stage.tiles.clone(); + let heard = owner.clone(); + MapView::new("map", start()) + .tiles(move |tile| { + tiles.borrow_mut().insert(tile); + picture() + }) + .markers([MapMarker::new("middle", LatLon::new(0.0, 0.0)).label("Null Island")]) + .on_marker(move |key, _, cx| say(&heard, key, cx)) + .on_viewport(move |view, _, cx| hear(&owner, view, cx)) + .into_any_element() +} + +fn at(x: f32, y: f32) -> gpui::Point { + point(px(x), px(y)) +} + +/// A view point as the window has it, past the map's border. +fn window_at((x, y): (f32, f32)) -> gpui::Point { + at(x + 1.0, y + 1.0) +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn near(a: (f32, f32), b: (f32, f32)) -> bool { + (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 +} + +/// Focuses the map with a press on open water, away from its pin and buttons. +fn focus_map(cx: &mut VisualTestContext) { + cx.simulate_click(window_at((40.0, 250.0)), Modifiers::none()); + settle(cx); +} + +#[gpui::test] +fn a_drag_pans_the_map_with_the_pointer(cx: &mut TestAppContext) { + let (host, cx) = stage(map, cx); + let place = start().to_geo((60.0, 200.0), SIZE, TILE); + let none = Modifiers::none(); + cx.simulate_mouse_move(window_at((60.0, 200.0)), None, none); + cx.simulate_mouse_down(window_at((60.0, 200.0)), MouseButton::Left, none); + for (x, y) in [(70.0, 190.0), (90.0, 175.0), (110.0, 170.0)] { + cx.simulate_mouse_move(window_at((x, y)), Some(MouseButton::Left), none); + settle(cx); + } + cx.simulate_mouse_up(window_at((110.0, 170.0)), MouseButton::Left, none); + settle(cx); + let moved = last(&host, cx).to_view(place, SIZE, TILE); + assert!( + near(moved, (110.0, 170.0)), + "the place followed the pointer: {moved:?}" + ); +} + +#[gpui::test] +fn command_and_the_wheel_zoom_about_the_pointer(cx: &mut TestAppContext) { + let (host, cx) = stage(map, cx); + let place = start().to_geo((300.0, 60.0), SIZE, TILE); + cx.simulate_event(ScrollWheelEvent { + position: window_at((300.0, 60.0)), + delta: ScrollDelta::Pixels(point(px(0.0), px(-240.0))), + modifiers: Modifiers::command(), + touch_phase: TouchPhase::Moved, + }); + settle(cx); + let view = last(&host, cx); + assert!( + (view.zoom - (2.0 + LOG2_E)).abs() < 1e-6, + "a turn of 240 zooms by e: {}", + view.zoom + ); + assert!(near(view.to_view(place, SIZE, TILE), (300.0, 60.0))); + cx.simulate_event(ScrollWheelEvent { + position: window_at((300.0, 60.0)), + delta: ScrollDelta::Pixels(point(px(0.0), px(-50.0))), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + settle(cx); + let panned = last(&host, cx).to_view(place, SIZE, TILE); + assert!( + near(panned, (300.0, 10.0)), + "a plain wheel pans: {panned:?}" + ); +} + +#[gpui::test] +fn keys_pan_and_zoom_the_focused_map(cx: &mut TestAppContext) { + let (host, cx) = stage(map, cx); + focus_map(cx); + let middle = (SIZE.0 / 2.0, SIZE.1 / 2.0); + let place = start().to_geo(middle, SIZE, TILE); + press("left", cx); + let step = SIZE.0.min(SIZE.1) / 4.0; + let moved = last(&host, cx).to_view(place, SIZE, TILE); + assert!( + near(moved, (middle.0 + step, middle.1)), + "left shows what lies west: {moved:?}" + ); + press("=", cx); + assert_eq!(last(&host, cx).zoom, 3.0); + press("-", cx); + assert_eq!(last(&host, cx).zoom, 2.0); + let count = host.read_with(cx, |stage, _| stage.views.len()); + press("cmd-left", cx); + assert_eq!( + host.read_with(cx, |stage, _| stage.views.len()), + count, + "the app keeps Command" + ); +} + +#[gpui::test] +fn a_marker_takes_a_press_and_enter_by_its_key(cx: &mut TestAppContext) { + let (host, cx) = stage(map, cx); + let (x, y) = start().to_view(LatLon::new(0.0, 0.0), SIZE, TILE); + cx.simulate_click(window_at((x, y - 12.0)), Modifiers::none()); + settle(cx); + assert_eq!(said(&host, cx), ["middle"], "a press on the pin"); + focus_map(cx); + press("tab", cx); + press("enter", cx); + assert_eq!( + said(&host, cx), + ["middle", "middle"], + "Tab reaches the pin, Enter presses it" + ); +} + +fn quiet(_: &Stage, owner: Entity) -> AnyElement { + MapView::new("map", start()) + .markers([MapMarker::new("middle", LatLon::new(0.0, 0.0))]) + .on_viewport(move |view, _, cx| hear(&owner, view, cx)) + .into_any_element() +} + +/// A map without a marker handler leaves its pins out of Tab: the next stop is the zoom button. +#[gpui::test] +fn pins_without_a_handler_take_no_tab(cx: &mut TestAppContext) { + let (host, cx) = stage(quiet, cx); + focus_map(cx); + press("tab", cx); + press("enter", cx); + assert_eq!(last(&host, cx).zoom, 3.0, "Enter on the zoom button"); + press("tab", cx); + press("enter", cx); + assert_eq!(last(&host, cx).zoom, 2.0, "the next stop zooms out"); +} + +fn tiled(stage: &Stage, _: Entity) -> AnyElement { + let tiles = stage.tiles.clone(); + MapView::new("map", MapViewport::new(LatLon::new(0.0, 0.0), 1.0)) + .tiles(move |tile| { + tiles.borrow_mut().insert(tile); + picture() + }) + .into_any_element() +} + +fn requested(host: &Entity, cx: &mut VisualTestContext) -> HashSet { + host.read_with(cx, |stage, _| stage.tiles.borrow().clone()) +} + +#[gpui::test] +fn the_host_supplies_each_tile_the_view_covers(cx: &mut TestAppContext) { + let (host, cx) = stage(tiled, cx); + let expected: HashSet = [(0, 0), (1, 0), (0, 1), (1, 1)] + .map(|(x, y)| Tile { z: 1, x, y }) + .into(); + assert_eq!(requested(&host, cx), expected); +} + +fn capped(stage: &Stage, _: Entity) -> AnyElement { + let tiles = stage.tiles.clone(); + MapView::new("map", MapViewport::new(LatLon::new(0.0, 0.0), 1.0)) + .tile_zooms(0, 0) + .tiles(move |tile| { + tiles.borrow_mut().insert(tile); + picture() + }) + .into_any_element() +} + +#[gpui::test] +fn past_the_hosts_zooms_one_tile_scales(cx: &mut TestAppContext) { + let (host, cx) = stage(capped, cx); + assert_eq!( + requested(&host, cx), + HashSet::from([Tile { z: 0, x: 0, y: 0 }]) + ); +} + +fn with_popup(_: &Stage, owner: Entity) -> AnyElement { + let closed = owner.clone(); + MapView::new("map", start()) + .popup( + MapPopup::new(LatLon::new(0.0, 0.0), "Null Island") + .child("Where the equator meets the prime meridian.") + .on_close(move |_, cx| say(&closed, "closed", cx)), + ) + .on_viewport(move |view, _, cx| hear(&owner, view, cx)) + .into_any_element() +} + +#[gpui::test] +fn the_popup_closes_by_its_button_and_by_escape(cx: &mut TestAppContext) { + let (host, cx) = stage(with_popup, cx); + focus_map(cx); + press("escape", cx); + assert_eq!(said(&host, cx), ["closed"], "Escape on the map"); + focus_map(cx); + press("tab", cx); + press("enter", cx); + assert_eq!( + said(&host, cx), + ["closed", "closed"], + "Tab reaches the close button first" + ); +} + +fn far_popup(_: &Stage, owner: Entity) -> AnyElement { + MapView::new("map", start()) + .popup(MapPopup::new(LatLon::new(-60.0, 120.0), "Far").on_close(|_, _| {})) + .on_viewport(move |view, _, cx| hear(&owner, view, cx)) + .into_any_element() +} + +/// A popup whose place lies outside the view is not drawn, so Tab passes to the zoom button. +#[gpui::test] +fn a_popup_away_from_the_view_hides(cx: &mut TestAppContext) { + let (host, cx) = stage(far_popup, cx); + focus_map(cx); + press("tab", cx); + press("enter", cx); + assert_eq!(last(&host, cx).zoom, 3.0); +} + +fn picker(_: &Stage, owner: Entity) -> AnyElement { + LocationPicker::new("picker", MapView::new("pick-map", start())) + .on_pick(move |at, _, cx| say(&owner, &format!("{:.4},{:.4}", at.lat, at.lon), cx)) + .into_any_element() +} + +#[gpui::test] +fn the_picker_picks_the_center_as_the_map_moves(cx: &mut TestAppContext) { + let (host, cx) = stage(picker, cx); + cx.simulate_click(at(40.0, 40.0), Modifiers::none()); + settle(cx); + press("right", cx); + press("down", cx); + let heard = said(&host, cx); + assert_eq!(heard.len(), 2, "one pick per move: {heard:?}"); + let first: Vec = heard[0] + .split(',') + .map(|part| part.parse().expect("a number")) + .collect(); + assert_eq!(first[0], 0.0, "right moves east along the equator"); + assert!(first[1] > 0.0); + let second: Vec = heard[1] + .split(',') + .map(|part| part.parse().expect("a number")) + .collect(); + assert!( + second[0] < 0.0 && second[1] == first[1], + "down moves south: {heard:?}" + ); +} + +fn loud(_: &Stage, owner: Entity) -> AnyElement { + LocationPicker::new( + "picker", + MapView::new("pick-map", start()).on_viewport(move |view, _, cx| hear(&owner, view, cx)), + ) + .into_any_element() +} + +#[gpui::test] +#[should_panic(expected = "it hears the map's moves")] +fn a_picker_whose_map_hears_its_own_moves_fails(cx: &mut TestAppContext) { + stage(loud, cx); +} + +fn nearest(_: &Stage, owner: Entity) -> AnyElement { + MapView::new( + "map", + MapViewport::new(LatLon::new(0.0, 0.0), MapViewport::ZOOMS.1), + ) + .on_viewport(move |view, _, cx| hear(&owner, view, cx)) + .into_any_element() +} + +/// At the nearest zoom the zoom-in button rests and leaves Tab, so the next stop zooms out. +#[gpui::test] +fn the_zoom_buttons_rest_at_the_limits(cx: &mut TestAppContext) { + let (host, cx) = stage(nearest, cx); + focus_map(cx); + press("tab", cx); + press("enter", cx); + assert_eq!(last(&host, cx).zoom, MapViewport::ZOOMS.1 - 1.0); +} + +#[test] +#[should_panic(expected = "tile zooms 4 to 2")] +fn tile_zooms_out_of_order_fail() { + let _ = MapView::new("map", start()).tile_zooms(4, 2); +} diff --git a/src/maps/tile.rs b/src/maps/tile.rs new file mode 100644 index 0000000..0ef8a30 --- /dev/null +++ b/src/maps/tile.rs @@ -0,0 +1,143 @@ +use std::{ + hash::{Hash, Hasher}, + sync::Arc, +}; + +use gpui::{ + AnyElement, App, Asset, ImageAssetLoader, ImageSource, IntoElement, ObjectFit, ParentElement, + Pixels, RenderImage, Styled, StyledImage, Window, div, img, +}; +use image::{Frame, RgbaImage}; + +use super::geo::Laid; +use crate::{ + primitives::{Icon, IconName}, + theme::ActiveTheme, +}; + +/// A decoded tile; two are alike when they hold the same picture. +#[derive(Clone)] +pub(crate) struct Rim(pub Arc); + +impl Hash for Rim { + fn hash(&self, state: &mut H) { + self.0.id.hash(state); + } +} + +/// A tile with a one-pixel rim copied from its own edge, made off the main thread. gpui packs pictures in its atlas without padding and scales them with a linear filter, so a bare tile's edge takes color from its atlas neighbor. +pub(crate) enum Rimmed {} + +impl Asset for Rimmed { + type Source = Rim; + type Output = Arc; + + fn load( + source: Self::Source, + _: &mut App, + ) -> impl Future + Send + 'static { + let Rim(image) = source; + async move { Arc::new(rimmed(&image)) } + } +} + +/// Every frame of `image` one pixel larger each side, the rim repeating the edge beside it. +pub(crate) fn rimmed(image: &RenderImage) -> RenderImage { + let frames: Vec = (0..image.frame_count()) + .map(|ix| { + let size = image.size(ix); + let (w, h) = (size.width.0 as u32, size.height.0 as u32); + let bytes = image.as_bytes(ix).expect("a frame in range").to_vec(); + let inner = RgbaImage::from_raw(w, h, bytes).expect("a frame's bytes fill its size"); + let outer = RgbaImage::from_fn(w + 2, h + 2, |x, y| { + *inner.get_pixel( + x.saturating_sub(1).min(w - 1), + y.saturating_sub(1).min(h - 1), + ) + }); + Frame::from_parts(outer, 0, 0, image.delay(ix)) + }) + .collect(); + RenderImage::new(frames) +} + +/// A tile's picture in its box: its rim lies one pixel past each side, clipped, so the box shows its own pixels alone. Nothing while it loads; a broken-picture mark when it cannot. +pub(crate) fn laid_tile( + laid: &Laid, + source: ImageSource, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let slot = div() + .absolute() + .left(Pixels::from(laid.x)) + .top(Pixels::from(laid.y)) + .w(Pixels::from(laid.w)) + .h(Pixels::from(laid.h)) + .overflow_hidden(); + let decoded = match source { + ImageSource::Render(image) => Some(Ok(image)), + ImageSource::Resource(resource) => window.use_asset::(&resource, cx), + _ => panic!( + "map tile {:?}: a web address, a file or a picture drawn in code", + laid.tile + ), + }; + let image = match decoded { + None => return slot.into_any_element(), + Some(Err(_)) => { + let mark = cx.theme().colors.fg_subtle; + return slot + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::ImageOff).color(mark)) + .into_any_element(); + } + Some(Ok(image)) => image, + }; + let Some(rimmed) = window.use_asset::(&Rim(image.clone()), cx) else { + return slot.into_any_element(); + }; + let size = image.size(0); + let (dx, dy) = (laid.w / size.width.0 as f32, laid.h / size.height.0 as f32); + slot.child( + img(ImageSource::Render(rimmed)) + .absolute() + .left(Pixels::from(-dx)) + .top(Pixels::from(-dy)) + .w(Pixels::from(laid.w + dx * 2.0)) + .h(Pixels::from(laid.h + dy * 2.0)) + .object_fit(ObjectFit::Fill), + ) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use gpui::RenderImage; + use image::{Frame, RgbaImage}; + + use super::rimmed; + + #[test] + fn a_rim_repeats_the_edge_beside_it() { + let mut wide = RgbaImage::new(2, 1); + wide.get_pixel_mut(0, 0).0 = [1, 0, 0, 255]; + wide.get_pixel_mut(1, 0).0 = [2, 0, 0, 255]; + let rimmed = rimmed(&RenderImage::new(vec![Frame::new(wide)])); + let size = rimmed.size(0); + assert_eq!((size.width.0, size.height.0), (4, 3)); + let bytes = rimmed.as_bytes(0).expect("a frame"); + let first = |row: usize| { + bytes[row * 16..row * 16 + 16] + .iter() + .step_by(4) + .copied() + .collect::>() + }; + assert_eq!(first(0), [1, 1, 2, 2], "the top rim repeats the row below"); + assert_eq!(first(1), [1, 1, 2, 2], "each side repeats its edge pixel"); + assert_eq!(first(2), [1, 1, 2, 2]); + } +} diff --git a/src/maps/view.rs b/src/maps/view.rs new file mode 100644 index 0000000..4c6df21 --- /dev/null +++ b/src/maps/view.rs @@ -0,0 +1,330 @@ +use std::{f64::consts::LOG2_E, rc::Rc}; + +use gpui::{ + AnyElement, App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, ImageSource, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, +}; + +use super::{ + MapViewport, Tile, + marker::{MapMarker, MapPopup, OnMarker}, + tile::laid_tile, +}; +use crate::{ + buttons::{ButtonVariant, IconButton}, + layout::seeded::use_seeded, + primitives::{FocusRing, IconName, tab_stop}, + theme::{ActiveTheme, Radius, TextSize}, +}; + +type TileSource = Rc ImageSource>; +pub(crate) type OnMapViewport = Rc; +/// The viewport and the view's size as they stand now. +type Now = Rc (MapViewport, (f32, f32))>; + +/// A drag that pans a map, and whose map it is. +struct Pan { + owner: EntityId, +} + +/// The map's box, and the pointer and viewport at the press of a pan under way. +#[derive(Default)] +struct Stage { + bounds: Bounds, + grip: Option<(Point, MapViewport)>, +} + +/// A map of tiles the host supplies by zoom, column and row, with pins and a popup over them. A drag or the wheel pans it; Command or Control with the wheel zooms about the pointer; focused, the arrows pan and + and - zoom, as its buttons do. The owner keeps the viewport; a new one from the owner shows at once. It fills its box; the host gives it a height. +#[derive(IntoElement)] +pub struct MapView { + id: ElementId, + pub(crate) viewport: MapViewport, + tiles: Option, + zooms: (u8, u8), + attribution: Option, + markers: Vec, + on_marker: Option, + popup: Option, + pub(crate) on_viewport: Option, +} + +impl MapView { + pub fn new(id: impl Into, viewport: MapViewport) -> Self { + Self { + id: id.into(), + viewport, + tiles: None, + zooms: (0, MapViewport::ZOOMS.1 as u8), + attribution: None, + markers: Vec::new(), + on_marker: None, + popup: None, + on_viewport: None, + } + } + + /// The picture of each tile: a web address, a file, or a picture drawn in code. + pub fn tiles(mut self, source: impl Fn(Tile) -> ImageSource + 'static) -> Self { + self.tiles = Some(Rc::new(source)); + self + } + + /// The zooms the host cuts tiles at; nearer in, the nearest cut scales up. + pub fn tile_zooms(mut self, least: u8, most: u8) -> Self { + assert!( + least <= most && f64::from(most) <= MapViewport::ZOOMS.1, + "tile zooms {least} to {most}" + ); + self.zooms = (least, most); + self + } + + /// Whose tiles and data these are, in the corner, as their licenses ask. + pub fn attribution(mut self, text: impl Into) -> Self { + self.attribution = Some(text.into()); + self + } + + pub fn markers(mut self, markers: impl IntoIterator) -> Self { + self.markers = markers.into_iter().collect(); + self + } + + /// Hears a pressed marker by its key; without it, markers take no press. + pub fn on_marker( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_marker = Some(Rc::new(handler)); + self + } + + /// A card at a place; Escape on the focused map closes it too. + pub fn popup(mut self, popup: MapPopup) -> Self { + self.popup = Some(popup); + self + } + + /// Gets the viewport after each pan and zoom. + pub fn on_viewport( + mut self, + handler: impl Fn(MapViewport, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_viewport = Some(Rc::new(handler)); + self + } +} + +fn sized(bounds: Bounds) -> (f32, f32) { + (f32::from(bounds.size.width), f32::from(bounds.size.height)) +} + +impl RenderOnce for MapView { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id.clone(); + let local = use_seeded((id.clone(), "view"), self.viewport, window, cx); + let stage: Entity = + window.use_keyed_state((id.clone(), "stage"), cx, |_, _| Stage::default()); + let focus = tab_stop((id.clone(), "focus").into(), true, window, cx); + let view = local.read(cx).value; + let size = sized(stage.read(cx).bounds); + let tile = f32::from(cx.theme().maps().tile); + let tiles: Vec = match &self.tiles { + Some(source) => view + .tiles(size, tile, self.zooms) + .iter() + .map(|laid| laid_tile(laid, source(laid.tile), window, cx)) + .collect(), + None => Vec::new(), + }; + let theme = cx.theme(); + let colors = theme.colors.clone(); + let set: OnMapViewport = { + let (local, on_viewport) = (local.clone(), self.on_viewport.clone()); + Rc::new(move |next, window, cx| { + log::info!( + "map: {:.4}, {:.4} at zoom {:.2}", + next.center.lat, + next.center.lon, + next.zoom + ); + local.update(cx, |local, cx| { + local.value = next; + cx.notify(); + }); + if let Some(on_viewport) = &on_viewport { + on_viewport(next, window, cx); + } + }) + }; + let now: Now = { + let (local, stage) = (local.clone(), stage.clone()); + Rc::new(move |cx| (local.read(cx).value, sized(stage.read(cx).bounds))) + }; + let markers: Vec = self + .markers + .into_iter() + .map(|marker| { + let at = view.to_view(marker.at, size, tile); + marker.place(&id, at, self.on_marker.clone(), cx) + }) + .collect(); + let closer = self.popup.as_ref().and_then(MapPopup::closer); + let popup = self.popup.and_then(|popup| { + let at = view.to_view(popup.at, size, tile); + popup.place(&id, at, size, window, cx) + }); + let zoom = |levels: f64, key: &'static str, icon: IconName, words: &'static str| { + let (now, set) = (now.clone(), set.clone()); + let (least, most) = MapViewport::ZOOMS; + let spent = if levels > 0.0 { + view.zoom >= most + } else { + view.zoom <= least + }; + IconButton::new((id.clone(), key), icon) + .variant(ButtonVariant::Secondary) + .tooltip(words) + .disabled(spent) + .on_click(move |_, window, cx| { + let (view, size) = now(cx); + set( + view.zoomed(levels, (size.0 / 2.0, size.1 / 2.0), size, tile), + window, + cx, + ); + }) + }; + let controls = div() + .absolute() + .top_2() + .right_2() + .occlude() + .flex() + .flex_col() + .gap_1() + .child(zoom(1.0, "zoom-in", IconName::Plus, "Zoom in")) + .child(zoom(-1.0, "zoom-out", IconName::Minus, "Zoom out")); + let attribution = self.attribution.map(|text| { + div() + .absolute() + .bottom_0() + .right_0() + .max_w_full() + .px_1() + .rounded_tl(theme.radius(Radius::Sm)) + .bg(colors.bg.alpha(0.85)) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(text) + }); + let measured = { + let stage = stage.clone(); + canvas( + move |bounds, window, cx| { + if stage.read(cx).bounds != bounds { + stage.update(cx, |stage, cx| { + stage.bounds = bounds; + cx.notify(); + }); + window.request_animation_frame(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() + }; + let owner = stage.entity_id(); + let (keyed, wheeled, held) = (now.clone(), now.clone(), now); + let (by_key, by_wheel, by_drag) = (set.clone(), set.clone(), set); + let (scrolled, pressed, dragged, dropped) = + (stage.clone(), stage.clone(), stage.clone(), stage); + div() + .id(id) + .track_focus(&focus) + .relative() + .h_full() + .overflow_hidden() + .bg(colors.sunken) + .border_1() + .border_color(gpui::transparent_black()) + .focus_ring(cx) + .cursor_grab() + .on_key_down(move |event, window, cx| { + let held = &event.keystroke.modifiers; + if held.platform || held.control || held.alt { + return; + } + let (view, size) = keyed(cx); + let step = size.0.min(size.1) / 4.0; + let middle = (size.0 / 2.0, size.1 / 2.0); + let next = match event.keystroke.key.as_str() { + "left" => view.panned((step, 0.0), size, tile), + "right" => view.panned((-step, 0.0), size, tile), + "up" => view.panned((0.0, step), size, tile), + "down" => view.panned((0.0, -step), size, tile), + "+" | "=" => view.zoomed(1.0, middle, size, tile), + "-" => view.zoomed(-1.0, middle, size, tile), + "escape" => { + if let Some(close) = &closer { + cx.stop_propagation(); + close(window, cx); + } + return; + } + _ => return, + }; + cx.stop_propagation(); + by_key(next, window, cx); + }) + .on_scroll_wheel(move |event, window, cx| { + let delta = event.delta.pixel_delta(window.line_height()); + let (dx, dy) = (f32::from(delta.x), f32::from(delta.y)); + if (dx, dy) == (0.0, 0.0) { + return; + } + let (view, size) = wheeled(cx); + let pointer = event.position - scrolled.read(cx).bounds.origin; + let about = (f32::from(pointer.x), f32::from(pointer.y)); + let next = if event.modifiers.platform || event.modifiers.control { + view.zoomed(-f64::from(dy) / 240.0 * LOG2_E, about, size, tile) + } else { + view.panned((dx, dy), size, tile) + }; + cx.stop_propagation(); + by_wheel(next, window, cx); + }) + .on_mouse_down(MouseButton::Left, move |event, _, cx| { + let (view, _) = held(cx); + pressed.update(cx, |stage, _| stage.grip = Some((event.position, view))) + }) + .on_drag(Pan { owner }, |_, _, _, cx| cx.new(|_| EmptyView)) + .on_drag_move::(move |event, window, cx| { + if event.drag(cx).owner != owner { + return; + } + let stage = dragged.read(cx); + let Some((press, start)) = stage.grip else { + return; + }; + let size = sized(stage.bounds); + let way = event.event.position - press; + by_drag( + start.panned((f32::from(way.x), f32::from(way.y)), size, tile), + window, + cx, + ); + }) + .on_drop(move |_: &Pan, _, cx| dropped.update(cx, |stage, _| stage.grip = None)) + .children(tiles) + .children(markers) + .children(popup) + .child(controls) + .children(attribution) + .child(measured) + } +} diff --git a/src/theme/maps.rs b/src/theme/maps.rs new file mode 100644 index 0000000..7dc5f15 --- /dev/null +++ b/src/theme/maps.rs @@ -0,0 +1,24 @@ +use gpui::{Pixels, Rems, px}; + +use super::{Theme, tokens::px_to_rems}; + +/// A map's measures. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MapSizes { + /// A tile's side at its own zoom, as tile servers cut them. + pub tile: Pixels, + /// A popup's width, less where the map is narrower. + pub popup: Rems, + /// Room a popup keeps from the map's edges and from its point. + pub margin: Rems, +} + +impl Theme { + pub fn maps(&self) -> MapSizes { + MapSizes { + tile: px(256.0), + popup: px_to_rems(240.0), + margin: px_to_rems(8.0), + } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index e219779..35028ad 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -5,6 +5,7 @@ mod dashboard; mod files; mod generative; mod interaction; +mod maps; mod media; mod messaging; mod onboarding; @@ -29,6 +30,7 @@ pub use dashboard::DashboardSizes; pub use files::FileSizes; pub use generative::GenerativeSizes; pub use interaction::InteractionSizes; +pub use maps::MapSizes; pub use media::MediaSizes; pub use messaging::MessagingSizes; pub use onboarding::OnboardingSizes; diff --git a/src/typography/copy.rs b/src/typography/copy.rs index 032e2ed..9cd9860 100644 --- a/src/typography/copy.rs +++ b/src/typography/copy.rs @@ -3,6 +3,7 @@ use gpui::{ Styled, Window, div, prelude::*, }; +use super::tabular; use crate::{buttons::CopyButton, theme::ActiveTheme}; /// Text with a copy button that confirms with a check. @@ -11,6 +12,7 @@ pub struct CopyableText { id: ElementId, text: SharedString, mono: bool, + tabular: bool, } impl CopyableText { @@ -19,6 +21,7 @@ impl CopyableText { id: id.into(), text: text.into(), mono: false, + tabular: false, } } @@ -26,6 +29,12 @@ impl CopyableText { self.mono = true; self } + + /// Figures of one width, so text that changes holds still. + pub fn tabular(mut self) -> Self { + self.tabular = true; + self + } } impl RenderOnce for CopyableText { @@ -37,6 +46,7 @@ impl RenderOnce for CopyableText { .items_center() .gap_1() .when(self.mono, |row| row.font_family(mono)) + .when(self.tabular, tabular) .child(self.text.clone()) .child(CopyButton::new("copy-slot", self.text)) } diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index d3eaf52..33cbd6f 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -290,11 +290,11 @@ ## T41a Maps · View — `src/maps` -- [ ] MapView — host tiles -- [ ] MapMarker -- [ ] MapPopup -- [ ] LocationPicker -- [ ] CoordinateDisplay +- [x] MapView — host tiles → `maps::MapView`: the host's tile for each zoom, column and row, the nearest cut scaled past its zooms; a drag, the wheel or the arrows pan, Command-wheel, + and - or its buttons zoom +- [x] MapMarker → `maps::MapMarker`, a pin keyed by its place's name, pressable when the map hears it +- [x] MapPopup → `maps::MapPopup`, over or under its point toward the larger half, held inside the map +- [x] LocationPicker → `maps::LocationPicker`, the place under a center crosshair +- [x] CoordinateDisplay → `maps::CoordinateDisplay`, degrees or degrees, minutes and seconds, copyable ## T41b Maps · Layers