2026-05-13 23:54:26 +02:00
|
|
|
#pragma once
|
|
|
|
|
|
|
|
|
|
#include <helpers/ui/DisplayDriver.h>
|
|
|
|
|
#include <Arduino.h>
|
2026-06-15 17:30:43 +02:00
|
|
|
#include "icons.h"
|
2026-05-13 23:54:26 +02:00
|
|
|
|
2026-05-20 09:29:38 +02:00
|
|
|
static const int FS_CHARS_MAX = 80; // max bytes per wrapped line
|
2026-05-13 23:54:26 +02:00
|
|
|
|
2026-06-16 08:54:11 +02:00
|
|
|
// Shared, non-reentrant wrap scratch. The UI renders on a single thread, and the
|
|
|
|
|
// fullscreen message view and the history list are never laid out in the same
|
|
|
|
|
// frame (opening the fullscreen view early-returns before the list loop runs),
|
|
|
|
|
// so one static buffer serves both. This keeps ~1.5 KB of line buffers off the
|
2026-06-29 17:43:17 +02:00
|
|
|
// render-call stack at the cost of a fixed RAM allocation. Only used inside
|
|
|
|
|
// render()/wrap helpers — never across a yield, so the single instance is safe.
|
2026-06-16 08:54:11 +02:00
|
|
|
static char s_wrap_trans[512];
|
|
|
|
|
static char s_wrap_lines[12][FS_CHARS_MAX];
|
|
|
|
|
|
2026-06-29 17:43:17 +02:00
|
|
|
// Parse a leading "@[nick] " reply prefix. Returns the message body that
|
|
|
|
|
// follows it (and any leading whitespace); when nick/nick_n are supplied,
|
|
|
|
|
// fills nick with the addressee, or "" when there's no prefix. One parser for
|
feat(ui): on-device channel management, remote admin tool, per-language keyboards
Messages:
- Add/edit/delete channels on-device (new ChannelsView, owned by the renamed
MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope).
Channel secret entry supports a typed passphrase (SHA-256'd, same primitive
the library already uses for the routing hash) or a raw 32-hex-char key.
- MyMesh::setChannelLocal() factors out the setChannel/saveChannels/
onChannelRemoved sequence previously duplicated across the two
CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths.
Tools > Admin (new):
- Log into a repeater/room server's admin account and send CLI commands,
the on-device equivalent of the app's repeater-admin feature.
- Commands are organised into category tabs (System/Radio/Routing/Actions)
with common get/set fields (name, radio profile, tx power, repeat, advert
intervals, ...) plus a free-text "Custom command..." fallback for anything
else. A field row fetches the current value, opens it pre-filled for
editing, and sends the change — falling back to a blank editor if the
fetch fails or times out.
- The admin password persists and self-heals exactly like room logins in
Messages: saved on a confirmed admin-level login, forgotten on a failed
one, left alone if merely under-privileged.
- New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so
a reply reaches the UI without touching the existing BLE/app CLI-terminal
path (queueMessage's should_display gate is untouched).
Shared TabBar.h extracted from NearbyScreen/BotScreen's independently
duplicated tab-carousel rendering (now a third consumer via Admin) — also
fixes neighbouring tabs vanishing outright when they didn't fully fit;
they now truncate with an ellipsis instead.
Keyboard: the combined "Ext.Latin" alphabet split into 8 separate,
linguistically complete per-language keyboards (Polish, Czech, Slovak,
German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug
where tall accented glyphs overlapped the keyboard's separator line
(SH1106's Lemon-font ascent constant was 2-3px short for them).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:40:53 +02:00
|
|
|
// both the history list (body only — see MessagesScreen::skipReplyPrefix) and
|
2026-06-29 17:43:17 +02:00
|
|
|
// the fullscreen view (which also shows the "To:" nick).
|
|
|
|
|
static inline const char* msgReplyBody(const char* text, char* nick = nullptr, int nick_n = 0) {
|
|
|
|
|
if (nick && nick_n > 0) nick[0] = '\0';
|
|
|
|
|
const char* body = text;
|
|
|
|
|
if (text[0] == '@' && text[1] == '[') {
|
|
|
|
|
const char* close = strchr(text + 2, ']');
|
|
|
|
|
if (close && close[1] == ' ' && close[2]) {
|
|
|
|
|
if (nick && nick_n > 0) {
|
|
|
|
|
int len = (int)(close - text) - 2;
|
|
|
|
|
if (len > nick_n - 1) len = nick_n - 1;
|
|
|
|
|
memcpy(nick, text + 2, len);
|
|
|
|
|
nick[len] = '\0';
|
|
|
|
|
}
|
|
|
|
|
body = close + 2;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
while (*body == '\n' || *body == '\r' || *body == ' ') body++;
|
|
|
|
|
return body;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 23:54:26 +02:00
|
|
|
struct FullscreenMsgView {
|
|
|
|
|
int scroll;
|
|
|
|
|
bool active;
|
2026-06-16 08:54:11 +02:00
|
|
|
int _max_scroll; // last value computed in render(); bounds KEY_DOWN
|
2026-05-13 23:54:26 +02:00
|
|
|
|
2026-06-16 08:54:11 +02:00
|
|
|
FullscreenMsgView() : scroll(0), active(false), _max_scroll(0) {}
|
2026-05-13 23:54:26 +02:00
|
|
|
|
2026-05-18 15:12:11 +02:00
|
|
|
enum Result { NONE, PREV, NEXT, CLOSE, REPLY };
|
2026-05-13 23:54:26 +02:00
|
|
|
|
|
|
|
|
void begin() { scroll = 0; active = true; }
|
|
|
|
|
|
2026-05-20 09:29:38 +02:00
|
|
|
// Pixel-accurate word-wrap: breaks at last space that fits within max_px,
|
2026-05-24 20:22:16 +02:00
|
|
|
// hard-breaks long words. Walks the string codepoint-by-codepoint, summing
|
|
|
|
|
// per-codepoint widths via DisplayDriver::getCodepointWidth — O(n) instead
|
|
|
|
|
// of O(n²) substring re-measurement. Works for both fixed- and
|
|
|
|
|
// variable-width fonts.
|
2026-05-20 09:29:38 +02:00
|
|
|
static int wrapLines(DisplayDriver& display, const char* text, int max_px,
|
|
|
|
|
char out[][FS_CHARS_MAX], int max_lines) {
|
2026-05-13 23:54:26 +02:00
|
|
|
int count = 0;
|
2026-05-24 20:22:16 +02:00
|
|
|
const uint8_t* seg = (const uint8_t*)text;
|
2026-05-20 09:29:38 +02:00
|
|
|
while (*seg && count < max_lines) {
|
2026-05-24 20:22:16 +02:00
|
|
|
const uint8_t* p = seg;
|
|
|
|
|
const uint8_t* last_sp = nullptr; // pointer to space byte (cut here, drop the space)
|
|
|
|
|
const uint8_t* last_fit = seg; // farthest point that still fits
|
|
|
|
|
int width = 0;
|
2026-05-20 09:29:38 +02:00
|
|
|
|
|
|
|
|
while (*p && (p - seg) < FS_CHARS_MAX - 1) {
|
2026-05-24 20:22:16 +02:00
|
|
|
const uint8_t* cp_start = p;
|
|
|
|
|
uint32_t cp = DisplayDriver::decodeCodepoint(p);
|
|
|
|
|
if ((p - seg) >= FS_CHARS_MAX) { p = cp_start; break; }
|
|
|
|
|
uint16_t cw = display.getCodepointWidth(cp);
|
|
|
|
|
if (width + cw > max_px && cp_start > seg) { p = cp_start; break; }
|
|
|
|
|
if (cp == ' ') last_sp = cp_start;
|
|
|
|
|
width += cw;
|
2026-05-20 09:29:38 +02:00
|
|
|
last_fit = p;
|
2026-05-13 23:54:26 +02:00
|
|
|
}
|
2026-05-20 09:29:38 +02:00
|
|
|
|
2026-05-24 20:22:16 +02:00
|
|
|
const uint8_t* end;
|
|
|
|
|
const uint8_t* next_seg;
|
2026-05-20 09:29:38 +02:00
|
|
|
if (!*p) {
|
|
|
|
|
end = p; next_seg = p;
|
|
|
|
|
} else if (last_sp && last_sp > seg) {
|
|
|
|
|
end = last_sp; next_seg = last_sp + 1;
|
|
|
|
|
} else {
|
|
|
|
|
end = last_fit; next_seg = last_fit;
|
2026-05-13 23:54:26 +02:00
|
|
|
}
|
2026-05-20 09:29:38 +02:00
|
|
|
|
|
|
|
|
int len = (int)(end - seg);
|
|
|
|
|
if (len <= 0) { seg = *seg ? seg + 1 : seg; continue; }
|
|
|
|
|
if (len > FS_CHARS_MAX - 1) len = FS_CHARS_MAX - 1;
|
|
|
|
|
memcpy(out[count], seg, len);
|
|
|
|
|
out[count][len] = '\0';
|
|
|
|
|
count++;
|
|
|
|
|
seg = next_seg;
|
2026-05-13 23:54:26 +02:00
|
|
|
}
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int render(DisplayDriver& display, const char* sender, const char* text,
|
|
|
|
|
bool has_prev, bool has_next) {
|
|
|
|
|
display.setTextSize(1);
|
2026-05-20 09:29:38 +02:00
|
|
|
const int lineH = display.getLineHeight();
|
|
|
|
|
const int max_px = display.width() - 6;
|
2026-05-18 15:00:01 +02:00
|
|
|
|
2026-06-29 17:43:17 +02:00
|
|
|
// "@[nick] " reply prefix → "To:" header + body (shared parser).
|
|
|
|
|
char to_nick[32];
|
|
|
|
|
const char* body = msgReplyBody(text, to_nick, sizeof(to_nick));
|
2026-05-18 15:00:01 +02:00
|
|
|
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
const int cw = display.getCharWidth();
|
|
|
|
|
const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2);
|
2026-05-18 15:00:01 +02:00
|
|
|
const int startY = header_h + 2;
|
2026-05-20 09:29:38 +02:00
|
|
|
const int visible = (display.height() - startY - lineH) / lineH;
|
2026-05-18 15:00:01 +02:00
|
|
|
|
2026-05-13 23:54:26 +02:00
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
2026-05-18 15:00:01 +02:00
|
|
|
display.fillRect(0, 0, display.width(), header_h);
|
2026-05-13 23:54:26 +02:00
|
|
|
display.setColor(DisplayDriver::DARK);
|
|
|
|
|
display.drawTextEllipsized(2, 1, display.width() - 4, sender);
|
2026-05-18 15:00:01 +02:00
|
|
|
if (to_nick[0]) {
|
2026-05-18 18:18:45 +02:00
|
|
|
char trans_nick[32], to_label[36];
|
|
|
|
|
display.translateUTF8ToBlocks(trans_nick, to_nick, sizeof(trans_nick));
|
|
|
|
|
snprintf(to_label, sizeof(to_label), "To: %s", trans_nick);
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.drawTextEllipsized(2, lineH + 3, display.width() - 4, to_label);
|
2026-05-18 15:00:01 +02:00
|
|
|
}
|
2026-05-13 23:54:26 +02:00
|
|
|
display.setColor(DisplayDriver::LIGHT);
|
|
|
|
|
|
2026-06-16 08:54:11 +02:00
|
|
|
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
|
|
|
|
|
int lcount = wrapLines(display, s_wrap_trans, max_px, s_wrap_lines, 12);
|
2026-06-15 17:30:43 +02:00
|
|
|
// Reserve the right-edge column for the scroll indicator and re-wrap so text
|
|
|
|
|
// can't run under it. Reserve appears only once the list overflows, and a
|
|
|
|
|
// narrower second pass only ever yields more lines — so it stays overflowing.
|
|
|
|
|
int reserve = scrollIndicatorReserve(display, lcount, visible);
|
|
|
|
|
if (reserve > 0)
|
2026-06-16 08:54:11 +02:00
|
|
|
lcount = wrapLines(display, s_wrap_trans, max_px - reserve, s_wrap_lines, 12);
|
2026-05-18 15:00:01 +02:00
|
|
|
int max_scroll = lcount > visible ? lcount - visible : 0;
|
2026-06-16 08:54:11 +02:00
|
|
|
_max_scroll = max_scroll;
|
2026-05-13 23:54:26 +02:00
|
|
|
if (scroll > max_scroll) scroll = max_scroll;
|
|
|
|
|
|
2026-05-18 15:00:01 +02:00
|
|
|
for (int i = 0; i < visible && (scroll + i) < lcount; i++) {
|
2026-05-20 09:29:38 +02:00
|
|
|
display.setCursor(0, startY + i * lineH);
|
2026-06-16 08:54:11 +02:00
|
|
|
display.print(s_wrap_lines[scroll + i]);
|
2026-05-13 23:54:26 +02:00
|
|
|
}
|
2026-06-15 17:30:43 +02:00
|
|
|
drawScrollIndicator(display, startY, visible * lineH, lcount, visible, scroll);
|
2026-05-20 09:29:38 +02:00
|
|
|
const int nav_y = display.height() - lineH;
|
2026-05-14 20:21:13 +02:00
|
|
|
if (has_next) {
|
2026-05-20 09:29:38 +02:00
|
|
|
display.setCursor(0, nav_y);
|
2026-05-13 23:54:26 +02:00
|
|
|
display.print("<");
|
|
|
|
|
}
|
2026-05-14 20:21:13 +02:00
|
|
|
if (has_prev) {
|
refactor: replace all hardcoded pixel positions with layout helpers
Add lineStep(), headerH(), listStart(), listVisible(), valCol() to
DisplayDriver so layout derives from getLineHeight() instead of fixed
OLED constants. Refactor all screens (SettingsScreen, NearbyScreen,
FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen,
AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus
the lock screen and splash screen in UITask.cpp.
On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all
text and widgets now scale correctly to the 250×122 display.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:05:09 +02:00
|
|
|
display.setCursor(display.width() - cw, nav_y);
|
2026-05-13 23:54:26 +02:00
|
|
|
display.print(">");
|
|
|
|
|
}
|
2026-05-16 23:42:52 +02:00
|
|
|
return 2000;
|
2026-05-13 23:54:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Result handleInput(char c) {
|
2026-05-18 15:12:11 +02:00
|
|
|
if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; }
|
2026-06-16 08:54:11 +02:00
|
|
|
if (c == KEY_DOWN) { if (scroll < _max_scroll) scroll++; return NONE; }
|
2026-06-29 18:01:29 +02:00
|
|
|
if (keyIsPrev(c)) return NEXT; // page between messages (encoder too)
|
|
|
|
|
if (keyIsNext(c)) return PREV;
|
2026-05-18 15:12:11 +02:00
|
|
|
if (c == KEY_CONTEXT_MENU) return REPLY;
|
2026-05-13 23:54:26 +02:00
|
|
|
if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE;
|
|
|
|
|
return NONE;
|
|
|
|
|
}
|
|
|
|
|
};
|