From 9a3be10235e62d3c4d1811aa3e33ea3f3e249d1b Mon Sep 17 00:00:00 2001 From: Ian Dolge <110300455+iandolge@users.noreply.github.com> Date: Tue, 19 May 2026 09:31:01 -0700 Subject: [PATCH 1/2] Add altitude and accuracy to LatLng structure --- android/src/toga_android/hardware/location.py | 12 +++++ core/src/toga/types.py | 44 +++++++++++++--- core/tests/test_types.py | 50 ++++++++++++++++++- 3 files changed, 99 insertions(+), 7 deletions(-) diff --git a/android/src/toga_android/hardware/location.py b/android/src/toga_android/hardware/location.py index 5d6bd81257..97ffe87df6 100644 --- a/android/src/toga_android/hardware/location.py +++ b/android/src/toga_android/hardware/location.py @@ -24,6 +24,18 @@ def toga_location(location): else: altitude = None + horizontal_accuracy: float | None = None + if location.hasAccuracy(): + horizontal_accuracy = location.getAccuracy() + + vertical_accuracy: float | None = None + if location.hasVerticalAccuracy(): + vertical_accuracy = location.getVerticalAccuracyMeters() + + latlng.altitude = altitude + latlng.horizontal_accuracy = horizontal_accuracy + latlng.vertical_accuracy = vertical_accuracy + return { "location": latlng, "altitude": altitude, diff --git a/core/src/toga/types.py b/core/src/toga/types.py index 2063184891..7dba2389cc 100644 --- a/core/src/toga/types.py +++ b/core/src/toga/types.py @@ -23,14 +23,46 @@ """ -class LatLng(NamedTuple): - """A geographic coordinate.""" +class LatLng(tuple): + """A geographic coordinate, with optional altitude and accuracy. - lat: float - """Latitude""" + LatLng can be compared with a 2-tuple of (lat, lng), the optional attributes + (altitude and accuracy values) are not considered when testing equality + """ - lng: float - """Longitude""" + altitude: float | None = None + """Altitude in meters, or None if not available.""" + + horizontal_accuracy: float | None = None + """Horizontal accuracy in meters, or None if not available.""" + + vertical_accuracy: float | None = None + """Vertical accuracy in meters, or None if not available.""" + + def __new__( + cls, + lat: float, + lng: float, + *, + altitude: float | None = None, + horizontal_accuracy: float | None = None, + vertical_accuracy: float | None = None, + ) -> LatLng: + self = super().__new__(cls, (lat, lng)) + self.altitude = altitude + self.horizontal_accuracy = horizontal_accuracy + self.vertical_accuracy = vertical_accuracy + return self + + @property + def lat(self) -> float: + """Latitude""" + return self[0] + + @property + def lng(self) -> float: + """Longitude""" + return self[1] def __str__(self) -> str: return f"({self.lat:6f}, {self.lng:6f})" diff --git a/core/tests/test_types.py b/core/tests/test_types.py index 704d2a3957..35fdc1c166 100644 --- a/core/tests/test_types.py +++ b/core/tests/test_types.py @@ -1,4 +1,52 @@ -from toga.types import Position, Size +import pytest + +from toga.types import LatLng, Position, Size + + +def test_latlng_required_properties(): + """A LatLng class has latitude and longitude values.""" + LAT = -31.95 + LNG = 115.86 + ll = LatLng(LAT, LNG) + assert ll.lat == pytest.approx(LAT) + assert ll.lng == pytest.approx(LNG) + assert str(ll) == f"({LAT:6f}, {LNG:6f})" + + assert ll == LatLng(LAT, LNG) + assert ll != LatLng(LAT, LNG + 1) + + assert ll == (LAT, LNG) # Tuple equivalence for backwards-compatibility + assert ll != (LAT + 1, LNG) + + # Optional values were not filled in + assert ll.altitude is None + assert ll.horizontal_accuracy is None + assert ll.vertical_accuracy is None + + +def test_latlng_optional_properties(): + """A LatLng class has optional values for altitude and accuracies.""" + LAT = -31.95 + LNG = 115.86 + ALT = 20.1 + HORIZONTAL_ACC = 10.2 + VERTICAL_ACC = 11.3 + bare_ll = LatLng(LAT, LNG) + ll = LatLng( + LAT, + LNG, + altitude=ALT, + horizontal_accuracy=HORIZONTAL_ACC, + vertical_accuracy=VERTICAL_ACC, + ) + assert ll.altitude == pytest.approx(ALT) + assert ll.horizontal_accuracy == pytest.approx(HORIZONTAL_ACC) + assert ll.vertical_accuracy == pytest.approx(VERTICAL_ACC) + + assert bare_ll == ll # optional values are not used for comparison + assert bare_ll.altitude != ll.altitude + assert bare_ll.horizontal_accuracy != ll.horizontal_accuracy + assert bare_ll.vertical_accuracy != ll.vertical_accuracy def test_position_properties(): From 6df9feda7acc3a2f1867f47c6409465f2fa5e09e Mon Sep 17 00:00:00 2001 From: Ian Dolge <110300455+iandolge@users.noreply.github.com> Date: Tue, 19 May 2026 11:39:43 -0700 Subject: [PATCH 2/2] implement new attributes in toga_location --- android/src/toga_android/hardware/location.py | 26 +++++++++---------- changes/3112.feature.md | 1 + cocoa/src/toga_cocoa/hardware/location.py | 15 ++++++----- core/src/toga/types.py | 7 ++--- core/tests/test_types.py | 1 + gtk/src/toga_gtk/hardware/location.py | 11 ++++++-- iOS/src/toga_iOS/hardware/location.py | 15 ++++++----- 7 files changed, 46 insertions(+), 30 deletions(-) create mode 100644 changes/3112.feature.md diff --git a/android/src/toga_android/hardware/location.py b/android/src/toga_android/hardware/location.py index 97ffe87df6..3b3a64ea5b 100644 --- a/android/src/toga_android/hardware/location.py +++ b/android/src/toga_android/hardware/location.py @@ -14,8 +14,6 @@ def toga_location(location): """Convert an Android location into a Toga LatLng and altitude.""" - latlng = LatLng(location.getLatitude(), location.getLongitude()) - # MSL altitude was added in API 34. We can't test this at runtime if Build.VERSION.SDK_INT >= 34 and location.hasMslAltitude(): # pragma: no cover altitude = location.getMslAltitudeMeters() @@ -24,17 +22,19 @@ def toga_location(location): else: altitude = None - horizontal_accuracy: float | None = None - if location.hasAccuracy(): - horizontal_accuracy = location.getAccuracy() - - vertical_accuracy: float | None = None - if location.hasVerticalAccuracy(): - vertical_accuracy = location.getVerticalAccuracyMeters() - - latlng.altitude = altitude - latlng.horizontal_accuracy = horizontal_accuracy - latlng.vertical_accuracy = vertical_accuracy + latlng = LatLng( + location.getLatitude(), + location.getLongitude(), + altitude=altitude, + horizontal_accuracy=( + location.getAccuracy() if location.hasAccuracy() else None + ), + vertical_accuracy=( + location.getVerticalAccuracyMeters() + if location.hasVerticalAccuracy() + else None + ), + ) return { "location": latlng, diff --git a/changes/3112.feature.md b/changes/3112.feature.md new file mode 100644 index 0000000000..f6b735c038 --- /dev/null +++ b/changes/3112.feature.md @@ -0,0 +1 @@ +Add altitude, horizontal accuracy and vertical accuracy to LatLng. diff --git a/cocoa/src/toga_cocoa/hardware/location.py b/cocoa/src/toga_cocoa/hardware/location.py index 74bf8bf51e..37251dd818 100644 --- a/cocoa/src/toga_cocoa/hardware/location.py +++ b/cocoa/src/toga_cocoa/hardware/location.py @@ -11,17 +11,20 @@ def toga_location(location): """Convert a Cocoa location into a Toga LatLng and altitude.""" + # A non-positive vertical accuracy indicates altitude is invalid. + altitude_valid = location.verticalAccuracy > 0.0 + altitude = location.altitude if altitude_valid else None + latlng = LatLng( location.coordinate.latitude, location.coordinate.longitude, + altitude=altitude, + horizontal_accuracy=( + location.horizontalAccuracy if location.horizontalAccuracy > 0.0 else None + ), + vertical_accuracy=location.verticalAccuracy if altitude_valid else None, ) - # A vertical accuracy that non-positive indicates altitude is invalid. - if location.verticalAccuracy > 0.0: - altitude = location.altitude - else: - altitude = None - return { "location": latlng, "altitude": altitude, diff --git a/core/src/toga/types.py b/core/src/toga/types.py index 7dba2389cc..177db1a4e2 100644 --- a/core/src/toga/types.py +++ b/core/src/toga/types.py @@ -24,10 +24,11 @@ class LatLng(tuple): - """A geographic coordinate, with optional altitude and accuracy. + """A geographic coordinate, with optional altitude and accuracy attributes. - LatLng can be compared with a 2-tuple of (lat, lng), the optional attributes - (altitude and accuracy values) are not considered when testing equality + A LatLng compares equal to a 2-tuple (lat, lng). The optional + altitude, horizontal_accuracy, and vertical_accuracy attributes + are not considered for equality or hashing. """ altitude: float | None = None diff --git a/core/tests/test_types.py b/core/tests/test_types.py index 35fdc1c166..c7d285cf12 100644 --- a/core/tests/test_types.py +++ b/core/tests/test_types.py @@ -17,6 +17,7 @@ def test_latlng_required_properties(): assert ll == (LAT, LNG) # Tuple equivalence for backwards-compatibility assert ll != (LAT + 1, LNG) + assert hash(ll) == hash((LAT, LNG)) # Optional values were not filled in assert ll.altitude is None diff --git a/gtk/src/toga_gtk/hardware/location.py b/gtk/src/toga_gtk/hardware/location.py index 3b36bbb507..0818d82997 100644 --- a/gtk/src/toga_gtk/hardware/location.py +++ b/gtk/src/toga_gtk/hardware/location.py @@ -34,8 +34,15 @@ def toga_location(location): """Convert a ``Geoclue.Location`` into ``OnLocationChangeHandler`` kwargs.""" - latlng = LatLng(location.props.latitude, location.props.longitude) - altitude = location.get_property("altitude") + altitude = location.props.altitude + latlng = LatLng( + location.props.latitude, + location.props.longitude, + altitude=altitude, + horizontal_accuracy=( + location.props.accuracy if location.props.accuracy > 0.0 else None + ), + ) return { "location": latlng, diff --git a/iOS/src/toga_iOS/hardware/location.py b/iOS/src/toga_iOS/hardware/location.py index bdf94500de..ef6d26e5e1 100644 --- a/iOS/src/toga_iOS/hardware/location.py +++ b/iOS/src/toga_iOS/hardware/location.py @@ -14,17 +14,20 @@ def toga_location(location): """Convert a Cocoa location into a Toga LatLng and altitude.""" + # A non-positive vertical accuracy indicates altitude is invalid. + altitude_valid = location.verticalAccuracy > 0.0 + altitude = location.altitude if altitude_valid else None + latlng = LatLng( location.coordinate.latitude, location.coordinate.longitude, + altitude=altitude, + horizontal_accuracy=( + location.horizontalAccuracy if location.horizontalAccuracy > 0.0 else None + ), + vertical_accuracy=location.verticalAccuracy if altitude_valid else None, ) - # A vertical accuracy that non-positive indicates altitude is invalid. - if location.verticalAccuracy > 0.0: - altitude = location.altitude - else: - altitude = None - return { "location": latlng, "altitude": altitude,