Maps · View: host tiles, pins and a popup, a picker, coordinates
maps::MapView lays the host's tile for each zoom, column and row in Web Mercator, the nearest cut scaled past the zooms it serves, on whole pixels. gpui packs pictures in its atlas without padding and samples them linearly, so each tile draws with a one-pixel rim copied from its own edge, one of its pixels larger each side and clipped, and no seam shows. A drag, the wheel or the arrows pan; Command-wheel, + and - or its buttons zoom about the pointer or the center; the owner keeps the viewport. MapMarker pins take a press and Enter when the map hears them; MapPopup opens toward the map's larger half and stays inside it, closing by its button or Escape. LocationPicker picks the place under a center crosshair; CoordinateDisplay writes degrees or degrees, minutes and seconds with tabular figures and a copy button. The gallery's tiles are Natural Earth's countries, cut z0 to z3 by scripts/tiles.py.
No files matched your search
@@ -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 <lucide-name>...` adds icons. Then add a line to `IconName` in `src/primitives/icon.rs`.
|
||||
- `python3 scripts/tiles.py <ne_110m_admin_0_countries.geojson>` 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::<I18n>().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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 230 B |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 678 B |
|
After Width: | Height: | Size: 256 B |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 513 B |
|
After Width: | Height: | Size: 206 B |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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::<SharedString>, 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."))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Writes the gallery's map tiles, z0 to z3, from Natural Earth's 110m countries.
|
||||
|
||||
Usage: python3 scripts/tiles.py <ne_110m_admin_0_countries.geojson>
|
||||
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])
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ElementId>, 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");
|
||||
}
|
||||
}
|
||||
@@ -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<Laid> {
|
||||
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<i64> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
type OnClose = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// 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<SharedString>,
|
||||
hue: Option<usize>,
|
||||
}
|
||||
|
||||
impl MapMarker {
|
||||
pub fn new(key: impl Into<SharedString>, at: LatLon) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
at,
|
||||
label: None,
|
||||
hue: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(mut self, label: impl Into<SharedString>) -> 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<OnMarker>,
|
||||
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<AnyElement>,
|
||||
on_close: Option<OnClose>,
|
||||
}
|
||||
|
||||
impl MapPopup {
|
||||
pub fn new(at: LatLon, title: impl Into<SharedString>) -> 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<OnClose> {
|
||||
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<AnyElement> {
|
||||
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<Spot> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<dyn Fn(LatLon, &mut Window, &mut App)>;
|
||||
|
||||
/// 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<OnPick>,
|
||||
}
|
||||
|
||||
impl LocationPicker {
|
||||
/// Takes the map's moves; a map that has its own `on_viewport` fails at render.
|
||||
pub fn new(id: impl Into<ElementId>, 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))
|
||||
}
|
||||
}
|
||||
@@ -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<Stage>) -> AnyElement,
|
||||
views: Vec<MapViewport>,
|
||||
said: Vec<SharedString>,
|
||||
tiles: Rc<RefCell<HashSet<Tile>>>,
|
||||
}
|
||||
|
||||
impl Render for Stage {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Stage>) -> AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Stage>, &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<Stage>, view: MapViewport, cx: &mut gpui::App) {
|
||||
owner.update(cx, |stage, cx| {
|
||||
stage.views.push(view);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn say(owner: &Entity<Stage>, words: &str, cx: &mut gpui::App) {
|
||||
owner.update(cx, |stage, cx| {
|
||||
stage.said.push(words.to_string().into());
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn last(host: &Entity<Stage>, cx: &mut VisualTestContext) -> MapViewport {
|
||||
*host
|
||||
.read_with(cx, |stage, _| stage.views.clone())
|
||||
.last()
|
||||
.expect("the map moved")
|
||||
}
|
||||
|
||||
fn said(host: &Entity<Stage>, cx: &mut VisualTestContext) -> Vec<SharedString> {
|
||||
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<Stage>) -> 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<gpui::Pixels> {
|
||||
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<gpui::Pixels> {
|
||||
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<Stage>) -> 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<Stage>) -> 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<Stage>, cx: &mut VisualTestContext) -> HashSet<Tile> {
|
||||
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<Tile> = [(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<Stage>) -> 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<Stage>) -> 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<Stage>) -> 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<Stage>) -> 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<f64> = 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<f64> = 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<Stage>) -> 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<Stage>) -> 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);
|
||||
}
|
||||
@@ -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<RenderImage>);
|
||||
|
||||
impl Hash for Rim {
|
||||
fn hash<H: Hasher>(&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<RenderImage>;
|
||||
|
||||
fn load(
|
||||
source: Self::Source,
|
||||
_: &mut App,
|
||||
) -> impl Future<Output = Self::Output> + 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<Frame> = (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::<ImageAssetLoader>(&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::<Rimmed>(&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::<Vec<_>>()
|
||||
};
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn(Tile) -> ImageSource>;
|
||||
pub(crate) type OnMapViewport = Rc<dyn Fn(MapViewport, &mut Window, &mut App)>;
|
||||
/// The viewport and the view's size as they stand now.
|
||||
type Now = Rc<dyn Fn(&App) -> (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<Pixels>,
|
||||
grip: Option<(Point<Pixels>, 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<TileSource>,
|
||||
zooms: (u8, u8),
|
||||
attribution: Option<SharedString>,
|
||||
markers: Vec<MapMarker>,
|
||||
on_marker: Option<OnMarker>,
|
||||
popup: Option<MapPopup>,
|
||||
pub(crate) on_viewport: Option<OnMapViewport>,
|
||||
}
|
||||
|
||||
impl MapView {
|
||||
pub fn new(id: impl Into<ElementId>, 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<SharedString>) -> Self {
|
||||
self.attribution = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn markers(mut self, markers: impl IntoIterator<Item = MapMarker>) -> 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<Pixels>) -> (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<Stage> =
|
||||
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<AnyElement> = 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<AnyElement> = 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::<Pan>(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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||