From a3eeb91869c366784cbe172858d927c86a81dbf8 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sun, 6 Sep 2026 11:01:13 +0200 Subject: [PATCH] Add Sync TUI page and Android sync contract --- CHANGELOG.md | 14 + docs/android.md | 26 ++ docs/reviews/sync_tui.md | 83 +++++ docs/roadmap.md | 55 +++ docs/tui.md | 97 +++++ tui/src/tui.c | 763 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 1022 insertions(+), 16 deletions(-) create mode 100644 docs/reviews/sync_tui.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 467545c..5ff19c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,3 +139,17 @@ Next: - validated a real Android internal-storage write/list/read roundtrip; - kept Android access mount-free: no GVFS/FUSE mount lifecycle is required. + + +### Sync TUI page + +- added `5 Sync / F5` to primary navigation; +- added dedicated Sync ASCII banner and framed page; +- added live direct-MTP device and storage status; +- suppressed libmtp terminal output during ncurses rendering; +- added focused-frame navigation with Tab/Shift+Tab; +- added clean list navigation and scrollbar behavior; +- exposed Android -> PC session/exercise/body synchronization directions; +- exposed PC -> Android canonical exercise-catalog direction; +- kept frozen Trainlog session JSON v1 unchanged. + diff --git a/docs/android.md b/docs/android.md index 91e4a6c..be768b3 100644 --- a/docs/android.md +++ b/docs/android.md @@ -234,3 +234,29 @@ slice before the Android recorder depends on it. The Android application itself will only need to produce a valid frozen Trainlog JSON v1 document and place it in the agreed exchange area. + + +## 16. Current Android implementation cursor + +The Linux transport and Sync TUI foundations are complete enough to begin the +Android client. + +Initial Android development uses fictitious data. + +First Android slice: + +```text +1. application scaffold +2. local exercise catalog +3. create/select exercise +4. session form +5. actual set entry +6. optional body measurements +7. fictitious completed session +``` + +Transport/export is added only after the local recorder workflow is comfortable. + +The Android client must preserve stable exercise IDs so a catalog snapshot from +the PC can normalize exercise selection on both sides. + diff --git a/docs/reviews/sync_tui.md b/docs/reviews/sync_tui.md new file mode 100644 index 0000000..1902fc1 --- /dev/null +++ b/docs/reviews/sync_tui.md @@ -0,0 +1,83 @@ +# Sync TUI checkpoint + +## Status + +```text +MTP_TRANSPORT_FOUNDATION=PASS +TUI_SYNC_PAGE=IMPLEMENTED +TUI_SYNC_DEVICE_STATUS=IMPLEMENTED +TUI_SYNC_FOCUS_NAVIGATION=IMPLEMENTED +TUI_SYNC_REMOTE_JSON_CANDIDATES=IMPLEMENTED +TUI_SYNC_LOCAL_CATALOG_COUNT=IMPLEMENTED +TRAINLOG_FORMAT_V1=FROZEN +ANDROID_APP_SCAFFOLD=NEXT +``` + +The TUI now exposes a dedicated `5 Sync / F5` primary page. + +The page uses the same visual identity as the rest of Trainlog: + +```text +TRAINLOG ASCII banner +top navigation +framed device status +framed synchronization list +footer shortcuts +``` + +Only the focused frame is highlighted with the warning/yellow border role. + +`Tab` / `Shift+Tab` moves focus between top navigation and synchronization +content. Direct `0-5` / `F1-F5` navigation remains available. + +## Device status + +The page scans the direct USB/MTP backend and displays: + +- connected MTP device; +- USB bus/device; +- VID:PID; +- serial when available; +- MTP storage readiness; +- free and total space. + +Backend stdout/stderr is suppressed during the live MTP scan so libmtp cannot +corrupt ncurses rendering. + +## Synchronization directions + +Incoming Android -> PC categories: + +```text +sessions +exercise metadata carried by valid sessions +body measurements carried by valid sessions +``` + +New exercise metadata in a valid incoming session is intended to reconcile +automatically with the canonical local exercise catalog. + +Body measurements embedded in the session are imported atomically with that +session. + +Outgoing PC -> Android: + +```text +canonical exercise catalog +``` + +Exercises created directly on the PC must be exportable to Android so both +sides use the same stable exercise IDs, display names, and tracking modes. + +Catalog synchronization will use its own versioned snapshot contract and will +not modify or overload the frozen Trainlog session JSON v1 format. + +## Current boundary + +The Sync page currently reports remote JSON candidates and the local exercise +count. + +It does not yet classify/import session JSON or emit the catalog snapshot. + +The next development slice is the Android application scaffold using fictitious +development records until the full transfer path is validated. diff --git a/docs/roadmap.md b/docs/roadmap.md index f362f54..0e6923f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -230,3 +230,58 @@ Next implementation slice: 4. begin the minimal Android recorder ``` + + +## Sync TUI integration + +```text +MTP_TRANSPORT_FOUNDATION=PASS +TUI_SYNC_PAGE=IMPLEMENTED +TUI_SYNC_DEVICE_STATUS=IMPLEMENTED +TUI_SYNC_REMOTE_JSON_CANDIDATES=IMPLEMENTED +TUI_SYNC_LOCAL_CATALOG_COUNT=IMPLEMENTED +JSON_V1_MTP_CLASSIFICATION_IMPORT=NEXT +CATALOG_SNAPSHOT_SYNC=AFTER +ANDROID_MINIMAL_RECORDER=AFTER +TRAINLOG_FORMAT_V1=FROZEN +``` + +Sync direction: + +```text +Android -> PC + sessions + new exercises embedded in sessions -> automatic reconciliation + body data embedded in sessions -> automatic import + +PC -> Android + canonical exercise catalog snapshot +``` + +Standalone body-observation exchange, if required by the Android recorder, gets +its own explicit versioned contract rather than changing session JSON v1. + + + +## Sync UI checkpoint complete + +```text +MTP_TRANSPORT_FOUNDATION=PASS +TUI_SYNC_PAGE=IMPLEMENTED +TUI_SYNC_DEVICE_STATUS=IMPLEMENTED +TUI_SYNC_FOCUS_NAVIGATION=IMPLEMENTED +TUI_SYNC_REMOTE_JSON_CANDIDATES=IMPLEMENTED +TUI_SYNC_LOCAL_CATALOG_COUNT=IMPLEMENTED +ANDROID_APP_SCAFFOLD=NEXT +ANDROID_FAKE_DATA_FLOW=AFTER +JSON_V1_ANDROID_EXPORT=AFTER +CATALOG_SNAPSHOT_SYNC=AFTER +TRAINLOG_FORMAT_V1=FROZEN +``` + +Development now moves to the Android client. + +Fictitious sessions, exercises, and body observations are used during Android +development and synchronization testing. The development database will be +purged before normal production use begins. + diff --git a/docs/tui.md b/docs/tui.md index 195ade2..7d80e36 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -577,3 +577,100 @@ preserved. The dashboard rolling 12-month axis clamps the final `MM/YY` label inside the dashboard frame so the current-month label does not overwrite the right border. + + +## Sync page + +The primary TUI navigation includes: + +```text +5 Sync / F5 +``` + +The Sync screen uses the same ASCII banner, top navigation, ncurses frames and +footer conventions as the other primary pages. + +The screen is a live overview of the direct USB/MTP transport: + +- connected physical MTP device; +- USB bus/device and VID:PID; +- device serial when available; +- selected MTP storage; +- free/capacity values; +- readiness of the root `Trainlog` exchange area. + +Incoming categories are presented explicitly: + +```text +Séances +Exercices +Mensurations +``` + +New exercise metadata arriving inside a valid session is intended to reconcile +automatically against the canonical local catalog. + +Body measurements carried by a valid session are imported with that session. + +The opposite direction is also explicit: exercises created directly on the PC +must be exportable to the Android application so both sides use the same stable +exercise IDs, names and tracking modes. This catalog synchronization uses a +separate versioned catalog snapshot; it does not overload or modify the frozen +Trainlog session JSON v1 contract. + +The first Sync-page slice displays remote JSON candidates and the local exercise +count. Actual JSON classification/import and catalog-snapshot export are the +next synchronization slices. + + + +## Sync page checkpoint + +Primary navigation now includes: + +```text +0 Accueil +1 Séance +2 Historique +3 Exercices +4 Corps +5 Sync +``` + +`5 Sync / F5` opens a dedicated page rather than drawing over the dashboard. + +The page contains: + +- `APPAREIL CONNECTE`; +- `SYNCHRONISATION`. + +Focus rules: + +```text +Tab / Shift+Tab switch focused frame +Left/Right move inside top navigation +Enter activate selected navigation item +Up/Down move through synchronization rows +PgUp/PgDn scroll synchronization content +r rescan USB/MTP state +b / Escape return +``` + +Only the focused frame uses the yellow border/title role. + +The live page reports the connected Android MTP device and exposes these sync +directions: + +```text +Android -> PC + sessions + exercises carried by sessions + body measurements carried by sessions + +PC -> Android + canonical exercise catalog +``` + +The session exchange remains frozen Trainlog JSON v1. +Catalog synchronization is a separate versioned contract. + diff --git a/tui/src/tui.c b/tui/src/tui.c index 92cc741..c56d272 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -6,6 +6,7 @@ #include "trainlog/tui.h" #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include @@ -21,8 +23,10 @@ #include "trainlog/catalog.h" #include "trainlog/duration.h" #include "trainlog/id.h" +#include "trainlog/mtp.h" #include "trainlog/theme.h" #include "trainlog/timeutil.h" +#include "trainlog/usb.h" #define MAX_EXERCISES 128U #define MAX_SESSION_EXERCISES 32U @@ -40,6 +44,7 @@ typedef enum DashboardAction { DASHBOARD_HISTORY, DASHBOARD_EXERCISES, DASHBOARD_BODY, + DASHBOARD_SYNC, DASHBOARD_QUIT } DashboardAction; @@ -52,6 +57,10 @@ static void screen_exercises( TrainlogDatabase *database ); +static void screen_sync( + TrainlogDatabase *database +); + static void dashboard_panel( int top, int left, @@ -1667,10 +1676,10 @@ primary_top_nav_forward(key)) { nav_selected = nav_selected > 0 ? nav_selected - 1 - : 4; + : 5; } else if (key == KEY_RIGHT) { nav_selected = - nav_selected < 4 + nav_selected < 5 ? nav_selected + 1 : 0; } else if ( @@ -3351,7 +3360,8 @@ static void primary_top_navbar( "1 Séance", "2 Historique", "3 Exercices", - "4 Corps" + "4 Corps", + "5 Sync" }; int column = 5; @@ -3367,7 +3377,7 @@ static void primary_top_navbar( ); for (index = 0; - index < 5; + index < 6; ++index) { int width = (int)strlen(labels[index]) + 4; @@ -3420,7 +3430,7 @@ static bool primary_top_nav_activate( ) { if (selected_page < 0 || - selected_page > 4) { + selected_page > 5) { return false; } @@ -3469,6 +3479,11 @@ static bool primary_top_nav_forward( forwarded = '4'; break; + case KEY_F(5): + case '5': + forwarded = '5'; + break; + default: return false; } @@ -4170,7 +4185,8 @@ static DashboardAction screen_dashboard( "1 Séance", "2 Historique", "3 Exercices", - "4 Corps" + "4 Corps", + "5 Sync" }; static const char *const footer = @@ -4215,7 +4231,7 @@ static DashboardAction screen_dashboard( large_layout ? 5 : 3; for (index = 0; - index < 5; + index < 6; ++index) { int width = (int)strlen(labels[index]) + 4; @@ -4279,13 +4295,13 @@ static DashboardAction screen_dashboard( selected = selected > 0 ? selected - 1 - : 4; + : 5; break; case KEY_DOWN: case KEY_RIGHT: selected = - selected < 4 + selected < 5 ? selected + 1 : 0; break; @@ -4303,6 +4319,8 @@ static DashboardAction screen_dashboard( return DASHBOARD_EXERCISES; case 4: return DASHBOARD_BODY; + case 5: + return DASHBOARD_SYNC; default: break; } @@ -4329,6 +4347,10 @@ static DashboardAction screen_dashboard( case '4': return DASHBOARD_BODY; + case KEY_F(5): + case '5': + return DASHBOARD_SYNC; + case 'q': case 'Q': return DASHBOARD_QUIT; @@ -4903,7 +4925,9 @@ static bool choose_session_type( key == '3' || key == KEY_F(3) || key == '4' || - key == KEY_F(4))) { + key == KEY_F(4) || + key == '5' || + key == KEY_F(5))) { if (primary_top_nav_forward(key)) { return false; } @@ -4917,10 +4941,10 @@ static bool choose_session_type( nav_selected = nav_selected > 0 ? nav_selected - 1 - : 4; + : 5; } else if (key == KEY_RIGHT) { nav_selected = - nav_selected < 4 + nav_selected < 5 ? nav_selected + 1 : 0; } else if ( @@ -6753,10 +6777,10 @@ static void screen_history( nav_selected = nav_selected > 0 ? nav_selected - 1 - : 4; + : 5; } else if (key == KEY_RIGHT) { nav_selected = - nav_selected < 4 + nav_selected < 5 ? nav_selected + 1 : 0; } else if ( @@ -8050,10 +8074,10 @@ static void screen_body( nav_selected = nav_selected > 0 ? nav_selected - 1 - : 4; + : 5; } else if (key == KEY_RIGHT) { nav_selected = - nav_selected < 4 + nav_selected < 5 ? nav_selected + 1 : 0; } else if ( @@ -8162,6 +8186,710 @@ static void screen_body( } } +/* TRAINLOG_SYNC_TUI */ + +#define SYNC_DEVICE_CAPACITY 8U +#define SYNC_STORAGE_CAPACITY 8U +#define SYNC_ENTRY_CAPACITY 128U + +typedef struct TrainlogSyncOverview { + bool connected; + bool storage_ready; + bool exchange_ready; + size_t device_count; + size_t remote_entry_count; + size_t remote_json_count; + size_t local_exercise_count; + TrainlogUsbDevice device; + TrainlogMtpStorage storage; + uint32_t exchange_folder_id; +} TrainlogSyncOverview; + +static bool sync_name_has_json_suffix( + const char *name +) +{ + size_t length; + + if (name == NULL) { + return false; + } + + length = strlen(name); + + return + length >= 5U && + strcmp( + name + length - 5U, + ".json" + ) == 0; +} + +static double sync_bytes_to_gib( + uint64_t bytes +) +{ + return + (double)bytes / + (1024.0 * 1024.0 * 1024.0); +} + +static void sync_load_overview( + TrainlogDatabase *database, + TrainlogSyncOverview *overview +) +{ + TrainlogUsbDevice devices[SYNC_DEVICE_CAPACITY]; + TrainlogMtpStorage storages[SYNC_STORAGE_CAPACITY]; + TrainlogMtpEntry entries[SYNC_ENTRY_CAPACITY]; + + size_t device_count = 0U; + size_t storage_count = 0U; + size_t entry_count = 0U; + size_t index; + bool folder_created = false; + + if (overview == NULL) { + return; + } + + (void)memset( + overview, + 0, + sizeof(*overview) + ); + + (void)trainlog_database_exercise_count( + database, + &overview->local_exercise_count + ); + + if (trainlog_usb_list_mtp_devices( + devices, + SYNC_DEVICE_CAPACITY, + &device_count + ) != TRAINLOG_STATUS_OK || + device_count == 0U) { + return; + } + + overview->connected = true; + overview->device_count = device_count; + overview->device = devices[0]; + + if (trainlog_mtp_list_storages( + overview->device.bus_number, + overview->device.device_number, + storages, + SYNC_STORAGE_CAPACITY, + &storage_count + ) != TRAINLOG_STATUS_OK || + storage_count == 0U) { + return; + } + + overview->storage_ready = true; + overview->storage = storages[0]; + + if (trainlog_mtp_ensure_root_folder( + overview->device.bus_number, + overview->device.device_number, + overview->storage.storage_id, + "Trainlog", + &overview->exchange_folder_id, + &folder_created + ) != TRAINLOG_STATUS_OK) { + return; + } + + (void)folder_created; + overview->exchange_ready = true; + + if (trainlog_mtp_list_folder( + overview->device.bus_number, + overview->device.device_number, + overview->storage.storage_id, + overview->exchange_folder_id, + entries, + SYNC_ENTRY_CAPACITY, + &entry_count + ) != TRAINLOG_STATUS_OK) { + return; + } + + overview->remote_entry_count = + entry_count; + + for (index = 0U; + index < entry_count; + ++index) { + if (!entries[index].folder && + sync_name_has_json_suffix( + entries[index].name + )) { + ++overview->remote_json_count; + } + } +} + +static void screen_sync( + TrainlogDatabase *database +) +{ + size_t selected = 0U; + int nav_selected = 5; + int focus = 1; + + for (;;) { + TrainlogSyncOverview overview; + bool large_layout = + COLS >= 100 && + LINES >= 30; + + const size_t item_count = 4U; + size_t top = 0U; + size_t index; + int list_top; + int list_bottom; + int first_row; + int visible_rows; + int key; + + int saved_stdout = -1; + int saved_stderr = -1; + int null_fd = -1; + + /* + * libmtp may print raw-device identification directly to stdout or + * stderr. Redirect both temporarily so backend diagnostics cannot + * corrupt ncurses' physical-screen state. + */ + (void)fflush(stdout); + (void)fflush(stderr); + + saved_stdout = + dup(STDOUT_FILENO); + + saved_stderr = + dup(STDERR_FILENO); + + null_fd = + open( + "/dev/null", + O_WRONLY | O_CLOEXEC + ); + + if (saved_stdout >= 0 && + saved_stderr >= 0 && + null_fd >= 0) { + (void)dup2( + null_fd, + STDOUT_FILENO + ); + + (void)dup2( + null_fd, + STDERR_FILENO + ); + } + + sync_load_overview( + database, + &overview + ); + + (void)fflush(stdout); + (void)fflush(stderr); + + if (saved_stdout >= 0) { + (void)dup2( + saved_stdout, + STDOUT_FILENO + ); + + (void)close( + saved_stdout + ); + } + + if (saved_stderr >= 0) { + (void)dup2( + saved_stderr, + STDERR_FILENO + ); + + (void)close( + saved_stderr + ); + } + + if (null_fd >= 0) { + (void)close( + null_fd + ); + } + + /* + * Force a complete physical redraw. This also protects the page if a + * backend writes directly to the terminal despite stdio redirection. + */ + clear(); + clearok( + stdscr, + TRUE + ); + + if (!large_layout) { + draw_shell( + "TRAINLOG — Sync", + "↑↓ parcourir r actualiser b/Échap retour" + ); + + if (!overview.connected) { + mvprintw( + 4, + 4, + "Aucun appareil Android MTP connecté." + ); + } else { + mvprintw( + 4, + 4, + "Appareil : %.*s", + COLS - 16, + overview.device.model + ); + + mvprintw( + 6, + 4, + "Séances : %zu JSON candidat(s)", + overview.remote_json_count + ); + + mvprintw( + 7, + 4, + "Catalogue PC : %zu exercice(s)", + overview.local_exercise_count + ); + } + + refresh(); + key = getch(); + + if (key == 'b' || + key == 'B' || + key == 27) { + return; + } + + continue; + } + + list_top = 17; + list_bottom = LINES - 4; + first_row = list_top + 2; + visible_rows = + list_bottom - + first_row; + + if (visible_rows < 1) { + return; + } + + if (selected >= item_count) { + selected = + item_count - 1U; + } + + if (selected >= + (size_t)visible_rows) { + top = + selected - + (size_t)visible_rows + + 1U; + } + + box( + stdscr, + 0, + 0 + ); + + section_ascii_header( + ":: S Y N C ::" + ); + + primary_top_navbar( + 5, + nav_selected, + focus == 0 + ); + + dashboard_panel( + 11, + 2, + 16, + COLS - 3, + "APPAREIL CONNECTE" + ); + + focused_panel( + list_top, + 2, + list_bottom, + COLS - 3, + "SYNCHRONISATION", + focus == 1 + ); + + if (!overview.connected) { + attron( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + + mvprintw( + 13, + 5, + "Aucun appareil Android en mode partage de fichiers." + ); + + attroff( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_WARNING + ) + ); + + mvprintw( + 14, + 5, + "%.*s", + COLS - 10, + "Branchez et déverrouillez le téléphone puis choisissez Transfert de fichiers." + ); + } else { + attron( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_SUCCESS + ) + ); + + mvprintw( + 12, + 5, + "✓ MTP direct connecté" + ); + + attroff( + A_BOLD | + trainlog_theme_attribute( + TRAINLOG_COLOR_SUCCESS + ) + ); + + mvprintw( + 13, + 5, + "%.*s", + COLS - 10, + overview.device.model[0] != '\0' + ? overview.device.model + : overview.device.vendor + ); + + mvprintw( + 14, + 5, + "USB %03u:%03u %04x:%04x série %.*s", + overview.device.bus_number, + overview.device.device_number, + overview.device.vendor_id, + overview.device.product_id, + COLS - 48, + overview.device.serial[0] != '\0' + ? overview.device.serial + : "—" + ); + + if (overview.storage_ready) { + mvprintw( + 15, + 5, + "%.*s · %.2f GiB libres / %.2f GiB", + 24, + overview.storage.description[0] != '\0' + ? overview.storage.description + : "Stockage MTP", + sync_bytes_to_gib( + overview.storage.free_space_bytes + ), + sync_bytes_to_gib( + overview.storage.max_capacity_bytes + ) + ); + } + } + + for (index = 0U; + index < (size_t)visible_rows && + top + index < item_count; + ++index) { + size_t absolute = + top + index; + + int row = + first_row + + (int)index; + + const char *direction; + const char *category; + char status[128]; + + switch (absolute) { + case 0U: + direction = "↓"; + category = + "Séances Android -> PC"; + + (void)snprintf( + status, + sizeof(status), + "%zu JSON candidat(s) à analyser", + overview.remote_json_count + ); + break; + + case 1U: + direction = "↓"; + category = + "Exercices Android -> PC"; + + (void)snprintf( + status, + sizeof(status), + "%s", + "import automatique avec séance valide" + ); + break; + + case 2U: + direction = "↓"; + category = + "Mensurations Android -> PC"; + + (void)snprintf( + status, + sizeof(status), + "%s", + "import automatique avec séance valide" + ); + break; + + case 3U: + default: + direction = "↑"; + category = + "Catalogue PC -> Android"; + + (void)snprintf( + status, + sizeof(status), + "%zu exercice(s) locaux à publier", + overview.local_exercise_count + ); + break; + } + + if (focus == 1 && + absolute == selected) { + attron( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + + mvprintw( + row, + 5, + " %s %-30.30s %-.*s ", + direction, + category, + COLS - 45, + status + ); + + if (focus == 1 && + absolute == selected) { + attroff( + A_REVERSE | + trainlog_theme_attribute( + TRAINLOG_COLOR_ACCENT + ) + ); + } + } + + if (overview.exchange_ready && + LINES > 31) { + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + list_bottom - 2, + 5, + "Zone Trainlog : %zu objet(s) · JSON séance v1 gelé · snapshot catalogue séparé", + overview.remote_entry_count + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + } + + section_scrollbar( + first_row, + list_bottom - 1, + COLS - 5, + selected, + item_count, + (size_t)visible_rows + ); + + attron( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + mvprintw( + LINES - 2, + 2, + "%.*s", + COLS - 4, + "Tab zone ←→ menu ↑↓/PgUp/PgDn parcourir Entrée ouvrir r actualiser 0-5/F1-F5 direct b/Échap retour" + ); + + attroff( + trainlog_theme_attribute( + TRAINLOG_COLOR_MUTED + ) + ); + + touchwin( + stdscr + ); + + refresh(); + key = getch(); + + if (key == '\t' || + key == KEY_BTAB) { + focus = + focus == 0 + ? 1 + : 0; + continue; + } + + if (primary_top_nav_forward( + key + )) { + return; + } + + if (key == 'b' || + key == 'B' || + key == 27) { + return; + } + + if (focus == 0) { + if (key == KEY_LEFT) { + nav_selected = + nav_selected > 0 + ? nav_selected - 1 + : 5; + } else if (key == KEY_RIGHT) { + nav_selected = + nav_selected < 5 + ? nav_selected + 1 + : 0; + } else if ( + key == '\n' || + key == KEY_ENTER + ) { + if (nav_selected == 5) { + focus = 1; + } else if ( + primary_top_nav_activate( + nav_selected + ) + ) { + return; + } + } + + continue; + } + + if (key == KEY_UP) { + selected = + selected > 0U + ? selected - 1U + : 0U; + continue; + } + + if (key == KEY_DOWN) { + selected = + selected + 1U < item_count + ? selected + 1U + : item_count - 1U; + continue; + } + + if (key == KEY_PPAGE) { + size_t jump = + (size_t)visible_rows; + + selected = + selected > jump + ? selected - jump + : 0U; + continue; + } + + if (key == KEY_NPAGE) { + size_t jump = + (size_t)visible_rows; + + selected = + selected + jump < item_count + ? selected + jump + : item_count - 1U; + continue; + } + + /* + * 'r' intentionally reaches the next loop iteration. Every iteration + * performs a fresh USB/MTP scan before repainting the page. + */ + } +} + int trainlog_tui_run(TrainlogDatabase *database) { if (database == NULL) { @@ -8217,6 +8945,9 @@ int trainlog_tui_run(TrainlogDatabase *database) case DASHBOARD_BODY: screen_body(database); break; + case DASHBOARD_SYNC: + screen_sync(database); + break; case DASHBOARD_QUIT: endwin(); return 0;