diff --git a/docs/mesh_pocket_gui.md b/docs/mesh_pocket_gui.md index 9878c12d7..3a70b933d 100644 --- a/docs/mesh_pocket_gui.md +++ b/docs/mesh_pocket_gui.md @@ -37,10 +37,10 @@ saved timeouts are retained; set `display.timeout` and `display.usb.timeout` to `900` to use 15 minutes. The return-to-board delay is a separate setting. The map centres on this node's location, or the first contact with a location. -Auto zoom shows a world overview when no locations are available, or a 200 km -view when a location is available. Selected zoom levels also work without -nodes; the map then centres on latitude 0, longitude 0 until a location is -available. Contacts appear as dots, with nearby names at closer zooms. +With no locations it shows a world overview, keeping the selected zoom saved +for when a location becomes available. Zoom controls require a location. +Auto zoom uses a 200 km view when a location is available. Contacts appear as +dots, with nearby names at closer zooms. The view spans 20,000 km down to 5 km vertically; coastline detail stays coarse at close zooms. It contains no streets, terrain, regional downloads or ocean tiles. See the [map data notes](https://github.com/mikecarper/MeshCore/blob/keymindCascade/tools/meshpocket_map/README.md) diff --git a/examples/companion_radio/ui-new/PocketGui.h b/examples/companion_radio/ui-new/PocketGui.h index b8be8bb9f..e35aabc79 100644 --- a/examples/companion_radio/ui-new/PocketGui.h +++ b/examples/companion_radio/ui-new/PocketGui.h @@ -368,12 +368,12 @@ public: normal(); footer(); } - void renderMapChrome(unsigned actual_height_km = 0) { + void renderMapChrome(unsigned actual_height_km = 0, const char* hint = nullptr) { char range[24]; snprintf(range, sizeof(range), "%u km", actual_height_km != 0 ? actual_height_km : (unsigned)mapHeightKm(_settings)); header("Map", range); - footer(); + footer(hint); } void mapEmpty(const char* message) { font(true); textCentre(_width / 2, 54, message); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4ee3d8159..c7d45d803 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -705,7 +705,12 @@ class HomeScreen : public UIScreen { if (pocketLocation(latitude, longitude)) { located = true; break; } } } + if (_pocket_map_located != located) displayForceClean(); _pocket_map_located = located; + if (!located && _pocket_zooming) { + _pocket_zooming = false; + if (_pocket_dirty) flushPocketSettings(); + } MapView view = {located ? latitude : 0, located ? longitude : 0, mapHeightKm(settings, located), _pocket.width(), _pocket.bodyBottom() - _pocket.bodyTop()}; @@ -740,7 +745,9 @@ class HomeScreen : public UIScreen { else snprintf(summary, sizeof(summary), "%sno node locations", view.height_km == 20000 ? "World / " : ""); _pocket.label(3, _pocket.bodyTop() + 9, summary); - _pocket.renderMapChrome((unsigned)view.height_km); + _pocket.renderMapChrome((unsigned)view.height_km, located + ? (_pocket_zooming ? "1x zoom in 2x out hold done" : "click next 2x back hold zoom") + : "click next 2x back"); } int renderPocket(DisplayDriver& display) { @@ -1676,6 +1683,10 @@ public: } if (_page == HomePage::MAP) { if (key == KEY_ENTER) { + if (!_pocket_map_located) { + _task->showAlert("Set location in app to zoom", 1500); + return true; + } _pocket_zooming = !_pocket_zooming; if (!_pocket_zooming) flushPocketSettings(); return true; diff --git a/src/helpers/ui/PocketGuiSettings.h b/src/helpers/ui/PocketGuiSettings.h index 4f1be06f5..2f3e8d08b 100644 --- a/src/helpers/ui/PocketGuiSettings.h +++ b/src/helpers/ui/PocketGuiSettings.h @@ -96,7 +96,9 @@ inline unsigned nameCharacters(const Settings& value) { } inline uint8_t mapZoomIndex(const Settings& value, bool located = true) { - return value.map_zoom < MAP_ZOOM_AUTO ? value.map_zoom : (located ? 3 : 0); + // A local range has no useful center until a node location is available. + if (!located) return 0; + return value.map_zoom < MAP_ZOOM_AUTO ? value.map_zoom : 3; } inline double mapHeightKm(const Settings& value, bool located = true) { @@ -105,6 +107,7 @@ inline double mapHeightKm(const Settings& value, bool located = true) { } inline bool zoomMap(Settings& value, bool closer, bool located) { + if (!located) return false; const uint8_t index = mapZoomIndex(value, located); if ((closer && index == 8) || (!closer && index == 0)) return false; value.map_zoom = closer ? index + 1 : index - 1; diff --git a/test/fixtures/pocket_gui/test_pocket_controls.cpp b/test/fixtures/pocket_gui/test_pocket_controls.cpp index 04eadeff6..4dbc1c5f4 100644 --- a/test/fixtures/pocket_gui/test_pocket_controls.cpp +++ b/test/fixtures/pocket_gui/test_pocket_controls.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #define MAX_GROUP_CHANNELS 40 @@ -10,10 +11,14 @@ uint32_t millis() { return clock_millis; } struct Task { mesh::ui::pocket::Settings settings; int reader_opens = 0; + int alerts = 0, alert_millis = 0; + const char* alert_text = nullptr; mesh::ui::pocket::Settings& pocketSettings() { return settings; } unsigned pocketBoardCount() const { return 3; } void showReader() { ++reader_opens; } - void showAlert(const char*, int) {} + void showAlert(const char* text, int duration) { + ++alerts; alert_text = text; alert_millis = duration; + } }; struct Mesh { bool dual = true; @@ -53,18 +58,18 @@ static void key(HomeScreen& home, uint8_t value) { } static void mapZoomControls() { - for (bool located : {false, true}) { + { Task task; HomeScreen home(task); home._page = HomeScreen::MAP; - home._pocket_map_located = located; + home._pocket_map_located = true; key(home, KEY_ENTER); assert(home._pocket_zooming && task.settings.map_zoom == 9); key(home, KEY_NEXT); - assert(task.settings.map_zoom == (located ? 4 : 1)); + assert(task.settings.map_zoom == 4); assert(home._pocket_dirty && home.clean_count == 1); key(home, KEY_PREV); - assert(task.settings.map_zoom == (located ? 3 : 0)); + assert(task.settings.map_zoom == 3); assert(home.clean_count == 2); key(home, KEY_ENTER); assert(!home._pocket_zooming && !home._pocket_dirty); @@ -90,12 +95,25 @@ static void mapZoomControls() { } } - Task task; - HomeScreen home(task); - home._page = HomeScreen::MAP; - key(home, KEY_ENTER); - key(home, KEY_PREV); - assert(task.settings.map_zoom == 9 && !home._pocket_dirty && home.clean_count == 0); + // A hold with no location explains why zoom is unavailable. Subsequent + // clicks still navigate pages rather than becoming invisible zoom changes. + for (unsigned zoom = 0; zoom < 10; ++zoom) { + Task task; + task.settings.map_zoom = zoom; + HomeScreen home(task); + home._page = HomeScreen::MAP; + key(home, KEY_ENTER); + assert(!home._pocket_zooming && task.settings.map_zoom == zoom); + assert(!home._pocket_dirty && home.clean_count == 0 && home.save_count == 0); + assert(task.alerts == 1 && task.alert_millis == 1500); + assert(std::strcmp(task.alert_text, "Set location in app to zoom") == 0); + key(home, KEY_NEXT); + assert(home._page == HomeScreen::STATUS && task.settings.map_zoom == zoom); + key(home, KEY_PREV); + assert(home._page == HomeScreen::MAP); + key(home, KEY_PREV); + assert(home._page == HomeScreen::FIRST && task.settings.map_zoom == zoom); + } } int main() { @@ -117,12 +135,13 @@ int main() { assert(home.clean_count == 0); key(home, KEY_NEXT); assert(home._page == HomeScreen::MAP && home.clean_count == 1); + home._pocket_map_located = true; key(home, KEY_ENTER); assert(home._pocket_zooming); key(home, KEY_NEXT); - assert(task.settings.map_zoom == 1 && home._pocket_dirty); + assert(task.settings.map_zoom == 4 && home._pocket_dirty); key(home, KEY_PREV); - assert(task.settings.map_zoom == 0); + assert(task.settings.map_zoom == 3); key(home, KEY_ENTER); assert(!home._pocket_zooming && !home._pocket_dirty); key(home, KEY_NEXT); diff --git a/test/fixtures/pocket_gui/test_pocket_map_binding.cpp b/test/fixtures/pocket_gui/test_pocket_map_binding.cpp index 413bc5ae7..e8a26e677 100644 --- a/test/fixtures/pocket_gui/test_pocket_map_binding.cpp +++ b/test/fixtures/pocket_gui/test_pocket_map_binding.cpp @@ -54,6 +54,8 @@ bool projectWorldPoint(const MapView& view, double, double, int& x, int& y) { class PocketGui { public: std::vector labels; + std::string hint; + Settings _settings; unsigned markers = 0, own_markers = 0, range = 0; int width() const { return 250; } int bodyTop() const { return 20; } @@ -66,7 +68,15 @@ public: ++markers; if (own) ++own_markers; } - void renderMapChrome(unsigned height) { range = height; } + void header(const char* title, const char* range_label) { + assert(std::strcmp(title, "Map") == 0); + assert(std::sscanf(range_label, "%u km", &range) == 1); + } + void footer(const char* footer_hint) { + assert(footer_hint != nullptr); + hint = footer_hint; + } + // @MAP_CHROME_IMPLEMENTATION@ }; }}} @@ -81,7 +91,11 @@ public: Task* _task; mesh::ui::pocket::PocketGui _pocket; bool _pocket_map_located = false; + bool _pocket_zooming = false, _pocket_dirty = false; + unsigned save_count = 0, clean_count = 0; explicit HomeScreen(Task& task) : _task(&task) {} + bool flushPocketSettings() { ++save_count; _pocket_dirty = false; return true; } + void displayForceClean() { ++clean_count; } // @MAP_IMPLEMENTATION@ }; @@ -93,6 +107,9 @@ static void checkView(const HomeScreen& home, double latitude, double longitude, assert(rendered_view.height_km == height && home._pocket.range == height); assert(rendered_view.width == 250 && rendered_view.height == 82); assert(home._pocket_map_located == located); + assert(home._pocket.hint == (!located ? "click next 2x back" + : home._pocket_zooming ? "1x zoom in 2x out hold done" + : "click next 2x back hold zoom")); } int main() { @@ -102,54 +119,77 @@ int main() { home.renderPocketMap(); assert(renders == 1 && home._pocket.markers == 0); checkView(home, 0, 0, 20000, false); + assert(home.clean_count == 0); assert(home._pocket.labels.back() == "World / no node locations"); - // Executing the actual page binding is essential: valid zoom settings used - // to be discarded here whenever no contact or self location was available. - const unsigned heights[] = {20000, 5000, 1000, 200, 100, 50, 20, 10, 5}; + // Even a saved 5 km preference must show the complete world without a + // location. Rendering the fallback must not overwrite that preference. the_mesh.contacts = {{0, 0, "Unset"}, {91000000, 1000000, "Invalid"}}; - for (unsigned zoom = 0; zoom < 9; ++zoom) { + for (unsigned zoom = 0; zoom < 10; ++zoom) { task.settings.map_zoom = zoom; home.renderPocketMap(); - checkView(home, 0, 0, heights[zoom], false); + checkView(home, 0, 0, 20000, false); + assert(task.settings.map_zoom == zoom); assert(home._pocket.markers == 0); - assert(home._pocket.labels.back() == (zoom == 0 - ? "World / no node locations" : "no node locations")); + assert(home._pocket.labels.back() == "World / no node locations"); + assert(home.clean_count == 0); } - task.settings = Settings{}; - assert(zoomMap(task.settings, true, home._pocket_map_located)); - home.renderPocketMap(); - checkView(home, 0, 0, 5000, false); - assert(zoomMap(task.settings, false, home._pocket_map_located)); - home.renderPocketMap(); - checkView(home, 0, 0, 20000, false); - - // Auto uses the real local center when it is known and returns to the - // overview if that location disappears. It never invents a node at 0,0. - task.settings = Settings{}; + // The first frame with an own location resumes 5 km and immediately shows + // the zoom hint, even though the previous frame had no location. + task.settings.map_zoom = 8; the_mesh.contacts.clear(); sensors = {47.5, -122.25}; home.renderPocketMap(); - checkView(home, 47.5, -122.25, 200, true); + checkView(home, 47.5, -122.25, 5, true); assert(home._pocket.own_markers == 1); + assert(home.clean_count == 1); + + home._pocket_zooming = home._pocket_dirty = true; + home.renderPocketMap(); + checkView(home, 47.5, -122.25, 5, true); + assert(home._pocket_zooming && home._pocket_dirty && home.save_count == 0); + assert(home.clean_count == 1); + + // Losing the location while zooming saves the pending preference once and + // releases the controls for page navigation. The footer changes this frame. sensors = {0, 0}; home.renderPocketMap(); checkView(home, 0, 0, 20000, false); - assert(home._pocket.own_markers == 1); + assert(task.settings.map_zoom == 8 && !home._pocket_zooming && !home._pocket_dirty); + assert(home.save_count == 1); + assert(home.clean_count == 2); + home.renderPocketMap(); + assert(home.save_count == 1 && home.clean_count == 2); + home._pocket_zooming = true; + home.renderPocketMap(); + assert(!home._pocket_zooming && home.save_count == 1 && home.clean_count == 2); // With no own position, the first valid contact supplies the center; later - // contacts do not move it. Invalid or unset contact locations are skipped. + // contacts do not move it. The saved 5 km range resumes for this case too. + home._pocket.markers = home._pocket.own_markers = 0; sensors = {std::numeric_limits::quiet_NaN(), 999}; the_mesh.contacts = {{0, 0, "Unset"}, {91000000, 1000000, "Invalid"}, {37500000, 139100000, "First"}, {48000000, -120000000, "Later"}}; home.renderPocketMap(); - checkView(home, 37.5, 139.1, 200, true); - assert(home._pocket.markers == 3 && home._pocket.own_markers == 1); + checkView(home, 37.5, 139.1, 5, true); + assert(home._pocket.markers == 2 && home._pocket.own_markers == 0); + assert(home.clean_count == 3); sensors = {47.5, -122.25}; home.renderPocketMap(); + checkView(home, 47.5, -122.25, 5, true); + assert(home._pocket.own_markers == 1); + assert(home.clean_count == 3); + + // Auto still chooses a 200 km local view and the world fallback. + task.settings = Settings{}; + home.renderPocketMap(); checkView(home, 47.5, -122.25, 200, true); - assert(home._pocket.own_markers == 2); + sensors = {0, 0}; + the_mesh.contacts.clear(); + home.renderPocketMap(); + checkView(home, 0, 0, 20000, false); + assert(home.clean_count == 4); } diff --git a/test/fixtures/pocket_gui/test_pocket_state.cpp b/test/fixtures/pocket_gui/test_pocket_state.cpp index ff95167c8..7a4a16a23 100644 --- a/test/fixtures/pocket_gui/test_pocket_state.cpp +++ b/test/fixtures/pocket_gui/test_pocket_state.cpp @@ -97,7 +97,8 @@ static void settingsRoundTrip() { assert(decodeSettings(migrated, bytes, sizeof(bytes))); if (version == 1) old.refresh = 3; assert(equal(migrated, old)); - assert(mapZoomIndex(migrated, false) == zoom); + assert(mapZoomIndex(migrated, true) == zoom); + assert(mapZoomIndex(migrated, false) == 0); } } // Auto was not a valid choice in these formats, even with a valid CRC. @@ -151,17 +152,9 @@ static void mapZoomBehavior() { Settings value; assert(mapZoomIndex(value, true) == 3 && mapZoomIndex(value, false) == 0); - // With no position, Auto starts at the world overview. The first click - // must select the adjacent range rather than skip to the 100 km setting. - assert(zoomMap(value, true, false)); - assert(value.map_zoom == 1 && mapHeightKm(value, false) == 5000); - assert(zoomMap(value, false, false)); - assert(value.map_zoom == 0 && mapHeightKm(value, false) == 20000); - const Settings world = value; - assert(!zoomMap(value, false, false) && equal(value, world)); - - value = Settings{}; + // Without a location, show the world and leave the zoom preference alone. const Settings automatic = value; + assert(!zoomMap(value, true, false) && equal(value, automatic)); assert(!zoomMap(value, false, false) && equal(value, automatic)); assert(zoomMap(value, true, true)); assert(value.map_zoom == 4 && mapHeightKm(value, true) == 100); @@ -169,18 +162,19 @@ static void mapZoomBehavior() { assert(zoomMap(value, false, true)); assert(value.map_zoom == 2 && mapHeightKm(value, true) == 1000); - // Manual selections behave the same before and after a location arrives. + // Every manual selection resumes when a location arrives. Falling back to + // the overview must not replace a saved close range with the world range. for (unsigned zoom = 0; zoom < 9; ++zoom) { value.map_zoom = zoom; - for (bool located : {false, true}) { - assert(mapZoomIndex(value, located) == zoom); - assert(mapHeightKm(value, located) == heights[zoom]); - Settings closer = value, farther = value; - assert(zoomMap(closer, true, located) == (zoom < 8)); - assert(closer.map_zoom == (zoom < 8 ? zoom + 1 : 8)); - assert(zoomMap(farther, false, located) == (zoom > 0)); - assert(farther.map_zoom == (zoom > 0 ? zoom - 1 : 0)); - } + assert(mapZoomIndex(value, false) == 0 && mapHeightKm(value, false) == 20000); + Settings closer = value, farther = value; + assert(!zoomMap(closer, true, false) && equal(closer, value)); + assert(!zoomMap(farther, false, false) && equal(farther, value)); + assert(mapZoomIndex(value, true) == zoom && mapHeightKm(value, true) == heights[zoom]); + assert(zoomMap(closer, true, true) == (zoom < 8)); + assert(closer.map_zoom == (zoom < 8 ? zoom + 1 : 8)); + assert(zoomMap(farther, false, true) == (zoom > 0)); + assert(farther.map_zoom == (zoom > 0 ? zoom - 1 : 0)); } value.map_zoom = 8; diff --git a/test/test_pocket_gui.py b/test/test_pocket_gui.py index 50c237b01..30c3b00d3 100644 --- a/test/test_pocket_gui.py +++ b/test/test_pocket_gui.py @@ -93,13 +93,16 @@ class PocketGuiTest(unittest.TestCase): "-DCOMPANION_FEATURE_READER=1", "-DUI_NO_DISCOVER_SCREEN=1", "-Wno-unused-parameter"]) - def test_map_page_honors_zoom_without_node_locations(self): + def test_map_page_shows_world_without_locations_and_preserves_zoom(self): source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text() implementation = "\n".join(extract_braced(source, signature) for signature in ( "static bool pocketLocation(", "__attribute__((noinline)) void renderPocketMap() {")) fixture = (FIXTURE / "test_pocket_map_binding.cpp").read_text() fixture = fixture.replace("// @MAP_IMPLEMENTATION@", implementation) + gui = (ROOT / "examples/companion_radio/ui-new/PocketGui.h").read_text() + fixture = fixture.replace("// @MAP_CHROME_IMPLEMENTATION@", + extract_braced(gui, "void renderMapChrome(")) with tempfile.TemporaryDirectory(prefix="mesh-pocket-map-binding-") as directory: generated = Path(directory) / "map.cpp" generated.write_text(fixture)