mirror of
https://github.com/pelgraine/Meck.git
synced 2026-08-10 10:42:42 +02:00
Remove T-Watch code from repo
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
#include "ModemManager.h" // Serial CLI modem commands
|
||||
#endif
|
||||
|
||||
#if defined(LilyGo_TDeck_Pro_Max) || defined(LILYGO_TWATCH_S3)
|
||||
#if defined(LilyGo_TDeck_Pro_Max)
|
||||
#include "DRV2605Haptic.h" // inline haptic driver for the 'buzz' CLI test command
|
||||
#endif
|
||||
|
||||
@@ -1427,20 +1427,8 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i
|
||||
// auto-discovery overwrite it. Skip the base class call entirely — ACKs
|
||||
// embedded in path responses will still be delivered via separate ACK packets.
|
||||
if (contact.flags & CONTACT_FLAG_CUSTOM_PATH) {
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: keep the manually set path (don't let discovery overwrite it), but
|
||||
// STILL deliver an embedded login/response reply riding on this path packet
|
||||
// -- otherwise logins to custom-path contacts silently time out. Gated to
|
||||
// the watch so other builds' admin flow is unchanged.
|
||||
MESH_DEBUG_PRINTLN("onContactPathRecv: custom-path %s, keeping path, delivering response (type=%d len=%d)", contact.name, extra_type, extra_len);
|
||||
if (extra_type == PAYLOAD_TYPE_RESPONSE && extra_len > 0) {
|
||||
onContactResponse(contact, extra, extra_len);
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
MESH_DEBUG_PRINTLN("onContactPathRecv: skipping path update for custom-path contact %s", contact.name);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len);
|
||||
}
|
||||
@@ -2561,10 +2549,6 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
if (strcmp(sp, "gps") == 0) {
|
||||
_prefs.gps_enabled = (np[0] == '1') ? 1 : 0;
|
||||
savePrefs();
|
||||
#if defined(MECK_TWATCH)
|
||||
// Apply the BLDO1 GPS rail live so the toggle takes effect now, no reboot
|
||||
if (_prefs.gps_enabled) board.gpsPowerOn(); else board.gpsPowerOff();
|
||||
#endif
|
||||
} else if (strcmp(sp, "gps_interval") == 0) {
|
||||
uint32_t interval_seconds = atoi(np);
|
||||
_prefs.gps_interval = constrain(interval_seconds, 0, 86400);
|
||||
@@ -3748,7 +3732,7 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
}
|
||||
|
||||
}
|
||||
#if defined(LilyGo_TDeck_Pro_Max) || defined(LILYGO_TWATCH_S3)
|
||||
#if defined(LilyGo_TDeck_Pro_Max)
|
||||
else if (strncmp(cli_command, "buzz", 4) == 0 &&
|
||||
(cli_command[4] == '\0' || cli_command[4] == ' ')) {
|
||||
// Fire a DRV2605 library-1 effect to confirm the motor works.
|
||||
|
||||
@@ -109,7 +109,7 @@ struct NodePrefs { // persisted to file
|
||||
// upward → fillRect must start above baseline to cover ascenders.
|
||||
// T5S3: always 0 (both sizes use baseline fonts with highlight at y).
|
||||
inline int smallHighlightOff() const {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
return 0;
|
||||
#else
|
||||
// Custom 7pt fonts at textSize 0 use GFXfont (baseline rendering), not built-in.
|
||||
|
||||
@@ -709,31 +709,13 @@
|
||||
// =============================================================================
|
||||
|
||||
// Define MECK_TOUCH_ENABLED for any platform with touch support
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || (defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN)) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || (defined(LilyGo_TDeck_Pro) && defined(HAS_TOUCHSCREEN))
|
||||
#define MECK_TOUCH_ENABLED 1
|
||||
#endif
|
||||
|
||||
// --- T-Watch S3 Plus: screen headers for the touch UI ---
|
||||
// The watch needs the same concrete screen types as the gesture machine casts
|
||||
// to, but none of the T5S3/T-Deck hardware baggage (GT911, SD, TCA8418 keyboard).
|
||||
#if defined(MECK_TWATCH)
|
||||
#include "TextReaderScreen.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "ContactsScreen.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "ChannelPickerScreen.h"
|
||||
#include "MeckExport.h"
|
||||
#include "MeckImport.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "RepeaterAdminScreen.h"
|
||||
#include "DiscoveryScreen.h"
|
||||
#include "LastHeardScreen.h"
|
||||
#include "PathEditorScreen.h"
|
||||
#include "Tracescreen.h"
|
||||
#include "GamesMenuScreen.h"
|
||||
#include "SnakeScreen.h"
|
||||
#include "MinesweeperScreen.h"
|
||||
#endif
|
||||
|
||||
// --- T5S3: GT911 capacitive touch driver ---
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
@@ -901,8 +883,6 @@
|
||||
#define TOUCH_LONG_PRESS_MS 750
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#define TOUCH_SWIPE_THRESHOLD 60 // T5S3: 960×540 — 60px ≈ 6% of width
|
||||
#elif defined(MECK_TWATCH)
|
||||
#define TOUCH_SWIPE_THRESHOLD 16 // Watch: getTouch() returns 0..119 (240px/UI_ZOOM); 16 is ~13% of width
|
||||
#else
|
||||
#define TOUCH_SWIPE_THRESHOLD 30 // T-Deck Pro: 240×320 — 30px ≈ 12.5% of width
|
||||
#endif
|
||||
@@ -939,18 +919,6 @@
|
||||
}
|
||||
#elif defined(LilyGo_TDeck_Pro)
|
||||
return touchInput.getPoint(*outX, *outY);
|
||||
#elif defined(MECK_TWATCH)
|
||||
{
|
||||
// FT6336U is read through the LovyanGFX panel backing the display.
|
||||
// display.getTouch() returns coordinates already divided by UI_ZOOM.
|
||||
int tx, ty;
|
||||
if (display.getTouch(&tx, &ty)) {
|
||||
*outX = (int16_t)tx;
|
||||
*outY = (int16_t)ty;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
@@ -964,11 +932,6 @@
|
||||
#elif defined(LilyGo_TDeck_Pro)
|
||||
float sx = (float)EINK_WIDTH / 128.0f; // 240/128 = 1.875
|
||||
float sy = (float)EINK_HEIGHT / 128.0f; // 320/128 = 2.5
|
||||
#elif defined(MECK_TWATCH)
|
||||
// display.getTouch() already divides by UI_ZOOM, so px/py span the
|
||||
// 240/UI_ZOOM logical canvas (0..119 at UI_ZOOM=2). Scale to 128 virtual.
|
||||
float sx = (240.0f / UI_ZOOM) / 128.0f;
|
||||
float sy = (240.0f / UI_ZOOM) / 128.0f;
|
||||
#endif
|
||||
vx = (int)(px / sx);
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
@@ -1058,9 +1021,6 @@ static uint32_t _atoi(const char* sp) {
|
||||
DataStore store(LittleFS, rtc_clock);
|
||||
#elif defined(ESP32)
|
||||
#include <SPIFFS.h>
|
||||
#if defined(MECK_TWATCH)
|
||||
#include <LittleFS.h> // watch: dedicated 'maps' tiles partition
|
||||
#endif
|
||||
DataStore store(SPIFFS, rtc_clock);
|
||||
#endif
|
||||
|
||||
@@ -1125,13 +1085,7 @@ static uint32_t _atoi(const char* sp) {
|
||||
/* GLOBAL OBJECTS */
|
||||
#ifdef DISPLAY_CLASS
|
||||
#include "UITask.h"
|
||||
#if defined(MECK_TWATCH)
|
||||
#include "WatchNotesScreen.h" // After UITask.h -- needs NodePrefs
|
||||
#include "WatchChannelConfigScreen.h" // After UITask.h -- needs NodePrefs, the_mesh
|
||||
#endif
|
||||
#if defined(MECK_TWATCH) && HAS_GPS
|
||||
#include "WatchMapScreen.h" // After BLE -- PNGdec headers conflict with BLE if included earlier
|
||||
#elif HAS_GPS && !defined(LILYGO_TECHO_CARD)
|
||||
#if HAS_GPS && !defined(LILYGO_TECHO_CARD)
|
||||
#include "MapScreen.h" // After BLE -- PNGdec headers conflict with BLE if included earlier
|
||||
#endif
|
||||
UITask ui_task(&board, &serial_interface);
|
||||
@@ -1348,15 +1302,7 @@ static void lastHeardToggleContact() {
|
||||
|
||||
// Snake screen: tap = Enter (start / restart)
|
||||
if (ui_task.isOnSnakeScreen()) {
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: tap starts/restarts, or steers by tapping the up/down/left/right
|
||||
// zone relative to the board centre while playing.
|
||||
SnakeScreen* sk = (SnakeScreen*)ui_task.getSnakeScreen();
|
||||
if (sk) sk->handleTap(x, y);
|
||||
return 0;
|
||||
#else
|
||||
return '\r';
|
||||
#endif
|
||||
}
|
||||
|
||||
// Minesweeper screen: tap = Enter (reveal cell / start / restart)
|
||||
@@ -1364,49 +1310,6 @@ static void lastHeardToggleContact() {
|
||||
return '\r';
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH) && HAS_GPS
|
||||
// Map screen: footer +/- buttons zoom; a tap in the map area recenters on
|
||||
// GPS. (A top-strip tap is already handled above -> home.)
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
|
||||
if (wms) wms->handleTap(x, y);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
// Notes screen: list rows, view scroll zones and the footer are all tap
|
||||
// targets, in the same logical coords as the alarm screen.
|
||||
if (ui_task.isOnWatchNotesScreen()) {
|
||||
WatchNotesScreen* wn = (WatchNotesScreen*)ui_task.getWatchNotesScreen();
|
||||
if (wn) wn->handleTap(x, y);
|
||||
return 0;
|
||||
}
|
||||
// Channel config screen: list rows, detail action rows and the footer are
|
||||
// all tap targets, in the same logical coords as the alarm/notes screens.
|
||||
if (ui_task.isOnWatchChannelConfigScreen()) {
|
||||
WatchChannelConfigScreen* wc = (WatchChannelConfigScreen*)ui_task.getWatchChannelConfigScreen();
|
||||
if (wc) wc->handleTap(x, y);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
#ifdef TWATCH_COMPOSE_ENABLED
|
||||
// TWatch channel screen: touch is handled entirely in the screen's own
|
||||
// poll() (ticker select, hop-overlay dismiss, compose bar). Do not
|
||||
// synthesise keys here -- the hop-path overlay must open only on the
|
||||
// physical button (PWR on the S3 / boot on the Plus), whose KEY_ENTER /
|
||||
// KEY_NEXT arrive via a separate inject path.
|
||||
if (ui_task.isOnTWatchChannelScreen()) {
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: tiles open on long-press and pages change on swipe, so a plain tap
|
||||
// on any home page does nothing -- skip the T5S3 tile hit-test below (wrong
|
||||
// geometry for the 2-column watch grid) and the left/right page cycling.
|
||||
if (ui_task.isOnHomeScreen()) {
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
// Home screen FIRST page: tile taps (virtual coordinate hit test)
|
||||
if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) {
|
||||
const int tileW = 40, tileH = 22, gapX = 1, gapY = 1;
|
||||
@@ -1576,81 +1479,6 @@ static void lastHeardToggleContact() {
|
||||
if (ui_task.isOnContactsScreen()) {
|
||||
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
|
||||
if (cs) {
|
||||
#if defined(MECK_TWATCH)
|
||||
if (cs->isActionOverlayOpen()) {
|
||||
int btn = cs->actionOverlayButtonAtVY(vy);
|
||||
if (btn == 0) {
|
||||
// Open -- route by contact type (mirrors the pre-overlay long-press open)
|
||||
cs->closeActionOverlay();
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
uint8_t ctype = cs->getSelectedContactType();
|
||||
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
|
||||
if (ui_task.hasDMUnread(idx)) {
|
||||
char cname[32];
|
||||
cs->getSelectedContactName(cname, sizeof(cname));
|
||||
ui_task.clearDMUnread(idx);
|
||||
ui_task.gotoDMConversation(cname);
|
||||
return 0;
|
||||
}
|
||||
ui_task.openTWatchKeyboard(1, idx); // TWKB_DM
|
||||
return 0;
|
||||
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
|
||||
ui_task.gotoRepeaterAdmin(idx);
|
||||
return 0;
|
||||
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
|
||||
ui_task.gotoRepeaterAdmin(idx);
|
||||
return 0;
|
||||
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
|
||||
char cname[32];
|
||||
cs->getSelectedContactName(cname, sizeof(cname));
|
||||
ui_task.clearDMUnread(idx);
|
||||
ui_task.gotoDMConversation(cname);
|
||||
return 0;
|
||||
}
|
||||
ui_task.forceRefresh();
|
||||
return 0;
|
||||
} else if (btn == 1) {
|
||||
// Favourite -- flip flags bit 0 on the selected contact and persist
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
if (idx >= 0) {
|
||||
ContactInfo tmp;
|
||||
if (the_mesh.getContactByIdx(idx, tmp)) {
|
||||
ContactInfo* cp = the_mesh.lookupContactByPubKey(tmp.id.pub_key, PUB_KEY_SIZE);
|
||||
if (cp) {
|
||||
cp->flags ^= 0x01;
|
||||
the_mesh.saveContacts();
|
||||
ui_task.showAlert((cp->flags & 0x01) ? "Favourited" : "Unfavourited", 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
cs->closeActionOverlay();
|
||||
cs->invalidateCache();
|
||||
ui_task.forceRefresh();
|
||||
return 0;
|
||||
} else if (btn == 2) {
|
||||
// Delete -- remove the selected contact and persist
|
||||
int idx = cs->getSelectedContactIdx();
|
||||
if (idx >= 0) {
|
||||
ContactInfo c;
|
||||
if (the_mesh.getContactByIdx(idx, c)) {
|
||||
if (the_mesh.removeContact(c)) {
|
||||
the_mesh.saveContacts();
|
||||
ui_task.showAlert("Contact deleted", 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
cs->closeActionOverlay();
|
||||
cs->invalidateCache();
|
||||
ui_task.forceRefresh();
|
||||
return 0;
|
||||
} else {
|
||||
// Tap outside the buttons -- dismiss
|
||||
cs->closeActionOverlay();
|
||||
ui_task.forceRefresh();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
int result = cs->selectRowAtVY(vy);
|
||||
if (result == 1) {
|
||||
ui_task.forceRefresh();
|
||||
@@ -1682,27 +1510,6 @@ static void lastHeardToggleContact() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Trace screen (watch): tap to select row, tap same to activate. Mode row
|
||||
// toggles 1/2-byte; Type Path opens the keyboard (handleInput -> wantsKeyboard).
|
||||
// Guarded to the watch: selectRowAtVY uses the watch's 128-space coords, and
|
||||
// T5S3/T-Deck keep their existing boot-button/keyboard trace handling.
|
||||
if (ui_task.isOnTraceScreen()) {
|
||||
TraceScreen* ts = (TraceScreen*)ui_task.getTraceScreen();
|
||||
if (ts) {
|
||||
int result = ts->selectRowAtVY(vy);
|
||||
if (result == 1) {
|
||||
ui_task.forceRefresh();
|
||||
return 0;
|
||||
}
|
||||
if (result == 2) {
|
||||
if (ts->isOnModeRow()) return 'd'; // toggle 1-byte / 2-byte
|
||||
return KEY_ENTER; // Type Path / Add / Remove / Run / Exit / hop
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Discovery screen: tap to select, tap same to add
|
||||
if (ui_task.isOnDiscoveryScreen()) {
|
||||
@@ -1738,16 +1545,6 @@ static void lastHeardToggleContact() {
|
||||
// Settings screen: tap to select row, tap same row to activate
|
||||
if (ui_task.isOnSettingsScreen()) {
|
||||
SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen();
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: while editing UTC offset, path hash size, or TX power, tap the
|
||||
// upper half to increment and the lower half to decrement. Long-press saves.
|
||||
if (ss && ss->isEditing() &&
|
||||
(ss->getCurrentRowType() == ROW_UTC_OFFSET ||
|
||||
ss->getCurrentRowType() == ROW_PATH_HASH_SIZE ||
|
||||
ss->getCurrentRowType() == ROW_TX_POWER)) {
|
||||
return (vy < 64) ? 'w' : 's';
|
||||
}
|
||||
#endif
|
||||
if (ss && !ss->isEditing()) {
|
||||
int result = ss->selectRowAtVY(vy);
|
||||
if (result == 1) {
|
||||
@@ -1765,27 +1562,6 @@ static void lastHeardToggleContact() {
|
||||
if (ui_task.isOnSMSScreen()) return 0;
|
||||
#endif
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Repeater admin (watch): tap selects a menu row (tap again = activate);
|
||||
// param entry opens the keyboard for the value.
|
||||
if (ui_task.isOnRepeaterAdmin()) {
|
||||
RepeaterAdminScreen* admin = (RepeaterAdminScreen*)ui_task.getRepeaterAdminScreen();
|
||||
if (admin) {
|
||||
RepeaterAdminScreen::AdminState astate = admin->getState();
|
||||
if (astate == RepeaterAdminScreen::STATE_CATEGORY_MENU ||
|
||||
astate == RepeaterAdminScreen::STATE_COMMAND_MENU) {
|
||||
int result = admin->selectRowAtVY(vy);
|
||||
if (result == 1) { ui_task.forceRefresh(); return 0; }
|
||||
if (result == 2) return KEY_ENTER; // tapped current row -- activate
|
||||
return 0;
|
||||
}
|
||||
if (astate == RepeaterAdminScreen::STATE_PARAM_ENTRY) {
|
||||
ui_task.openTWatchKeyboard(3, 0); // tap opens keyboard for the value
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// All other screens: tap = select
|
||||
return KEY_ENTER;
|
||||
@@ -1800,14 +1576,6 @@ static void lastHeardToggleContact() {
|
||||
if (ui_task.isOnSMSScreen()) return 0;
|
||||
#endif
|
||||
|
||||
#if defined(MECK_TWATCH) && HAS_GPS
|
||||
// Map screen: swipe pans the viewport (logical-pixel delta)
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
|
||||
if (wms) wms->panByPixels(dx, dy);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Snake screen: swipes control direction
|
||||
if (ui_task.isOnSnakeScreen()) {
|
||||
@@ -1907,11 +1675,6 @@ static void lastHeardToggleContact() {
|
||||
if (ui_task.isOnSMSScreen()) return 0;
|
||||
#endif
|
||||
|
||||
#ifdef TWATCH_COMPOSE_ENABLED
|
||||
// TWatch channel screen: no synthesised keys (see mapTouchTap) -- a long
|
||||
// press on the header/messages must not open the hop-path overlay.
|
||||
if (ui_task.isOnTWatchChannelScreen()) return 0;
|
||||
#endif
|
||||
|
||||
// Snake screen: long press exits to games menu
|
||||
if (ui_task.isOnSnakeScreen()) {
|
||||
@@ -1928,56 +1691,6 @@ static void lastHeardToggleContact() {
|
||||
return 'q';
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch FIRST page: long-press on a coloured tile opens its screen.
|
||||
// The grid renders in the logical (display.width()) space; touchToVirtual
|
||||
// yields 128-space coords, so scale them back to render space to hit-test.
|
||||
if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) {
|
||||
int vx, vy;
|
||||
touchToVirtual(x, y, vx, vy);
|
||||
const int W = display.width(); // logical width (120)
|
||||
int rvx = vx * W / 128;
|
||||
int rvy = vy * W / 128;
|
||||
const int cols = 2, rows = 4;
|
||||
const int tileW = 56, tileH = 24, gapX = 4, gapY = 2;
|
||||
const int gridW = tileW * cols + gapX * (cols - 1); // 116
|
||||
const int gridX = (W - gridW) / 2; // 2
|
||||
int gridY = ui_task.getTileGridVY();
|
||||
if (rvx >= gridX && rvx < gridX + gridW &&
|
||||
rvy >= gridY && rvy < gridY + rows * (tileH + gapY)) {
|
||||
int col = (rvx - gridX) / (tileW + gapX); if (col > 1) col = 1;
|
||||
int row = (rvy - gridY) / (tileH + gapY); if (row > 3) row = 3;
|
||||
if (row == 0 && col == 0) { ui_task.openTWatchPicker(); return 0; }
|
||||
if (row == 0 && col == 1) { ui_task.gotoContactsScreen(); return 0; }
|
||||
if (row == 1 && col == 0) { ui_task.gotoSettingsScreen(); return 0; }
|
||||
if (row == 1 && col == 1) { ui_task.gotoDiscoveryScreen(); return 0; }
|
||||
if (row == 2 && col == 0) { ui_task.gotoTraceScreen(); return 0; }
|
||||
#if defined(LILYGO_TWATCH_S3)
|
||||
// No GNSS on the plain S3, so this slot is the games launcher.
|
||||
if (row == 2 && col == 1) { ui_task.gotoGamesMenu(); return 0; }
|
||||
#elif HAS_GPS
|
||||
if (row == 2 && col == 1) {
|
||||
// Maps: mark the tile FS ready (detectZoomRange in enter() needs it)
|
||||
// before opening; GPS centre + markers are populated in the main loop.
|
||||
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
|
||||
if (wms) wms->setMapsReady(LittleFS.exists("/tiles"));
|
||||
ui_task.gotoMapScreen();
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
if (row == 3 && col == 0) { ui_task.gotoNotesScreen(); return 0; }
|
||||
if (row == 3 && col == 1) { ui_task.gotoStepsScreen(); return 0; }
|
||||
}
|
||||
return 0; // consume long-press on the tile page
|
||||
}
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
// Steps screen: long press opens the 7-day history subscreen.
|
||||
// (On the S3 the PWR key's short press also delivers KEY_ENTER here.)
|
||||
if (ui_task.isOnStepsScreen()) {
|
||||
return (char)KEY_ENTER;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Home screen: long press = activate current page action
|
||||
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
|
||||
@@ -2113,13 +1826,6 @@ static void lastHeardToggleContact() {
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
#elif defined(MECK_TWATCH)
|
||||
// Watch: long press raises the contact action overlay
|
||||
// ([Open] [Favourite] [Delete]). The tap handler dispatches the choice;
|
||||
// "Open" reproduces the type-aware DM/admin routing this branch used to do.
|
||||
cs->openActionOverlay();
|
||||
ui_task.forceRefresh();
|
||||
return 0;
|
||||
#else
|
||||
// T-Deck Pro: long press enters select mode
|
||||
cs->enterSelectMode();
|
||||
@@ -2165,22 +1871,10 @@ static void lastHeardToggleContact() {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
ui_task.showVirtualKeyboard(VKB_ADMIN_PASSWORD, "Admin Password", "", 32);
|
||||
return 0;
|
||||
#elif defined(MECK_TWATCH)
|
||||
ui_task.openTWatchKeyboard(2, 0); // TWKB_ADMIN_PASSWORD
|
||||
return 0;
|
||||
#else
|
||||
return KEY_ENTER; // T-Deck Pro: keyboard handles password entry
|
||||
#endif
|
||||
}
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: keyboard only for param entry; menu/other states activate the
|
||||
// highlighted item (no raw CLI keyboard).
|
||||
if (astate == RepeaterAdminScreen::STATE_PARAM_ENTRY) {
|
||||
ui_task.openTWatchKeyboard(3, 0); // param value entry
|
||||
return 0;
|
||||
}
|
||||
return KEY_ENTER;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2197,24 +1891,9 @@ static void lastHeardToggleContact() {
|
||||
if (ui_task.isOnSettingsScreen()) {
|
||||
SettingsScreen* ss = (SettingsScreen*)ui_task.getSettingsScreen();
|
||||
if (ss) {
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: long-press while editing UTC offset, path hash size, or TX
|
||||
// power saves the value.
|
||||
if (ss->isEditing() &&
|
||||
(ss->getCurrentRowType() == ROW_UTC_OFFSET ||
|
||||
ss->getCurrentRowType() == ROW_PATH_HASH_SIZE ||
|
||||
ss->getCurrentRowType() == ROW_TX_POWER)) {
|
||||
return '\r';
|
||||
}
|
||||
#endif
|
||||
if (ss->isEditing()) {
|
||||
return 0; // Consume — don't interfere with active edit mode
|
||||
}
|
||||
#if defined(MECK_TWATCH)
|
||||
// Aim activation at the row under the finger, not just the current
|
||||
// cursor, so a single long-press opens/edits the pressed row.
|
||||
{ int vx, vy; touchToVirtual(x, y, vx, vy); ss->selectRowAtVY(vy); }
|
||||
#endif
|
||||
if (ss->isOnDeletableChannel()) {
|
||||
return 'x'; // Long press on channel row → delete
|
||||
}
|
||||
@@ -2446,19 +2125,6 @@ void setup() {
|
||||
}
|
||||
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Mount the dedicated 'maps' partition as LittleFS (map tiles live here,
|
||||
// kept separate from the SPIFFS DataStore that holds identity/contacts).
|
||||
// formatOnFail=true so a freshly flashed (raw) maps partition is formatted
|
||||
// and immediately usable for ESPConnect tile uploads.
|
||||
if (LittleFS.begin(true, "/maps", 10, "maps")) {
|
||||
Serial.printf("maps: LittleFS mounted (%u KB total, %u KB used)\n",
|
||||
(unsigned)(LittleFS.totalBytes() / 1024),
|
||||
(unsigned)(LittleFS.usedBytes() / 1024));
|
||||
} else {
|
||||
Serial.println("maps: LittleFS mount FAILED");
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Early SD card init -- needed BEFORE the_mesh.begin() so we can restore
|
||||
@@ -2904,15 +2570,6 @@ void setup() {
|
||||
Serial.println("setup() - SD features initialized");
|
||||
}
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: load persisted channel-message history from LittleFS (no SD).
|
||||
{
|
||||
ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chanScr && chanScr->loadFromLittleFS()) {
|
||||
MESH_DEBUG_PRINTLN("setup() - Message history loaded from LittleFS");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Check if node name is still the default hex prefix (first 4 bytes of pub key)
|
||||
// If so, launch onboarding wizard to set name and radio preset
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2940,9 +2597,6 @@ void setup() {
|
||||
#ifdef PIN_GPS_EN
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
board.gpsPowerOn(); // GPS power is the AXP2101 BLDO1 rail
|
||||
#endif
|
||||
#if defined(LilyGo_TDeck_Pro_Max)
|
||||
// Speed up / improve the MAX GPS fix: enable multi-constellation
|
||||
// (GPS + GLONASS + BeiDou) and EASY predicted ephemeris (warm/hot starts
|
||||
@@ -2960,9 +2614,6 @@ void setup() {
|
||||
#if defined(LilyGo_TDeck_Pro_Max)
|
||||
board.gpsPowerOff(); // MAX: GPS power is XL9555-routed, not PIN_GPS_EN
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
board.gpsPowerOff(); // GPS power is the AXP2101 BLDO1 rail
|
||||
#endif
|
||||
sensors.setSettingValue("gps", "0");
|
||||
}
|
||||
Serial.printf("GPS: power %s\n", gps_wanted ? "ON" : "OFF");
|
||||
@@ -3034,14 +2685,6 @@ void otaResumeRadio() {
|
||||
#endif
|
||||
|
||||
void loop() {
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: flush pending channel-message history to LittleFS on a 30s debounce
|
||||
// (saveToSD marks it dirty on each message; at most one ~57 KB write / 30s).
|
||||
{
|
||||
ChannelScreen* chanScr = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (chanScr) chanScr->flushMessagesIfDue(30000UL);
|
||||
}
|
||||
#endif
|
||||
// T-Echo Card: lazy Codec2 init from shallow stack context.
|
||||
// codec2_create needs ~3KB stack for FFT/trig init. The loop task
|
||||
// has only 4KB total. Calling from render() (deep call chain) overflows.
|
||||
@@ -3146,7 +2789,7 @@ void loop() {
|
||||
|
||||
// Map screen: periodically update own GPS position and contact markers
|
||||
#ifdef DISPLAY_CLASS
|
||||
#if HAS_GPS && !defined(LILYGO_TECHO_CARD) && !defined(MECK_TWATCH)
|
||||
#if HAS_GPS && !defined(LILYGO_TECHO_CARD)
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
static unsigned long lastMapUpdate = 0;
|
||||
if (millis() - lastMapUpdate > 30000) { // Every 30 seconds
|
||||
@@ -3173,41 +2816,6 @@ void loop() {
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(MECK_TWATCH) && HAS_GPS
|
||||
// Watch map screen: on open, centre on GPS and populate contact markers; then
|
||||
// refresh own position + markers periodically while it stays open.
|
||||
{
|
||||
static bool mapWasOpen = false;
|
||||
static unsigned long lastMapUpdate = 0;
|
||||
if (ui_task.isOnMapScreen()) {
|
||||
WatchMapScreen* wms = (WatchMapScreen*)ui_task.getMapScreen();
|
||||
if (wms) {
|
||||
bool justOpened = !mapWasOpen;
|
||||
mapWasOpen = true;
|
||||
if (justOpened || (millis() - lastMapUpdate > 30000)) {
|
||||
lastMapUpdate = millis();
|
||||
if (justOpened) {
|
||||
wms->setGPSPosition(sensors.node_lat, sensors.node_lon);
|
||||
} else {
|
||||
wms->updateGPSPosition(sensors.node_lat, sensors.node_lon);
|
||||
}
|
||||
wms->clearMarkers();
|
||||
ContactsIterator it = the_mesh.startContactsIterator();
|
||||
ContactInfo ci;
|
||||
while (it.hasNext(&the_mesh, ci)) {
|
||||
if (ci.gps_lat != 0 || ci.gps_lon != 0) {
|
||||
double lat = ((double)ci.gps_lat) / 1000000.0;
|
||||
double lon = ((double)ci.gps_lon) / 1000000.0;
|
||||
wms->addMarker(lat, lon, ci.name, ci.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mapWasOpen = false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// CPU frequency auto-timeout back to idle
|
||||
cpuPower.loop();
|
||||
@@ -3216,7 +2824,7 @@ void loop() {
|
||||
// is active. The mesh radio has its own FIFO so packets are buffered;
|
||||
// 50 ms yield means the loop still runs 20×/sec which is more than enough
|
||||
// to drain the radio FIFO before overflow.
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
{
|
||||
static bool wasLocked = false;
|
||||
bool nowLocked = ui_task.isLocked();
|
||||
@@ -4031,12 +3639,6 @@ void loop() {
|
||||
// Channel screen: horizontal swipe opens picker instead of cycling
|
||||
if (ui_task.isOnChannelScreen() && (c == 'a' || c == 'd')) {
|
||||
ui_task.gotoChannelPickerScreen();
|
||||
#if defined(MECK_TWATCH)
|
||||
} else if (ui_task.isOnChannelScreen() && (c == 'w' || c == 's')) {
|
||||
// Watch: a vertical swipe pages the message view (one page/swipe).
|
||||
ChannelScreen* cs = (ChannelScreen*)ui_task.getChannelScreen();
|
||||
if (cs) { cs->pageScroll(c == 'w'); ui_task.forceRefresh(); }
|
||||
#endif
|
||||
} else {
|
||||
ui_task.injectKey(c);
|
||||
}
|
||||
@@ -4655,7 +4257,7 @@ void loop() {
|
||||
// The RTOS idle task executes WFI (wait-for-interrupt) during delay(),
|
||||
// dramatically reducing CPU power draw. 50 ms gives 20 loop cycles/sec
|
||||
// which is ample for LoRa packet reception (radio has hardware FIFO).
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
if (ui_task.isLocked()) {
|
||||
delay(50);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
#if defined(HAS_SDCARD) && defined(ESP32)
|
||||
#include <SD.h>
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
#include <LittleFS.h> // watch has no SD -- message history persists to LittleFS
|
||||
#endif
|
||||
|
||||
// DM send status codes (session only). Guarded so AbstractUITask.h can carry
|
||||
// the same definitions without a clash.
|
||||
@@ -107,9 +104,6 @@ private:
|
||||
int _msgsPerPage; // Messages that fit on screen
|
||||
uint8_t _viewChannelIdx; // Which channel we're currently viewing
|
||||
bool _sdReady; // SD card is available for persistence
|
||||
#if defined(MECK_TWATCH)
|
||||
unsigned long _msgDirtySince = 0; // watch: pending LittleFS write (0 = clean)
|
||||
#endif
|
||||
bool _showPathOverlay; // Show path detail overlay for last received msg
|
||||
int _pathScrollPos; // Scroll offset within path overlay hop list
|
||||
int _pathHopsVisible; // Hops that fit on screen (set during render)
|
||||
@@ -223,58 +217,7 @@ public:
|
||||
|
||||
// Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
|
||||
// The text already contains "Sender: message" format
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch build: drop emoji entirely instead of storing sprite escape bytes.
|
||||
// The watch UI has no sprite rendering and no room for emoji at 120x120
|
||||
// virtual. Non-emoji UTF-8 (accented letters etc.) passes through
|
||||
// unchanged, the same as emojiSanitize does.
|
||||
{
|
||||
const uint8_t* s = (const uint8_t*)text;
|
||||
int srcLen = (int)strlen(text);
|
||||
int si = 0, di = 0;
|
||||
while (si < srcLen && di < CHANNEL_MSG_TEXT_LEN - 1) {
|
||||
uint8_t b = s[si];
|
||||
if (b < 0x80) { msg->text[di++] = (char)b; si++; continue; }
|
||||
int consumed;
|
||||
uint32_t cp = emojiDecodeUtf8(s + si, srcLen - si, &consumed);
|
||||
if (cp == 0xFE0F) { si += consumed; continue; } // variation selector
|
||||
if (cp == 0xFFFD) { si += consumed; continue; } // invalid UTF-8 -- skip
|
||||
bool isEmoji = false;
|
||||
for (int e = 0; e < EMOJI_COUNT; e++) {
|
||||
if (EMOJI_CODEPOINTS[e].cp == cp) {
|
||||
if (EMOJI_CODEPOINTS[e].cp2 != 0) {
|
||||
// two-codepoint emoji: only a match when the pair matches
|
||||
int consumed2;
|
||||
if (si + consumed < srcLen) {
|
||||
uint32_t cp2 = emojiDecodeUtf8(s + si + consumed, srcLen - si - consumed, &consumed2);
|
||||
if (cp2 == EMOJI_CODEPOINTS[e].cp2) {
|
||||
si += consumed + consumed2;
|
||||
isEmoji = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
si += consumed;
|
||||
isEmoji = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isEmoji) {
|
||||
for (int a = 0; a < EMOJI_ALIAS_COUNT; a++) {
|
||||
if (EMOJI_ALIASES[a].cp == cp) { si += consumed; isEmoji = true; break; }
|
||||
}
|
||||
}
|
||||
if (isEmoji) continue; // dropped
|
||||
if (di + consumed >= CHANNEL_MSG_TEXT_LEN) break;
|
||||
for (int k = 0; k < consumed; k++) msg->text[di++] = (char)s[si + k];
|
||||
si += consumed;
|
||||
}
|
||||
msg->text[di] = 0;
|
||||
}
|
||||
#else
|
||||
emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
|
||||
#endif
|
||||
|
||||
if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) {
|
||||
_msgCount++;
|
||||
@@ -570,13 +513,6 @@ public:
|
||||
// Save the entire message buffer to SD card.
|
||||
// File: /meshcore/messages.bin (~50 KB for 300 messages)
|
||||
void saveToSD() {
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: defer to the LittleFS flush cadence -- a ~57 KB write on every
|
||||
// message would wear the flash. Timestamp the pending write; the main loop
|
||||
// flushes it (see flushMessagesIfDue), and shutdown flushes directly.
|
||||
if (_msgDirtySince == 0) _msgDirtySince = millis();
|
||||
return;
|
||||
#endif
|
||||
#if defined(HAS_SDCARD) && defined(ESP32)
|
||||
if (!_sdReady) return;
|
||||
|
||||
@@ -619,93 +555,6 @@ public:
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// --- Watch message-history persistence (LittleFS; the watch has no SD) ------
|
||||
// Flush the ring to LittleFS. Called on an interval from the main loop and
|
||||
// directly on shutdown, so traffic produces at most one write per interval.
|
||||
void flushMessagesNow() {
|
||||
if (!LittleFS.exists("/meshcore")) {
|
||||
LittleFS.mkdir("/meshcore");
|
||||
}
|
||||
File f = LittleFS.open(MSG_FILE_PATH, "w", true);
|
||||
if (!f) {
|
||||
Serial.println("ChannelScreen: LittleFS save failed - can't open file");
|
||||
return;
|
||||
}
|
||||
MsgFileHeader hdr;
|
||||
hdr.magic = MSG_FILE_MAGIC;
|
||||
hdr.version = MSG_FILE_VERSION;
|
||||
hdr.capacity = CHANNEL_MSG_HISTORY_SIZE;
|
||||
hdr.count = (uint16_t)_msgCount;
|
||||
hdr.newestIdx = (int16_t)_newestIdx;
|
||||
f.write((uint8_t*)&hdr, sizeof(hdr));
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
MsgFileRecord rec;
|
||||
rec.timestamp = _messages[i].timestamp;
|
||||
rec.path_len = _messages[i].path_len;
|
||||
rec.channel_idx = _messages[i].channel_idx;
|
||||
rec.valid = _messages[i].valid ? 1 : 0;
|
||||
rec.snr = _messages[i].snr;
|
||||
rec.dm_peer_hash = _messages[i].dm_peer_hash;
|
||||
memcpy(rec.path, _messages[i].path, MSG_PATH_MAX);
|
||||
memcpy(rec.text, _messages[i].text, CHANNEL_MSG_TEXT_LEN);
|
||||
f.write((uint8_t*)&rec, sizeof(rec));
|
||||
}
|
||||
f.close();
|
||||
_msgDirtySince = 0;
|
||||
}
|
||||
|
||||
// Flush only if a write is pending and the debounce interval has elapsed.
|
||||
void flushMessagesIfDue(unsigned long intervalMs) {
|
||||
if (_msgDirtySince && millis() - _msgDirtySince >= intervalMs) flushMessagesNow();
|
||||
}
|
||||
|
||||
// Load the ring from LittleFS at boot. Returns true if any messages loaded.
|
||||
bool loadFromLittleFS() {
|
||||
if (!LittleFS.exists(MSG_FILE_PATH)) {
|
||||
Serial.println("ChannelScreen: no saved messages on LittleFS");
|
||||
return false;
|
||||
}
|
||||
File f = LittleFS.open(MSG_FILE_PATH, "r");
|
||||
if (!f) {
|
||||
Serial.println("ChannelScreen: LittleFS load failed - can't open file");
|
||||
return false;
|
||||
}
|
||||
MsgFileHeader hdr;
|
||||
if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr)) { f.close(); return false; }
|
||||
if (hdr.magic != MSG_FILE_MAGIC) { f.close(); return false; }
|
||||
if (hdr.version != MSG_FILE_VERSION) { f.close(); return false; }
|
||||
if (hdr.capacity != CHANNEL_MSG_HISTORY_SIZE) { f.close(); return false; }
|
||||
int loaded = 0;
|
||||
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||
MsgFileRecord rec;
|
||||
if (f.read((uint8_t*)&rec, sizeof(rec)) != sizeof(rec)) break;
|
||||
_messages[i].timestamp = rec.timestamp;
|
||||
_messages[i].path_len = rec.path_len;
|
||||
_messages[i].channel_idx = rec.channel_idx;
|
||||
_messages[i].valid = (rec.valid != 0);
|
||||
_messages[i].snr = rec.snr;
|
||||
_messages[i].dm_peer_hash = rec.dm_peer_hash;
|
||||
memcpy(_messages[i].path, rec.path, MSG_PATH_MAX);
|
||||
memcpy(_messages[i].text, rec.text, CHANNEL_MSG_TEXT_LEN);
|
||||
_messages[i].scope_idx = 0xFF;
|
||||
_messages[i].send_ref = 0;
|
||||
_messages[i].dm_status = DM_SEND_NONE;
|
||||
_messages[i].dm_attempt = 0;
|
||||
_messages[i].dm_total = 0;
|
||||
if (_messages[i].valid) loaded++;
|
||||
}
|
||||
_msgCount = (int)hdr.count;
|
||||
_newestIdx = (int)hdr.newestIdx;
|
||||
_scrollPos = 0;
|
||||
if (_newestIdx < -1 || _newestIdx >= CHANNEL_MSG_HISTORY_SIZE) _newestIdx = -1;
|
||||
if (_msgCount < 0 || _msgCount > CHANNEL_MSG_HISTORY_SIZE) _msgCount = loaded;
|
||||
f.close();
|
||||
Serial.printf("ChannelScreen: Loaded %d messages from LittleFS (count=%d, newest=%d)\n",
|
||||
loaded, _msgCount, _newestIdx);
|
||||
return loaded > 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Load message buffer from SD card. Returns true if messages were loaded.
|
||||
bool loadFromSD() {
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <MeshCore.h>
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
#include <helpers/ui/LGFXDisplay.h> // blue rounded-rect overlay primitives (watch LCD)
|
||||
#endif
|
||||
|
||||
// Timestamps before this (Jan 1 2026 UTC) are treated as invalid/unsynced
|
||||
#define EPOCH_2026 1735689600UL
|
||||
@@ -57,17 +54,6 @@ private:
|
||||
bool _selectMode;
|
||||
uint8_t* _selectedBits; // Bitfield: 1 bit per MAX_CONTACTS raw index
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// --- Action overlay state (watch) ---
|
||||
// Long-press raises a small [Open] [Favourite] [Delete] overlay. Buttons are
|
||||
// three full-width bands defined in the 128-space touch grid; render() maps
|
||||
// them to the 120-space display via display.height().
|
||||
bool _actionOverlay = false;
|
||||
static const int OVL_BTN_H = 26;
|
||||
static const int OVL_BTN0_TOP = 21; // (128 - (3*26 + 2*4)) / 2
|
||||
static const int OVL_BTN1_TOP = 51; // 21 + 26 + 4
|
||||
static const int OVL_BTN2_TOP = 81; // 51 + 26 + 4
|
||||
#endif
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
@@ -196,9 +182,6 @@ public:
|
||||
void resetScroll() {
|
||||
_scrollPos = 0;
|
||||
_cacheValid = false;
|
||||
#if defined(MECK_TWATCH)
|
||||
_actionOverlay = false; // never carry a stale overlay across screen entry
|
||||
#endif
|
||||
}
|
||||
|
||||
FilterMode getFilter() const { return _filter; }
|
||||
@@ -307,21 +290,6 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// --- Action overlay (watch) ---
|
||||
void openActionOverlay() { _actionOverlay = true; }
|
||||
void closeActionOverlay() { _actionOverlay = false; }
|
||||
bool isActionOverlayOpen() const { return _actionOverlay; }
|
||||
|
||||
// Hit-test a 128-space touch Y against the overlay buttons.
|
||||
// Returns 0=Open, 1=Favourite, 2=Delete, -1=miss (tap outside dismisses).
|
||||
int actionOverlayButtonAtVY(int vy) const {
|
||||
if (vy >= OVL_BTN0_TOP && vy < OVL_BTN0_TOP + OVL_BTN_H) return 0;
|
||||
if (vy >= OVL_BTN1_TOP && vy < OVL_BTN1_TOP + OVL_BTN_H) return 1;
|
||||
if (vy >= OVL_BTN2_TOP && vy < OVL_BTN2_TOP + OVL_BTN_H) return 2;
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
if (!_cacheValid) rebuildCache();
|
||||
@@ -336,11 +304,7 @@ public:
|
||||
int selCount = getSelectedCount();
|
||||
snprintf(tmp, sizeof(tmp), "%d Selected [%s]", selCount, filterLabel(_filter));
|
||||
} else {
|
||||
#if defined(MECK_TWATCH)
|
||||
snprintf(tmp, sizeof(tmp), "Contacts[%s]", filterLabel(_filter));
|
||||
#else
|
||||
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
|
||||
#endif
|
||||
}
|
||||
display.print(tmp);
|
||||
|
||||
@@ -505,11 +469,6 @@ public:
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.setCursor(0, footerY);
|
||||
if (!_selectMode) {
|
||||
display.print("Long Press: Enter");
|
||||
}
|
||||
#elif defined(LILYGO_TECHO_LITE)
|
||||
display.setCursor(0, footerY);
|
||||
if (_selectMode) {
|
||||
@@ -535,36 +494,6 @@ public:
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Action overlay -- drawn last so it sits on top of the contact list.
|
||||
if (_actionOverlay) {
|
||||
const int H = display.height(); // 120 on the watch
|
||||
const int W = display.width(); // 120
|
||||
int y0 = OVL_BTN0_TOP * H / 128;
|
||||
int y1 = OVL_BTN1_TOP * H / 128;
|
||||
int y2 = OVL_BTN2_TOP * H / 128;
|
||||
int bh = OVL_BTN_H * H / 128;
|
||||
int bx = 6;
|
||||
int bw = W - 12;
|
||||
|
||||
// Dark backdrop to hide the contact list behind the overlay
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
display.fillRect(bx - 2, y0 - 4, bw + 4, (y2 + bh) - y0 + 8);
|
||||
|
||||
// Blue rounded buttons with white centred labels, matching the watch's
|
||||
// Notes/config style (0x231D = the home-tile blue, 0xFFFF = white).
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(bx, y0, bw, bh, 3);
|
||||
d->drawRoundRect(bx, y1, bw, bh, 3);
|
||||
d->drawRoundRect(bx, y2, bw, bh, 3);
|
||||
|
||||
d->setRawColor(0xFFFF);
|
||||
display.drawTextCentered(W / 2, y0 + (bh - 8) / 2, "Open");
|
||||
display.drawTextCentered(W / 2, y1 + (bh - 8) / 2, "Favourite");
|
||||
display.drawTextCentered(W / 2, y2 + (bh - 8) / 2, "Delete");
|
||||
}
|
||||
#endif
|
||||
|
||||
return 5000; // e-ink: next render after 5s
|
||||
}
|
||||
|
||||
@@ -103,14 +103,6 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 28);
|
||||
display.print(active ? "Listening for adverts..." : "No nodes found");
|
||||
#if defined(MECK_TWATCH)
|
||||
display.setCursor(4, 48);
|
||||
display.print("Hold: Rescan");
|
||||
display.setCursor(4, 58);
|
||||
display.print("Tap: Select");
|
||||
display.setCursor(4, 68);
|
||||
display.print("Tap Again: Add");
|
||||
#else
|
||||
if (!active) {
|
||||
display.setCursor(4, 38);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
@@ -119,7 +111,6 @@ public:
|
||||
display.print("F: Scan again Q: Back");
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
// Center visible window around selected item
|
||||
int maxVisible = (maxY - headerHeight) / lineHeight;
|
||||
@@ -208,12 +199,6 @@ public:
|
||||
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
|
||||
display.print(mid);
|
||||
|
||||
const char* right = "Hold:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Tap:Slct");
|
||||
|
||||
const char* right = "Hold:Rescan";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
|
||||
@@ -136,7 +136,7 @@ public:
|
||||
|
||||
// --- Footer ---
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.setTextSize(0);
|
||||
display.drawTextCentered(display.width() / 2, display.height() - 8, "Tap to select");
|
||||
display.setTextSize(1);
|
||||
|
||||
@@ -42,16 +42,6 @@ class UITask;
|
||||
#define MINE_FTR 14
|
||||
#define MINE_OFFX_ADJ 2
|
||||
#define MINE_OFFY_ADJ 5
|
||||
#elif defined(MECK_TWATCH)
|
||||
// Watch LCD (120x120): denser grid than the 9x9 default, 8px cells so the
|
||||
// numbers render like the T5S3 (size-0 font). 14*8=112 wide, 11*8=88 tall.
|
||||
#define MINE_GRID_W 14
|
||||
#define MINE_GRID_H 11
|
||||
#define MINE_COUNT 22
|
||||
#define MINE_CELL_W 8
|
||||
#define MINE_CELL_H 8
|
||||
#define MINE_HDR 14
|
||||
#define MINE_FTR 14
|
||||
#else
|
||||
#define MINE_GRID_W 9
|
||||
#define MINE_GRID_H 9
|
||||
@@ -77,9 +67,6 @@ class UITask;
|
||||
#elif defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#define MINE_TEXT_SIZE 0
|
||||
#define MINE_TEXT_DY 1
|
||||
#elif defined(MECK_TWATCH)
|
||||
#define MINE_TEXT_SIZE 0
|
||||
#define MINE_TEXT_DY 1
|
||||
#else
|
||||
#define MINE_TEXT_SIZE 1
|
||||
#define MINE_TEXT_DY 3
|
||||
@@ -413,7 +400,7 @@ public:
|
||||
display.drawTextCentered(cx, y, "Minesweeper");
|
||||
y += 16;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(cx, y, "Swipe to move cursor");
|
||||
y += 11;
|
||||
display.drawTextCentered(cx, y, "Tap to reveal");
|
||||
@@ -432,7 +419,7 @@ public:
|
||||
snprintf(info, sizeof(info), "%dx%d grid, %d mines", MINE_GRID_W, MINE_GRID_H, MINE_COUNT);
|
||||
display.drawTextCentered(cx, y, info);
|
||||
y += 16;
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(cx, y, "Tap to start");
|
||||
#else
|
||||
display.drawTextCentered(cx, y, "Press Enter to start");
|
||||
@@ -440,7 +427,7 @@ public:
|
||||
|
||||
// Footer
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if !defined(LilyGo_T5S3_EPaper_Pro) && !defined(MECK_TWATCH)
|
||||
#if !defined(LilyGo_T5S3_EPaper_Pro)
|
||||
int fy = display.height() - 12;
|
||||
display.drawRect(0, fy - 2, display.width(), 1);
|
||||
display.setCursor(2, fy);
|
||||
|
||||
@@ -57,9 +57,6 @@ private:
|
||||
|
||||
bool _dirty; // Path has been modified
|
||||
bool _wantExit; // Set by Save & Exit — caller should navigate back
|
||||
#if defined(MECK_TWATCH)
|
||||
bool _wantKeyboard = false; // Set by Add hop -- caller opens the path keyboard
|
||||
#endif
|
||||
bool _directLocked; // True = path is explicitly set to direct (0 hops, locked)
|
||||
|
||||
// --- helpers ---
|
||||
@@ -192,9 +189,6 @@ public:
|
||||
_repScroll = 0;
|
||||
_dirty = false;
|
||||
_wantExit = false;
|
||||
#if defined(MECK_TWATCH)
|
||||
_wantKeyboard = false;
|
||||
#endif
|
||||
_directLocked = false;
|
||||
|
||||
// Load contact info
|
||||
@@ -287,7 +281,7 @@ public:
|
||||
|
||||
if (selected) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.fillRect(0, y, display.width(), lineH);
|
||||
#else
|
||||
display.fillRect(0, y + 5, display.width(), lineH);
|
||||
@@ -389,12 +383,6 @@ public:
|
||||
const char* right = "Hold:Select";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Tap:Slct");
|
||||
const char* right = "Hold:Entr";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
#else
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bk W/S:Nav");
|
||||
@@ -495,60 +483,6 @@ public:
|
||||
return 5000;
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: replace the whole path from a typed string of comma-separated hex
|
||||
// hops. Each hop is _bytesPerHop bytes (2*_bytesPerHop hex chars), matching
|
||||
// the current mode toggle -- e.g. 1B "36,21,dd", 2B "3601,2198,dddd", 3B
|
||||
// "360184,2198a7,dddddd". Returns NULL on success, or an error message (the
|
||||
// existing path is left unchanged on any error). UITask is only forward
|
||||
// declared here, so the caller shows the alert rather than this method.
|
||||
const char* applyComposedPath(const char* str) {
|
||||
if (str == nullptr) return "Empty path";
|
||||
const int expectChars = _bytesPerHop * 2;
|
||||
uint8_t tmp[MAX_PATH_SIZE];
|
||||
int nbytes = 0, hops = 0;
|
||||
const char* p = str;
|
||||
while (*p) {
|
||||
char tok[16];
|
||||
int tlen = 0;
|
||||
while (*p && *p != ',') {
|
||||
if (*p != ' ') {
|
||||
if (tlen >= (int)sizeof(tok) - 1) return "Hop too long";
|
||||
tok[tlen++] = *p;
|
||||
}
|
||||
p++;
|
||||
}
|
||||
if (*p == ',') p++;
|
||||
if (tlen == 0) continue; // tolerate stray/trailing commas
|
||||
if (tlen != expectChars) return "Wrong hop length";
|
||||
if (hops >= 8) return "Max 8 hops";
|
||||
for (int b = 0; b < _bytesPerHop; b++) {
|
||||
int hi = hexNibble(tok[b * 2]);
|
||||
int lo = hexNibble(tok[b * 2 + 1]);
|
||||
if (hi < 0 || lo < 0) return "Bad hex";
|
||||
if (nbytes >= MAX_PATH_SIZE) return "Path too long";
|
||||
tmp[nbytes++] = (uint8_t)((hi << 4) | lo);
|
||||
}
|
||||
hops++;
|
||||
}
|
||||
if (hops == 0) return "Empty path";
|
||||
memset(_pathBuf, 0, sizeof(_pathBuf));
|
||||
memcpy(_pathBuf, tmp, nbytes);
|
||||
_hopCount = hops;
|
||||
_pathLen = encodePath();
|
||||
_directLocked = false;
|
||||
_dirty = true;
|
||||
_menuCount = buildMenuCount();
|
||||
_menuSel = 0;
|
||||
return nullptr;
|
||||
}
|
||||
static int hexNibble(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (_state == STATE_PICK_HOP) {
|
||||
@@ -613,18 +547,11 @@ public:
|
||||
|
||||
case MENU_ADD_HOP:
|
||||
_directLocked = false;
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: request the on-screen keyboard to type the whole path as
|
||||
// comma-separated hex hops. main loop polls wantsKeyboard() and opens
|
||||
// it (UITask is only forward declared here, so we can't call it).
|
||||
_wantKeyboard = true;
|
||||
#else
|
||||
// Enter picker mode -- adding a hop clears direct lock
|
||||
buildRepeaterList();
|
||||
_repSel = 0;
|
||||
_repScroll = 0;
|
||||
_state = STATE_PICK_HOP;
|
||||
#endif
|
||||
return true;
|
||||
|
||||
case MENU_SET_DIRECT:
|
||||
@@ -774,11 +701,6 @@ public:
|
||||
EditorState getState() const { return _state; }
|
||||
bool isDirty() const { return _dirty; }
|
||||
bool wantsExit() const { return _wantExit; }
|
||||
#if defined(MECK_TWATCH)
|
||||
bool wantsKeyboard() const { return _wantKeyboard; }
|
||||
void clearWantKeyboard() { _wantKeyboard = false; }
|
||||
int getContactIdx() const { return _contactIdx; }
|
||||
#endif
|
||||
|
||||
private:
|
||||
void switchMode(int newBytesPerHop) {
|
||||
|
||||
@@ -595,11 +595,6 @@ public:
|
||||
|
||||
// --- Header ---
|
||||
display.setTextSize(1);
|
||||
#if defined(MECK_TWATCH)
|
||||
// Clip long lines at the screen edge instead of wrapping them onto the row
|
||||
// below (restored before returning so other screens are unaffected).
|
||||
((LGFXDisplay*)&display)->setTextWrap(false);
|
||||
#endif
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
const char* hdrPrefix = (_state == STATE_PASSWORD_ENTRY || _state == STATE_LOGGING_IN)
|
||||
@@ -644,8 +639,6 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Exit");
|
||||
renderFooterRight(display, footerY, "Hold:Type");
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Long Press: login");
|
||||
#else
|
||||
display.print("Sh+Del:Exit");
|
||||
renderFooterRight(display, footerY, "Ent:Login");
|
||||
@@ -665,8 +658,6 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Exit");
|
||||
renderFooterMidRight(display, footerY, "Back:Exit", "Tap:Open", "Swipe:Sel");
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Long Press: Select");
|
||||
#else
|
||||
display.print("Q:Exit");
|
||||
renderFooterMidRight(display, footerY, "Q:Exit", "Ent:Open", "W/S:Sel");
|
||||
@@ -677,8 +668,6 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Back");
|
||||
renderFooterMidRight(display, footerY, "Back:Back", "Tap:Run", "Swipe:Sel");
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Long Press: Run");
|
||||
#else
|
||||
display.print("Q:Back");
|
||||
renderFooterMidRight(display, footerY, "Q:Back", "Ent:Run", "W/S:Sel");
|
||||
@@ -689,8 +678,6 @@ public:
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Cancel");
|
||||
renderFooterRight(display, footerY, "Tap:Send");
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Tap: Enter value");
|
||||
#else
|
||||
display.print("Sh+Del:Cancel");
|
||||
renderFooterRight(display, footerY, "Ent:Send");
|
||||
@@ -723,9 +710,6 @@ public:
|
||||
break;
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
((LGFXDisplay*)&display)->setTextWrap(true); // restore default before returning
|
||||
#endif
|
||||
|
||||
if (_state == STATE_LOGGING_IN || _state == STATE_COMMAND_PENDING) return 30000; // static text; poll()/callbacks force refresh on state change
|
||||
if (_state == STATE_PASSWORD_ENTRY && _lastCharAt > 0 && (millis() - _lastCharAt) < 800) {
|
||||
@@ -1125,11 +1109,6 @@ private:
|
||||
void renderResponse(DisplayDriver& display, int y, int bodyHeight) {
|
||||
display.setTextSize(the_mesh.getNodePrefs()->smallTextSize());
|
||||
int lineHeight = the_mesh.getNodePrefs()->smallLineH();
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: clip long response lines at the screen edge instead of wrapping
|
||||
// them onto the row below.
|
||||
((LGFXDisplay*)&display)->setTextWrap(false);
|
||||
#endif
|
||||
|
||||
display.setColor((_state == STATE_ERROR) ? DisplayDriver::YELLOW : DisplayDriver::LIGHT);
|
||||
|
||||
@@ -1167,9 +1146,6 @@ private:
|
||||
}
|
||||
|
||||
display.setTextSize(1);
|
||||
#if defined(MECK_TWATCH)
|
||||
((LGFXDisplay*)&display)->setTextWrap(true); // restore default
|
||||
#endif
|
||||
}
|
||||
|
||||
bool handleResponseInput(char c) {
|
||||
|
||||
@@ -140,10 +140,6 @@ public:
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
int count = the_mesh.getRxLogCount();
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: no keyboard or touch scroll, so cycle through entries automatically.
|
||||
if (count > 1) { _scrollPos++; if (_scrollPos >= count) _scrollPos = 0; }
|
||||
#endif
|
||||
if (_scrollPos < 0) _scrollPos = 0;
|
||||
if (_scrollPos > count - 1) _scrollPos = (count > 0) ? count - 1 : 0;
|
||||
|
||||
@@ -157,11 +153,7 @@ public:
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
int headerHeight = 14;
|
||||
#if defined(MECK_TWATCH)
|
||||
int footerHeight = 2; // no footer on the watch
|
||||
#else
|
||||
int footerHeight = 14;
|
||||
#endif
|
||||
int maxY = display.height() - footerHeight;
|
||||
int y = headerHeight;
|
||||
|
||||
@@ -188,7 +180,6 @@ public:
|
||||
display.setTextSize(1);
|
||||
|
||||
// === Footer ===
|
||||
#if !defined(MECK_TWATCH)
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
@@ -198,13 +189,8 @@ public:
|
||||
#else
|
||||
display.print("Q:Bk W/S:Scroll");
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
return 3000; // watch: advance to the next packet every 3s
|
||||
#else
|
||||
return 5000; // refresh every 5s to pick up newly received packets
|
||||
#endif
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#if defined(MECK_TWATCH)
|
||||
#include <helpers/ui/LGFXDisplay.h> // setTextWrap/getCursorX for the row ticker
|
||||
// Row ticker scroll speed -- matches TW_TICKER_MS_PER_PX in TWatchComposeScreens.h.
|
||||
#define SETTINGS_TICKER_MS_PER_PX 20
|
||||
#endif
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <helpers/TransportKeyStore.h>
|
||||
#include <MeshCore.h>
|
||||
@@ -347,14 +342,6 @@ private:
|
||||
// T5S3: signal UITask to open VKB when entering text edit mode
|
||||
bool _needsTextVKB;
|
||||
bool _wantsWatchChannels; // Watch: hand off to WatchChannelConfigScreen (polled by UITask)
|
||||
#if defined(MECK_TWATCH)
|
||||
// Selected-row ticker (marquee for rows wider than the screen), mirroring
|
||||
// TWatchChannelScreen: 20 ms/px, 24 px gap between the looping copies.
|
||||
int _tickerRow = -1; // row index currently anchored
|
||||
unsigned long _tickerStartMs = 0; // animation anchor
|
||||
int _tickerTextW = 0; // measured on pass 0 of the previous frame
|
||||
bool _tickerActive = false;
|
||||
#endif
|
||||
|
||||
// 4G modem state (runtime cache of config)
|
||||
#ifdef HAS_4G_MODEM
|
||||
@@ -454,9 +441,6 @@ private:
|
||||
|
||||
void rebuildRows() {
|
||||
_numRows = 0;
|
||||
#if defined(MECK_TWATCH)
|
||||
_tickerRow = -1; // row indices are about to change; re-anchor the ticker
|
||||
#endif
|
||||
|
||||
if (_subScreen == SUB_CONTACTS) {
|
||||
// --- Contacts sub-screen: only contact-related rows ---
|
||||
@@ -516,18 +500,14 @@ private:
|
||||
addRow(ROW_BACKLIGHT_BRIGHTNESS);
|
||||
addRow(ROW_KB_BACKLIGHT);
|
||||
#endif
|
||||
#if !defined(MECK_TWATCH)
|
||||
addRow(ROW_MSG_NOTIFY);
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
addRow(ROW_GPS_BAUD);
|
||||
#endif
|
||||
addRow(ROW_PATH_HASH_SIZE);
|
||||
addRow(ROW_DEFAULT_SCOPE);
|
||||
#if !defined(MECK_TWATCH)
|
||||
addRow(ROW_DARK_MODE);
|
||||
#endif
|
||||
#if !defined(LILYGO_TECHO_LITE) && !defined(MECK_TWATCH)
|
||||
#if !defined(LILYGO_TECHO_LITE)
|
||||
addRow(ROW_LARGE_FONT);
|
||||
addRow(ROW_FONT_STYLE);
|
||||
#endif
|
||||
@@ -1830,13 +1810,6 @@ public:
|
||||
|
||||
int y = headerH;
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: rows longer than the 120px line (e.g. "GPS Baud: Default (9600)")
|
||||
// must clip at the screen edge, not wrap onto the row below and overprint
|
||||
// it. Restored after the loop -- other screens rely on wrap being on.
|
||||
((LGFXDisplay*)&display)->setTextWrap(false);
|
||||
_tickerActive = false;
|
||||
#endif
|
||||
|
||||
for (int i = _scrollTop; i < endIdx && y + lineHeight <= maxY; i++) {
|
||||
bool selected = (i == _cursor);
|
||||
@@ -1857,31 +1830,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
// Selected-row ticker, mirroring TWatchChannelScreen::drawTicker. The
|
||||
// row text is only known inside the switch below, so the marquee's
|
||||
// seamless second copy is drawn by running the row's case twice, one
|
||||
// period apart. Width is measured on pass 0 via getCursorX, so a newly
|
||||
// selected row shows its first frame unscrolled and scrolls from the
|
||||
// next frame on.
|
||||
int rowStartX = 0;
|
||||
int passes = 1;
|
||||
if (selected) {
|
||||
if (_tickerRow != i) { _tickerRow = i; _tickerStartMs = millis(); _tickerTextW = 0; }
|
||||
if (_tickerTextW > display.width() - sbW - 4) {
|
||||
int period = _tickerTextW + 24;
|
||||
int off = (int)(((millis() - _tickerStartMs) / SETTINGS_TICKER_MS_PER_PX) % (unsigned long)period);
|
||||
rowStartX = 2 - off;
|
||||
passes = 2;
|
||||
_tickerActive = true;
|
||||
}
|
||||
}
|
||||
int rowTextW = 0;
|
||||
for (int tickerPass = 0; tickerPass < passes; tickerPass++) {
|
||||
display.setCursor(rowStartX + tickerPass * (_tickerTextW + 24), y);
|
||||
#else
|
||||
display.setCursor(0, y);
|
||||
#endif
|
||||
|
||||
switch (_rows[i].type) {
|
||||
case ROW_NAME:
|
||||
@@ -2369,22 +2318,10 @@ public:
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
if (selected && tickerPass == 0) {
|
||||
// getCursorX() is unusable here: LGFXDisplay::print() calls buffer.println(),
|
||||
// whose newline resets the cursor X to 0. Measure the string directly.
|
||||
rowTextW = display.getTextWidth(tmp);
|
||||
}
|
||||
} // ticker pass loop
|
||||
if (selected) _tickerTextW = rowTextW;
|
||||
#endif
|
||||
|
||||
y += lineHeight;
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
((LGFXDisplay*)&display)->setTextWrap(true);
|
||||
#endif
|
||||
|
||||
// Scrollbar (track + proportional thumb), mirroring the notif-sound picker.
|
||||
if (showScrollbar) {
|
||||
@@ -3005,17 +2942,9 @@ public:
|
||||
} else if (_subScreen != SUB_NONE) {
|
||||
display.print("Q:Back");
|
||||
} else {
|
||||
#if defined(MECK_TWATCH)
|
||||
display.print("Hold:Edit");
|
||||
#else
|
||||
display.print("Q:Bk");
|
||||
#endif
|
||||
}
|
||||
#if defined(MECK_TWATCH)
|
||||
const char* r = (_subScreen == SUB_NONE) ? "Tap:Cycle" : "Tap/Ent:Edit";
|
||||
#else
|
||||
const char* r = "Tap/Ent:Edit";
|
||||
#endif
|
||||
display.setCursor(display.width() - display.getTextWidth(r) - 2, footerY);
|
||||
display.print(r);
|
||||
}
|
||||
@@ -3029,13 +2958,6 @@ public:
|
||||
return 200; // 200ms — fast enough for web server responsiveness
|
||||
}
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
// Marquee running, or a wide row was just selected (its width was measured
|
||||
// this frame): refresh fast so scrolling starts promptly instead of after
|
||||
// the 1s idle interval.
|
||||
if (_tickerActive ||
|
||||
(_tickerRow == _cursor && _tickerTextW > display.width() - 10)) return 60;
|
||||
#endif
|
||||
return _editMode != EDIT_NONE ? 700 : 1000;
|
||||
}
|
||||
|
||||
@@ -3870,18 +3792,12 @@ public:
|
||||
Serial.println("Settings: entered Contacts sub-screen");
|
||||
break;
|
||||
case ROW_CHANNELS_SUBMENU:
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: the desktop sub-screen's per-channel actions are keyboard
|
||||
// hotkeys; hand off to the standalone WatchChannelConfigScreen.
|
||||
_wantsWatchChannels = true; // UITask::loop() polls and opens it
|
||||
#else
|
||||
_savedTopCursor = _cursor;
|
||||
_subScreen = SUB_CHANNELS;
|
||||
_cursor = 0;
|
||||
_scrollTop = 0;
|
||||
rebuildRows();
|
||||
Serial.println("Settings: entered Channels sub-screen");
|
||||
#endif
|
||||
break;
|
||||
|
||||
case ROW_RXLOG:
|
||||
|
||||
@@ -37,11 +37,7 @@ class UITask;
|
||||
#define SNAKE_HI_VERSION 1
|
||||
|
||||
// Tick cadence: the watch LCD has no e-ink refresh limit, so run a touch faster.
|
||||
#if defined(MECK_TWATCH)
|
||||
#define SNAKE_TICK_MS 250
|
||||
#else
|
||||
#define SNAKE_TICK_MS 500
|
||||
#endif
|
||||
|
||||
class SnakeScreen : public UIScreen {
|
||||
public:
|
||||
@@ -403,9 +399,7 @@ public:
|
||||
display.drawTextCentered(cx, y, "Classic Snake");
|
||||
y += 14;
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(MECK_TWATCH)
|
||||
display.drawTextCentered(cx, y, "Tap to steer");
|
||||
#elif defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(cx, y, "Swipe to steer");
|
||||
#else
|
||||
display.drawTextCentered(cx, y, "W/S/A/D to steer");
|
||||
@@ -440,7 +434,7 @@ public:
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.drawTextCentered(cx, y, "Tap to start");
|
||||
#else
|
||||
display.drawTextCentered(cx, y, "Press Enter to start");
|
||||
@@ -493,16 +487,12 @@ public:
|
||||
ty += 12;
|
||||
}
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
#if defined(MECK_TWATCH)
|
||||
display.drawTextCentered(cx, ty, "Tap to retry");
|
||||
#else
|
||||
display.drawTextCentered(cx, ty, "Enter:Retry Sh+Del:Back");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
char footBuf[32];
|
||||
snprintf(footBuf, sizeof(footBuf), "Score: %d", _score);
|
||||
display.setTextSize(0);
|
||||
|
||||
@@ -91,9 +91,6 @@ private:
|
||||
int _resultScroll;
|
||||
|
||||
bool _wantExit;
|
||||
#if defined(MECK_TWATCH)
|
||||
bool _wantKeyboard; // Watch: Type Path tapped -> UITask opens the on-screen keyboard
|
||||
#endif
|
||||
|
||||
// --- Menu helpers (STATE_BUILD) ---
|
||||
// Menu layout:
|
||||
@@ -321,50 +318,6 @@ public:
|
||||
return _state == STATE_BUILD && menuItemAt(_menuSel) == MENU_PATH_SIZE;
|
||||
}
|
||||
|
||||
#if defined(MECK_TWATCH)
|
||||
bool wantsKeyboard() const { return _wantKeyboard; }
|
||||
void clearWantKeyboard() { _wantKeyboard = false; }
|
||||
|
||||
// Tap-to-select row mapping for the watch (mirrors PathEditor/Discovery).
|
||||
// vy is in the 128-tall virtual touch space. Returns:
|
||||
// 0 = miss, 1 = moved the cursor, 2 = tapped the already-selected row.
|
||||
int selectRowAtVY(int vy) {
|
||||
if (_state == STATE_BUILD) return selectBuildRowAtVY(vy);
|
||||
if (_state == STATE_PICK_HOP) return selectPickerRowAtVY(vy);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int selectBuildRowAtVY(int vy) {
|
||||
int menuCount = buildMenuCount();
|
||||
if (menuCount == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = 11;
|
||||
const int bodyTop = headerH; // renderBuild draws row 0 at y=14
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
int maxVisible = (128 - headerH - footerH) / lineH;
|
||||
if (maxVisible < 1) maxVisible = 1;
|
||||
// Replicate renderBuild's scroll window so a tap maps to the drawn row.
|
||||
int scrollTop = 0;
|
||||
if (_menuSel >= scrollTop + maxVisible) scrollTop = _menuSel - maxVisible + 1;
|
||||
if (_menuSel < scrollTop) scrollTop = _menuSel;
|
||||
int tappedRow = scrollTop + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= menuCount) return 0;
|
||||
if (tappedRow == _menuSel) return 2;
|
||||
_menuSel = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int selectPickerRowAtVY(int vy) {
|
||||
if (_repCount == 0) return 0;
|
||||
const int headerH = 14, footerH = 14, lineH = 11;
|
||||
const int bodyTop = headerH; // renderPicker draws row 0 at y=14
|
||||
if (vy < bodyTop || vy >= 128 - footerH) return 0;
|
||||
int tappedRow = _repScroll + (vy - bodyTop) / lineH;
|
||||
if (tappedRow < 0 || tappedRow >= _repCount) return 0;
|
||||
if (tappedRow == _repSel) return 2;
|
||||
_repSel = tappedRow;
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Returns the current path formatted as a comma-separated string, suitable
|
||||
// for pre-populating an external text editor (e.g. the T5S3 virtual keyboard).
|
||||
@@ -396,9 +349,6 @@ public:
|
||||
_repSel = 0;
|
||||
_repScroll = 0;
|
||||
_wantExit = false;
|
||||
#if defined(MECK_TWATCH)
|
||||
_wantKeyboard = false;
|
||||
#endif
|
||||
_resultScroll = 0;
|
||||
memset(_pathBuf, 0, sizeof(_pathBuf));
|
||||
memset(&_result, 0, sizeof(_result));
|
||||
@@ -518,8 +468,6 @@ private:
|
||||
} else {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
snprintf(tmp, sizeof(tmp), "%c Type Path: [Long press]", prefix);
|
||||
#elif defined(MECK_TWATCH)
|
||||
snprintf(tmp, sizeof(tmp), "%c Type Path: [Tap]", prefix);
|
||||
#else
|
||||
snprintf(tmp, sizeof(tmp), "%c Type Path: [Press Enter]", prefix);
|
||||
#endif
|
||||
@@ -591,8 +539,6 @@ private:
|
||||
} else {
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Exit Tap:Sel");
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Tap:Sel Hold:Go");
|
||||
#else
|
||||
display.print("Q:Exit W/S:Nav Ent:Sel");
|
||||
#endif
|
||||
@@ -656,8 +602,6 @@ private:
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Back Tap:Add");
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Tap:Sel Hold:Add");
|
||||
#else
|
||||
display.print("Q:Back W/S:Scroll Ent:Add");
|
||||
#endif
|
||||
@@ -810,8 +754,6 @@ private:
|
||||
display.setCursor(0, footerY);
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
display.print("Boot:Back Tap:New Trace");
|
||||
#elif defined(MECK_TWATCH)
|
||||
display.print("Hold: New Trace");
|
||||
#else
|
||||
display.print("Q:Back Ent:New Trace");
|
||||
#endif
|
||||
@@ -908,14 +850,8 @@ private:
|
||||
switch (item) {
|
||||
case MENU_TYPE_PATH:
|
||||
pathToEditBuf();
|
||||
#if defined(MECK_TWATCH)
|
||||
// Watch: no physical keys -- request the on-screen keyboard. The main
|
||||
// loop polls wantsKeyboard() and opens it, pre-filled with the path.
|
||||
_wantKeyboard = true;
|
||||
#else
|
||||
// Enter inline edit mode -- pre-fill with current path if any
|
||||
_editing = true;
|
||||
#endif
|
||||
return true;
|
||||
|
||||
case MENU_ADD_HOP:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@
|
||||
#include "AlarmScreen.h"
|
||||
#endif
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#include "VirtualKeyboard.h"
|
||||
#endif
|
||||
|
||||
@@ -109,40 +109,11 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
|
||||
#endif
|
||||
UIScreen* map_screen; // Map tile screen (GPS + SD card tiles)
|
||||
#ifdef TWATCH_COMPOSE_ENABLED
|
||||
UIScreen* tw_picker;
|
||||
UIScreen* tw_channel;
|
||||
UIScreen* tw_keyboard;
|
||||
#endif
|
||||
UIScreen* curr;
|
||||
bool _homeShowingTiles = false; // Set by HomeScreen render when tile grid is visible
|
||||
int _tileGridVY = 44; // Virtual Y of tile grid top (updated each render)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
UIScreen* lock_screen; // Lock screen (big clock + battery + unread)
|
||||
#if defined(MECK_TWATCH)
|
||||
UIScreen* steps_screen; // Steps screen (big daily step count)
|
||||
UIScreen* steps_history_screen; // Steps subscreen: today + the previous 6 days
|
||||
// Step state. The BMA423 feature engine is re-flashed by bma423_write_config_file()
|
||||
// inside SensorBMA423::begin() on every boot, which zeroes the step register. So
|
||||
// the raw count cannot be used as an absolute; deltas are accumulated instead and
|
||||
// the totals are persisted. _lastRaw is seeded from the chip on the first read
|
||||
// rather than from the file, which is correct whether or not the count survived.
|
||||
int32_t _lastStepDay = 0; // local day-of-epoch that _todaySteps belongs to
|
||||
uint32_t _todaySteps = 0; // accumulated steps for _lastStepDay
|
||||
uint32_t _stepHistory[6] = {0}; // [0] = yesterday ... [5] = six days ago
|
||||
uint32_t _lastRaw = 0; // last raw BMA423 count seen (runtime only)
|
||||
bool _stepsSeeded = false; // _lastRaw has been seeded from the chip
|
||||
unsigned long _stepsDirtySince = 0; // millis of oldest unsaved change; 0 = clean
|
||||
void loadSteps();
|
||||
void saveSteps();
|
||||
void updateSteps(); // called every loop()
|
||||
void rollStepDays(int32_t newDay);
|
||||
void shiftStepHistory(uint32_t completedDay);
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
UIScreen* watch_notes_screen; // LittleFS note pad (shared NotesScreen needs SD)
|
||||
UIScreen* watch_channel_cfg_screen; // Per-channel region/notif/delete (touch UI)
|
||||
#endif
|
||||
UIScreen* _screenBeforeLock = nullptr;
|
||||
bool _locked = false;
|
||||
unsigned long _lastInputMillis = 0; // Auto-lock idle tracking
|
||||
@@ -218,10 +189,6 @@ public:
|
||||
void gotoHomeScreen();
|
||||
void gotoChannelScreen(bool resetDmView = true); // Navigate to channel message screen
|
||||
void gotoChannelPickerScreen(); // Navigate to channel picker (bubble/list)
|
||||
#ifdef TWATCH_COMPOSE_ENABLED
|
||||
void openTWatchPicker();
|
||||
void openTWatchKeyboard(int purpose, int contextIdx);
|
||||
#endif
|
||||
void gotoDMTab(); // Navigate directly to DM tab on channel screen
|
||||
void gotoDMConversation(const char* contactName, int contactIdx = -1, uint8_t perms = 0);
|
||||
void gotoContactsScreen(); // Navigate to contacts list
|
||||
@@ -244,12 +211,6 @@ public:
|
||||
void gotoGamesMenu(); // Navigate to games launcher menu
|
||||
void gotoSnakeScreen(); // Navigate to snake game
|
||||
void gotoMinesweeperScreen(); // Navigate to minesweeper game
|
||||
#if defined(MECK_TWATCH)
|
||||
void gotoStepsScreen(); // Navigate to the step counter screen
|
||||
void gotoStepsHistoryScreen(); // Navigate to the 7-day step history
|
||||
uint32_t getTodaySteps(); // Steps accumulated today
|
||||
uint32_t getStepHistory(int daysAgo); // 0 = today, 1..6 = previous days
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
void gotoMapScreen(); // Navigate to map tile screen
|
||||
#endif
|
||||
@@ -311,25 +272,14 @@ public:
|
||||
bool isOnTraceScreen() const { return curr == trace_screen; }
|
||||
bool isOnGamesMenu() const { return curr == games_menu_screen; }
|
||||
bool isOnSnakeScreen() const { return curr == snake_screen; }
|
||||
#if defined(MECK_TWATCH)
|
||||
bool isOnStepsScreen() const { return curr == steps_screen; }
|
||||
bool isOnStepsHistoryScreen() const { return curr == steps_history_screen; }
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
bool isOnWatchNotesScreen() const { return curr == watch_notes_screen; }
|
||||
bool isOnWatchChannelConfigScreen() const { return curr == watch_channel_cfg_screen; }
|
||||
#endif
|
||||
#ifdef TWATCH_COMPOSE_ENABLED
|
||||
bool isOnTWatchChannelScreen() const { return curr == tw_channel; }
|
||||
#endif
|
||||
bool isOnMinesweeperScreen() const { return curr == minesweeper_screen; }
|
||||
bool isOnMapScreen() const { return curr == map_screen; }
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
|
||||
bool isLocked() const { return _locked; }
|
||||
void lockScreen();
|
||||
void unlockScreen();
|
||||
#endif
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
bool isVKBActive() const { return _vkbActive; }
|
||||
unsigned long vkbOpenedAt() const { return _vkbOpenedAt; }
|
||||
VirtualKeyboard& getVKB() { return _vkb; }
|
||||
@@ -414,14 +364,6 @@ public:
|
||||
UIScreen* getTraceScreen() const { return trace_screen; }
|
||||
UIScreen* getGamesMenuScreen() const { return games_menu_screen; }
|
||||
UIScreen* getSnakeScreen() const { return snake_screen; }
|
||||
#if defined(MECK_TWATCH)
|
||||
UIScreen* getStepsScreen() const { return steps_screen; }
|
||||
UIScreen* getStepsHistoryScreen() const { return steps_history_screen; }
|
||||
#endif
|
||||
#if defined(MECK_TWATCH)
|
||||
UIScreen* getWatchNotesScreen() const { return watch_notes_screen; }
|
||||
UIScreen* getWatchChannelConfigScreen() const { return watch_channel_cfg_screen; }
|
||||
#endif
|
||||
UIScreen* getMinesweeperScreen() const { return minesweeper_screen; }
|
||||
UIScreen* getMapScreen() const { return map_screen; }
|
||||
#ifdef MECK_WEB_READER
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// if (keyboard.status() == VKB_SUBMITTED) { ... keyboard.getText() ... }
|
||||
// =============================================================================
|
||||
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_TWATCH)
|
||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||
#ifndef VIRTUAL_KEYBOARD_H
|
||||
#define VIRTUAL_KEYBOARD_H
|
||||
|
||||
@@ -536,4 +536,4 @@ private:
|
||||
};
|
||||
|
||||
#endif // VIRTUAL_KEYBOARD_H
|
||||
#endif // LilyGo_T5S3_EPaper_Pro || MECK_TWATCH
|
||||
#endif // LilyGo_T5S3_EPaper_Pro
|
||||
@@ -1,382 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// WatchChannelConfigScreen -- per-channel settings for the T-Watch S3 / S3 Plus.
|
||||
//
|
||||
// The SettingsScreen SUB_CHANNELS sub-screen drives every per-channel action
|
||||
// from physical-keyboard hotkeys on the selected row (Ent: edit region scope,
|
||||
// N: cycle notifications, T: tone, X/Hold: delete), none of which exist on the
|
||||
// touch-only watch. This standalone screen replaces it on MECK_TWATCH builds,
|
||||
// following the WatchAlarmScreen / WatchNotesScreen conventions.
|
||||
//
|
||||
// LIST mode shows each channel with its region scope tag and notification
|
||||
// state; tap a row to select it, tap again (or PWR/Enter) to open it. DETAIL
|
||||
// mode offers: Region (opens the watch keyboard via TWKB_SCOPE, pre-filled
|
||||
// with the current scope), Clear region (reverts to the device default),
|
||||
// Notify (tap-cycles All -> Mentions -> Off, mirroring the desktop order),
|
||||
// and Delete via the button (channel 1+ only): a short press of the PWR key
|
||||
// (S3) or the boot button (S3 Plus) arms it, a second press within 3s
|
||||
// confirms. The tone picker is deliberately absent -- no audio path.
|
||||
//
|
||||
// Exit: the footer Back returns to the Settings screen (via a flag polled by
|
||||
// UITask::loop); the universal top-strip tap still goes home.
|
||||
//
|
||||
// Geometry: 240x240 physical, UI_ZOOM=2, so display.width()/height() = 120.
|
||||
// =============================================================================
|
||||
|
||||
// NOTE on include order: mirrors WatchNotesScreen.h -- this header uses
|
||||
// NodePrefs (NOTIF_* constants, channel_notif[]) and the_mesh, which the two
|
||||
// call sites (UITask.cpp, main.cpp) already have in scope before including it.
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
#include <MeshCore.h>
|
||||
#include <helpers/ChannelDetails.h>
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
|
||||
class UITask;
|
||||
|
||||
#ifndef MAX_GROUP_CHANNELS
|
||||
#define MAX_GROUP_CHANNELS 20
|
||||
#endif
|
||||
|
||||
#define WCC_ROWS 4 // list rows per page
|
||||
|
||||
class WatchChannelConfigScreen : public UIScreen {
|
||||
public:
|
||||
enum Mode { LIST, DETAIL };
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
NodePrefs* _node_prefs;
|
||||
|
||||
uint8_t _chIdx[MAX_GROUP_CHANNELS]; // populated channel slots, in order
|
||||
int _numCh = 0;
|
||||
|
||||
Mode _mode = LIST;
|
||||
int _sel = 0; // selected list row (index into _chIdx)
|
||||
int _scrollTop = 0;
|
||||
int _detail = -1; // channel slot open in DETAIL (value from _chIdx)
|
||||
bool _confirmDelete = false;
|
||||
unsigned long _confirmAt = 0;
|
||||
|
||||
// Keyboard / exit handshake -- UITask::loop() polls these (UITask is only
|
||||
// forward declared here, so this screen cannot call it directly).
|
||||
bool _wantKeyboard = false;
|
||||
bool _wantExit = false;
|
||||
char _editScope[31]; // pre-fill text handed to the keyboard
|
||||
|
||||
// Layout (logical pixels)
|
||||
static const int HEADER_H = 14;
|
||||
static const int ROW_H = 20;
|
||||
static const int FOOTER_Y = 100;
|
||||
|
||||
void rebuildList() {
|
||||
// Scan ALL slots -- the companion app may write non-contiguously, and
|
||||
// gaps can appear after deletion (same scan as SettingsScreen).
|
||||
_numCh = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
_chIdx[_numCh++] = i;
|
||||
}
|
||||
}
|
||||
if (_sel >= _numCh) _sel = _numCh > 0 ? _numCh - 1 : 0;
|
||||
if (_scrollTop > _sel) _scrollTop = _sel;
|
||||
}
|
||||
|
||||
const char* notifLabel(uint8_t idx) const {
|
||||
uint8_t n = _node_prefs->channel_notif[idx];
|
||||
return (n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "@" : "All";
|
||||
}
|
||||
|
||||
// Replicates SettingsScreen::deleteChannel (kept in sync manually). Note:
|
||||
// channel_notif[] is not shifted during compaction -- this matches the
|
||||
// desktop behaviour exactly.
|
||||
void deleteChannel(uint8_t idx) {
|
||||
ChannelDetails empty;
|
||||
memset(&empty, 0, sizeof(empty));
|
||||
int total = 0;
|
||||
for (uint8_t i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') {
|
||||
total = i + 1;
|
||||
}
|
||||
}
|
||||
for (int i = idx; i < total - 1; i++) {
|
||||
ChannelDetails next;
|
||||
if (the_mesh.getChannel(i + 1, next)) {
|
||||
the_mesh.setChannel(i, next);
|
||||
}
|
||||
}
|
||||
the_mesh.setChannel(total - 1, empty);
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("WatchChannelCfg: Deleted channel at idx %d, compacted %d channels\n", idx, total);
|
||||
}
|
||||
|
||||
void ensureVisible() {
|
||||
if (_sel < _scrollTop) _scrollTop = _sel;
|
||||
if (_sel >= _scrollTop + WCC_ROWS) _scrollTop = _sel - WCC_ROWS + 1;
|
||||
}
|
||||
|
||||
public:
|
||||
WatchChannelConfigScreen(UITask* task, NodePrefs* node_prefs)
|
||||
: _task(task), _node_prefs(node_prefs) {
|
||||
_editScope[0] = 0;
|
||||
}
|
||||
|
||||
void enter() {
|
||||
rebuildList();
|
||||
_mode = LIST;
|
||||
_detail = -1;
|
||||
_confirmDelete = false;
|
||||
_wantKeyboard = false;
|
||||
_wantExit = false;
|
||||
}
|
||||
|
||||
// --- handshake (polled from UITask::loop) ---
|
||||
bool wantsKeyboard() const { return _wantKeyboard; }
|
||||
void clearWantKeyboard() { _wantKeyboard = false; }
|
||||
bool wantsExit() const { return _wantExit; }
|
||||
void clearWantExit() { _wantExit = false; }
|
||||
const char* getEditText() const { return _editScope; }
|
||||
|
||||
// Save the keyboard result as the open channel's region scope. An empty
|
||||
// string clears it back to the device default (same as Clear). Returns
|
||||
// NULL on success, else an error message.
|
||||
const char* applyComposedScope(const char* txt) {
|
||||
if (_detail < 0) return "No channel open";
|
||||
ChannelDetails ch;
|
||||
if (!the_mesh.getChannel((uint8_t)_detail, ch)) return "Channel gone";
|
||||
if (txt == nullptr) txt = "";
|
||||
strncpy(ch.scope_name, txt, sizeof(ch.scope_name));
|
||||
ch.scope_name[30] = '\0';
|
||||
the_mesh.setChannel((uint8_t)_detail, ch);
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("WatchChannelCfg: Channel %d scope set to '%s'\n",
|
||||
_detail, ch.scope_name[0] ? ch.scope_name : "(device default)");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering. 120x120 logical.
|
||||
// ---------------------------------------------------------------------------
|
||||
private:
|
||||
void renderList(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 4, "Channels");
|
||||
|
||||
if (_numCh == 0) {
|
||||
d->setRawColor(0x7BEF);
|
||||
display.drawTextCentered(display.width() / 2, 48, "No channels");
|
||||
}
|
||||
|
||||
char buf[40];
|
||||
int visible = _numCh - _scrollTop;
|
||||
if (visible > WCC_ROWS) visible = WCC_ROWS;
|
||||
for (int i = 0; i < visible; i++) {
|
||||
uint8_t idx = _chIdx[_scrollTop + i];
|
||||
int y = HEADER_H + i * ROW_H;
|
||||
ChannelDetails ch;
|
||||
if (!the_mesh.getChannel(idx, ch)) continue;
|
||||
|
||||
if (_scrollTop + i == _sel) {
|
||||
d->setRawColor(0x231D); // same blue as the home tile
|
||||
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
|
||||
}
|
||||
d->setRawColor(0xFFFF);
|
||||
char nameBuf[15];
|
||||
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
|
||||
nameBuf[sizeof(nameBuf) - 1] = 0;
|
||||
display.setCursor(5, y + 2);
|
||||
display.print(nameBuf);
|
||||
|
||||
snprintf(buf, sizeof(buf), "[%s] %s",
|
||||
ch.scope_name[0] ? ch.scope_name : "*", notifLabel(idx));
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(5, y + 13, buf);
|
||||
}
|
||||
|
||||
// Footer: Back (left) and, when the list overflows a page, More (right).
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(20, FOOTER_Y + 8, "Back");
|
||||
if (_numCh > WCC_ROWS) {
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(61, FOOTER_Y, 58, 18, 3);
|
||||
d->setRawColor(0xC618);
|
||||
d->printSmallFont(78, FOOTER_Y + 8, "More");
|
||||
}
|
||||
}
|
||||
|
||||
void renderDetail(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
char buf[44];
|
||||
|
||||
ChannelDetails ch;
|
||||
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
|
||||
_mode = LIST;
|
||||
return;
|
||||
}
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
char nameBuf[18];
|
||||
strncpy(nameBuf, ch.name, sizeof(nameBuf) - 1);
|
||||
nameBuf[sizeof(nameBuf) - 1] = 0;
|
||||
display.drawTextCentered(display.width() / 2, 3, nameBuf);
|
||||
|
||||
// Region row -- opens the keyboard.
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, 16, display.width() - 2, 18, 3);
|
||||
snprintf(buf, sizeof(buf), "Region: %s",
|
||||
ch.scope_name[0] ? ch.scope_name : "(default)");
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(6, 24, buf);
|
||||
|
||||
// Clear-region row -- dim when there is nothing to clear.
|
||||
d->setRawColor(ch.scope_name[0] ? 0x231D : 0x18C3);
|
||||
d->drawRoundRect(1, 36, display.width() - 2, 18, 3);
|
||||
d->setRawColor(ch.scope_name[0] ? 0xC618 : 0x4208);
|
||||
d->printSmallFont(6, 44, "Clear region (use default)");
|
||||
|
||||
// Notify row -- tap cycles All -> Mentions -> Off.
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, 56, display.width() - 2, 18, 3);
|
||||
uint8_t n = _node_prefs->channel_notif[(uint8_t)_detail];
|
||||
snprintf(buf, sizeof(buf), "Notify: %s",
|
||||
(n == NOTIF_NONE) ? "Off" : (n == NOTIF_MENTIONS) ? "Mentions" : "All");
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(6, 64, buf);
|
||||
|
||||
// Delete row -- status only; deletion is driven by the button (see
|
||||
// handleInput). Channel 0 (public) cannot be deleted, same as desktop.
|
||||
bool canDel = (_detail > 0);
|
||||
d->setRawColor(canDel ? 0x231D : 0x18C3);
|
||||
d->drawRoundRect(1, 76, display.width() - 2, 18, 3);
|
||||
d->setRawColor(canDel ? (_confirmDelete ? 0xF800 : 0xC618) : 0x4208);
|
||||
d->printSmallFont(6, 84, !canDel ? "Delete (public ch)"
|
||||
: _confirmDelete ? "Delete? press again" : "Button: delete");
|
||||
|
||||
// Footer: Back to the list.
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, FOOTER_Y, 58, 18, 3);
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(20, FOOTER_Y + 8, "Back");
|
||||
}
|
||||
|
||||
public:
|
||||
int render(DisplayDriver& display) override {
|
||||
if (_confirmDelete && millis() - _confirmAt > 3000) _confirmDelete = false;
|
||||
switch (_mode) {
|
||||
case LIST: renderList(display); return 500;
|
||||
case DETAIL: renderDetail(display); return 500;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
|
||||
// Physical tap in logical coordinates. The top-strip home tap is handled by
|
||||
// main.cpp before this is called. Returns true if the tap was consumed.
|
||||
bool handleTap(int lx, int ly) {
|
||||
if (_mode == LIST) {
|
||||
if (ly >= FOOTER_Y) {
|
||||
if (lx < 60) { // Back -> Settings
|
||||
_wantExit = true;
|
||||
} else if (_numCh > WCC_ROWS) { // More: next page (wraps)
|
||||
_scrollTop += WCC_ROWS;
|
||||
if (_scrollTop >= _numCh) _scrollTop = 0;
|
||||
_sel = _scrollTop;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ly >= HEADER_H) {
|
||||
int i = (ly - HEADER_H) / ROW_H;
|
||||
int idx = _scrollTop + i;
|
||||
if (i < WCC_ROWS && idx < _numCh) {
|
||||
if (idx == _sel) { // second tap on the row: open it
|
||||
_detail = _chIdx[idx];
|
||||
_mode = DETAIL;
|
||||
_confirmDelete = false;
|
||||
} else {
|
||||
_sel = idx; // first tap: select
|
||||
ensureVisible();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// DETAIL
|
||||
if (ly >= FOOTER_Y) { // Back -> list
|
||||
_confirmDelete = false;
|
||||
_mode = LIST;
|
||||
rebuildList();
|
||||
return true;
|
||||
}
|
||||
ChannelDetails ch;
|
||||
if (_detail < 0 || !the_mesh.getChannel((uint8_t)_detail, ch)) {
|
||||
_mode = LIST;
|
||||
return true;
|
||||
}
|
||||
if (ly >= 16 && ly < 34) { // Region -> keyboard, pre-filled
|
||||
strncpy(_editScope, ch.scope_name, sizeof(_editScope));
|
||||
_editScope[30] = '\0';
|
||||
_wantKeyboard = true;
|
||||
return true;
|
||||
}
|
||||
if (ly >= 36 && ly < 54) { // Clear region
|
||||
if (ch.scope_name[0]) {
|
||||
ch.scope_name[0] = '\0';
|
||||
the_mesh.setChannel((uint8_t)_detail, ch);
|
||||
the_mesh.saveChannels();
|
||||
Serial.printf("WatchChannelCfg: Channel %d scope cleared\n", _detail);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ly >= 56 && ly < 74) { // Notify: cycle All -> @ -> Off
|
||||
uint8_t cur = _node_prefs->channel_notif[(uint8_t)_detail];
|
||||
_node_prefs->channel_notif[(uint8_t)_detail] = (cur + 1) % 3;
|
||||
the_mesh.savePrefs();
|
||||
return true;
|
||||
}
|
||||
return true; // swallow other taps (incl. delete row)
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// The PMU short press (S3) arrives as KEY_ENTER; the S3 Plus boot button
|
||||
// arrives as KEY_NEXT.
|
||||
if (_mode == LIST && c == KEY_ENTER) {
|
||||
if (_sel < _numCh) {
|
||||
_detail = _chIdx[_sel];
|
||||
_mode = DETAIL;
|
||||
_confirmDelete = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (_mode == DETAIL && (c == KEY_ENTER || c == KEY_NEXT)) {
|
||||
// Button click deletes the open channel (channel 0 is protected):
|
||||
// first press arms, a second press within 3s confirms. Back is the
|
||||
// touch footer.
|
||||
if (_detail > 0) {
|
||||
if (_confirmDelete) {
|
||||
deleteChannel((uint8_t)_detail);
|
||||
_confirmDelete = false;
|
||||
_detail = -1;
|
||||
rebuildList();
|
||||
_mode = LIST;
|
||||
} else {
|
||||
_confirmDelete = true;
|
||||
_confirmAt = millis();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,703 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// WatchMapScreen -- T-Watch S3 Plus map screen.
|
||||
//
|
||||
// Renders standard OSM "slippy map" B&W PNG tiles from the LittleFS "maps"
|
||||
// partition onto the LovyanGFX sprite buffer. Tiles are stored at
|
||||
// /tiles/{zoom}/{x}/{y}.png -- the same layout as the e-ink MapScreen -- but
|
||||
// read via LittleFS instead of SD. The maps partition is mounted at basePath
|
||||
// "/maps" in main.cpp, and the Arduino VFS prepends that mountpoint
|
||||
// automatically, so tile paths passed to LittleFS stay relative ("/tiles/..").
|
||||
//
|
||||
// This is a separate screen from the e-ink MapScreen: the watch draws into a
|
||||
// 120x120 logical sprite (UI_ZOOM=2 scales it 2x to the 240x240 panel) via the
|
||||
// generic DisplayDriver / LGFXDisplay interface, whereas MapScreen is welded to
|
||||
// GxEPDDisplay's drawPixelRaw path. Coordinate math, tile stitching and the
|
||||
// PNGdec decode are carried over unchanged; only the pixel writes, colours,
|
||||
// dimensions, filesystem and input (touch instead of WASD) differ.
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <LittleFS.h>
|
||||
#include <PNGdec.h>
|
||||
#undef local // PNGdec's zutil.h defines 'local' as 'static' -- breaks any variable named 'local'
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
#define WMAP_TILE_SIZE 256 // Standard OSM tile size in pixels
|
||||
#define WMAP_DEFAULT_ZOOM 13
|
||||
#define WMAP_MIN_ZOOM 1
|
||||
#define WMAP_MAX_ZOOM 17
|
||||
|
||||
// PNG file read buffer (PSRAM). Tiles stream row-by-row via PNGdec.
|
||||
#define WMAP_PNG_BUF_SIZE (65536)
|
||||
|
||||
// Tile path relative to the LittleFS "maps" mountpoint ("/maps" is prepended
|
||||
// by the VFS, so the on-disk file is /maps/tiles/{z}/{x}/{y}.png).
|
||||
#define WMAP_TILE_ROOT "/tiles"
|
||||
|
||||
// Contact type (for label display -- matches AdvertDataHelpers.h)
|
||||
#ifndef ADV_TYPE_REPEATER
|
||||
#define ADV_TYPE_REPEATER 2
|
||||
#endif
|
||||
|
||||
// Pan step: fraction of viewport to move per press (unused for touch, kept for
|
||||
// completeness / any future key routing)
|
||||
#define WMAP_PAN_FRACTION 4
|
||||
|
||||
// Max contact markers (PSRAM-allocated)
|
||||
#define WMAP_MAX_MARKERS 500
|
||||
|
||||
// Footer bar (logical pixels)
|
||||
#define WMAP_FOOTER_H 16
|
||||
|
||||
// B&W tile colours (RGB565; the sprite is 8-bit but LovyanGFX converts)
|
||||
#define WMAP_BLACK 0x0000
|
||||
#define WMAP_WHITE 0xFFFF
|
||||
|
||||
|
||||
class UITask;
|
||||
|
||||
class WatchMapScreen : public UIScreen {
|
||||
public:
|
||||
WatchMapScreen(UITask* task)
|
||||
: _task(task),
|
||||
_lgfx(nullptr),
|
||||
_mapsReady(false),
|
||||
_needsRedraw(true),
|
||||
_hasFix(false),
|
||||
_centerLat(-33.8688), // Default: Sydney
|
||||
_centerLon(151.2093),
|
||||
_gpsLat(0.0),
|
||||
_gpsLon(0.0),
|
||||
_zoom(WMAP_DEFAULT_ZOOM),
|
||||
_zoomMin(WMAP_MIN_ZOOM),
|
||||
_zoomMax(WMAP_MAX_ZOOM),
|
||||
_pngBuf(nullptr),
|
||||
_lineBuf(nullptr),
|
||||
_tileFound(false),
|
||||
_w(120),
|
||||
_h(120),
|
||||
_viewportH(120 - WMAP_FOOTER_H),
|
||||
_markers(nullptr),
|
||||
_numMarkers(0)
|
||||
{
|
||||
}
|
||||
|
||||
~WatchMapScreen() {
|
||||
if (_pngBuf) { free(_pngBuf); _pngBuf = nullptr; }
|
||||
if (_lineBuf) { free(_lineBuf); _lineBuf = nullptr; }
|
||||
if (_markers) { free(_markers); _markers = nullptr; }
|
||||
}
|
||||
|
||||
// Whether the maps LittleFS partition mounted (set from main.cpp on open)
|
||||
void setMapsReady(bool ready) { _mapsReady = ready; }
|
||||
|
||||
// Set initial GPS position (called when opening map -- centers viewport)
|
||||
void setGPSPosition(double lat, double lon) {
|
||||
if (lat != 0.0 || lon != 0.0) {
|
||||
_gpsLat = lat;
|
||||
_gpsLon = lon;
|
||||
_centerLat = lat;
|
||||
_centerLon = lon;
|
||||
_hasFix = true;
|
||||
_needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Update own GPS position without moving viewport (called periodically)
|
||||
void updateGPSPosition(double lat, double lon) {
|
||||
if (lat == 0.0 && lon == 0.0) return;
|
||||
if (lat != _gpsLat || lon != _gpsLon) {
|
||||
_gpsLat = lat;
|
||||
_gpsLon = lon;
|
||||
_hasFix = true;
|
||||
_needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Marker management (call once per contact before/while on map)
|
||||
void clearMarkers() { _numMarkers = 0; }
|
||||
void addMarker(double lat, double lon, const char* name = "", uint8_t type = 0) {
|
||||
if (!_markers) {
|
||||
_markers = (MapMarker*)ps_calloc(WMAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
if (!_markers) return;
|
||||
}
|
||||
if (_numMarkers >= WMAP_MAX_MARKERS) return;
|
||||
if (lat == 0.0 && lon == 0.0) return;
|
||||
_markers[_numMarkers].lat = lat;
|
||||
_markers[_numMarkers].lon = lon;
|
||||
_markers[_numMarkers].type = type;
|
||||
strncpy(_markers[_numMarkers].name, name, sizeof(_markers[0].name) - 1);
|
||||
_markers[_numMarkers].name[sizeof(_markers[0].name) - 1] = '\0';
|
||||
_numMarkers++;
|
||||
}
|
||||
int getNumMarkers() const { return _numMarkers; }
|
||||
|
||||
// Called when navigating to the map screen
|
||||
void enter(DisplayDriver& display) {
|
||||
_lgfx = (LGFXDisplay*)&display;
|
||||
_w = display.width();
|
||||
_h = display.height();
|
||||
_viewportH = _h - WMAP_FOOTER_H;
|
||||
_needsRedraw = true;
|
||||
|
||||
if (!_markers) {
|
||||
_markers = (MapMarker*)ps_calloc(WMAP_MAX_MARKERS, sizeof(MapMarker));
|
||||
}
|
||||
if (!_pngBuf) {
|
||||
_pngBuf = (uint8_t*)ps_malloc(WMAP_PNG_BUF_SIZE);
|
||||
if (!_pngBuf) _pngBuf = (uint8_t*)malloc(WMAP_PNG_BUF_SIZE);
|
||||
}
|
||||
if (!_lineBuf) {
|
||||
_lineBuf = (uint16_t*)ps_malloc(WMAP_TILE_SIZE * sizeof(uint16_t));
|
||||
if (!_lineBuf) _lineBuf = (uint16_t*)malloc(WMAP_TILE_SIZE * sizeof(uint16_t));
|
||||
}
|
||||
|
||||
detectZoomRange();
|
||||
}
|
||||
|
||||
// ---- Touch actions (called from main.cpp gesture dispatch) ----
|
||||
|
||||
void zoomIn() {
|
||||
if (_zoom < _zoomMax) { _zoom++; _needsRedraw = true; }
|
||||
}
|
||||
void zoomOut() {
|
||||
if (_zoom > _zoomMin) { _zoom--; _needsRedraw = true; }
|
||||
}
|
||||
void recenter() {
|
||||
if (_hasFix) {
|
||||
_centerLat = _gpsLat;
|
||||
_centerLon = _gpsLon;
|
||||
_needsRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Pan the viewport by a logical-pixel delta (swipe). Exact: convert centre to
|
||||
// global pixel space, offset, convert back.
|
||||
void panByPixels(int dx, int dy) {
|
||||
int ctX, ctY, cpX, cpY;
|
||||
latLonToTileXY(_centerLat, _centerLon, _zoom, ctX, ctY, cpX, cpY);
|
||||
|
||||
long globalX = (long)ctX * WMAP_TILE_SIZE + cpX - dx;
|
||||
long globalY = (long)ctY * WMAP_TILE_SIZE + cpY - dy;
|
||||
|
||||
long span = (long)(1 << _zoom) * WMAP_TILE_SIZE;
|
||||
// Longitude wraps
|
||||
while (globalX < 0) globalX += span;
|
||||
while (globalX >= span) globalX -= span;
|
||||
// Latitude clamps
|
||||
if (globalY < 0) globalY = 0;
|
||||
if (globalY > span - 1) globalY = span - 1;
|
||||
|
||||
int nTileX = (int)(globalX / WMAP_TILE_SIZE);
|
||||
int nPixX = (int)(globalX % WMAP_TILE_SIZE);
|
||||
int nTileY = (int)(globalY / WMAP_TILE_SIZE);
|
||||
int nPixY = (int)(globalY % WMAP_TILE_SIZE);
|
||||
|
||||
tileXYToLatLon(nTileX, nTileY, nPixX, nPixY, _zoom, _centerLat, _centerLon);
|
||||
_needsRedraw = true;
|
||||
}
|
||||
|
||||
// Handle a discrete tap at logical coords. Footer +/- buttons zoom; a tap in
|
||||
// the map area recenters on GPS. Returns true if the tap was consumed.
|
||||
bool handleTap(int x, int y) {
|
||||
int footerY = _h - WMAP_FOOTER_H;
|
||||
if (y >= footerY) {
|
||||
if (inRect(x, y, _plusX, _btnY, _btnW, _btnH)) { zoomIn(); return true; }
|
||||
if (inRect(x, y, _minusX, _btnY, _btnW, _btnH)) { zoomOut(); return true; }
|
||||
return false; // footer, but not on a button
|
||||
}
|
||||
// Tap in the map area -> recenter on own position
|
||||
recenter();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- UIScreen interface ----
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
_lgfx = (LGFXDisplay*)&display;
|
||||
_w = display.width();
|
||||
_h = display.height();
|
||||
_viewportH = _h - WMAP_FOOTER_H;
|
||||
|
||||
if (!_mapsReady) {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setCursor(4, 20);
|
||||
display.print("No map tiles");
|
||||
renderFooter(display);
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool wasRedraw = _needsRedraw;
|
||||
_needsRedraw = false;
|
||||
|
||||
renderMapViewport();
|
||||
renderContactMarkers();
|
||||
renderCrosshair();
|
||||
renderFooter(display);
|
||||
|
||||
return wasRedraw ? 500 : 30000;
|
||||
}
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
LGFXDisplay* _lgfx;
|
||||
bool _mapsReady;
|
||||
bool _needsRedraw;
|
||||
bool _hasFix;
|
||||
|
||||
double _centerLat;
|
||||
double _centerLon;
|
||||
double _gpsLat;
|
||||
double _gpsLon;
|
||||
int _zoom;
|
||||
int _zoomMin;
|
||||
int _zoomMax;
|
||||
|
||||
uint8_t* _pngBuf;
|
||||
uint16_t* _lineBuf;
|
||||
bool _tileFound;
|
||||
|
||||
int _w;
|
||||
int _h;
|
||||
int _viewportH;
|
||||
|
||||
// Footer button rects (set in renderFooter)
|
||||
int _plusX = 0, _minusX = 0, _btnY = 0, _btnW = 18, _btnH = 14;
|
||||
|
||||
PNG _png;
|
||||
|
||||
struct MapMarker {
|
||||
double lat;
|
||||
double lon;
|
||||
char name[20];
|
||||
uint8_t type;
|
||||
};
|
||||
MapMarker* _markers;
|
||||
int _numMarkers;
|
||||
|
||||
struct DrawContext {
|
||||
LGFXDisplay* display;
|
||||
PNG* png;
|
||||
int offsetX;
|
||||
int offsetY;
|
||||
int viewportH;
|
||||
int dispW;
|
||||
uint16_t* lineBuf;
|
||||
};
|
||||
DrawContext _drawCtx;
|
||||
|
||||
static bool inRect(int px, int py, int x, int y, int w, int h) {
|
||||
return px >= x && px < x + w && py >= y && py < y + h;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Detect available zoom levels from /tiles/{z}/ directories on LittleFS
|
||||
// ==========================================================================
|
||||
void detectZoomRange() {
|
||||
if (!_mapsReady) return;
|
||||
|
||||
_zoomMin = WMAP_MAX_ZOOM;
|
||||
_zoomMax = WMAP_MIN_ZOOM;
|
||||
|
||||
char path[32];
|
||||
for (int z = WMAP_MIN_ZOOM; z <= WMAP_MAX_ZOOM; z++) {
|
||||
snprintf(path, sizeof(path), WMAP_TILE_ROOT "/%d", z);
|
||||
if (LittleFS.exists(path)) {
|
||||
if (z < _zoomMin) _zoomMin = z;
|
||||
if (z > _zoomMax) _zoomMax = z;
|
||||
}
|
||||
}
|
||||
|
||||
if (_zoomMin > _zoomMax) {
|
||||
_zoomMin = WMAP_MIN_ZOOM;
|
||||
_zoomMax = WMAP_MAX_ZOOM;
|
||||
Serial.println("WatchMapScreen: no tile directories found");
|
||||
} else {
|
||||
Serial.printf("WatchMapScreen: detected zoom range %d-%d\n", _zoomMin, _zoomMax);
|
||||
}
|
||||
|
||||
if (_zoom > _zoomMax) _zoom = _zoomMax;
|
||||
if (_zoom < _zoomMin) _zoom = _zoomMin;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Tile coordinate math (Web Mercator / Slippy Map) -- identical to MapScreen
|
||||
// ==========================================================================
|
||||
static void latLonToTileXY(double lat, double lon, int zoom,
|
||||
int& tileX, int& tileY,
|
||||
int& pixelX, int& pixelY)
|
||||
{
|
||||
int n = 1 << zoom;
|
||||
double x = (lon + 180.0) / 360.0 * n;
|
||||
tileX = (int)floor(x);
|
||||
pixelX = (int)((x - tileX) * WMAP_TILE_SIZE);
|
||||
|
||||
double latRad = lat * PI / 180.0;
|
||||
double y = (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0 * n;
|
||||
tileY = (int)floor(y);
|
||||
pixelY = (int)((y - tileY) * WMAP_TILE_SIZE);
|
||||
}
|
||||
|
||||
static void tileXYToLatLon(int tileX, int tileY, int pixelX, int pixelY,
|
||||
int zoom, double& lat, double& lon)
|
||||
{
|
||||
int n = 1 << zoom;
|
||||
double x = tileX + (double)pixelX / WMAP_TILE_SIZE;
|
||||
double y = tileY + (double)pixelY / WMAP_TILE_SIZE;
|
||||
|
||||
lon = x / n * 360.0 - 180.0;
|
||||
double latRad = atan(sinh(PI * (1.0 - 2.0 * y / n)));
|
||||
lat = latRad * 180.0 / PI;
|
||||
}
|
||||
|
||||
bool latLonToScreen(double lat, double lon, int& screenX, int& screenY) {
|
||||
int centerTileX, centerTileY, centerPixelX, centerPixelY;
|
||||
latLonToTileXY(_centerLat, _centerLon, _zoom,
|
||||
centerTileX, centerTileY, centerPixelX, centerPixelY);
|
||||
|
||||
int targetTileX, targetTileY, targetPixelX, targetPixelY;
|
||||
latLonToTileXY(lat, lon, _zoom,
|
||||
targetTileX, targetTileY, targetPixelX, targetPixelY);
|
||||
|
||||
int dx = (targetTileX - centerTileX) * WMAP_TILE_SIZE + (targetPixelX - centerPixelX);
|
||||
int dy = (targetTileY - centerTileY) * WMAP_TILE_SIZE + (targetPixelY - centerPixelY);
|
||||
|
||||
screenX = _w / 2 + dx;
|
||||
screenY = _viewportH / 2 + dy;
|
||||
|
||||
return (screenX >= 0 && screenX < _w &&
|
||||
screenY >= 0 && screenY < _viewportH);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Tile loading and rendering
|
||||
// ==========================================================================
|
||||
static void buildTilePath(char* buf, int bufSize, int zoom, int x, int y) {
|
||||
snprintf(buf, bufSize, WMAP_TILE_ROOT "/%d/%d/%d.png", zoom, x, y);
|
||||
}
|
||||
|
||||
bool loadAndRenderTile(int tileX, int tileY, int screenX, int screenY) {
|
||||
if (!_pngBuf || !_lineBuf || !_lgfx) return false;
|
||||
|
||||
char path[64];
|
||||
buildTilePath(path, sizeof(path), _zoom, tileX, tileY);
|
||||
|
||||
if (!LittleFS.exists(path)) return false;
|
||||
|
||||
File f = LittleFS.open(path, FILE_READ);
|
||||
if (!f) return false;
|
||||
|
||||
int fileSize = f.size();
|
||||
if (fileSize > WMAP_PNG_BUF_SIZE) {
|
||||
Serial.printf("WatchMapScreen: tile too large: %s (%d bytes)\n", path, fileSize);
|
||||
f.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
int bytesRead = f.read(_pngBuf, fileSize);
|
||||
f.close();
|
||||
|
||||
if (bytesRead != fileSize) {
|
||||
Serial.printf("WatchMapScreen: short read: %s (%d/%d)\n", path, bytesRead, fileSize);
|
||||
return false;
|
||||
}
|
||||
|
||||
_drawCtx.display = _lgfx;
|
||||
_drawCtx.png = &_png;
|
||||
_drawCtx.offsetX = screenX;
|
||||
_drawCtx.offsetY = screenY;
|
||||
_drawCtx.viewportH = _viewportH;
|
||||
_drawCtx.dispW = _w;
|
||||
_drawCtx.lineBuf = _lineBuf;
|
||||
|
||||
int rc = _png.openRAM(_pngBuf, fileSize, pngDrawCallback);
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("WatchMapScreen: PNG open failed: %s (rc=%d)\n", path, rc);
|
||||
return false;
|
||||
}
|
||||
|
||||
rc = _png.decode(&_drawCtx, 0);
|
||||
_png.close();
|
||||
|
||||
if (rc != PNG_SUCCESS) {
|
||||
Serial.printf("WatchMapScreen: PNG decode failed: %s (rc=%d)\n", path, rc);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// PNGdec scanline callback -- draws one decoded row into the sprite buffer.
|
||||
static int pngDrawCallback(PNGDRAW* pDraw) {
|
||||
DrawContext* ctx = (DrawContext*)pDraw->pUser;
|
||||
if (!ctx || !ctx->display || !ctx->png || !ctx->lineBuf) return 0;
|
||||
|
||||
int screenY = ctx->offsetY + pDraw->y;
|
||||
if (screenY < 0 || screenY >= ctx->viewportH) return 1;
|
||||
|
||||
uint16_t lineWidth = pDraw->iWidth;
|
||||
if (lineWidth > WMAP_TILE_SIZE) lineWidth = WMAP_TILE_SIZE;
|
||||
ctx->png->getLineAsRGB565(pDraw, ctx->lineBuf, PNG_RGB565_LITTLE_ENDIAN, 0xFFFFFFFF);
|
||||
|
||||
for (int x = 0; x < lineWidth; x++) {
|
||||
int screenX = ctx->offsetX + x;
|
||||
if (screenX < 0 || screenX >= ctx->dispW) continue;
|
||||
|
||||
uint16_t pixel = ctx->lineBuf[x];
|
||||
uint16_t color = (pixel > 0x7FFF) ? WMAP_WHITE : WMAP_BLACK;
|
||||
ctx->display->drawPixelRaw(screenX, screenY, color);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Viewport rendering -- stitch tiles to fill the map area
|
||||
// ==========================================================================
|
||||
void renderMapViewport() {
|
||||
if (!_lgfx) return;
|
||||
|
||||
int centerTileX, centerTileY, centerPixelX, centerPixelY;
|
||||
latLonToTileXY(_centerLat, _centerLon, _zoom,
|
||||
centerTileX, centerTileY, centerPixelX, centerPixelY);
|
||||
|
||||
int viewCenterX = _w / 2;
|
||||
int viewCenterY = _viewportH / 2;
|
||||
|
||||
int baseTileScreenX = viewCenterX - centerPixelX;
|
||||
int baseTileScreenY = viewCenterY - centerPixelY;
|
||||
|
||||
int startDX = 0, startDY = 0;
|
||||
int endDX = 0, endDY = 0;
|
||||
|
||||
while (baseTileScreenX + startDX * WMAP_TILE_SIZE > 0) startDX--;
|
||||
while (baseTileScreenY + startDY * WMAP_TILE_SIZE > 0) startDY--;
|
||||
while (baseTileScreenX + (endDX + 1) * WMAP_TILE_SIZE < _w) endDX++;
|
||||
while (baseTileScreenY + (endDY + 1) * WMAP_TILE_SIZE < _viewportH) endDY++;
|
||||
|
||||
int maxTile = (1 << _zoom) - 1;
|
||||
int loaded = 0, missing = 0;
|
||||
|
||||
for (int dy = startDY; dy <= endDY; dy++) {
|
||||
for (int dx = startDX; dx <= endDX; dx++) {
|
||||
int tx = centerTileX + dx;
|
||||
int ty = centerTileY + dy;
|
||||
|
||||
if (tx < 0) tx += (1 << _zoom);
|
||||
if (tx > maxTile) tx -= (1 << _zoom);
|
||||
if (ty < 0 || ty > maxTile) continue;
|
||||
|
||||
int screenX = baseTileScreenX + dx * WMAP_TILE_SIZE;
|
||||
int screenY = baseTileScreenY + dy * WMAP_TILE_SIZE;
|
||||
|
||||
if (loadAndRenderTile(tx, ty, screenX, screenY)) loaded++;
|
||||
else missing++;
|
||||
yield();
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("WatchMapScreen: rendered %d tiles, %d missing\n", loaded, missing);
|
||||
_tileFound = (loaded > 0);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Contact marker overlay
|
||||
// ==========================================================================
|
||||
void renderContactMarkers() {
|
||||
if (!_lgfx || !_markers) return;
|
||||
|
||||
for (int i = 0; i < _numMarkers; i++) {
|
||||
int sx, sy;
|
||||
if (latLonToScreen(_markers[i].lat, _markers[i].lon, sx, sy)) {
|
||||
int r = markerRadius();
|
||||
drawDiamond(sx, sy, r);
|
||||
if (_markers[i].name[0] != '\0' &&
|
||||
(_markers[i].type == ADV_TYPE_REPEATER || _zoom >= 14)) {
|
||||
drawLabel(sx, sy - r - 2, _markers[i].name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_hasFix) {
|
||||
int sx, sy;
|
||||
if (latLonToScreen(_gpsLat, _gpsLon, sx, sy)) {
|
||||
drawOwnPosition(sx, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Marker radius scaled by zoom (smaller than e-ink -- 120px canvas)
|
||||
int markerRadius() {
|
||||
int r = _zoom - 9;
|
||||
if (r < 2) r = 2;
|
||||
if (r > 6) r = 6;
|
||||
return r;
|
||||
}
|
||||
|
||||
void drawDiamond(int cx, int cy, int r) {
|
||||
// White outline (1px larger than fill)
|
||||
for (int dy = -(r + 1); dy <= (r + 1); dy++) {
|
||||
int span = (r + 1) - abs(dy);
|
||||
int innerSpan = r - abs(dy);
|
||||
for (int dx = -span; dx <= span; dx++) {
|
||||
if (abs(dy) <= r && abs(dx) <= innerSpan) continue;
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < _w && py >= 0 && py < _viewportH) {
|
||||
_lgfx->drawPixelRaw(px, py, WMAP_WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Filled black diamond
|
||||
for (int dy = -r; dy <= r; dy++) {
|
||||
int span = r - abs(dy);
|
||||
for (int dx = -span; dx <= span; dx++) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < _w && py >= 0 && py < _viewportH) {
|
||||
_lgfx->drawPixelRaw(px, py, WMAP_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int extractAsciiLabel(const char* src, char* dest, int destSize) {
|
||||
int j = 0;
|
||||
for (int i = 0; src[i] != '\0' && j < destSize - 1; i++) {
|
||||
uint8_t ch = (uint8_t)src[i];
|
||||
if (ch >= 0x20 && ch <= 0x7E) dest[j++] = src[i];
|
||||
}
|
||||
dest[j] = '\0';
|
||||
int start = 0;
|
||||
while (dest[start] == ' ') start++;
|
||||
if (start > 0) {
|
||||
memmove(dest, dest + start, j - start + 1);
|
||||
j -= start;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
// Draw a marker label with a white background box, using the normal buffered
|
||||
// text API (Font0 6x8). Kept short -- the canvas is only 120px wide.
|
||||
void drawLabel(int cx, int topY, const char* text) {
|
||||
if (!_lgfx) return;
|
||||
char clean[16];
|
||||
int len = extractAsciiLabel(text, clean, sizeof(clean));
|
||||
if (len == 0) return;
|
||||
if (len > 10) { len = 10; clean[len] = '\0'; }
|
||||
|
||||
int textW = len * 6;
|
||||
int textH = 8;
|
||||
int lx = cx - textW / 2;
|
||||
int ly = topY - textH;
|
||||
|
||||
if (lx < 1) lx = 1;
|
||||
if (lx + textW >= _w - 1) lx = _w - textW - 1;
|
||||
if (ly < 0) ly = 0;
|
||||
if (ly + textH >= _viewportH) return;
|
||||
|
||||
_lgfx->setColor(DisplayDriver::LIGHT);
|
||||
_lgfx->fillRect(lx - 1, ly - 1, textW + 2, textH + 2);
|
||||
_lgfx->setColor(DisplayDriver::DARK);
|
||||
_lgfx->setTextSize(1);
|
||||
_lgfx->setCursor(lx, ly);
|
||||
_lgfx->print(clean);
|
||||
}
|
||||
|
||||
// Own-position marker: bold circle with filled centre dot
|
||||
void drawOwnPosition(int cx, int cy) {
|
||||
int r = 6;
|
||||
// White halo
|
||||
for (int dy = -(r + 2); dy <= (r + 2); dy++) {
|
||||
for (int dx = -(r + 2); dx <= (r + 2); dx++) {
|
||||
if (dx * dx + dy * dy <= (r + 2) * (r + 2)) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < _w && py >= 0 && py < _viewportH)
|
||||
_lgfx->drawPixelRaw(px, py, WMAP_WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Black ring
|
||||
for (int dy = -r; dy <= r; dy++) {
|
||||
for (int dx = -r; dx <= r; dx++) {
|
||||
int d2 = dx * dx + dy * dy;
|
||||
if (d2 >= (r - 2) * (r - 2) && d2 <= r * r) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < _w && py >= 0 && py < _viewportH)
|
||||
_lgfx->drawPixelRaw(px, py, WMAP_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Centre dot
|
||||
for (int dy = -2; dy <= 2; dy++) {
|
||||
for (int dx = -2; dx <= 2; dx++) {
|
||||
if (dx * dx + dy * dy <= 4) {
|
||||
int px = cx + dx, py = cy + dy;
|
||||
if (px >= 0 && px < _w && py >= 0 && py < _viewportH)
|
||||
_lgfx->drawPixelRaw(px, py, WMAP_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Crosshair at viewport centre
|
||||
// ==========================================================================
|
||||
void renderCrosshair() {
|
||||
if (!_lgfx) return;
|
||||
|
||||
int cx = _w / 2;
|
||||
int cy = _viewportH / 2;
|
||||
int len = markerRadius() + 2;
|
||||
|
||||
for (int x = cx - len; x <= cx + len; x++) {
|
||||
if (x >= 0 && x < _w) {
|
||||
if (cy - 1 >= 0) _lgfx->drawPixelRaw(x, cy - 1, WMAP_WHITE);
|
||||
if (cy + 1 < _viewportH) _lgfx->drawPixelRaw(x, cy + 1, WMAP_WHITE);
|
||||
_lgfx->drawPixelRaw(x, cy, WMAP_BLACK);
|
||||
}
|
||||
}
|
||||
for (int y = cy - len; y <= cy + len; y++) {
|
||||
if (y >= 0 && y < _viewportH) {
|
||||
if (cx - 1 >= 0) _lgfx->drawPixelRaw(cx - 1, y, WMAP_WHITE);
|
||||
if (cx + 1 < _w) _lgfx->drawPixelRaw(cx + 1, y, WMAP_WHITE);
|
||||
_lgfx->drawPixelRaw(cx, y, WMAP_BLACK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Footer -- zoom level + on-screen +/- zoom buttons
|
||||
// ==========================================================================
|
||||
void renderFooter(DisplayDriver& display) {
|
||||
int footerY = _h - WMAP_FOOTER_H;
|
||||
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(0, footerY - 1, _w, 1);
|
||||
|
||||
// Left: zoom level
|
||||
char left[8];
|
||||
snprintf(left, sizeof(left), "Z%d", _zoom);
|
||||
display.setCursor(1, footerY + 4);
|
||||
display.print(left);
|
||||
|
||||
// Right: [-] [+] buttons
|
||||
_btnW = 18;
|
||||
_btnH = WMAP_FOOTER_H - 2;
|
||||
_btnY = footerY + 1;
|
||||
_plusX = _w - _btnW - 1;
|
||||
_minusX = _plusX - _btnW - 2;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.drawRect(_minusX, _btnY, _btnW, _btnH);
|
||||
display.drawRect(_plusX, _btnY, _btnW, _btnH);
|
||||
display.setCursor(_minusX + 6, _btnY + 3);
|
||||
display.print("-");
|
||||
display.setCursor(_plusX + 5, _btnY + 3);
|
||||
display.print("+");
|
||||
}
|
||||
};
|
||||
@@ -1,433 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// =============================================================================
|
||||
// WatchNotesScreen -- short notes for the LilyGo T-Watch S3 / S3 Plus.
|
||||
//
|
||||
// The T-Deck Pro's NotesScreen.h is bound to the SD card (list/read/write/
|
||||
// rename all via SD.h), which the watches do not have. This is a separate,
|
||||
// much smaller screen that stores notes as .txt files on the LittleFS "maps"
|
||||
// partition (the same one WatchAlarmScreen uses for /alarms.cfg).
|
||||
//
|
||||
// Notes are composed and edited on the TWatchKeyboardScreen (purpose
|
||||
// TWKB_NOTE), so a note is capped at the keyboard buffer (133 chars). Editing
|
||||
// opens the keyboard pre-filled with the note text and saving replaces the
|
||||
// file. Files longer than the view buffer (e.g. copied on by other means) are
|
||||
// shown truncated, and editing one truncates it to the keyboard cap.
|
||||
//
|
||||
// Filenames are auto timestamps, YYMMDD-HHMMSS.txt in local time, so a plain
|
||||
// descending name sort is newest-first. If the clock is not set yet the name
|
||||
// falls back to u<millis>.txt.
|
||||
//
|
||||
// LIST: tap a row to select it, tap the selected row again (or press
|
||||
// PWR/Enter) to open it; the [New] footer button composes a new note.
|
||||
// VIEW: footer [Back] [Edit] [Del]; Del asks once ("Sure?") before deleting;
|
||||
// tapping the text area scrolls when the note is longer than one screen.
|
||||
// The top status strip is handled by main.cpp before this screen and returns
|
||||
// home, matching the other watch screens.
|
||||
//
|
||||
// Geometry: the display is 240x240 physical, UI_ZOOM=2, so display.width()
|
||||
// and display.height() both report 120 logical pixels.
|
||||
// =============================================================================
|
||||
|
||||
// NOTE on include order: this header uses NodePrefs, which lives at
|
||||
// examples/companion_radio/NodePrefs.h and is not on this variant's -I path.
|
||||
// UITask.h pulls it in via "../NodePrefs.h", so WatchNotesScreen.h must always
|
||||
// be included *after* UITask.h. Both call sites (UITask.cpp, main.cpp) do.
|
||||
// This mirrors WatchAlarmScreen.h.
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
#include <LittleFS.h>
|
||||
#include <MeshCore.h> // mesh::RTCClock
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
|
||||
class UITask;
|
||||
|
||||
#define WATCH_NOTES_DIR "/notes"
|
||||
#define WATCH_NOTES_MAX 24 // list cap
|
||||
#define WATCH_NOTE_NAME_LEN 20 // "YYMMDD-HHMMSS.txt" + NUL
|
||||
#define WATCH_NOTE_PREVIEW 22 // first-line preview chars kept per entry
|
||||
#define WATCH_NOTE_BUF 512 // view/edit buffer (on-watch notes are <=133)
|
||||
#define WATCH_NOTE_COLS 19 // wrap width at textSize(1) on 120px
|
||||
#define WATCH_NOTE_VIEW_LINES 9 // wrapped lines visible above the footer
|
||||
|
||||
class WatchNotesScreen : public UIScreen {
|
||||
public:
|
||||
enum Mode { LIST, VIEW };
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
NodePrefs* _node_prefs;
|
||||
|
||||
Mode _mode = LIST;
|
||||
int _sel = 0; // selected row in LIST
|
||||
int _scroll = 0; // first visible row in LIST
|
||||
|
||||
int _count = 0;
|
||||
char _names[WATCH_NOTES_MAX][WATCH_NOTE_NAME_LEN];
|
||||
char _previews[WATCH_NOTES_MAX][WATCH_NOTE_PREVIEW];
|
||||
|
||||
// VIEW state
|
||||
char _curName[WATCH_NOTE_NAME_LEN];
|
||||
char _noteBuf[WATCH_NOTE_BUF];
|
||||
int _noteLen = 0;
|
||||
int _lineCount = 0;
|
||||
int _viewScroll = 0; // first visible wrapped line
|
||||
bool _delArmed = false;
|
||||
|
||||
// Keyboard handshake (UITask::loop polls these; UITask is only forward
|
||||
// declared here, so this screen never calls it directly)
|
||||
bool _wantKeyboard = false;
|
||||
bool _editPending = false; // true = prefill keyboard with _noteBuf
|
||||
char _editName[WATCH_NOTE_NAME_LEN];
|
||||
|
||||
int32_t localNow() const {
|
||||
uint32_t utc = _rtc->getCurrentTime();
|
||||
if (utc < 1700000000) return -1; // clock not set yet
|
||||
return (int32_t)utc + ((int32_t)_node_prefs->utc_offset_hours * 3600);
|
||||
}
|
||||
|
||||
// days-from-epoch -> civil date (Howard Hinnant's algorithm)
|
||||
static void civilFromDays(int32_t z, int& y, int& m, int& d) {
|
||||
z += 719468;
|
||||
int32_t era = (z >= 0 ? z : z - 146096) / 146097;
|
||||
uint32_t doe = (uint32_t)(z - era * 146097);
|
||||
uint32_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
int32_t yr = (int32_t)yoe + era * 400;
|
||||
uint32_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
uint32_t mp = (5 * doy + 2) / 153;
|
||||
d = (int)(doy - (153 * mp + 2) / 5 + 1);
|
||||
m = (int)(mp < 10 ? mp + 3 : mp - 9);
|
||||
y = (int)(yr + (m <= 2));
|
||||
}
|
||||
|
||||
void makeName(char* out, size_t outLen) {
|
||||
int32_t local = localNow();
|
||||
if (local < 0) {
|
||||
snprintf(out, outLen, "u%07lu.txt", (unsigned long)(millis() % 10000000UL));
|
||||
return;
|
||||
}
|
||||
int y, mo, d;
|
||||
civilFromDays(local / 86400, y, mo, d);
|
||||
int hh = (local / 3600) % 24;
|
||||
int mi = (local / 60) % 60;
|
||||
int ss = local % 60;
|
||||
snprintf(out, outLen, "%02d%02d%02d-%02d%02d%02d.txt",
|
||||
y % 100, mo, d, hh, mi, ss);
|
||||
}
|
||||
|
||||
// "260714-173245.txt" -> "14/07 17:32"; u-names -> "no clock"
|
||||
static void dateLabel(const char* name, char* out, size_t outLen) {
|
||||
if (name[0] == 'u' || strlen(name) < 17) {
|
||||
snprintf(out, outLen, "no clock");
|
||||
return;
|
||||
}
|
||||
snprintf(out, outLen, "%.2s/%.2s %.2s:%.2s",
|
||||
name + 4, name + 2, name + 7, name + 9);
|
||||
}
|
||||
|
||||
static const char* leafOf(const char* n) {
|
||||
const char* s = strrchr(n, '/');
|
||||
return s ? s + 1 : n;
|
||||
}
|
||||
|
||||
void readPreview(const char* leaf, char* out, size_t outLen) {
|
||||
char path[40];
|
||||
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", leaf);
|
||||
out[0] = 0;
|
||||
File f = LittleFS.open(path, "r");
|
||||
if (!f) return;
|
||||
size_t i = 0;
|
||||
while (i < outLen - 1 && f.available()) {
|
||||
int ch = f.read();
|
||||
if (ch < 0 || ch == '\n' || ch == '\r') break;
|
||||
out[i++] = (char)ch;
|
||||
}
|
||||
out[i] = 0;
|
||||
f.close();
|
||||
}
|
||||
|
||||
void scanNotes() {
|
||||
_count = 0;
|
||||
if (!LittleFS.exists(WATCH_NOTES_DIR)) LittleFS.mkdir(WATCH_NOTES_DIR);
|
||||
File root = LittleFS.open(WATCH_NOTES_DIR);
|
||||
if (!root || !root.isDirectory()) return;
|
||||
File f = root.openNextFile();
|
||||
while (f && _count < WATCH_NOTES_MAX) {
|
||||
if (!f.isDirectory()) {
|
||||
const char* leaf = leafOf(f.name());
|
||||
size_t n = strlen(leaf);
|
||||
if (n > 4 && n < WATCH_NOTE_NAME_LEN &&
|
||||
strcmp(leaf + n - 4, ".txt") == 0) {
|
||||
// insertion sort, descending name = newest first
|
||||
int pos = _count;
|
||||
while (pos > 0 && strcmp(leaf, _names[pos - 1]) > 0) pos--;
|
||||
for (int k = _count; k > pos; k--) {
|
||||
memcpy(_names[k], _names[k - 1], WATCH_NOTE_NAME_LEN);
|
||||
}
|
||||
strncpy(_names[pos], leaf, WATCH_NOTE_NAME_LEN - 1);
|
||||
_names[pos][WATCH_NOTE_NAME_LEN - 1] = 0;
|
||||
_count++;
|
||||
}
|
||||
}
|
||||
f = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
for (int i = 0; i < _count; i++) readPreview(_names[i], _previews[i], WATCH_NOTE_PREVIEW);
|
||||
if (_sel >= _count) _sel = _count ? _count - 1 : 0;
|
||||
if (_scroll > _sel) _scroll = _sel;
|
||||
}
|
||||
|
||||
void countLines() {
|
||||
_lineCount = (_noteLen + WATCH_NOTE_COLS - 1) / WATCH_NOTE_COLS;
|
||||
if (_lineCount < 1) _lineCount = 1;
|
||||
}
|
||||
|
||||
bool loadNote(const char* leaf) {
|
||||
char path[40];
|
||||
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", leaf);
|
||||
File f = LittleFS.open(path, "r");
|
||||
if (!f) return false;
|
||||
_noteLen = f.read((uint8_t*)_noteBuf, WATCH_NOTE_BUF - 1);
|
||||
if (_noteLen < 0) _noteLen = 0;
|
||||
_noteBuf[_noteLen] = 0;
|
||||
f.close();
|
||||
strncpy(_curName, leaf, WATCH_NOTE_NAME_LEN - 1);
|
||||
_curName[WATCH_NOTE_NAME_LEN - 1] = 0;
|
||||
countLines();
|
||||
_viewScroll = 0;
|
||||
_delArmed = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void openSelected() {
|
||||
if (_sel < 0 || _sel >= _count) return;
|
||||
if (loadNote(_names[_sel])) _mode = VIEW;
|
||||
}
|
||||
|
||||
void deleteCurrent() {
|
||||
char path[40];
|
||||
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", _curName);
|
||||
LittleFS.remove(path);
|
||||
_delArmed = false;
|
||||
_mode = LIST;
|
||||
scanNotes();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering. 120x120 logical.
|
||||
// ---------------------------------------------------------------------------
|
||||
static const int ROW_Y0 = 18;
|
||||
static const int ROW_H = 20;
|
||||
static const int ROWS_VISIBLE = 4;
|
||||
static const int FOOTER_Y = 104;
|
||||
|
||||
void ensureVisible() {
|
||||
if (_sel < _scroll) _scroll = _sel;
|
||||
if (_sel >= _scroll + ROWS_VISIBLE) _scroll = _sel - ROWS_VISIBLE + 1;
|
||||
}
|
||||
|
||||
void renderList(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
display.drawTextCentered(display.width() / 2, 6, "Notes");
|
||||
|
||||
if (_count == 0) {
|
||||
d->setRawColor(0x7BEF);
|
||||
display.drawTextCentered(display.width() / 2, 50, "No notes yet");
|
||||
}
|
||||
|
||||
char buf[16];
|
||||
for (int i = 0; i < ROWS_VISIBLE; i++) {
|
||||
int idx = _scroll + i;
|
||||
if (idx >= _count) break;
|
||||
int y = ROW_Y0 + i * ROW_H;
|
||||
|
||||
if (idx == _sel) {
|
||||
d->setRawColor(0x231D); // same blue as the home tile
|
||||
d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3);
|
||||
}
|
||||
|
||||
d->setRawColor(0xFFFF);
|
||||
display.setCursor(5, y + 2);
|
||||
display.print(_previews[idx][0] ? _previews[idx] : "(empty)");
|
||||
|
||||
dateLabel(_names[idx], buf, sizeof(buf));
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(5, y + 13, buf);
|
||||
}
|
||||
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(31, FOOTER_Y, 58, 14, 3);
|
||||
d->setRawColor(0xFFFF);
|
||||
d->printSmallFont(52, FOOTER_Y + 8, "New");
|
||||
}
|
||||
|
||||
void renderView(DisplayDriver& display) {
|
||||
LGFXDisplay* d = (LGFXDisplay*)&display;
|
||||
char buf[16];
|
||||
|
||||
dateLabel(_curName, buf, sizeof(buf));
|
||||
d->setRawColor(0x7BEF);
|
||||
d->printSmallFont(4, 8, buf);
|
||||
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.setTextSize(1);
|
||||
int maxScroll = _lineCount - WATCH_NOTE_VIEW_LINES;
|
||||
if (maxScroll < 0) maxScroll = 0;
|
||||
if (_viewScroll > maxScroll) _viewScroll = maxScroll;
|
||||
char line[WATCH_NOTE_COLS + 1];
|
||||
for (int i = 0; i < WATCH_NOTE_VIEW_LINES; i++) {
|
||||
int ln = _viewScroll + i;
|
||||
int off = ln * WATCH_NOTE_COLS;
|
||||
if (off >= _noteLen) break;
|
||||
int n = _noteLen - off;
|
||||
if (n > WATCH_NOTE_COLS) n = WATCH_NOTE_COLS;
|
||||
memcpy(line, _noteBuf + off, n);
|
||||
line[n] = 0;
|
||||
d->setRawColor(0xFFFF);
|
||||
display.setCursor(2, 14 + i * 10);
|
||||
display.print(line);
|
||||
}
|
||||
|
||||
// Footer: [Back] [Edit] [Del]
|
||||
d->setRawColor(0x231D);
|
||||
d->drawRoundRect(1, FOOTER_Y, 38, 14, 3);
|
||||
d->drawRoundRect(41, FOOTER_Y, 38, 14, 3);
|
||||
d->setRawColor(_delArmed ? 0xF800 : 0x231D);
|
||||
d->drawRoundRect(81, FOOTER_Y, 38, 14, 3);
|
||||
d->setRawColor(0xC618);
|
||||
d->printSmallFont(9, FOOTER_Y + 8, "Back");
|
||||
d->printSmallFont(49, FOOTER_Y + 8, "Edit");
|
||||
d->setRawColor(_delArmed ? 0xF800 : 0xC618);
|
||||
d->printSmallFont(87, FOOTER_Y + 8, _delArmed ? "Sure?" : "Del");
|
||||
}
|
||||
|
||||
public:
|
||||
WatchNotesScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* node_prefs)
|
||||
: _task(task), _rtc(rtc), _node_prefs(node_prefs) {
|
||||
_curName[0] = 0;
|
||||
_editName[0] = 0;
|
||||
}
|
||||
|
||||
void enter() {
|
||||
_mode = LIST;
|
||||
_delArmed = false;
|
||||
_wantKeyboard = false;
|
||||
_editPending = false;
|
||||
scanNotes();
|
||||
_sel = 0;
|
||||
_scroll = 0;
|
||||
}
|
||||
|
||||
Mode getMode() const { return _mode; }
|
||||
|
||||
// --- Keyboard handshake, polled from UITask::loop ---
|
||||
bool wantsKeyboard() const { return _wantKeyboard; }
|
||||
void clearWantKeyboard() { _wantKeyboard = false; }
|
||||
bool editPending() const { return _editPending; }
|
||||
const char* getEditText() const { return _noteBuf; }
|
||||
|
||||
// Result of a TWKB_NOTE keyboard session. Returns NULL on success or an
|
||||
// error message for UITask to show; the caller returns to this screen.
|
||||
const char* applyComposedNote(const char* text) {
|
||||
if (text == nullptr || text[0] == 0) { _editPending = false; return "Empty note"; }
|
||||
char name[WATCH_NOTE_NAME_LEN];
|
||||
if (_editPending && _editName[0]) {
|
||||
strncpy(name, _editName, sizeof(name) - 1);
|
||||
name[sizeof(name) - 1] = 0;
|
||||
} else {
|
||||
makeName(name, sizeof(name));
|
||||
}
|
||||
_editPending = false;
|
||||
_editName[0] = 0;
|
||||
char path[40];
|
||||
snprintf(path, sizeof(path), WATCH_NOTES_DIR "/%s", name);
|
||||
if (!LittleFS.exists(WATCH_NOTES_DIR)) LittleFS.mkdir(WATCH_NOTES_DIR);
|
||||
File f = LittleFS.open(path, "w");
|
||||
if (!f) return "Save failed";
|
||||
f.print(text);
|
||||
f.close();
|
||||
scanNotes();
|
||||
if (loadNote(name)) _mode = VIEW; else _mode = LIST;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Physical tap in logical (display.width()) coordinates. Returns true if the
|
||||
// tap was consumed. The status-bar strip is handled by main.cpp before this.
|
||||
bool handleTap(int lx, int ly) {
|
||||
if (_mode == LIST) {
|
||||
if (ly >= FOOTER_Y) { // footer: New
|
||||
if (lx >= 31 && lx < 89) {
|
||||
_editPending = false;
|
||||
_editName[0] = 0;
|
||||
_wantKeyboard = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ly >= ROW_Y0) {
|
||||
int row = (ly - ROW_Y0) / ROW_H;
|
||||
int idx = _scroll + row;
|
||||
if (row >= 0 && row < ROWS_VISIBLE && idx < _count) {
|
||||
if (idx == _sel) openSelected(); // second tap opens
|
||||
else { _sel = idx; ensureVisible(); } // first tap selects
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// VIEW
|
||||
if (ly >= FOOTER_Y) {
|
||||
if (lx < 40) { _delArmed = false; _mode = LIST; } // Back
|
||||
else if (lx < 80) { // Edit
|
||||
_delArmed = false;
|
||||
strncpy(_editName, _curName, sizeof(_editName) - 1);
|
||||
_editName[sizeof(_editName) - 1] = 0;
|
||||
_editPending = true;
|
||||
_wantKeyboard = true;
|
||||
} else { // Del
|
||||
if (_delArmed) deleteCurrent();
|
||||
else _delArmed = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_delArmed = false;
|
||||
if (_lineCount > WATCH_NOTE_VIEW_LINES) { // scroll zones
|
||||
if (ly < FOOTER_Y / 2) { if (_viewScroll > 0) _viewScroll--; }
|
||||
else { if (_viewScroll < _lineCount - WATCH_NOTE_VIEW_LINES) _viewScroll++; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// The PMU short press (S3) / user button arrives as KEY_ENTER.
|
||||
if (_mode == LIST) {
|
||||
if (c == KEY_ENTER) { openSelected(); return true; }
|
||||
return false; // KEY_CANCEL falls through -> home
|
||||
}
|
||||
// VIEW
|
||||
if (c == KEY_ENTER) { // button = Edit, matching the footer
|
||||
_delArmed = false;
|
||||
strncpy(_editName, _curName, sizeof(_editName) - 1);
|
||||
_editName[sizeof(_editName) - 1] = 0;
|
||||
_editPending = true;
|
||||
_wantKeyboard = true;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL) { _delArmed = false; _mode = LIST; return true; }
|
||||
return true; // swallow the rest while viewing
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
switch (_mode) {
|
||||
case LIST: renderList(display); return 500;
|
||||
case VIEW: renderView(display); return 500;
|
||||
}
|
||||
return 500;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user