refactor(ui): reorganize Tools › Trail (popup, map, waypoints)

Tidies the Trail screen end to end, with no functional change beyond the
intended UX cleanups. Squashed from refactor/trail-screen.

Popup:
- Two-level action menu (Hold Enter): short main menu (Start/Stop, Mark
  here, Waypoints…, Trail file…, Settings…) plus "Trail file…" and
  "Settings…" submenus, replacing one flat ~12-item list that mixed
  settings and actions. One interaction pattern per level; Reset lives in
  Trail file…, away from a stray Enter. Settings submenu is view-aware
  (Grid only on Map, Readout only on Summary).

Map:
- MapProjection (geo→pixel) built once and shared by the map, grid and
  marker drawing — removes the duplicated projection math and renderGrid's
  11 scalar params.
- renderMap split into computeBounds() and drawMarkers() helpers.

Grid:
- Square cells fitted to the frame: the shorter side is divided into a
  whole number of equal cells (grid touches that pair of borders), and the
  longer side centres the whole cells that fit — square, symmetric, no
  one-sided drift. Drawn as one dot per intersection (legible on OLED).

Waypoints:
- Extracted the whole waypoint management UI (list / navigate / add-by-
  coords / mark / rename / delete / send) into a self-contained
  WaypointsView component that TrailScreen owns and delegates to.
- Dropped the redundant bulk "Clear all" (per-waypoint Delete covers it).

Docs updated (tools_screen.md) + design rationale (docs/design/
trail_redesign.md). Builds clean on solo envs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekZegare4
2026-06-15 00:05:49 +02:00
parent bcf8774962
commit 38e9273ec7
4 changed files with 797 additions and 524 deletions

View File

@@ -0,0 +1,186 @@
# Trail (Tools Trail) — analiza i propozycja uporządkowania
> Branch: `refactor/trail-screen`
> Plik źródłowy: [TrailScreen.h](../../examples/companion_radio/ui-new/TrailScreen.h) (1065 linii)
> Status: **propozycja** (do akceptacji przed implementacją)
---
## 1. Jak ekran jest zbudowany dzisiaj
```
TrailScreen
├── 3 widoki (LEFT/RIGHT): Summary · Map · List
├── popup akcji (Hold Enter) — JEDNA płaska lista, do 12 pozycji
│ ├── ustawienia (LEFT/RIGHT cykluje w miejscu): Min dist · Readout · Grid
│ ├── toggle: Start/Stop tracking
│ ├── waypointy: Mark here · Waypoints · Clear waypoints
│ └── trail: Save · Load · Export(live) · Export(saved) · Reset
├── pod-ekrany waypointów (nakładane na widoki):
│ ├── WP_LIST (lista + dystanse; Trail-start + „+ Add by coords")
│ ├── WP_NAV (navview)
│ ├── WP_ADD (formularz lat/lon/label)
│ └── _wp_ctx popup: Rename · Delete · Send
└── KeyboardWidget (label / lat / lon) — nakładka pełnoekranowa
```
Renderowanie mapy: `renderMap()` (≈140 linii) + `renderGrid()` (≈130 linii)
+ 7 funkcji rysujących markery.
---
## 2. Co jest nieuporządkowane
### 2.1 Popup akcji to jedna płaska lista 12 pozycji z mieszanymi rolami
`openActionMenu()` (`TrailScreen.h:363`) buduje jedno menu, które miesza
**cztery różne klasy** pozycji:
| Klasa | Pozycje | Interakcja |
| ---------------- | ----------------------------------------- | --------------------- |
| Ustawienia | Min dist, Readout, Grid | LEFT/RIGHT (w miejscu) |
| Stan nagrywania | Start/Stop tracking | Enter |
| Waypointy | Mark here, Waypoints, Clear waypoints | Enter |
| Plik trasy | Save, Load, Export(live), Export(saved), Reset | Enter |
Problemy:
- **Długi scroll** — na OLED widać ~4 wiersze naraz, więc do „Reset trail"
trzeba przewinąć przez całą listę;
- **Dwa wzorce interakcji w jednym menu** — część pozycji reaguje na
LEFT/RIGHT (ustawienia), część na Enter (akcje). Enter na wierszu
ustawień nic sensownego nie robi — tylko zamyka i otwiera menu od nowa
(`reopenAt`, `TrailScreen.h:581`);
- **Brak kontekstu widoku** — `Grid` (dotyczy tylko mapy) i `Readout`
(dotyczy tylko Summary) są widoczne zawsze, też tam, gdzie nie mają
efektu;
- **`Grid` ma dwie ścieżki** — i LEFT/RIGHT (`:221`) i Enter (`:234`)
robią to samo; lekko myli.
### 2.2 `renderGrid` dostaje 11 skalarnych parametrów — brak wspólnej projekcji
`renderMap` liczy projekcję (lokalne lambdy `projectLL`/`project`,
`:816`), a `renderGrid` (`:876`) dostaje **11 osobnych liczb**
(`area_*`, `min/max_lat`, `min_lon`, `lon_scale_geo`, `scale`, `off_*`) i
**powtarza tę samą matematykę projekcji** ręcznie w pętli (`:988`,
`:992`). To samo równanie żyje w trzech miejscach. Każda zmiana modelu
mapy wymaga edycji w kilku miejscach naraz.
### 2.3 Wybór kroku siatki — wielostopniowa heurystyka z nieaktualnym komentarzem
`renderGrid` wybiera krok siatki w **czterech** następujących po sobie
korektach (`:912``:952`):
1. największy krok ≤ `target_m`,
2. zwiększaj aż odstęp pikseli ≥ `MIN_GRID_PX` (22 px),
3. zmniejszaj aż zmieszczą się ≥2 interwały,
4. zwiększaj aż liczba linii ≤ `MAX_GRID_LINES` (40).
Uwagi:
- Komentarz przy kroku 4 (`:937`) mówi o „static buffers (40×40 = ~1600
intersections)" — **takich buforów już nie ma**; pętla rysuje na bieżąco
z `continue`-guardami (`:986``1002`). Cap 40 ogranicza dziś tylko
liczbę iteracji pętli (wydajność), nie chroni żadnego bufora. Komentarz
wprowadza w błąd.
- Kroki 2 i 3 mogą sobie przeczyć na bardzo małych ekranach
(`MIN_GRID_PX = 22` vs `shorter_px/2`, gdy `shorter_px < 44`).
Nie powoduje błędu, ale „ostateczny" krok bywa wtedy przypadkowy.
### 2.4 Drobne
- `_act_map[16]` z komentarzem „12 used today; pad" — ręczne pilnowanie
rozmiaru; `pushAction` już to zabezpiecza, więc magiczna 16 jest zbędna.
- Bounding-box mapy i markery mają sporo powtarzalnego clamp-to-edge
(`:852`, `:1011`).
---
## 3. Propozycja uporządkowania
### 3.1 Popup: dwa poziomy zamiast jednej płaskiej listy
Górne menu krótkie (akcje), ustawienia i operacje na pliku w podmenu:
```
Hold Enter → Trail
• Start / Stop tracking
• Mark here
• Waypoints… → istniejący WP_LIST
• Trail file… → Save / Load / Export (live) / Export (saved) / Reset
• Settings… → Min dist · Readout · Grid (LEFT/RIGHT w miejscu)
```
Korzyści:
- górne menu to **~5 pozycji, bez scrolla** na OLED;
- **jeden wzorzec na poziom**: górny i „Trail file" = Enter-akcje;
„Settings" = wartości cyklowane LEFT/RIGHT — bez mieszania w jednym
widoku;
- destrukcyjny `Reset` przeniesiony do „Trail file…", dalej od przypadkowego
Entera;
- (opcjonalnie) `Grid` pokazywać tylko gdy aktywny jest widok Map, a
`Readout` tylko przy Summary — menu zależne od kontekstu widoku.
> Wariant minimalny (mniej kodu): zostać przy jednej liście, ale
> **pogrupować** (ustawienia → akcje → plik), `Reset` na sam dół, usunąć
> podwójną ścieżkę `Grid`. Mniej porządku niż podmenu, ale tańsze.
### 3.2 Mapa: wspólny obiekt projekcji
Wydzielić mały `MapProjection` liczony raz w `renderMap` i przekazywany
do `renderGrid` oraz markerów:
```cpp
struct MapProjection {
int32_t min_lat, max_lat, min_lon;
float lon_scale_geo, scale;
int off_x, off_y, area_x, area_y, area_w, area_h;
void project(int32_t lat, int32_t lon, int& px, int& py) const;
};
```
- `renderGrid(display, proj)` zamiast 11 parametrów;
- jedno równanie projekcji (dziś powielone 3×);
- markery/waypointy też przez `proj.project(...)`.
Czysto refaktoryzacyjne — bez zmiany wyglądu mapy.
### 3.3 Siatka: uproszczenie i naprawa komentarza
- poprawić/skasować komentarz o „static buffers" (już nieaktualny);
- scalić wybór kroku w jedną pętlę „znajdź najmniejszy krok, który daje
odstęp ≥ MIN_GRID_PX i ≤ MAX_GRID_LINES linii" zamiast czterech
następujących korekt;
- bbox etykiety/strzałki północy liczyć z jednej funkcji pomocniczej.
Wynik wizualnie identyczny, logika krótsza i łatwiejsza do utrzymania.
---
## 4. Proponowany zakres (etapami)
| Etap | Zmiana | Ryzyko |
| ---- | ----------------------------------------------------------------- | :----: |
| 1 | Popup → dwa poziomy (Trail file…, Settings…); Reset głębiej; usuń podwójny Grid | niskie |
| 2 | Wydziel `MapProjection`; `renderGrid` i markery przez projekcję | średnie (czysty refactor) |
| 3 | Uprość wybór kroku siatki; popraw nieaktualne komentarze; sprzątnij `_act_map` | niskie |
Etap 1 = największa poprawa „uporządkowania popupu" (główna prośba).
Etapy 23 = czyszczenie logiki mapy/siatki bez zmiany wyglądu.
---
## 5. Decyzje (zatwierdzone 2026-06-14)
1. **Popup — pełne podmenu.** Górne menu krótkie; `Trail file…` i
`Settings…` jako podmenu.
2. **Menu zależne od widoku — tak.** `Grid` widoczny w Settings tylko na
widoku Map, `Readout` tylko na Summary.
3. **Mapa — `MapProjection` + uproszczenie siatki** (etap 2 i 3 razem).
4. **Siatka — kwadratowe oczka, dociągnięte do krótszego boku.** Krok w
przestrzeni pikseli (skala izotropiczna). Krótszy bok dzielony na
całkowitą liczbę równych kwadratów (siatka dotyka tej pary krawędzi —
na poziomym OLED: góra/dół), na dłuższym boku mieści się całkowita
liczba tych samych kwadratów, wyśrodkowana. Dzięki temu oczka są
**kwadratowe**, siatka wpisana w ramkę i symetryczna (brak
jednostronnego przesunięcia). Etykieta skali = **nominalna** okrągła
wartość. (Kwadraty + linie na wszystkich 4 krawędziach są
geometrycznie niemożliwe dla dowolnego prostokąta — wybrano kwadraty +
2 krawędzie + symetria.)

View File

@@ -77,31 +77,44 @@ Cycle views with **LEFT / RIGHT**:
| View | Content |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Summary** | Distance, elapsed time, avg speed or pace, point count, tracking status |
| **Map** | Auto-fit dot-and-line plot with cos(lat) aspect correction; segment breaks marked; north arrow; scale grid (toggle with **LEFT/RIGHT** on the Grid row in the menu). Your **current GPS position** and all **waypoints** are always drawn — even with no trail recording — so the map is useful standalone |
| **Map** | Auto-fit dot-and-line plot with cos(lat) aspect correction; segment breaks marked; north arrow; square scale grid fitted to the map frame (toggle under **Hold Enter → Settings → Grid**, Map view only). Your **current GPS position** and all **waypoints** are always drawn — even with no trail recording — so the map is useful standalone |
| **List** | Per-point rows showing local time (HH:MM) and delta distance from the previous point; segment-start rows show `start`; scroll with **UP/DOWN** |
| OLED | E-Ink |
| :-----------------------: | :-----------------------: |
| ![](./tls_scr_5_oled.png) | ![](./tls_scr_5_eink.png) |
**Hold Enter** opens the action menu:
**Hold Enter** opens the **action menu**. It is two-level — a short main menu, plus **Trail file…** and **Settings…** submenus. **Cancel/Back** in a submenu returns to the main menu.
| Item | Interaction | Action |
| --------------------- | ------------------- | --------------------------------------------------- |
| Min dist | LEFT/RIGHT | Sample gate, 4 levels — metric: 5/10/25/100 m, imperial: 15/30/75/300 ft |
| Readout | LEFT/RIGHT or Enter | Summary shows Speed or Pace (in the global unit system) |
| Grid | LEFT/RIGHT or Enter | Toggle scale grid on the map |
| Mark here | Enter | Drop a waypoint at the current GPS fix (see below) |
| Waypoints | Enter | Open the waypoint list / navigation / add-by-coords |
| Start / Stop tracking | Enter | Begin or end a recording session |
| Save trail | Enter | Write RAM ring to flash (`/trail`) |
| Load trail | Enter | Restore flash trail into RAM |
| Export (live) | Enter | Stream live RAM trail as GPX 1.1 over USB Serial |
| Export (saved) | Enter | Stream saved flash trail as GPX 1.1 over USB Serial |
| Clear waypoints | Enter | Delete all saved waypoints (trail untouched) |
| Reset trail | Enter | Clear RAM ring and elapsed time |
**Main menu:**
(Clear waypoints appears only once at least one waypoint exists. Mark here needs a GPS fix; Waypoints is always available.)
| Item | Action |
| --------------------- | --------------------------------------------------- |
| Start / Stop tracking | Begin or end a recording session |
| Mark here | Drop a waypoint at the current GPS fix (see below) |
| Waypoints… | Open the waypoint list / navigation / add-by-coords |
| Trail file… | Open the file submenu (below) |
| Settings… | Open the settings submenu (below) |
**Trail file…** (only the operations that apply right now appear):
| Item | Action |
| -------------- | ----------------------------------------------- |
| Save trail | Write RAM ring to flash (`/trail`) |
| Load trail | Restore flash trail into RAM |
| Export (live) | Stream live RAM trail as GPX 1.1 over USB Serial |
| Export (saved) | Stream saved flash trail as GPX 1.1 over USB Serial |
| Reset trail | Clear RAM ring and elapsed time |
**Settings…** (values cycle with **LEFT/RIGHT** or **Enter**; shown only where they apply):
| Item | Available | Action |
| -------- | --------- | ------------------------------------------------------- |
| Min dist | always | Sample gate, 4 levels — metric: 5/10/25/100 m, imperial: 15/30/75/300 ft |
| Readout | Summary view | Summary shows Speed or Pace (in the global unit system) |
| Grid | Map view | Toggle scale grid on the map |
(Trail file… appears only when a live or saved trail exists. Mark here needs a GPS fix; Waypoints is always available.)
### Waypoints
@@ -128,7 +141,7 @@ A waypoint is a saved spot — your car, camp, a water source — that you can n
There is no magnetometer, so the screen shows two *absolute* bearings and you compare them: target at 145°, travelling at 90° → bear right. The **Hdg** line is derived from GPS movement (see Compass) and reads `--` until you move.
**Managing****Hold Enter** on a waypoint row offers **Rename** / **Delete** / **Send** (the *Trail start* row is navigate-only). **Hold Enter → Clear waypoints** on the Trail screen wipes them all at once.
**Managing****Hold Enter** on a waypoint row offers **Rename** / **Delete** / **Send** (the *Trail start* row is navigate-only). Delete removes one at a time; there is no bulk clear.
**Sharing****Send** hands the waypoint to the Messages screen: pick a contact or channel, and the message is pre-filled as `[WAY]<lat>,<lon> <label>` (e.g. `[WAY]37.42123,-122.08456 CAR`) for you to confirm or edit before sending. On the receiving device, opening that message and **Hold Enter → Navigate / Save waypoint** turns it back into a navigable point (see *Messages Fullscreen message view*). The format is plain text, so it stays readable on other firmware and the phone app.

View File

@@ -9,6 +9,7 @@
#include "../GeoUtils.h"
#include "GfxUtils.h"
#include "NavView.h"
#include "WaypointsView.h"
#include <math.h>
class TrailScreen : public UIScreen {
@@ -22,35 +23,23 @@ class TrailScreen : public UIScreen {
bool _cfg_dirty = false;
bool _map_grid = true; // show scale grid in map view (Enter to toggle)
// Waypoint sub-screens layered over the trail views. WP_OFF = normal trail.
enum WpMode { WP_OFF, WP_LIST, WP_NAV, WP_ADD };
uint8_t _wp_mode = WP_OFF;
int _wp_sel = 0;
int _wp_scroll = 0;
PopupMenu _wp_ctx; // Rename / Delete on a selected waypoint
KeyboardWidget _wp_kb; // label entry (mark new / rename)
bool _kb_active = false;
int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index
int32_t _mark_lat = 0, _mark_lon = 0; // pending new-mark position
uint32_t _mark_ts = 0;
// WP_ADD form — type a waypoint by coordinates. The keyboard has no comma or
// minus, so lat/lon are entered as magnitude + a hemisphere toggle (LEFT/RIGHT).
char _add_lat[12] = "", _add_lon[12] = ""; // magnitude strings
char _add_label[WAYPOINT_LABEL_LEN] = "";
bool _add_lat_neg = false; // true = S
bool _add_lon_neg = false; // true = W
int _add_sel = 0; // 0=Lat 1=Lon 2=Label 3=Save
int _kb_field = -1; // which WP_ADD field the keyboard edits (-1 = label/rename)
// Waypoint management UI (list / nav / add / mark / rename / delete / send)
// lives in its own component; TrailScreen delegates to it while it's active.
WaypointsView _wp;
// Action popup (Hold Enter / KEY_CONTEXT_MENU). Carries both settings rows
// (Min dist, Units — cycled with LEFT/RIGHT while focused) and actions
// (Start/Stop tracking, Reset). PopupMenu stores label pointers verbatim, so
// the labels live in member buffers that get refreshed in openActionMenu()
// and after every LEFT/RIGHT cycle.
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_WP_CLEAR };
enum ActionId { ACT_MIN_DIST, ACT_UNITS, ACT_GRID, ACT_TOGGLE, ACT_SAVE, ACT_LOAD, ACT_RESET, ACT_EXPORT, ACT_EXPORT_SAVED, ACT_MARK, ACT_WAYPOINTS, ACT_FILE, ACT_SETTINGS };
// The action popup is two-level: a short main menu, plus "Trail file…" and
// "Settings…" submenus. _menu_level tracks which is open so input is routed
// correctly (settings rows cycle with LEFT/RIGHT; everything else is Enter).
enum MenuLevel { ML_MAIN, ML_FILE, ML_SETTINGS };
PopupMenu _action_menu;
uint8_t _act_map[16]; // 12 used today; pad so adding an action doesn't OOB
uint8_t _menu_level = ML_MAIN;
uint8_t _act_map[16]; // max rows on any one level; pushAction guards the cap
uint8_t _act_count = 0;
char _act_min_dist_label[24];
char _act_units_label[24];
@@ -60,7 +49,8 @@ class TrailScreen : public UIScreen {
static const int SUMMARY_ITEM_COUNT = 5;
public:
TrailScreen(UITask* task, TrailStore* store) : _task(task), _store(store) {}
TrailScreen(UITask* task, TrailStore* store)
: _task(task), _store(store), _wp(task, store) {}
void enter() {
_view = V_SUMMARY;
@@ -68,27 +58,16 @@ public:
_list_scroll = 0;
_cfg_dirty = false;
_action_menu.active = false;
_wp_mode = WP_OFF;
_wp_ctx.active = false;
_kb_active = false;
_kb_field = -1;
_menu_level = ML_MAIN;
_wp.reset();
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
// Label keyboard (mark / rename) owns the whole screen while open.
if (_kb_active) return _wp_kb.render(display);
// Waypoint sub-screens replace the trail views.
if (_wp_mode == WP_ADD) { renderAddForm(display); return 1000; }
if (_wp_mode == WP_NAV) { renderWpNav(display); return 1000; }
if (_wp_mode == WP_LIST) {
renderWpList(display);
if (_wp_ctx.active) _wp_ctx.render(display);
return 1000;
}
// Waypoint management UI owns the screen while active.
if (_wp.active()) return _wp.render(display);
// Title carries the view counter so the bottom hint row can be reclaimed
// for content.
@@ -109,140 +88,45 @@ public:
}
bool handleInput(char c) override {
// Label keyboard (mark new / rename) takes all input first.
if (_kb_active) {
auto r = _wp_kb.handleInput(c);
if (r == KeyboardWidget::DONE) {
if (_kb_field >= 0) applyAddField(_wp_kb.buf); // WP_ADD field edit
else commitLabel(_wp_kb.buf); // mark / rename label
_kb_active = false; _kb_field = -1;
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false; _kb_field = -1;
}
return true;
}
// Waypoint Rename/Delete popup. Operates on the saved-waypoint index
// (the synthetic Trail-start row, if any, is never editable).
if (_wp_ctx.active) {
auto res = _wp_ctx.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _wp_ctx.selectedIndex();
int wi = wpIndex();
if (sel == 0) { // Rename
if (wi >= 0 && wi < _task->waypoints().count()) {
_kb_rename_idx = wi;
_wp_kb.begin(_task->waypoints().at(wi).label, WAYPOINT_LABEL_LEN - 1);
_kb_active = true;
}
} else if (sel == 1) { // Delete
if (wi >= 0 && wi < _task->waypoints().count()) {
_task->waypoints().remove(wi);
_task->saveWaypoints();
if (_wp_sel >= wpListCount()) _wp_sel = wpListCount() - 1;
if (_wp_sel < 0) _wp_sel = 0;
}
} else { // Send (share in a message)
if (wi >= 0 && wi < _task->waypoints().count()) {
const Waypoint& w = _task->waypoints().at(wi);
double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0;
char text[80];
if (w.label[0]) snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f %s", lat, lon, w.label);
else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon);
_task->shareToMessage(text); // hands off to the Messages screen
}
}
}
return true;
}
// WP_ADD form: type lat/lon (magnitude) + hemisphere toggle + label.
if (_wp_mode == WP_ADD) {
if (c == KEY_CANCEL) { _wp_mode = WP_OFF; return true; }
if (c == KEY_UP && _add_sel > 0) { _add_sel--; return true; }
if (c == KEY_DOWN && _add_sel < 3) { _add_sel++; return true; }
if (c == KEY_LEFT || c == KEY_RIGHT) {
if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S
else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W
return true;
}
if (c == KEY_ENTER) {
if (_add_sel == 0) { _kb_field = 0; _wp_kb.begin(_add_lat, 11); _kb_active = true; }
else if (_add_sel == 1) { _kb_field = 1; _wp_kb.begin(_add_lon, 11); _kb_active = true; }
else if (_add_sel == 2) { _kb_field = 2; _wp_kb.begin(_add_label, WAYPOINT_LABEL_LEN - 1); _kb_active = true; }
else { commitAddForm(); }
return true;
}
return true;
}
// Waypoint navigation view — any nav key returns to the list.
if (_wp_mode == WP_NAV) {
if (c == KEY_CANCEL || c == KEY_LEFT || c == KEY_PREV ||
c == KEY_RIGHT || c == KEY_NEXT) { _wp_mode = WP_LIST; }
return true;
}
// Waypoint list (row 0 is the synthetic "Trail start" when a trail exists;
// the final row is "+ Add by coords").
if (_wp_mode == WP_LIST) {
int n = wpListCount();
int total = n + 1; // + the "Add by coords" row
if (c == KEY_CANCEL) { _wp_mode = WP_OFF; return true; }
if (c == KEY_UP && _wp_sel > 0) { _wp_sel--; if (_wp_sel < _wp_scroll) _wp_scroll = _wp_sel; return true; }
if (c == KEY_DOWN && _wp_sel < total - 1) { _wp_sel++; return true; }
if (c == KEY_ENTER) {
if (_wp_sel == n) handleAddByCoords(); // last row → open the add form
else _wp_mode = WP_NAV; // a waypoint / Trail-start row
return true;
}
// Rename/Delete/Send apply to saved waypoints only — not Trail-start or Add.
if (c == KEY_CONTEXT_MENU && !selIsStart() && wpIndex() < _task->waypoints().count()) {
_wp_ctx.begin("Waypoint", 3);
_wp_ctx.addItem("Rename");
_wp_ctx.addItem("Delete");
_wp_ctx.addItem("Send");
return true;
}
return true;
}
// Waypoint management UI consumes all input while active.
if (_wp.active()) return _wp.handleInput(c);
// Action popup overrides everything else.
if (_action_menu.active) {
// LEFT/RIGHT on settings rows cycles the value in-place and refreshes
// the popup label — the popup stays open so the user can keep tapping.
// LEFT/RIGHT cycles the focused value — only meaningful in the Settings
// submenu; the popup stays open so the user can keep tapping.
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_PREV || c == KEY_NEXT) {
int idx = _action_menu.selectedIndex();
if (idx >= 0 && idx < _act_count) {
NodePrefs* p = _task->getNodePrefs();
int dir = (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1;
if (_act_map[idx] == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (_act_map[idx] == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (_act_map[idx] == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
if (_menu_level == ML_SETTINGS) {
int idx = _action_menu.selectedIndex();
if (idx >= 0 && idx < _act_count)
cycleSetting((ActionId)_act_map[idx], (c == KEY_RIGHT || c == KEY_NEXT) ? 1 : -1);
}
return true; // swallow on action rows
return true; // swallow elsewhere
}
auto res = _action_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _action_menu.selectedIndex();
if (sel >= 0 && sel < _act_count) {
ActionId act = (ActionId)_act_map[sel];
if (act == ACT_TOGGLE) { handleToggle(); }
else if (act == ACT_SAVE) { handleSave(); }
else if (act == ACT_LOAD) { handleLoad(); }
else if (act == ACT_RESET) { handleReset(); }
else if (act == ACT_GRID) { _map_grid = !_map_grid; }
else if (act == ACT_EXPORT) { handleExport(); }
else if (act == ACT_EXPORT_SAVED) { handleExportSaved(); }
else if (act == ACT_MARK) { handleMarkHere(); }
else if (act == ACT_WAYPOINTS) { _wp_mode = WP_LIST; _wp_sel = 0; _wp_scroll = 0; }
else if (act == ACT_WP_CLEAR) { _task->waypoints().clear(); _task->saveWaypoints();
_task->showAlert("Waypoints cleared", 800); }
else /* MIN_DIST / UNITS */ { reopenAt(sel); return true; } // re-open keeping focus
ActionId act = (sel >= 0 && sel < _act_count) ? (ActionId)_act_map[sel] : ACT_TOGGLE;
switch (act) {
case ACT_FILE: buildFileMenu(); return true; // descend into submenu
case ACT_SETTINGS: buildSettingsMenu(); return true;
// Settings rows: Enter advances/toggles the value and keeps focus.
case ACT_MIN_DIST:
case ACT_UNITS:
case ACT_GRID: cycleSetting(act, 1); reopenSettingsAt(sel); return true;
case ACT_TOGGLE: handleToggle(); break;
case ACT_MARK: _wp.markHere(); break;
case ACT_WAYPOINTS: _wp.openList(); break;
case ACT_SAVE: handleSave(); break;
case ACT_LOAD: handleLoad(); break;
case ACT_RESET: handleReset(); break;
case ACT_EXPORT: handleExport(); break;
case ACT_EXPORT_SAVED: handleExportSaved(); break;
}
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
} else if (res == PopupMenu::CANCELLED) {
if (_cfg_dirty) { the_mesh.savePrefs(); _cfg_dirty = false; }
if (_menu_level != ML_MAIN) buildMainMenu(); // Cancel backs out of a submenu
}
return true;
}
@@ -360,26 +244,57 @@ private:
_action_menu.addItem(label);
}
void openActionMenu() {
bool fileMenuHasItems() const { return !_store->empty() || savedTrailExists(); }
// Hold-Enter entry point — always opens the short main menu.
void openActionMenu() { buildMainMenu(); }
void buildMainMenu() {
refreshActionLabels();
_act_count = 0;
_menu_level = ML_MAIN;
_act_count = 0;
_action_menu.begin("Trail", 4);
pushAction(ACT_MIN_DIST, _act_min_dist_label);
pushAction(ACT_UNITS, _act_units_label);
pushAction(ACT_GRID, _act_grid_label);
pushAction(ACT_TOGGLE, _act_toggle_label);
// Waypoints: mark the current spot, and browse/navigate saved ones.
pushAction(ACT_TOGGLE, _act_toggle_label);
pushAction(ACT_MARK, "Mark here");
// "Waypoints" opens the nav list — always available; it hosts the list,
// backtrack (Trail-start row) and the "+ Add by coords" entry.
pushAction(ACT_WAYPOINTS, "Waypoints");
if (_task->waypoints().count() > 0) pushAction(ACT_WP_CLEAR, "Clear waypoints");
if (!_store->empty()) pushAction(ACT_SAVE, "Save trail");
pushAction(ACT_WAYPOINTS, "Waypoints...");
if (fileMenuHasItems()) pushAction(ACT_FILE, "Trail file...");
pushAction(ACT_SETTINGS, "Settings...");
}
// Trail-file submenu — only the operations that make sense right now.
void buildFileMenu() {
_menu_level = ML_FILE;
_act_count = 0;
_action_menu.begin("Trail file", 4);
bool saved = savedTrailExists();
if (saved) pushAction(ACT_LOAD, "Load trail");
if (!_store->empty()) pushAction(ACT_EXPORT, "Export (live)");
if (saved) pushAction(ACT_EXPORT_SAVED, "Export (saved)");
if (!_store->empty()) pushAction(ACT_RESET, "Reset trail");
if (!_store->empty()) pushAction(ACT_SAVE, "Save trail");
if (saved) pushAction(ACT_LOAD, "Load trail");
if (!_store->empty()) pushAction(ACT_EXPORT, "Export (live)");
if (saved) pushAction(ACT_EXPORT_SAVED, "Export (saved)");
if (!_store->empty()) pushAction(ACT_RESET, "Reset trail");
}
// Settings submenu — values cycled with LEFT/RIGHT (or Enter). View-aware:
// Grid only matters on the Map, Readout only on the Summary.
void buildSettingsMenu() {
refreshActionLabels();
_menu_level = ML_SETTINGS;
_act_count = 0;
_action_menu.begin("Settings", 3);
pushAction(ACT_MIN_DIST, _act_min_dist_label);
if (_view == V_SUMMARY) pushAction(ACT_UNITS, _act_units_label);
if (_view == V_MAP) pushAction(ACT_GRID, _act_grid_label);
}
// Cycle a settings value. Returns true if `act` was a settings row.
bool cycleSetting(ActionId act, int dir) {
NodePrefs* p = _task->getNodePrefs();
if (act == ACT_MIN_DIST && p) { cycleMinDelta(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (act == ACT_UNITS && p) { cycleUnits(p, dir); _cfg_dirty = true; refreshActionLabels(); return true; }
if (act == ACT_GRID) { _map_grid = !_map_grid; refreshActionLabels(); return true; }
return false;
}
static bool savedTrailExists() {
@@ -391,195 +306,16 @@ private:
return ok;
}
// ── Waypoints ──────────────────────────────────────────────────────────────
// The nav list shows a synthetic "Trail start" row (index 0) whenever a trail
// exists, followed by the saved waypoints. These helpers map the combined
// row index (_wp_sel) onto that layout.
bool hasStart() const { return !_store->empty(); }
int wpListCount() const { return (hasStart() ? 1 : 0) + _task->waypoints().count(); }
bool selIsStart() const { return hasStart() && _wp_sel == 0; }
int wpIndex() const { return _wp_sel - (hasStart() ? 1 : 0); } // index into WaypointStore
// GPS / unit helpers shared by the trail views and the map. (Waypoint list /
// navigation / add / mark / rename / delete / send moved to WaypointsView.)
bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); }
bool useImperial() const { return _task && _task->useImperial(); }
void handleMarkHere() {
int32_t lat, lon;
if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
_kb_rename_idx = -1;
_wp_kb.begin("", WAYPOINT_LABEL_LEN - 1);
_kb_active = true;
}
// Add a waypoint by coordinates (no GPS fix needed). Opens the WP_ADD form.
void handleAddByCoords() {
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_add_lat[0] = _add_lon[0] = _add_label[0] = '\0';
_add_lat_neg = _add_lon_neg = false;
_add_sel = 0;
_wp_mode = WP_ADD;
}
// Store the just-typed value into the focused WP_ADD field.
void applyAddField(const char* buf) {
char* dst = (_kb_field == 0) ? _add_lat
: (_kb_field == 1) ? _add_lon : _add_label;
int cap = (_kb_field == 2) ? WAYPOINT_LABEL_LEN : (int)sizeof(_add_lat);
strncpy(dst, buf, cap - 1);
dst[cap - 1] = '\0';
}
// Parse a numeric field: true only when the whole string is a number (so a
// stray letter from the full keyboard can't silently read as 0).
static bool parseNum(const char* s, double& out) {
if (!s || !s[0]) return false;
char* end = nullptr;
out = strtod(s, &end);
if (end == s) return false; // nothing numeric parsed
while (*end == ' ') end++;
return *end == '\0'; // no trailing non-numeric chars
}
// Validate the form and save the waypoint.
void commitAddForm() {
double la, lo;
if (!parseNum(_add_lat, la) || !parseNum(_add_lon, lo)) {
_task->showAlert("Lat/Lon invalid", 1200); return;
}
if (_add_lat_neg) la = -la;
if (_add_lon_neg) lo = -lo;
if (la < -90.0 || la > 90.0 || lo < -180.0 || lo > 180.0) {
_task->showAlert("Out of range", 1200); return;
}
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
int32_t lat = (int32_t)(la * 1e6 + (la < 0 ? -0.5 : 0.5));
int32_t lon = (int32_t)(lo * 1e6 + (lo < 0 ? -0.5 : 0.5));
char label[WAYPOINT_LABEL_LEN];
if (_add_label[0]) { strncpy(label, _add_label, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; }
else snprintf(label, sizeof(label), "WP%d", _task->waypoints().count() + 1);
if (_task->addWaypoint(lat, lon, label)) { _wp_mode = WP_OFF; }
}
void renderAddForm(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "ADD WAYPOINT");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
const int top = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
for (int i = 0; i < 4; i++) {
int y = top + i * step;
bool sel = (i == _add_sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(0, y); display.print(sel ? ">" : " ");
char row[28];
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %s", _add_lat_neg ? 'S' : 'N', _add_lat[0] ? _add_lat : "--");
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %s", _add_lon_neg ? 'W' : 'E', _add_lon[0] ? _add_lon : "--");
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
else snprintf(row, sizeof(row), "[Save]");
display.setCursor(cw + 2, y); display.print(row);
display.setColor(DisplayDriver::LIGHT);
}
}
void commitLabel(const char* buf) {
if (_kb_rename_idx >= 0) {
_task->waypoints().rename(_kb_rename_idx, buf);
_task->saveWaypoints();
_kb_rename_idx = -1;
return;
}
char auto_lbl[WAYPOINT_LABEL_LEN];
if (!buf || !buf[0]) {
snprintf(auto_lbl, sizeof(auto_lbl), "WP%d", _task->waypoints().count() + 1);
buf = auto_lbl;
}
_task->addWaypoint(_mark_lat, _mark_lon, _mark_ts, buf);
}
void renderWpList(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
char title[24];
snprintf(title, sizeof(title), "WAYPOINTS %d/%d",
_task->waypoints().count(), WaypointStore::CAPACITY);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
int n = wpListCount();
int total = n + 1; // final row = "+ Add by coords"
const int top = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
int vis = display.listVisible();
if (vis < 1) vis = 1;
if (_wp_sel < _wp_scroll) _wp_scroll = _wp_sel;
if (_wp_sel >= _wp_scroll + vis) _wp_scroll = _wp_sel - vis + 1;
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
for (int i = 0; i < vis && (_wp_scroll + i) < total; i++) {
int row = _wp_scroll + i;
int y = top + i * step;
bool sel = (row == _wp_sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(0, y); display.print(sel ? ">" : " ");
if (row == n) { // the synthetic "Add" row
display.setCursor(cw + 2, y); display.print("+ Add by coords");
display.setColor(DisplayDriver::LIGHT);
continue;
}
int32_t tlat, tlon; const char* label;
if (!rowTarget(row, tlat, tlon, label)) continue;
char dist[12] = ""; int bw = 0;
if (have) {
geo::fmtDist(dist, sizeof(dist), geo::haversineKm(mylat, mylon, tlat, tlon), useImperial());
bw = display.getTextWidth(dist) + 2;
}
char nm[24];
display.translateUTF8ToBlocks(nm, label, sizeof(nm));
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw, nm);
if (dist[0]) { display.setCursor(display.width() - bw + 1, y); display.print(dist); }
display.setColor(DisplayDriver::LIGHT);
}
}
void renderWpNav(DisplayDriver& display) {
int32_t tlat, tlon; const char* label;
if (!rowTarget(_wp_sel, tlat, tlon, label)) { _wp_mode = WP_LIST; return; }
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
int cog; bool cogv = _task->currentCourse(cog);
navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog, useImperial());
}
// Resolve a combined-list row to a nav target. Row 0 is the trail start when
// a trail exists; the rest are saved waypoints.
bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) {
if (hasStart() && row == 0) {
const TrailPoint& s = _store->first();
lat = s.lat_1e6; lon = s.lon_1e6; label = "Trail start";
return true;
}
int wi = row - (hasStart() ? 1 : 0);
WaypointStore& wp = _task->waypoints();
if (wi >= 0 && wi < wp.count()) {
const Waypoint& w = wp.at(wi);
lat = w.lat_1e6; lon = w.lon_1e6;
label = w.label[0] ? w.label : "(unnamed)";
return true;
}
return false;
}
// After Enter on a settings row, the popup auto-closes per PopupMenu's
// semantics. Re-open it with focus restored to that row so the user can
// continue cycling.
void reopenAt(int sel) {
openActionMenu();
// semantics. Re-open the Settings submenu with focus restored to that row so
// the user can continue cycling.
void reopenSettingsAt(int sel) {
buildSettingsMenu();
_action_menu.setSelected(sel);
}
@@ -734,6 +470,71 @@ private:
_list_scroll > 0, _list_scroll + visible < total);
}
// Shared map projection: geographic (1e-6 deg) → screen pixels. The scale is
// isotropic (cos(lat) folded into lon), so a metre maps to the same pixel
// count on both axes. Built once in renderMap and handed to renderGrid and
// the marker drawing so the projection equation lives in exactly one place.
struct MapProjection {
int32_t min_lat, max_lat, min_lon;
float lon_scale_geo, scale;
int off_x, off_y, area_x, area_y, area_w, area_h;
void project(int32_t lat, int32_t lon, int& px, int& py) const {
px = off_x + (int)((float)(lon - min_lon) * lon_scale_geo * scale);
py = off_y + (int)((float)(max_lat - lat) * scale);
}
};
// Fold the trail / waypoints / live position into a bounding box. With a trail,
// only the route + live position define the box (far waypoints clamp to the
// edge when drawn); without one, the waypoints do. Returns false if empty.
bool computeBounds(bool have_trail, bool have_gps, int32_t my_lat, int32_t my_lon,
int32_t& min_lat, int32_t& min_lon,
int32_t& max_lat, int32_t& max_lon) const {
bool init = false;
auto fold = [&](int32_t lat, int32_t lon) {
if (!init) { min_lat = max_lat = lat; min_lon = max_lon = lon; init = true; }
else {
if (lat < min_lat) min_lat = lat; if (lat > max_lat) max_lat = lat;
if (lon < min_lon) min_lon = lon; if (lon > max_lon) max_lon = lon;
}
};
if (have_trail) {
int32_t a, b, c, d; _store->boundingBox(a, b, c, d);
fold(a, b); fold(c, d);
} else {
WaypointStore& wp = _task->waypoints();
for (int i = 0; i < wp.count(); i++) fold(wp.at(i).lat_1e6, wp.at(i).lon_1e6);
}
if (have_gps) fold(my_lat, my_lon);
return init;
}
// Draw the saved waypoints (label's first two chars beside each, clamped to the
// frame) and the current-position marker (live GPS, else last trail point).
void drawMarkers(DisplayDriver& display, const MapProjection& proj,
bool have_gps, int32_t my_lat, int32_t my_lon, bool have_trail) {
const int area_x = proj.area_x, area_y = proj.area_y;
const int area_w = proj.area_w, area_h = proj.area_h;
WaypointStore& wp = _task->waypoints();
const int wp_x_max = area_x + area_w - 1, wp_y_max = area_y + area_h - 1;
for (int i = 0; i < wp.count(); i++) {
const Waypoint& w = wp.at(i);
int wx, wy; proj.project(w.lat_1e6, w.lon_1e6, wx, wy);
if (wx < area_x) wx = area_x; else if (wx > wp_x_max) wx = wp_x_max;
if (wy < area_y) wy = area_y; else if (wy > wp_y_max) wy = wp_y_max;
drawWaypointMarker(display, wx, wy);
char s[3] = { w.label[0], w.label[0] ? w.label[1] : (char)0, 0 };
drawWaypointLabel(display, s, wx, wy, area_x, area_y, area_w, area_h);
}
if (have_gps) {
int mx, my; proj.project(my_lat, my_lon, mx, my);
drawCurrentMarker(display, mx, my);
} else if (have_trail) {
int ex, ey; proj.project(_store->last().lat_1e6, _store->last().lon_1e6, ex, ey);
drawCurrentMarker(display, ex, ey);
}
}
void renderMap(DisplayDriver& display) {
const int top = display.listStart();
const int bottom = display.height() - 2;
@@ -761,24 +562,7 @@ private:
// Bounding box spans the trail, every waypoint, and the live position, so
// everything of interest stays in frame.
int32_t min_lat = 0, min_lon = 0, max_lat = 0, max_lon = 0;
bool init = false;
auto fold = [&](int32_t lat, int32_t lon) {
if (!init) { min_lat = max_lat = lat; min_lon = max_lon = lon; init = true; }
else {
if (lat < min_lat) min_lat = lat; if (lat > max_lat) max_lat = lat;
if (lon < min_lon) min_lon = lon; if (lon > max_lon) max_lon = lon;
}
};
// With a trail, frame the recorded route (and live position) and let far
// waypoints clamp to the map edge — they must not blow up the scale. Only
// when there's no trail do waypoints define the view.
if (have_trail) {
int32_t a, b, c, d; _store->boundingBox(a, b, c, d);
fold(a, b); fold(c, d);
} else {
for (int i = 0; i < nwp; i++) fold(wp.at(i).lat_1e6, wp.at(i).lon_1e6);
}
if (have_gps) fold(my_lat, my_lon);
computeBounds(have_trail, have_gps, my_lat, my_lon, min_lat, min_lon, max_lat, max_lon);
// Degenerate: everything at one coordinate — just centre the markers.
if (min_lat == max_lat && min_lon == max_lon) {
@@ -810,29 +594,26 @@ private:
int used_w = (int)(lon_span * scale);
int used_h = (int)(lat_span * scale);
int off_x = area_x + (area_w - used_w) / 2;
int off_y = area_y + (area_h - used_h) / 2;
auto projectLL = [&](int32_t lat, int32_t lon, int& px, int& py) {
px = off_x + (int)((float)(lon - min_lon) * lon_scale_geo * scale);
py = off_y + (int)((float)(max_lat - lat) * scale);
};
auto project = [&](const TrailPoint& p, int& px, int& py) {
projectLL(p.lat_1e6, p.lon_1e6, px, py);
};
MapProjection proj;
proj.min_lat = min_lat; proj.max_lat = max_lat; proj.min_lon = min_lon;
proj.lon_scale_geo = lon_scale_geo; proj.scale = scale;
proj.off_x = area_x + (area_w - used_w) / 2;
proj.off_y = area_y + (area_h - used_h) / 2;
proj.area_x = area_x; proj.area_y = area_y;
proj.area_w = area_w; proj.area_h = area_h;
if (_map_grid)
renderGrid(display, area_x, area_y, area_w, area_h,
min_lat, max_lat, min_lon, lon_scale_geo, scale, off_x, off_y);
if (_map_grid) renderGrid(display, proj);
if (have_trail) {
int x0, y0;
project(_store->at(0), x0, y0);
proj.project(_store->at(0).lat_1e6, _store->at(0).lon_1e6, x0, y0);
display.fillRect(x0, y0, 1, 1);
for (int i = 1; i < _store->count(); i++) {
const TrailPoint& pt = _store->at(i);
int x1, y1;
project(_store->at(i), x1, y1);
if (_store->at(i).flags & TRAIL_FLAG_SEG_START) {
proj.project(pt.lat_1e6, pt.lon_1e6, x1, y1);
if (pt.flags & TRAIL_FLAG_SEG_START) {
drawFilledDot(display, x0, y0);
drawOpenDot(display, x1, y1);
} else {
@@ -842,51 +623,25 @@ private:
y0 = y1;
}
int sx, sy;
project(_store->first(), sx, sy);
proj.project(_store->first().lat_1e6, _store->first().lon_1e6, sx, sy);
drawStartMarker(display, sx, sy);
}
// Waypoints, with the first two label characters beside the marker so
// nearby waypoints can be told apart. A waypoint that falls outside the
// (trail-framed) view is clamped to the nearest edge instead of being lost.
const int wp_x_max = area_x + area_w - 1, wp_y_max = area_y + area_h - 1;
for (int i = 0; i < nwp; i++) {
const Waypoint& w = wp.at(i);
int wx, wy; projectLL(w.lat_1e6, w.lon_1e6, wx, wy);
if (wx < area_x) wx = area_x; else if (wx > wp_x_max) wx = wp_x_max;
if (wy < area_y) wy = area_y; else if (wy > wp_y_max) wy = wp_y_max;
drawWaypointMarker(display, wx, wy);
char s[3] = { w.label[0], w.label[0] ? w.label[1] : (char)0, 0 };
drawWaypointLabel(display, s, wx, wy, area_x, area_y, area_w, area_h);
}
// Current position marker on top — live GPS if we have a fix, otherwise the
// last recorded trail point.
if (have_gps) {
int mx, my; projectLL(my_lat, my_lon, mx, my);
drawCurrentMarker(display, mx, my);
} else if (have_trail) {
int ex, ey; project(_store->last(), ex, ey);
drawCurrentMarker(display, ex, ey);
}
drawMarkers(display, proj, have_gps, my_lat, my_lon, have_trail);
drawNorthArrow(display, area_x + area_w - 4, area_y + display.getLineHeight() + 1);
}
void renderGrid(DisplayDriver& display,
int area_x, int area_y, int area_w, int area_h,
int32_t min_lat, int32_t max_lat, int32_t min_lon,
float lon_scale_geo, float scale, int off_x, int off_y) {
void renderGrid(DisplayDriver& display, const MapProjection& proj) {
const int area_x = proj.area_x, area_y = proj.area_y;
const int area_w = proj.area_w, area_h = proj.area_h;
static constexpr float M_PER_1E6 = 0.11132f; // metres per 1e-6 deg lat
float ppm = scale / M_PER_1E6;
float ppm = proj.scale / M_PER_1E6; // pixels per metre (isotropic)
if (ppm <= 0.0f) return;
float shorter_m = (float)(area_w < area_h ? area_w : area_h) / ppm;
float target_m = shorter_m / 3.0f;
// Round scale steps with matching labels. The grid geometry works in metres,
// so imperial steps are stored as their metre equivalents but labelled with
// their exact round ft/mi value. Both tables are parallel (step ↔ label).
// Round scale steps with matching labels. Imperial steps are stored as their
// metre equivalents but labelled with their exact round ft/mi value. Both
// tables are parallel (step ↔ label).
static const float METRIC_STEPS[] = {
1, 2, 5, 10, 20, 50, 100, 200, 500,
1000, 2000, 5000, 10000, 20000, 50000, 100000
@@ -909,95 +664,67 @@ private:
const int N_STEPS = imp ? (int)(sizeof(IMPERIAL_STEPS)/sizeof(IMPERIAL_STEPS[0]))
: (int)(sizeof(METRIC_STEPS)/sizeof(METRIC_STEPS[0]));
// Pick largest step ≤ target_m (gives ~3 intervals across shorter dim).
int sel = 0;
for (int si = N_STEPS - 1; si >= 0; si--) {
if (STEPS[si] <= target_m) { sel = si; break; }
}
// Bump up until pixel spacing between intersections is at least MIN_GRID_PX.
// This keeps OLED grids sparse even when target_m selects a fine step.
// Pick the round step nearest ~1/3 of the shorter side, then keep it sparse
// enough to read on an OLED (≥ MIN_GRID_PX between intersections).
static const float MIN_GRID_PX = 22.0f;
while (sel < N_STEPS - 1 && STEPS[sel] / M_PER_1E6 * scale < MIN_GRID_PX)
sel++;
// Clamp back down: ensure at least 2 intervals (3 visible lines) across
// the shorter dimension. MIN_GRID_PX must not leave only 1 interval.
float shorter_px = (float)(area_w < area_h ? area_w : area_h);
while (sel > 0 && STEPS[sel] / M_PER_1E6 * scale > shorter_px / 2.0f)
sel--;
int shorter_px = area_w < area_h ? area_w : area_h;
float target_m = (shorter_px / ppm) / 3.0f;
int sel = 0;
for (int si = N_STEPS - 1; si >= 0; si--) { if (STEPS[si] <= target_m) { sel = si; break; } }
while (sel < N_STEPS - 1 && STEPS[sel] * ppm < MIN_GRID_PX) sel++;
float vis_lat_max = (float)max_lat + (float)(off_y - area_y) / scale;
float vis_lat_min = (float)max_lat - (float)(area_y + area_h - off_y) / scale;
float vis_lon_min = (float)min_lon - (float)(off_x - area_x) / (lon_scale_geo * scale);
float vis_lon_max = (float)min_lon + (float)(area_x + area_w - off_x) / (lon_scale_geo * scale);
// Square cells, snapped to the shorter side: divide the shorter dimension
// into a whole number of equal cells (so the grid touches that pair of
// borders), use that exact size as the square cell, and centre the whole
// number of cells that fit along the longer dimension. Result: square grid,
// reaches the frame top↔bottom (landscape), symmetric left↔right — no
// one-sided drift, no rectangular cells. Cap so spacing stays ≥ MIN_GRID_PX.
float step_px = STEPS[sel] * ppm;
bool h_shorter = (area_h <= area_w);
int short_px = h_shorter ? area_h : area_w;
int long_px = h_shorter ? area_w : area_h;
int max_short = (int)(short_px / MIN_GRID_PX); if (max_short < 1) max_short = 1;
int n_short = (int)((float)short_px / step_px + 0.5f);
if (n_short < 1) n_short = 1; if (n_short > max_short) n_short = max_short;
float cell = (float)short_px / (float)n_short; // exact square size
int n_long = (int)((float)long_px / cell); // whole cells that fit
if (n_long < 1) n_long = 1;
int off_long = (int)(((float)long_px - (float)n_long * cell) / 2.0f + 0.5f);
// Final guard: a very elongated trail (lat_span ≪ lon_span or vice-versa)
// shares a single isotropic scale, so the visible window in the *short*
// direction can span many grid_m. Bump the step up until the resulting
// line count fits the static buffers (40×40 = ~1600 intersections, plenty
// for any sane display).
static const int MAX_GRID_LINES = 40;
float grid_m = STEPS[sel];
float gs_lat = grid_m / M_PER_1E6;
float gs_lon = grid_m / (M_PER_1E6 * lon_scale_geo);
int lat_n = (int)((vis_lat_max - vis_lat_min) / gs_lat) + 2;
int lon_n = (int)((vis_lon_max - vis_lon_min) / gs_lon) + 2;
while ((lat_n > MAX_GRID_LINES || lon_n > MAX_GRID_LINES) && sel < N_STEPS - 1) {
sel++;
grid_m = STEPS[sel];
gs_lat = grid_m / M_PER_1E6;
gs_lon = grid_m / (M_PER_1E6 * lon_scale_geo);
lat_n = (int)((vis_lat_max - vis_lat_min) / gs_lat) + 2;
lon_n = (int)((vis_lon_max - vis_lon_min) / gs_lon) + 2;
}
if (lat_n > MAX_GRID_LINES || lon_n > MAX_GRID_LINES) return; // huge step still won't fit — give up
// Map the short/long axes back to screen x/y.
int x0 = h_shorter ? area_x + off_long : area_x;
int y0 = h_shorter ? area_y : area_y + off_long;
int cx = h_shorter ? n_long : n_short; // cell count along x
int cy = h_shorter ? n_short : n_long; // cell count along y
// Anchor to display edges so lines always start at the border and space inward.
float first_lat = vis_lat_min;
float first_lon = vis_lon_min;
const char* lbl = LABELS[sel];
int lh = display.getLineHeight();
int cw = display.getCharWidth();
int x_max = area_x + area_w - 1, y_max = area_y + area_h - 1;
int x_max = area_x + area_w - 1;
int y_max = area_y + area_h - 1;
auto fillSafe = [&](int x, int y) {
if (x >= area_x && x <= x_max && y >= area_y && y <= y_max)
display.fillRect(x, y, 1, 1);
};
// Label is the exact round step value for the selected unit system.
char lbl[12];
snprintf(lbl, sizeof(lbl), "%s", LABELS[sel]);
int lh = display.getLineHeight();
int cw = display.getCharWidth();
// Scale label bbox (bottom-left corner)
int lbl_x1 = area_x;
// Exclusion boxes so grid dots don't smear the scale label (bottom-left) or
// the north arrow (top-right, mirrors drawNorthArrow).
int lbl_x2 = area_x + (int)strlen(lbl) * cw + 1;
int lbl_y1 = area_y + area_h - lh - 1;
int lbl_y2 = area_y + area_h;
int arr_x1 = area_x + area_w - 4 - cw - 1;
int arr_y2 = area_y + lh + 1 + 7;
// North-arrow bbox — mirrors drawNorthArrow(cx = area_x+area_w-4, cy = area_y+lh+1)
int arr_cx = area_x + area_w - 4;
int arr_cy = area_y + lh + 1;
int arr_x1 = arr_cx - cw - 1;
int arr_x2 = x_max;
int arr_y1 = area_y;
int arr_y2 = arr_cy + 7;
auto fillSafe = [&](int x, int y) {
if (x < area_x || x > x_max || y < area_y || y > y_max) return;
if (x <= lbl_x2 && y >= lbl_y1) return; // under the scale label
if (x >= arr_x1 && y <= arr_y2) return; // under the north arrow
display.fillRect(x, y, 1, 1);
};
for (int i = 0; i < lat_n; i++) {
float lat_val = first_lat + (float)i * gs_lat;
int py = off_y + (int)(((float)max_lat - lat_val) * scale);
if (py < area_y - 1 || py > y_max + 1) continue;
for (int j = 0; j < lon_n; j++) {
float lon_val = first_lon + (float)j * gs_lon;
int px = off_x + (int)((lon_val - (float)min_lon) * lon_scale_geo * scale);
if (px < area_x - 1 || px > x_max + 1) continue;
if (px >= lbl_x1 && px <= lbl_x2 && py >= lbl_y1 && py <= lbl_y2) continue;
if (px >= arr_x1 && px <= arr_x2 && py >= arr_y1 && py <= arr_y2) continue;
fillSafe(px, py);
fillSafe(px - 1, py);
fillSafe(px + 1, py);
fillSafe(px, py - 1);
fillSafe(px, py + 1);
// A single dot per intersection — cleaner and more legible on an OLED than
// a 5-pixel cross (whose arms also clipped at the frame edges).
for (int i = 0; i <= cx; i++) {
int px = x0 + (int)((float)i * cell + 0.5f);
if (px > x_max) px = x_max;
for (int j = 0; j <= cy; j++) {
int py = y0 + (int)((float)j * cell + 0.5f);
if (py > y_max) py = y_max;
fillSafe(px, py);
}
}

View File

@@ -0,0 +1,347 @@
#pragma once
// Waypoint management UI for Tools Trail: list / navigate / add-by-coords,
// plus mark-here, rename, delete and send. Extracted from TrailScreen as a
// self-contained component that TrailScreen owns and delegates to while active.
// It needs the TrailStore only for the synthetic "Trail start" backtrack row.
//
// Included by TrailScreen.h (which UITask.cpp pulls in after UITask is fully
// defined, so the inline _task->… calls below see the complete type).
#include "../Waypoint.h"
#include "../GeoUtils.h"
#include "../Trail.h"
#include "KeyboardWidget.h"
#include "PopupMenu.h"
#include "NavView.h"
class WaypointsView {
UITask* _task;
TrailStore* _store;
// Sub-modes layered over the trail views. OFF = the component is dormant.
enum Mode { OFF, LIST, NAV, ADD };
uint8_t _mode = OFF;
int _sel = 0;
int _scroll = 0;
PopupMenu _ctx; // Rename / Delete / Send on a selected waypoint
KeyboardWidget _kb; // label entry (mark new / rename)
bool _kb_active = false;
int _kb_rename_idx = -1; // -1 = marking new, ≥0 = renaming that index
int32_t _mark_lat = 0, _mark_lon = 0; // pending new-mark position
uint32_t _mark_ts = 0;
// ADD form — type a waypoint by coordinates. The keyboard has no comma or
// minus, so lat/lon are entered as magnitude + a hemisphere toggle (LEFT/RIGHT).
char _add_lat[12] = "", _add_lon[12] = ""; // magnitude strings
char _add_label[WAYPOINT_LABEL_LEN] = "";
bool _add_lat_neg = false; // true = S
bool _add_lon_neg = false; // true = W
int _add_sel = 0; // 0=Lat 1=Lon 2=Label 3=Save
int _kb_field = -1; // which ADD field the keyboard edits (-1 = label/rename)
bool useImperial() const { return _task && _task->useImperial(); }
bool ownPos(int32_t& lat, int32_t& lon) const { return _task->currentLocation(lat, lon); }
// The nav list shows a synthetic "Trail start" row (index 0) whenever a trail
// exists, followed by the saved waypoints. These map the combined row index
// (_sel) onto that layout.
bool hasStart() const { return !_store->empty(); }
int wpListCount() const { return (hasStart() ? 1 : 0) + _task->waypoints().count(); }
bool selIsStart() const { return hasStart() && _sel == 0; }
int wpIndex() const { return _sel - (hasStart() ? 1 : 0); } // index into WaypointStore
// Add a waypoint by coordinates (no GPS fix needed). Opens the ADD form.
void openAddForm() {
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_add_lat[0] = _add_lon[0] = _add_label[0] = '\0';
_add_lat_neg = _add_lon_neg = false;
_add_sel = 0;
_mode = ADD;
}
// Store the just-typed value into the focused ADD field.
void applyAddField(const char* buf) {
char* dst = (_kb_field == 0) ? _add_lat
: (_kb_field == 1) ? _add_lon : _add_label;
int cap = (_kb_field == 2) ? WAYPOINT_LABEL_LEN : (int)sizeof(_add_lat);
strncpy(dst, buf, cap - 1);
dst[cap - 1] = '\0';
}
// Parse a numeric field: true only when the whole string is a number (so a
// stray letter from the full keyboard can't silently read as 0).
static bool parseNum(const char* s, double& out) {
if (!s || !s[0]) return false;
char* end = nullptr;
out = strtod(s, &end);
if (end == s) return false; // nothing numeric parsed
while (*end == ' ') end++;
return *end == '\0'; // no trailing non-numeric chars
}
// Validate the ADD form and save the waypoint.
void commitAddForm() {
double la, lo;
if (!parseNum(_add_lat, la) || !parseNum(_add_lon, lo)) {
_task->showAlert("Lat/Lon invalid", 1200); return;
}
if (_add_lat_neg) la = -la;
if (_add_lon_neg) lo = -lo;
if (la < -90.0 || la > 90.0 || lo < -180.0 || lo > 180.0) {
_task->showAlert("Out of range", 1200); return;
}
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
int32_t lat = (int32_t)(la * 1e6 + (la < 0 ? -0.5 : 0.5));
int32_t lon = (int32_t)(lo * 1e6 + (lo < 0 ? -0.5 : 0.5));
char label[WAYPOINT_LABEL_LEN];
if (_add_label[0]) { strncpy(label, _add_label, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; }
else snprintf(label, sizeof(label), "WP%d", _task->waypoints().count() + 1);
if (_task->addWaypoint(lat, lon, label)) { _mode = OFF; }
}
// Commit a mark-here / rename label from the keyboard.
void commitLabel(const char* buf) {
if (_kb_rename_idx >= 0) {
_task->waypoints().rename(_kb_rename_idx, buf);
_task->saveWaypoints();
_kb_rename_idx = -1;
return;
}
char auto_lbl[WAYPOINT_LABEL_LEN];
if (!buf || !buf[0]) {
snprintf(auto_lbl, sizeof(auto_lbl), "WP%d", _task->waypoints().count() + 1);
buf = auto_lbl;
}
_task->addWaypoint(_mark_lat, _mark_lon, _mark_ts, buf);
}
void renderAddForm(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "ADD WAYPOINT");
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
const int top = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
for (int i = 0; i < 4; i++) {
int y = top + i * step;
bool sel = (i == _add_sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(0, y); display.print(sel ? ">" : " ");
char row[28];
if (i == 0) snprintf(row, sizeof(row), "Lat: %c %s", _add_lat_neg ? 'S' : 'N', _add_lat[0] ? _add_lat : "--");
else if (i == 1) snprintf(row, sizeof(row), "Lon: %c %s", _add_lon_neg ? 'W' : 'E', _add_lon[0] ? _add_lon : "--");
else if (i == 2) snprintf(row, sizeof(row), "Label: %s", _add_label[0] ? _add_label : "(auto)");
else snprintf(row, sizeof(row), "[Save]");
display.setCursor(cw + 2, y); display.print(row);
display.setColor(DisplayDriver::LIGHT);
}
}
void renderWpList(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
char title[24];
snprintf(title, sizeof(title), "WAYPOINTS %d/%d",
_task->waypoints().count(), WaypointStore::CAPACITY);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
int n = wpListCount();
int total = n + 1; // final row = "+ Add by coords"
const int top = display.listStart();
const int step = display.lineStep();
const int cw = display.getCharWidth();
int vis = display.listVisible();
if (vis < 1) vis = 1;
if (_sel < _scroll) _scroll = _sel;
if (_sel >= _scroll + vis) _scroll = _sel - vis + 1;
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
for (int i = 0; i < vis && (_scroll + i) < total; i++) {
int row = _scroll + i;
int y = top + i * step;
bool sel = (row == _sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
display.setCursor(0, y); display.print(sel ? ">" : " ");
if (row == n) { // the synthetic "Add" row
display.setCursor(cw + 2, y); display.print("+ Add by coords");
display.setColor(DisplayDriver::LIGHT);
continue;
}
int32_t tlat, tlon; const char* label;
if (!rowTarget(row, tlat, tlon, label)) continue;
char dist[12] = ""; int bw = 0;
if (have) {
geo::fmtDist(dist, sizeof(dist), geo::haversineKm(mylat, mylon, tlat, tlon), useImperial());
bw = display.getTextWidth(dist) + 2;
}
char nm[24];
display.translateUTF8ToBlocks(nm, label, sizeof(nm));
display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw, nm);
if (dist[0]) { display.setCursor(display.width() - bw + 1, y); display.print(dist); }
display.setColor(DisplayDriver::LIGHT);
}
}
void renderWpNav(DisplayDriver& display) {
int32_t tlat, tlon; const char* label;
if (!rowTarget(_sel, tlat, tlon, label)) { _mode = LIST; return; }
int32_t mylat, mylon; bool have = ownPos(mylat, mylon);
int cog; bool cogv = _task->currentCourse(cog);
navview::draw(display, have, mylat, mylon, tlat, tlon, label, cogv, cog, useImperial());
}
// Resolve a combined-list row to a nav target. Row 0 is the trail start when
// a trail exists; the rest are saved waypoints.
bool rowTarget(int row, int32_t& lat, int32_t& lon, const char*& label) {
if (hasStart() && row == 0) {
const TrailPoint& s = _store->first();
lat = s.lat_1e6; lon = s.lon_1e6; label = "Trail start";
return true;
}
int wi = row - (hasStart() ? 1 : 0);
WaypointStore& wp = _task->waypoints();
if (wi >= 0 && wi < wp.count()) {
const Waypoint& w = wp.at(wi);
lat = w.lat_1e6; lon = w.lon_1e6;
label = w.label[0] ? w.label : "(unnamed)";
return true;
}
return false;
}
public:
WaypointsView(UITask* task, TrailStore* store) : _task(task), _store(store) {}
void reset() { _mode = OFF; _ctx.active = false; _kb_active = false; _kb_field = -1; }
// True while the component owns the screen (a sub-mode or the keyboard is up).
bool active() const { return _mode != OFF || _kb_active; }
// Entry points called from TrailScreen's action menu.
void openList() { _mode = LIST; _sel = 0; _scroll = 0; }
void markHere() {
int32_t lat, lon;
if (!ownPos(lat, lon)) { _task->showAlert("No GPS fix", 1000); return; }
if (_task->waypoints().full()) { _task->showAlert("Waypoints full", 1000); return; }
_mark_lat = lat; _mark_lon = lon; _mark_ts = (uint32_t)rtc_clock.getCurrentTime();
_kb_rename_idx = -1;
_kb.begin("", WAYPOINT_LABEL_LEN - 1);
_kb_active = true;
}
// Only called while active().
int render(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_kb_active) return _kb.render(display); // keyboard owns the screen
if (_mode == ADD) { renderAddForm(display); return 1000; }
if (_mode == NAV) { renderWpNav(display); return 1000; }
renderWpList(display); // LIST
if (_ctx.active) _ctx.render(display);
return 1000;
}
// Returns true if the input was consumed (always, while active()).
bool handleInput(char c) {
// Label keyboard (mark new / rename / ADD field) takes all input first.
if (_kb_active) {
auto r = _kb.handleInput(c);
if (r == KeyboardWidget::DONE) {
if (_kb_field >= 0) applyAddField(_kb.buf); // ADD field edit
else commitLabel(_kb.buf); // mark / rename label
_kb_active = false; _kb_field = -1;
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false; _kb_field = -1;
}
return true;
}
// Rename/Delete/Send popup — operates on the saved-waypoint index (the
// synthetic Trail-start row, if any, is never editable).
if (_ctx.active) {
auto res = _ctx.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _ctx.selectedIndex();
int wi = wpIndex();
if (sel == 0) { // Rename
if (wi >= 0 && wi < _task->waypoints().count()) {
_kb_rename_idx = wi;
_kb.begin(_task->waypoints().at(wi).label, WAYPOINT_LABEL_LEN - 1);
_kb_active = true;
}
} else if (sel == 1) { // Delete
if (wi >= 0 && wi < _task->waypoints().count()) {
_task->waypoints().remove(wi);
_task->saveWaypoints();
if (_sel >= wpListCount()) _sel = wpListCount() - 1;
if (_sel < 0) _sel = 0;
}
} else { // Send (share in a message)
if (wi >= 0 && wi < _task->waypoints().count()) {
const Waypoint& w = _task->waypoints().at(wi);
double lat = w.lat_1e6 / 1000000.0, lon = w.lon_1e6 / 1000000.0;
char text[80];
if (w.label[0]) snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f %s", lat, lon, w.label);
else snprintf(text, sizeof(text), WAYPOINT_MSG_TAG "%.5f,%.5f", lat, lon);
_task->shareToMessage(text); // hands off to the Messages screen
}
}
}
return true;
}
// ADD form: type lat/lon (magnitude) + hemisphere toggle + label.
if (_mode == ADD) {
if (c == KEY_CANCEL) { _mode = OFF; return true; }
if (c == KEY_UP && _add_sel > 0) { _add_sel--; return true; }
if (c == KEY_DOWN && _add_sel < 3) { _add_sel++; return true; }
if (c == KEY_LEFT || c == KEY_RIGHT) {
if (_add_sel == 0) _add_lat_neg = !_add_lat_neg; // N <-> S
else if (_add_sel == 1) _add_lon_neg = !_add_lon_neg; // E <-> W
return true;
}
if (c == KEY_ENTER) {
if (_add_sel == 0) { _kb_field = 0; _kb.begin(_add_lat, 11); _kb_active = true; }
else if (_add_sel == 1) { _kb_field = 1; _kb.begin(_add_lon, 11); _kb_active = true; }
else if (_add_sel == 2) { _kb_field = 2; _kb.begin(_add_label, WAYPOINT_LABEL_LEN - 1); _kb_active = true; }
else { commitAddForm(); }
return true;
}
return true;
}
// Navigation view — any nav key returns to the list.
if (_mode == NAV) {
if (c == KEY_CANCEL || c == KEY_LEFT || c == KEY_PREV ||
c == KEY_RIGHT || c == KEY_NEXT) { _mode = LIST; }
return true;
}
// List (row 0 is the synthetic "Trail start" when a trail exists; the final
// row is "+ Add by coords"). Cancel returns control to the trail views.
int n = wpListCount();
int total = n + 1; // + the "Add by coords" row
if (c == KEY_CANCEL) { _mode = OFF; return true; }
if (c == KEY_UP && _sel > 0) { _sel--; if (_sel < _scroll) _scroll = _sel; return true; }
if (c == KEY_DOWN && _sel < total - 1) { _sel++; return true; }
if (c == KEY_ENTER) {
if (_sel == n) openAddForm(); // last row → open the add form
else _mode = NAV; // a waypoint / Trail-start row
return true;
}
// Rename/Delete/Send apply to saved waypoints only — not Trail-start or Add.
if (c == KEY_CONTEXT_MENU && !selIsStart() && _sel != n &&
wpIndex() >= 0 && wpIndex() < _task->waypoints().count()) {
_ctx.begin("Waypoint", 3);
_ctx.addItem("Rename");
_ctx.addItem("Delete");
_ctx.addItem("Send");
return true;
}
return true;
}
};