touch: "Fewest hops" sort for Contacts and Discovered (#718)

Both lists already show the hop count on each row; they can now be sorted
by it so the nearest repeaters come first. Combined with the Repeaters
filter this shows which repeaters are reachable directly.

Rows with no known path sink to the end in either direction, like the "?"
rows in the time-based sorts. Hops use pathHops() so 2/3-byte path hashes
don't read as 65+ hops. Language rows added for all 14 languages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael A. CojocariandCopilot committed 2026-10-20 14:17:29 -04:00
1 parent 81a34050af
commit 3dde94c2c4
15 files changed
+40 -5

No files matched your search

+1
View File
@@ -2160,3 +2160,4 @@ you вие
Flood in / out Flood вход / изход
Direct in / out Директни вход / изход
Airtime TX / RX Ефирно време TX / RX
Fewest hops Най-малко скокове
+1
View File
@@ -2176,3 +2176,4 @@ you vy
Flood in / out Flood příchozí / odchozí
Direct in / out Přímé příchozí / odchozí
Airtime TX / RX Vysílací čas TX / RX
Fewest hops Nejméně skoků
+1
View File
@@ -2158,3 +2158,4 @@ you du
Flood in / out Flood ein / aus
Direct in / out Direkt ein / aus
Airtime TX / RX Sendezeit TX / RX
Fewest hops Wenigste Hops
+1
View File
@@ -2160,3 +2160,4 @@ you εσείς
Flood in / out Flood εισ. / εξ.
Direct in / out Άμεσα εισ. / εξ.
Airtime TX / RX Χρόνος εκπομπής TX / RX
Fewest hops Λιγότερα άλματα
+1
View File
@@ -2160,3 +2160,4 @@ you tú
Flood in / out Flood entrada / salida
Direct in / out Directo entrada / salida
Airtime TX / RX Tiempo en aire TX / RX
Fewest hops Menos saltos
+1
View File
@@ -2159,3 +2159,4 @@ you vous
Flood in / out Flood reçus / émis
Direct in / out Direct reçus / émis
Airtime TX / RX Temps d'antenne TX / RX
Fewest hops Moins de sauts
+1
View File
@@ -2170,3 +2170,4 @@ you te
Flood in / out Flood be / ki
Direct in / out Direkt be / ki
Airtime TX / RX Adásidő TX / RX
Fewest hops Legkevesebb ugrás
+1
View File
@@ -2159,3 +2159,4 @@ you tu
Flood in / out Flood in / out
Direct in / out Diretti in / out
Airtime TX / RX Tempo in onda TX / RX
Fewest hops Meno salti
+1
View File
@@ -2158,3 +2158,4 @@ you jij
Flood in / out Flood in / uit
Direct in / out Direct in / uit
Airtime TX / RX Zendtijd TX / RX
Fewest hops Minste hops
+1
View File
@@ -2160,3 +2160,4 @@ you você
Flood in / out Flood entrada / saída
Direct in / out Direto entrada / saída
Airtime TX / RX Tempo no ar TX / RX
Fewest hops Menos saltos
+1
View File
@@ -2161,3 +2161,4 @@ you tu
Flood in / out Flood intrare / ieșire
Direct in / out Direct intrare / ieșire
Airtime TX / RX Timp în aer TX / RX
Fewest hops Cele mai puține salturi
+1
View File
@@ -2160,3 +2160,4 @@ you вы
Flood in / out Flood вход / выход
Direct in / out Прямые вход / выход
Airtime TX / RX Эфирное время TX / RX
Fewest hops Меньше всего хопов
+1
View File
@@ -2160,3 +2160,4 @@ you ви
Flood in / out Flood улаз / излаз
Direct in / out Директни улаз / излаз
Airtime TX / RX Време емитовања TX / RX
Fewest hops Најмање скокова
+1
View File
@@ -2160,3 +2160,4 @@ you ви
Flood in / out Flood вхід / вихід
Direct in / out Прямі вхід / вихід
Airtime TX / RX Ефірний час TX / RX
Fewest hops Найменше хопів
+26 -5
View File
@@ -2962,14 +2962,15 @@ enum ContactsSortMode : uint8_t {
CONTACTS_SORT_LAST_HEARD = 1, // most recent advert first (default)
CONTACTS_SORT_LAST_MSG = 2, // most recent message first (falls back to last-heard)
CONTACTS_SORT_DISTANCE = 3, // nearest first; rows with no shared location are excluded
CONTACTS_SORT_COUNT = 4,
CONTACTS_SORT_HOPS = 4, // fewest hops first; path-unknown rows sink to the end (#718)
CONTACTS_SORT_COUNT = 5,
};
// Default to most-recently-heard at the top — operators care who's on the
// air now far more than alphabetical order. Favorites still pin above all
// (see the qsort comparator in refreshContactsList).
static uint8_t g_contacts_sort = CONTACTS_SORT_LAST_HEARD;
// Reverse the active sort's natural order (the asc/desc toggle). Natural order is
// A→Z / newest-heard / newest-message / nearest; `desc` flips just the primary key
// A→Z / newest-heard / newest-message / nearest / fewest hops; `desc` flips just the primary key
// (favourites stay pinned on top, "?"-heard rows stay sunk). Reset when the mode
// changes; re-tapping the active mode in the Sort sheet flips it.
static bool g_contacts_sort_desc = false;
@@ -2980,6 +2981,7 @@ static const char* contactsSortLabel(uint8_t m) {
case CONTACTS_SORT_LAST_HEARD: return "RECENT";
case CONTACTS_SORT_LAST_MSG: return "MSG";
case CONTACTS_SORT_DISTANCE: return "DIST";
case CONTACTS_SORT_HOPS: return "HOPS";
}
return "?";
}
@@ -2990,6 +2992,7 @@ static const char* contactsSortOptName(uint8_t m) {
case CONTACTS_SORT_LAST_HEARD: return "Recently heard";
case CONTACTS_SORT_LAST_MSG: return "Recent message";
case CONTACTS_SORT_DISTANCE: return "Nearest first";
case CONTACTS_SORT_HOPS: return "Fewest hops";
}
return "?";
}
@@ -14154,14 +14157,15 @@ static DiscoveredAddCtx s_disc_add_ctx[DISCOVERED_MAX];
static void openDiscoveredModalCb(lv_event_t* e);
// ---- Discovered-list sort (mirrors the Contacts sort) -------------------------
// Recently heard / Name / Distance, each with an ascending/descending toggle.
// Recently heard / Name / Distance / Hops, each with an ascending/descending toggle.
// "Recent message" is contacts-only (discovered nodes have no message history) so
// it's omitted. Distance excludes entries that have shared no location.
enum DiscSortMode : uint8_t {
DISC_SORT_RECENT = 0, // newest advert first (default)
DISC_SORT_AZ = 1, // name A-Z
DISC_SORT_DISTANCE = 2, // nearest first; no-location entries excluded
DISC_SORT_COUNT = 3,
DISC_SORT_HOPS = 3, // fewest hops first; path-unknown entries sink to the end (#718)
DISC_SORT_COUNT = 4,
};
static uint8_t g_disc_sort = DISC_SORT_RECENT;
static bool g_disc_sort_desc = false;
@@ -14170,6 +14174,7 @@ static const char* discSortOptName(uint8_t m) {
case DISC_SORT_RECENT: return "Recently heard";
case DISC_SORT_AZ: return "Name (A-Z)";
case DISC_SORT_DISTANCE: return "Nearest first";
case DISC_SORT_HOPS: return "Fewest hops";
}
return "?";
}
@@ -14186,6 +14191,14 @@ static bool discSortBefore(int ia, int ib, double self_lat, double self_lon) {
if (da < db) prim = -1; else if (da > db) prim = 1;
break;
}
case DISC_SORT_HOPS: {
// Unknown path sinks to the end whichever way the list is flipped.
const bool ua = (A.path_len == OUT_PATH_UNKNOWN), ub = (B.path_len == OUT_PATH_UNKNOWN);
if (ua != ub) return ub;
const uint8_t ha = pathHops(A.path_len), hb = pathHops(B.path_len);
if (ha != hb) prim = (ha < hb) ? -1 : 1;
break;
}
case DISC_SORT_AZ:
prim = strcasecmp(A.ci.name, B.ci.name);
break;
@@ -32663,7 +32676,7 @@ static lv_obj_t* s_ct_sort_sheet = nullptr; // Sort & filter overlay
static lv_obj_t* s_ct_normal_bar = nullptr; // header toolbar shown in normal mode
static lv_obj_t* s_ct_select_bar = nullptr; // header toolbar shown in select mode
static lv_obj_t* s_ct_del_btn = nullptr; // "Delete (N)" button in the select bar
static lv_obj_t* s_ct_sort_btns[CONTACTS_SORT_COUNT] = { nullptr, nullptr, nullptr, nullptr };
static lv_obj_t* s_ct_sort_btns[CONTACTS_SORT_COUNT] = {};
static lv_obj_t* s_ct_filter_btns[6] = { nullptr, nullptr, nullptr, nullptr, nullptr, nullptr };
static lv_obj_t* s_ct_filter_btn_lbl = nullptr; // header Filter button label (shows the active filter)
static lv_obj_t* s_ct_disc_badge = nullptr; // count badge on the header Discovered button
@@ -33059,6 +33072,7 @@ static void openContactsSortSheet(){
s_ct_sort_btns[1] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_LAST_HEARD), ctSortOptCb, CONTACTS_SORT_LAST_HEARD, false);
s_ct_sort_btns[2] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_LAST_MSG), ctSortOptCb, CONTACTS_SORT_LAST_MSG, false);
s_ct_sort_btns[3] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_DISTANCE), ctSortOptCb, CONTACTS_SORT_DISTANCE, false);
s_ct_sort_btns[4] = ctSheetOption(card, contactsSortOptName(CONTACTS_SORT_HOPS), ctSortOptCb, CONTACTS_SORT_HOPS, false);
ctSortRowsRepaint(); // tap the active row again to flip ascending/descending
lv_obj_t* sp = lv_obj_create(card); lv_obj_remove_style_all(sp); lv_obj_set_size(sp, 1, 4);
ctSheetOption(card, LV_SYMBOL_TRASH " Select to delete", ctSelectFromSheetCb, 0, false);
@@ -45795,6 +45809,13 @@ static void refreshContactsList() {
const double db = contactDistanceKm(s_ct_sort_self_lat, s_ct_sort_self_lon,
eb->gps_lat / 1.0e6, eb->gps_lon / 1.0e6);
if (da < db) prim = -1; else if (da > db) prim = 1;
} else if (g_contacts_sort == CONTACTS_SORT_HOPS) {
// Fewest hops first; a row with no known path (shows no hop count) sinks to
// the end whichever way the list is flipped.
const bool ua = (ea->out_path_len == OUT_PATH_UNKNOWN), ub = (eb->out_path_len == OUT_PATH_UNKNOWN);
if (ua != ub) return ua ? 1 : -1;
const uint8_t ha = pathHops(ea->out_path_len), hb = pathHops(eb->out_path_len);
if (ha != hb) prim = (ha < hb) ? -1 : 1;
} else if (g_contacts_sort == CONTACTS_SORT_LAST_HEARD ||
g_contacts_sort == CONTACTS_SORT_LAST_MSG) {
// Contacts whose last-heard renders as "?" sink to the end of the time-based