From 6fe05b221953e89b8fda1db89cfe33d7b8896aca Mon Sep 17 00:00:00 2001 From: choupeanut Date: Sun, 8 Nov 2026 14:18:07 +0800 Subject: [PATCH] feat(ui): the status bar keeps the last pointer position off the map The coordinate readout keeps the last position over the map, dimmed, once the pointer leaves it, so it can be read and clicked to another format on the status bar. It reads a dash until the pointer has been over the map, and again on another map or project (Closes #1573). --- crates/ui-egui/src/chrome.rs | 6 +- crates/ui-egui/src/mapview.rs | 19 ++++++ crates/ui-egui/tests/coordinate_readout.rs | 76 ++++++++++++++++++++++ docs/user-guide/maps.md | 7 +- 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 338f7f77b..b24be81af 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -305,9 +305,13 @@ pub fn status_bar(app: &mut RustGisApp, ui: &mut Ui) { // Pointer coordinates. let format = app.map.coord_format; let earth = app.session.map().body().is_earth(); + // Kept after the pointer leaves the map, so a click here still shows the position; + // dimmed then, to tell it from a live one. + let kept = app.map.kept_pointer_lonlat(app.session.map().instance_id(), app.session.opened); + let live = app.map.pointer_lonlat.is_some(); let resp = ui.add( egui::Label::new( - egui::RichText::new(format.readout(app.map.pointer_lonlat, earth)).monospace().size(11.5).color(t.text_dim), + egui::RichText::new(format.readout(kept, earth)).monospace().size(11.5).color(if live { t.text_dim } else { t.text_disabled }), ) .sense(Sense::click()), ); diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index cec3399d3..89eb8c1d8 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -302,6 +302,9 @@ pub struct MapView { /// A map or layout waiting for the user to confirm its deletion. pub deleting: Option, pub pointer_lonlat: Option, + /// The last pointer position seen over a map, kept for the status bar readout after the + /// pointer leaves the map; with the map's instance and the session's `opened` it belongs to. + kept_lonlat: Option<(Coord, u64, u64)>, /// Where the map's context menu was opened: the pointer leaves the map for the menu. pub menu_lonlat: Option, /// The text graphic the context menu was opened on (layer id, row), for Edit Text. @@ -403,6 +406,7 @@ impl Default for MapView { popup_rev: 0, deleting: None, pointer_lonlat: None, + kept_lonlat: None, menu_lonlat: None, menu_text: None, coord_format: crate::CoordReadout::Decimal, @@ -437,6 +441,18 @@ impl Default for MapView { } impl MapView { + /// The position the status bar shows: the last one over this map, kept while the pointer is + /// elsewhere. A position from another map or project no longer applies, so it is dropped. + pub fn kept_pointer_lonlat(&mut self, map_instance: u64, opened: u64) -> Option { + match self.kept_lonlat { + Some((c, m, o)) if m == map_instance && o == opened => Some(c), + _ => { + self.kept_lonlat = None; + None + } + } + } + /// Whether an exact frame is being composed off the UI thread, or the frame shown waits for /// tile pictures still to be drawn. pub fn drawing(&self) -> bool { @@ -2257,6 +2273,9 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect let time = now(ui); let hover = resp.hover_pos(); app.map.pointer_lonlat = hover.and_then(|p| screen_to_lonlat(app, rect, p)); + if let Some(c) = app.map.pointer_lonlat.filter(|c| c.is_finite()) { + app.map.kept_lonlat = Some((c, app.session.map().instance_id(), app.session.opened)); + } let tool = app.session.tool; cancel_changed_selection(app, rect); diff --git a/crates/ui-egui/tests/coordinate_readout.rs b/crates/ui-egui/tests/coordinate_readout.rs index c4ec1526a..00f6508c3 100644 --- a/crates/ui-egui/tests/coordinate_readout.rs +++ b/crates/ui-egui/tests/coordinate_readout.rs @@ -136,3 +136,79 @@ fn the_status_bar_reads_the_pointer_in_the_chosen_format_and_a_click_switches() hover_map(&mut h); h.get_by_label(&rustgis_engine::geom::format_lonlat(h.state().map.pointer_lonlat.unwrap())); } + +/// Moves the mouse onto the status bar, off the map, for a few frames. +fn hover_status_bar(h: &mut egui_kittest::Harness<'_, RustGisApp>) { + for _ in 0..3 { + h.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(800.0, 892.0))); + h.step(); + } +} + +fn readout_at_center(h: &mut egui_kittest::Harness<'_, RustGisApp>) -> String { + h.state_mut().run("map.goToXY", json!({"lon": 44.4, "lat": 33.3, "scale": 10000})).unwrap(); + hover_map(h); + rustgis_engine::geom::format_lonlat(h.state().map.pointer_lonlat.expect("the pointer is over the map")) +} + +#[test] +fn the_readout_is_a_dash_until_the_pointer_has_been_over_the_map() { + let mut h = harness(RustGisApp::with_sample()); + h.step(); + h.get_by_label("—"); + hover_status_bar(&mut h); + h.get_by_label("—"); + assert!(h.state().map.pointer_lonlat.is_none()); +} + +#[test] +fn the_position_stays_when_the_pointer_leaves_the_map_and_a_click_switches_format() { + let mut h = harness(RustGisApp::with_sample()); + let decimal = readout_at_center(&mut h); + hover_status_bar(&mut h); + assert!(h.state().map.pointer_lonlat.is_none()); + h.get_by_label(&decimal); + // The pointer in the window's corner, or gone altogether, keeps it too. + h.input_mut().events.push(egui::Event::PointerGone); + h.step(); + h.get_by_label(&decimal); + // A click on the readout shows the same position in the next format. + h.get_by_label(&decimal).click(); + h.step(); + h.step(); + assert_eq!(h.state().map.coord_format, CoordReadout::Dms); + let at = h.state().map.pointer_lonlat; + assert!(at.is_none()); + h.get_by_label_contains("44°"); + // The pointer back over the map follows it again. + hover_map(&mut h); + assert!(h.state().map.pointer_lonlat.is_some()); +} + +#[test] +fn the_position_is_dropped_on_another_map_or_project() { + let mut h = harness(RustGisApp::with_sample()); + let decimal = readout_at_center(&mut h); + hover_status_bar(&mut h); + h.get_by_label(&decimal); + // Another map becoming the active one. + h.state_mut().run("map.new", json!({"name": "Second Map"})).unwrap(); + h.step(); + h.step(); + assert!(h.query_by_label(&decimal).is_none()); + h.get_by_label("—"); + // Back on the first map it is not remembered from before. + h.state_mut().run("map.activate", json!({"name": "World Atlas"})).ok(); + h.step(); + h.step(); + h.get_by_label("—"); + // A new position, then another project replacing this one. + let decimal = readout_at_center(&mut h); + hover_status_bar(&mut h); + h.get_by_label(&decimal); + h.state_mut().run("project.new", json!({})).unwrap(); + h.step(); + h.step(); + assert!(h.query_by_label(&decimal).is_none()); + h.get_by_label("—"); +} diff --git a/docs/user-guide/maps.md b/docs/user-guide/maps.md index 468a25209..204720709 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -138,8 +138,11 @@ and **View In Google Earth** open the place in your browser at about the same zo ### Coordinate readouts -The status bar shows the pointer's coordinates. Click them to step through four formats; the -choice is remembered and also sets the right-click menu's first row. The same place in each: +The status bar shows the pointer's coordinates while it is over the map, and keeps the last +position (dimmed) when it leaves, so you can move to the status bar and click it. It reads `—` +until the pointer has been over the map, and again after you switch to another map or open another +project. Click the coordinates to step through four formats; the choice is remembered and also +sets the right-click menu's first row. The same place in each: | Format | Example | | | --- | --- | --- |