commit b412f283ef77b14441c8926bb43a85e27b309aed
parent 3f38899f5a918f16be78ecea652c4c0b85a4787a
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 12:04:00 +0000
core: make home location lookup async
- move the home offline location tool onto the polled reverse-lookup contract instead of running queries on the ui thread
- add explicit idle pending ready and failed lookup state in the home tool and repaint while a lookup is in flight
- request an explicit three-result reverse lookup and require the offline geocoder to be ready before enabling the action
- cover the new state transitions with core tests including the deferred lookup poll path
Diffstat:
2 files changed, 316 insertions(+), 58 deletions(-)
diff --git a/crates/core/src/home_location_tools.rs b/crates/core/src/home_location_tools.rs
@@ -1,21 +1,40 @@
use crate::{
RadrootsAppBackend, RadrootsLocationPoint, RadrootsLocationReverseOptions,
- RadrootsOfflineGeocoderState, RadrootsResolvedLocation,
+ RadrootsOfflineGeocoderState, RadrootsResolvedLocation, RadrootsReverseLocationLookupResult,
};
use eframe::egui;
+const HOME_LOOKUP_RESULT_LIMIT: usize = 3;
+
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct HomeLocationLookupResult {
pub queried_point: RadrootsLocationPoint,
pub matches: Vec<RadrootsResolvedLocation>,
}
+#[derive(Debug, Clone, PartialEq)]
+enum HomeLocationLookupState {
+ Idle,
+ Pending {
+ queried_point: RadrootsLocationPoint,
+ },
+ Ready(HomeLocationLookupResult),
+ Failed {
+ message: String,
+ },
+}
+
+impl Default for HomeLocationLookupState {
+ fn default() -> Self {
+ Self::Idle
+ }
+}
+
#[derive(Debug, Default, Clone, PartialEq)]
pub(crate) struct HomeLocationTools {
latitude_input: String,
longitude_input: String,
- status_message: Option<String>,
- lookup_result: Option<HomeLocationLookupResult>,
+ lookup_state: HomeLocationLookupState,
}
impl HomeLocationTools {
@@ -26,8 +45,17 @@ impl HomeLocationTools {
pub(crate) fn clear(&mut self) {
self.latitude_input.clear();
self.longitude_input.clear();
- self.status_message = None;
- self.lookup_result = None;
+ self.lookup_state = HomeLocationLookupState::Idle;
+ }
+
+ #[cfg(test)]
+ pub(crate) fn set_query_inputs(
+ &mut self,
+ latitude: impl Into<String>,
+ longitude: impl Into<String>,
+ ) {
+ self.latitude_input = latitude.into();
+ self.longitude_input = longitude.into();
}
pub(crate) fn render(
@@ -59,15 +87,15 @@ impl HomeLocationTools {
});
ui.add_space(8.0);
- let resolve_enabled = is_resolve_enabled(offline_geocoder_state);
+ let resolve_enabled = is_resolve_enabled(offline_geocoder_state) && !self.is_pending();
if ui
.add_enabled(
resolve_enabled,
- egui::Button::new("Resolve Offline Location"),
+ egui::Button::new(self.resolve_button_label()),
)
.clicked()
{
- self.resolve_with_backend(backend);
+ self.begin_resolve_with_backend(backend);
}
if let Some(helper_message) = availability_message(offline_geocoder_state) {
@@ -75,19 +103,19 @@ impl HomeLocationTools {
ui.label(helper_message);
}
- if let Some(message) = &self.status_message {
+ if let Some(message) = self.status_message() {
ui.add_space(8.0);
ui.label(message);
}
- if let Some(result) = &self.lookup_result {
+ if let Some(result) = self.lookup_result() {
ui.add_space(12.0);
ui.label(format!(
"Query: {}, {}",
format_coordinate(result.queried_point.lat),
format_coordinate(result.queried_point.lng),
));
- for resolved in result.matches.iter().take(3) {
+ for resolved in result.matches.iter().take(HOME_LOOKUP_RESULT_LIMIT) {
ui.add_space(8.0);
ui.label(resolved.name.as_str());
if let Some(admin1_name) = &resolved.admin1_name {
@@ -107,45 +135,108 @@ impl HomeLocationTools {
}
}
- fn resolve_with_backend(&mut self, backend: &dyn RadrootsAppBackend) {
- self.status_message = None;
- self.lookup_result = None;
+ pub(crate) fn begin_resolve_with_backend(&mut self, backend: &dyn RadrootsAppBackend) {
+ self.lookup_state = HomeLocationLookupState::Idle;
let query_point = match self.parse_query_point() {
Ok(point) => point,
Err(message) => {
- self.status_message = Some(message);
+ self.lookup_state = HomeLocationLookupState::Failed { message };
return;
}
};
- match backend.reverse_location(query_point, Some(RadrootsLocationReverseOptions::default()))
- {
+ let options = RadrootsLocationReverseOptions {
+ limit: HOME_LOOKUP_RESULT_LIMIT,
+ ..RadrootsLocationReverseOptions::default()
+ };
+ match backend.request_reverse_location_lookup(query_point, Some(options)) {
+ Ok(()) => {
+ self.lookup_state = HomeLocationLookupState::Pending {
+ queried_point: query_point,
+ };
+ }
+ Err(error) => {
+ self.lookup_state = HomeLocationLookupState::Failed {
+ message: error.user_message().to_owned(),
+ };
+ }
+ }
+ }
+
+ pub(crate) fn apply_reverse_lookup_result(
+ &mut self,
+ result: RadrootsReverseLocationLookupResult,
+ ) {
+ let queried_point = match self.lookup_state {
+ HomeLocationLookupState::Pending { queried_point } => queried_point,
+ HomeLocationLookupState::Idle
+ | HomeLocationLookupState::Ready(_)
+ | HomeLocationLookupState::Failed { .. } => return,
+ };
+
+ match result {
Ok(matches) if matches.is_empty() => {
- self.status_message =
- Some("No offline location matched that coordinate.".to_owned());
+ self.lookup_state = HomeLocationLookupState::Failed {
+ message: "No offline location matched that coordinate.".to_owned(),
+ };
}
Ok(matches) => {
- self.lookup_result = Some(HomeLocationLookupResult {
- queried_point: query_point,
+ self.lookup_state = HomeLocationLookupState::Ready(HomeLocationLookupResult {
+ queried_point,
matches,
});
}
Err(error) => {
- self.status_message = Some(error.user_message().to_owned());
+ self.lookup_state = HomeLocationLookupState::Failed {
+ message: error.user_message().to_owned(),
+ };
}
}
}
+ pub(crate) fn apply_reverse_lookup_poll_error(&mut self, message: String) {
+ self.lookup_state = HomeLocationLookupState::Failed { message };
+ }
+
+ pub(crate) fn is_pending(&self) -> bool {
+ matches!(self.lookup_state, HomeLocationLookupState::Pending { .. })
+ }
+
fn parse_query_point(&self) -> Result<RadrootsLocationPoint, String> {
let lat = parse_coordinate(self.latitude_input.as_str(), "latitude", -90.0, 90.0)?;
let lng = parse_coordinate(self.longitude_input.as_str(), "longitude", -180.0, 180.0)?;
Ok(RadrootsLocationPoint { lat, lng })
}
+
+ fn resolve_button_label(&self) -> &'static str {
+ if self.is_pending() {
+ "Resolving Offline Location..."
+ } else {
+ "Resolve Offline Location"
+ }
+ }
+
+ pub(crate) fn status_message(&self) -> Option<&str> {
+ match &self.lookup_state {
+ HomeLocationLookupState::Idle | HomeLocationLookupState::Ready(_) => None,
+ HomeLocationLookupState::Pending { .. } => Some("Resolving offline location..."),
+ HomeLocationLookupState::Failed { message } => Some(message.as_str()),
+ }
+ }
+
+ pub(crate) fn lookup_result(&self) -> Option<&HomeLocationLookupResult> {
+ match &self.lookup_state {
+ HomeLocationLookupState::Ready(result) => Some(result),
+ HomeLocationLookupState::Idle
+ | HomeLocationLookupState::Pending { .. }
+ | HomeLocationLookupState::Failed { .. } => None,
+ }
+ }
}
fn is_resolve_enabled(state: Option<&RadrootsOfflineGeocoderState>) -> bool {
- matches!(state, None | Some(RadrootsOfflineGeocoderState::Ready))
+ matches!(state, Some(RadrootsOfflineGeocoderState::Ready))
}
fn availability_message(state: Option<&RadrootsOfflineGeocoderState>) -> Option<&str> {
@@ -189,10 +280,20 @@ mod tests {
use crate::{
IdentityGateState, RadrootsLocationCountry, RadrootsLocationResolverError, SetupActionState,
};
+ use std::cell::RefCell;
+ use std::rc::Rc;
#[derive(Clone)]
struct ResolveBackend {
- response: Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError>,
+ start_response: Result<(), RadrootsLocationResolverError>,
+ requested: Rc<
+ RefCell<
+ Vec<(
+ RadrootsLocationPoint,
+ Option<RadrootsLocationReverseOptions>,
+ )>,
+ >,
+ >,
}
impl RadrootsAppBackend for ResolveBackend {
@@ -212,12 +313,13 @@ mod tests {
Ok(None)
}
- fn reverse_location(
+ fn request_reverse_location_lookup(
&self,
- _point: RadrootsLocationPoint,
- _options: Option<RadrootsLocationReverseOptions>,
- ) -> Result<Vec<RadrootsResolvedLocation>, RadrootsLocationResolverError> {
- self.response.clone()
+ point: RadrootsLocationPoint,
+ options: Option<RadrootsLocationReverseOptions>,
+ ) -> Result<(), RadrootsLocationResolverError> {
+ self.requested.borrow_mut().push((point, options));
+ self.start_response.clone()
}
fn list_location_countries(
@@ -239,21 +341,15 @@ mod tests {
let mut tools = HomeLocationTools::new();
tools.latitude_input = "10.5".to_owned();
tools.longitude_input = "20.5".to_owned();
- tools.status_message = Some("lookup failed".to_owned());
- tools.lookup_result = Some(HomeLocationLookupResult {
- queried_point: RadrootsLocationPoint {
- lat: 10.5,
- lng: 20.5,
- },
- matches: Vec::new(),
- });
+ tools.lookup_state = HomeLocationLookupState::Failed {
+ message: "lookup failed".to_owned(),
+ };
tools.clear();
assert_eq!(tools.latitude_input, "");
assert_eq!(tools.longitude_input, "");
- assert_eq!(tools.status_message, None);
- assert_eq!(tools.lookup_result, None);
+ assert_eq!(tools.lookup_state, HomeLocationLookupState::Idle);
}
#[test]
@@ -308,31 +404,72 @@ mod tests {
}
#[test]
- fn resolve_with_backend_stores_matches() {
+ fn begin_resolve_with_backend_starts_pending_lookup_with_three_result_limit() {
+ let requested = Rc::new(RefCell::new(Vec::new()));
let backend = ResolveBackend {
- response: Ok(vec![RadrootsResolvedLocation {
- id: 1,
- name: "Oslo".to_owned(),
- admin1_id: Some(2),
- admin1_name: Some("Oslo".to_owned()),
- country_id: "NO".to_owned(),
- country_name: Some("Norway".to_owned()),
- point: RadrootsLocationPoint {
+ start_response: Ok(()),
+ requested: requested.clone(),
+ };
+ let mut tools = HomeLocationTools::new();
+ tools.latitude_input = "59.9139".to_owned();
+ tools.longitude_input = "10.7522".to_owned();
+
+ tools.begin_resolve_with_backend(&backend);
+
+ assert_eq!(
+ tools.lookup_state,
+ HomeLocationLookupState::Pending {
+ queried_point: RadrootsLocationPoint {
lat: 59.9139,
lng: 10.7522,
},
- }]),
+ }
+ );
+ assert_eq!(requested.borrow().len(), 1);
+ assert_eq!(
+ requested.borrow()[0],
+ (
+ RadrootsLocationPoint {
+ lat: 59.9139,
+ lng: 10.7522,
+ },
+ Some(RadrootsLocationReverseOptions {
+ limit: HOME_LOOKUP_RESULT_LIMIT,
+ degree_offset: 0.5,
+ }),
+ )
+ );
+ }
+
+ #[test]
+ fn apply_reverse_lookup_result_stores_matches() {
+ let requested = Rc::new(RefCell::new(Vec::new()));
+ let backend = ResolveBackend {
+ start_response: Ok(()),
+ requested,
};
let mut tools = HomeLocationTools::new();
tools.latitude_input = "59.9139".to_owned();
tools.longitude_input = "10.7522".to_owned();
+ tools.begin_resolve_with_backend(&backend);
+
+ tools.apply_reverse_lookup_result(Ok(vec![RadrootsResolvedLocation {
+ id: 1,
+ name: "Oslo".to_owned(),
+ admin1_id: Some(2),
+ admin1_name: Some("Oslo".to_owned()),
+ country_id: "NO".to_owned(),
+ country_name: Some("Norway".to_owned()),
+ point: RadrootsLocationPoint {
+ lat: 59.9139,
+ lng: 10.7522,
+ },
+ }]));
- tools.resolve_with_backend(&backend);
-
- assert_eq!(tools.status_message, None);
+ assert_eq!(tools.status_message(), None);
assert_eq!(
tools
- .lookup_result
+ .lookup_result()
.as_ref()
.map(|result| result.matches.len()),
Some(1)
@@ -340,20 +477,43 @@ mod tests {
}
#[test]
- fn resolve_with_backend_uses_user_safe_query_error_message() {
+ fn begin_resolve_with_backend_uses_user_safe_query_error_message() {
+ let requested = Rc::new(RefCell::new(Vec::new()));
+ let backend = ResolveBackend {
+ start_response: Err(RadrootsLocationResolverError::Unavailable),
+ requested,
+ };
+ let mut tools = HomeLocationTools::new();
+ tools.latitude_input = "59.9139".to_owned();
+ tools.longitude_input = "10.7522".to_owned();
+
+ tools.begin_resolve_with_backend(&backend);
+
+ assert_eq!(
+ tools.status_message(),
+ Some("Offline location resolution is not available on this device.")
+ );
+ assert_eq!(tools.lookup_result(), None);
+ }
+
+ #[test]
+ fn apply_reverse_lookup_result_uses_user_safe_query_error_message() {
+ let requested = Rc::new(RefCell::new(Vec::new()));
let backend = ResolveBackend {
- response: Err(RadrootsLocationResolverError::Unavailable),
+ start_response: Ok(()),
+ requested,
};
let mut tools = HomeLocationTools::new();
tools.latitude_input = "59.9139".to_owned();
tools.longitude_input = "10.7522".to_owned();
+ tools.begin_resolve_with_backend(&backend);
- tools.resolve_with_backend(&backend);
+ tools.apply_reverse_lookup_result(Err(RadrootsLocationResolverError::Unavailable));
assert_eq!(
- tools.status_message.as_deref(),
+ tools.status_message(),
Some("Offline location resolution is not available on this device.")
);
- assert_eq!(tools.lookup_result, None);
+ assert_eq!(tools.lookup_result(), None);
}
}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
@@ -316,6 +316,14 @@ impl RadrootsApp {
self.status_message = Some(err);
}
}
+ match self.backend.poll_reverse_location_lookup_result() {
+ Ok(Some(result)) => self.home_location_tools.apply_reverse_lookup_result(result),
+ Ok(None) => {}
+ Err(err) => {
+ self.home_location_tools
+ .apply_reverse_lookup_poll_error(err);
+ }
+ }
match self.backend.poll_identity_state() {
Ok(Some(state)) => self.apply_identity_state(state),
Ok(None) => {}
@@ -371,6 +379,9 @@ impl eframe::App for RadrootsApp {
) {
ctx.request_repaint_after(Duration::from_millis(100));
}
+ if self.home_location_tools.is_pending() {
+ ctx.request_repaint_after(Duration::from_millis(100));
+ }
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
@@ -568,6 +579,9 @@ mod tests {
import_paste_request: Rc<RefCell<VecDeque<Result<Option<String>, String>>>>,
home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<HomeActionResult, String>)>>>,
home_poll: Rc<RefCell<VecDeque<Result<Option<HomeActionResult>, String>>>>,
+ reverse_lookup_request: Rc<RefCell<VecDeque<Result<(), RadrootsLocationResolverError>>>>,
+ reverse_lookup_poll:
+ Rc<RefCell<VecDeque<Result<Option<RadrootsReverseLocationLookupResult>, String>>>>,
poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
}
@@ -591,6 +605,8 @@ mod tests {
import_paste_request: Rc::new(RefCell::new(VecDeque::new())),
home_request: Rc::new(RefCell::new(VecDeque::new())),
home_poll: Rc::new(RefCell::new(VecDeque::new())),
+ reverse_lookup_request: Rc::new(RefCell::new(VecDeque::new())),
+ reverse_lookup_poll: Rc::new(RefCell::new(VecDeque::new())),
poll: Rc::new(RefCell::new(poll.into())),
}
}
@@ -648,6 +664,16 @@ mod tests {
self.home_poll.borrow_mut().extend(poll);
self
}
+
+ fn with_reverse_lookup(
+ self,
+ request: Vec<Result<(), RadrootsLocationResolverError>>,
+ poll: Vec<Result<Option<RadrootsReverseLocationLookupResult>, String>>,
+ ) -> Self {
+ self.reverse_lookup_request.borrow_mut().extend(request);
+ self.reverse_lookup_poll.borrow_mut().extend(poll);
+ self
+ }
}
impl RadrootsAppBackend for MockBackend {
@@ -726,6 +752,26 @@ mod tests {
self.home_poll.borrow_mut().pop_front().unwrap_or(Ok(None))
}
+ fn request_reverse_location_lookup(
+ &self,
+ _point: RadrootsLocationPoint,
+ _options: Option<RadrootsLocationReverseOptions>,
+ ) -> Result<(), RadrootsLocationResolverError> {
+ self.reverse_lookup_request
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or(Err(RadrootsLocationResolverError::Unsupported))
+ }
+
+ fn poll_reverse_location_lookup_result(
+ &self,
+ ) -> Result<Option<RadrootsReverseLocationLookupResult>, String> {
+ self.reverse_lookup_poll
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or(Ok(None))
+ }
+
fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
self.poll.borrow_mut().pop_front().unwrap_or(Ok(None))
}
@@ -1157,6 +1203,58 @@ mod tests {
}
#[test]
+ fn deferred_home_location_lookup_updates_after_poll() {
+ let mut app = RadrootsApp::new(Box::new(
+ MockBackend::new(
+ Ok(IdentityGateState::Ready {
+ account_id: "abc".into(),
+ npub: "npub1abc".into(),
+ }),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )
+ .with_offline_geocoder_state(RadrootsOfflineGeocoderState::Ready, vec![])
+ .with_reverse_lookup(
+ vec![Ok(())],
+ vec![Ok(Some(Ok(vec![RadrootsResolvedLocation {
+ id: 7,
+ name: "Paris".into(),
+ admin1_id: Some(11),
+ admin1_name: Some("Ile-de-France".into()),
+ country_id: "FR".into(),
+ country_name: Some("France".into()),
+ point: RadrootsLocationPoint {
+ lat: 48.8566,
+ lng: 2.3522,
+ },
+ }])))],
+ ),
+ ));
+
+ app.home_location_tools
+ .set_query_inputs("48.8566", "2.3522");
+ app.home_location_tools
+ .begin_resolve_with_backend(app.backend.as_ref());
+ assert!(app.home_location_tools.is_pending());
+
+ app.sync_backend();
+
+ assert_eq!(app.home_location_tools.status_message(), None);
+ assert_eq!(
+ app.home_location_tools
+ .lookup_result()
+ .as_ref()
+ .map(|result| result.matches[0].name.as_str()),
+ Some("Paris")
+ );
+ }
+
+ #[test]
fn startup_uses_initial_offline_geocoder_state() {
let app = RadrootsApp::new(Box::new(
MockBackend::new(