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.
This commit is contained in:
1 parent
090520a9f1
commit
931e0ed6f5
102 files changed
+1978
-6
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
+347
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user