feat(clock,keyboard): alarm repeat + non-Latin keyboard alphabets

Alarm repeat (Clock Tools):
- NodePrefs::alarm_repeat_mask (weekday bitmask, struct tm::tm_wday
  convention) — new Repeat row cycles Off/Daily/Weekdays/Weekends.
  computeAlarmNextFire() scans the next 7 days for a matching weekday when
  set; evaluateAlarm() only clears alarm_on (one-shot) when the mask is
  empty, otherwise re-arms. mask==0 is byte-for-byte the original one-shot
  behaviour, so existing users see no change.

On-screen keyboard alphabets (Settings > Keyboard > Alphabet):
- KeyboardWidget reworked from single-byte ASCII cells to UTF-8 codepoints
  (insertion, backspace and T9 in-place cycling all codepoint-aware now, so a
  multi-byte character is never split) — see kbApplyCapsUtf8/kbUtf8Len/
  kbUtf8CharAt/kbUtf8LastCharBytes.
- Cyrillic, Greek and an Extended Latin set (Polish/Czech/Slovak/German/
  French/Spanish/Nordic diacritics) join the keyboard's existing #@/abc page
  cycle (Latin -> alt alphabet -> Symbols -> Latin) — no new key needed.
  NodePrefs::keyboard_alt_alphabet picks which one, if any, is active.
- Lemon font is forced on transiently inside KeyboardWidget::render() (saved
  and restored every call) whenever the alt-alphabet page is showing or
  already-typed text has non-ASCII bytes, so composing is visible even if
  Font is set to Default.
- Each script's caps-shift rule is distinct and documented in
  kbApplyCapsUtf8: flat -0x20 for ASCII/Cyrillic/Greek (with the ё/ς
  exceptions), flat -0x20 for Latin-1 (à-þ), and an adjacent-pair -1 for
  Latin Extended-A (verified against every character actually used, not a
  blanket rule for that whole Unicode block).

NodePrefs schema: two tail-appended fields this session (alarm_repeat_mask,
keyboard_alt_alphabet), SCHEMA_SENTINEL 0xC0DE001B -> 0xC0DE001D,
sizeof(NodePrefs) 2496 -> 2504 (verified via a standalone host compile).

Docs: Clock Tools' Repeat row, Settings > Keyboard > Alphabet, and the
Rooms keyboard note (no longer ASCII-only once an alphabet is enabled).

Not build-verified — no PlatformIO toolchain available this session. Caps
mappings cross-checked against Python's Unicode case tables; UTF-8 literal
bytes verified at the byte level.
This commit is contained in:
Jakub
2026-07-10 16:01:42 +02:00
parent cf3c2c0353
commit dfb993de53
9 changed files with 424 additions and 56 deletions

View File

@@ -68,11 +68,11 @@ Sensor fields show `--` when the sensor is not connected or has no data.
#### Alarm #### Alarm
A single one-shot wake alarm. Rows: **Hour**, **Minute** and **Armed**. **Enter** on Hour or Minute opens the digit editor (LEFT/RIGHT moves between the tens/units, UP/DOWN changes the digit); **Enter** on Armed toggles ON/OFF. The configured time is shown next to the **Alarm** menu row when armed, and the setting persists across reboots. A wake alarm with an optional repeat. Rows: **Hour**, **Minute**, **Repeat** and **Armed**. **Enter** on Hour or Minute opens the digit editor (LEFT/RIGHT moves between the tens/units, UP/DOWN changes the digit); **Enter** on Repeat cycles **Off → Daily → Weekdays → Weekends → Off**; **Enter** on Armed toggles ON/OFF. The configured time is shown next to the **Alarm** menu row when armed, and the setting persists across reboots.
While an alarm is armed a bell icon signals it in two places: the top-left corner of the **Clock page** itself, and the **top status bar** of the other home pages (the status bar is hidden on the Clock page, which is why the clock face carries its own indicator). The bell is icon-only — the exact alarm time is on the **Alarm** row inside Clock Tools. While an alarm is armed a bell icon signals it in two places: the top-left corner of the **Clock page** itself, and the **top status bar** of the other home pages (the status bar is hidden on the Clock page, which is why the clock face carries its own indicator). The bell is icon-only — the exact alarm time is on the **Alarm** row inside Clock Tools.
The alarm is scheduled as an absolute fire instant, so it is **robust to clock re-syncs** — the mesh (every inbound packet), the companion app, GPS and the CLI can all jump the device clock at any moment. A correction that moves the clock a little still fires at the right wall-clock time; a jump that skips over the alarm time still fires (late). After firing once, the alarm disarms itself. The alarm is scheduled as an absolute fire instant, so it is **robust to clock re-syncs** — the mesh (every inbound packet), the companion app, GPS and the CLI can all jump the device clock at any moment. A correction that moves the clock a little still fires at the right wall-clock time; a jump that skips over the alarm time still fires (late). With **Repeat** set to Off (the default) the alarm disarms itself after firing once, same as before; with a repeat pattern set, it stays armed and re-schedules itself for the next matching day instead.
The alarm only fires while the device is **awake** (it keeps running with the display off or locked). It cannot wake the device from a full **Shutdown** (the CPU and RAM are powered down), and needs a valid time source — it stays pending until the clock is synced. The alarm only fires while the device is **awake** (it keeps running with the display off or locked). It cannot wake the device from a full **Shutdown** (the CPU and RAM are powered down), and needs a valid time source — it stays pending until the clock is synced.

View File

@@ -47,9 +47,10 @@ Posting to a **room server** requires a login handshake first, so the device can
- **Passwords are remembered across reboots.** After a successful login the password is saved on the device, so picking that room again — even after a power cycle — logs back in silently and drops you straight into the chat. - **Passwords are remembered across reboots.** After a successful login the password is saved on the device, so picking that room again — even after a power cycle — logs back in silently and drops you straight into the chat.
- **A wrong or changed password self-heals.** If a saved password stops working (e.g. the server's password was changed), the failed login forgets it, so the next **Enter** prompts you to type a new one. - **A wrong or changed password self-heals.** If a saved password stops working (e.g. the server's password was changed), the failed login forgets it, so the next **Enter** prompts you to type a new one.
- **Re-login any time** with **Hold Enter** on the room → **Login…** (see the room context menu below) — useful to switch to a new password without waiting for a failure. - **Re-login any time** with **Hold Enter** on the room → **Login…** (see the room context menu below) — useful to switch to a new password without waiting for a failure.
- **Log out** with **Hold Enter** on a room you're currently logged into → **Logout** (only offered once logged in). Forgets the saved password on the device, so the next time you open that room it prompts for one again instead of silently reusing the old one.
- Passwords set from the **phone app** are saved on the device too, so it can post to that room standalone after a reboot. - Passwords set from the **phone app** are saved on the device too, so it can post to that room standalone after a reboot.
> The on-screen keyboard is limited to ASCII (letters, digits and common symbols); accented characters such as `ą`/`ę` can't be typed on the device. A password containing them can still be set from the phone app — the device stores and replays it byte-for-byte. > The on-screen keyboard's default (Latin) page is ASCII only. Typing accented or non-Latin characters — Polish/Czech/German/French diacritics, Cyrillic, or Greek — needs Settings Keyboard Alphabet set to the matching alphabet first; the keyboard's **#@/abc** key then cycles Latin → that alphabet → Symbols → Latin. A password containing characters outside whatever's currently enabled can still be set from the phone app — the device stores and replays it byte-for-byte.
--- ---
@@ -105,11 +106,12 @@ A location is any `lat,lon` pair in the text — exactly what the `{loc}` placeh
When **Pin to dial** is selected, a slot picker opens (Slot 16 showing current occupant name or "empty"). Choosing a slot that already holds another contact moves the new contact there. When **Pin to dial** is selected, a slot picker opens (Slot 16 showing current occupant name or "empty"). Choosing a slot that already holds another contact moves the new contact there.
In the **Rooms** list the context menu instead offers a single item: In the **Rooms** list the context menu instead offers:
| Item | Action | | Item | Action |
| ------- | ---------------------------------------------------------------------------- | | ------- | ---------------------------------------------------------------------------- |
| Login… | Opens the password prompt to (re-)log in to this room (see Rooms — logging in) | | Login… | Opens the password prompt to (re-)log in to this room (see Rooms — logging in) |
| Logout | Only shown once logged in. Forgets the saved password so the next open prompts for one again |
--- ---

View File

@@ -98,7 +98,8 @@ The **repeater** mode and its flood filters live on their own screen — see **T
| Setting | Options | Notes | | Setting | Options | Notes |
| -------- | ---------- | -------------------------------------------------------------------------------------------------- | | -------- | ---------- | -------------------------------------------------------------------------------------------------- |
| Layout | ABC / T9 | On-screen keyboard style. **ABC**: an a-b-c…z grid, one key per letter (the original layout). **T9**: phone-keypad multi-tap — each key is labelled with its **digit** and a letter group (e.g. `2abc`); repeated **Enter** presses cycle through the letters and then the digit itself. | | Layout | ABC / T9 | On-screen keyboard style. **ABC**: an a-b-c…z grid, one key per letter (the original layout). **T9**: phone-keypad multi-tap — each key is labelled with its **digit** and a letter group (e.g. `2abc`); repeated **Enter** presses cycle through the letters and then the digit itself. Applies to whichever alphabet page is active (see Alphabet below), not just Latin. |
| Alphabet | Latin / Cyrillic / Greek / Ext.Latin | Which extra (non-Latin) alphabet, if any, joins the keyboard's page cycle. **Latin** (default): only the Latin letters and Symbols pages, as before. Any other choice adds that alphabet's own page to the same **#@/abc** key's cycle (Latin → alphabet → Symbols → Latin) — no separate key to switch scripts. **Ext.Latin** is a curated set of Polish/Czech/Slovak/German/French/Spanish/Nordic diacritics (`ą ć ę ł ń ó ś ź ż`, `č š ž`, `ä ö ü ß`, `é è ê ë ñ`, etc.), not full Unicode coverage. **Greek** covers the 24-letter alphabet plus final sigma (`ς`) but not the tonos stress accents used in proper Modern Greek spelling. Typing in the chosen alphabet needs the **Lemon** font to actually display (Settings Display Font) — the keyboard forces it on temporarily while that page is open even if Font is set to Default, but received messages and everything else still follow your own Font choice. |
Applies to every on-screen text field (messages, waypoint labels, room passwords, preset names). Earlier releases labelled the grid *QWERTY*; the layout has always been alphabetical, so it is now named **ABC**. Applies to every on-screen text field (messages, waypoint labels, room passwords, preset names). Earlier releases labelled the grid *QWERTY*; the layout has always been alphabetical, so it is now named **ABC**.

View File

@@ -482,6 +482,19 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
rd(&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt)); rd(&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt));
if (_prefs.trail_autosave_lowbatt > 1) _prefs.trail_autosave_lowbatt = 0; if (_prefs.trail_autosave_lowbatt > 1) _prefs.trail_autosave_lowbatt = 0;
// → 0xC0DE001C: append alarm_repeat_mask at the tail. A pre-0x1C file has the
// old sentinel bytes / EOF here; any value that isn't one of the four presets
// clamps to 0 (no repeat / one-shot), matching the original alarm behaviour
// upgraders already had.
rd(&_prefs.alarm_repeat_mask, sizeof(_prefs.alarm_repeat_mask));
if (NodePrefs::alarmRepeatIdxForMask(_prefs.alarm_repeat_mask) == 0) _prefs.alarm_repeat_mask = 0;
// → 0xC0DE001D: append keyboard_alt_alphabet at the tail. A pre-0x1D file has
// the old sentinel bytes / EOF here; clamp anything out of range to 0 (Latin
// only), matching the keyboard's original (Latin-only) behaviour.
rd(&_prefs.keyboard_alt_alphabet, sizeof(_prefs.keyboard_alt_alphabet));
if (_prefs.keyboard_alt_alphabet >= NodePrefs::KB_ALPHABET_COUNT) _prefs.keyboard_alt_alphabet = 0;
// Schema sentinel: bumped on layout changes. Mismatch means an older file // Schema sentinel: bumped on layout changes. Mismatch means an older file
// (or a different schema); rd() already zero-inits any fields not present, // (or a different schema); rd() already zero-inits any fields not present,
// so we just log it — next savePrefs writes the current sentinel. // so we just log it — next savePrefs writes the current sentinel.
@@ -668,6 +681,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
file.write((uint8_t *)&_prefs.page_order[NodePrefs::PAGE_ORDER_LEN_V1], file.write((uint8_t *)&_prefs.page_order[NodePrefs::PAGE_ORDER_LEN_V1],
NodePrefs::PAGE_ORDER_LEN - NodePrefs::PAGE_ORDER_LEN_V1); NodePrefs::PAGE_ORDER_LEN - NodePrefs::PAGE_ORDER_LEN_V1);
file.write((uint8_t *)&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt)); file.write((uint8_t *)&_prefs.trail_autosave_lowbatt, sizeof(_prefs.trail_autosave_lowbatt));
file.write((uint8_t *)&_prefs.alarm_repeat_mask, sizeof(_prefs.alarm_repeat_mask));
file.write((uint8_t *)&_prefs.keyboard_alt_alphabet, sizeof(_prefs.keyboard_alt_alphabet));
// Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is
// the one we check: once the flash fills, writes return 0, so a good // the one we check: once the flash fills, writes return 0, so a good

View File

@@ -272,29 +272,89 @@ struct NodePrefs { // persisted to file
// Index into gpsAvgSecs(). [Tools Trail Settings Mark avg] // Index into gpsAvgSecs(). [Tools Trail Settings Mark avg]
uint8_t gps_avg_idx; uint8_t gps_avg_idx;
// Alarm clock — a single one-shot wake alarm, configured from the Clock page // Alarm clock — a wake alarm, configured from the Clock page (Enter Alarm).
// (Enter Alarm). Stored as a local time-of-day; the actual fire instant is // Stored as a local time-of-day; the actual fire instant is (re)computed as an
// (re)computed as an absolute time in tickBackground(), which is what makes it // absolute time in tickBackground(), which is what makes it robust to RTC
// robust to RTC re-syncs: the mesh (every inbound packet), the companion app, // re-syncs: the mesh (every inbound packet), the companion app, GPS and the
// GPS and the CLI can all jump getCurrentTime() at any moment, but an absolute // CLI can all jump getCurrentTime() at any moment, but an absolute target
// target instant stays correct across small corrections and still fires (late) // instant stays correct across small corrections and still fires (late) if
// if the clock jumps over it. Fires once, then alarm_on clears. The minutnik // the clock jumps over it. With alarm_repeat_mask == 0 it's one-shot: fires
// (countdown) and stoper (stopwatch) are runtime-only and not persisted. // once, then alarm_on clears. A non-zero mask instead re-arms it for the next
uint8_t alarm_on; // 0=off (default), 1=armed (one-shot) // matching weekday (see UITask::computeAlarmNextFire/evaluateAlarm) and
// alarm_on stays set. The minutnik (countdown) and stoper (stopwatch) are
// runtime-only and not persisted.
uint8_t alarm_on; // 0=off (default), 1=armed
uint8_t alarm_hour; // 0-23, local time uint8_t alarm_hour; // 0-23, local time
uint8_t alarm_min; // 0-59 uint8_t alarm_min; // 0-59
// Repeat days as a bitmask, bit i = 1<<tm_wday (Sunday=0 .. Saturday=6, per
// struct tm — matches what computeAlarmNextFire() already reads from
// gmtime()). 0 = no repeat (one-shot, the original/default behaviour).
// Cycled through the presets below via the alarm's Repeat row.
static const uint8_t ALARM_REPEAT_NONE = 0x00; // one-shot (default)
static const uint8_t ALARM_REPEAT_DAILY = 0x7F; // every day
static const uint8_t ALARM_REPEAT_WEEKDAYS = 0x3E; // Mon-Fri
static const uint8_t ALARM_REPEAT_WEEKENDS = 0x41; // Sat+Sun
static const uint8_t ALARM_REPEAT_COUNT = 4;
static uint8_t alarmRepeatMaskForIdx(uint8_t idx) {
static const uint8_t M[ALARM_REPEAT_COUNT] =
{ ALARM_REPEAT_NONE, ALARM_REPEAT_DAILY, ALARM_REPEAT_WEEKDAYS, ALARM_REPEAT_WEEKENDS };
return M[idx < ALARM_REPEAT_COUNT ? idx : 0];
}
// Reverse lookup for display: an arbitrary mask (e.g. loaded from a future
// custom-day picker) that doesn't match a preset just reads as "Off" here —
// it still fires correctly, computeAlarmNextFire() reads the raw mask.
static uint8_t alarmRepeatIdxForMask(uint8_t mask) {
switch (mask) {
case ALARM_REPEAT_DAILY: return 1;
case ALARM_REPEAT_WEEKDAYS: return 2;
case ALARM_REPEAT_WEEKENDS: return 3;
default: return 0;
}
}
static const char* alarmRepeatLabel(uint8_t idx) {
static const char* L[ALARM_REPEAT_COUNT] = { "Off", "Daily", "Weekdays", "Weekends" };
return L[idx < ALARM_REPEAT_COUNT ? idx : 0];
}
// On-screen keyboard layout, shared across every text-entry screen (Settings > // On-screen keyboard layout, shared across every text-entry screen (Settings >
// Keyboard). 0=ABC grid, alphabetical order (default), 1=T9 multi-tap // Keyboard). 0=ABC grid, alphabetical order (default), 1=T9 multi-tap
// (phone-keypad groups, cycled with repeated Enter presses — see KeyboardWidget.h). // (phone-keypad groups, cycled with repeated Enter presses — see KeyboardWidget.h).
uint8_t keyboard_type; uint8_t keyboard_type;
// Additional (non-Latin) keyboard alphabet, orthogonal to keyboard_type above
// — either layout style (ABC grid or T9) can show any alphabet's characters.
// 0 = Latin only (default): the keyboard's existing #@/abc page-cycle key
// toggles between just the Latin letters page and the symbols page, as
// today. A non-zero value adds that alphabet's own page to the same cycle
// key (Latin → alphabet → symbols → Latin), so composing in it needs no new
// key, just Settings Keyboard Alphabet to pick which one is available.
// See KeyboardWidget.h for the per-alphabet grids (KB_CYRILLIC_CHARS etc.)
// and Lemon font (src/helpers/ui/LemonFont.h) for on-screen rendering —
// every alphabet here must be in Lemon's U+0020-04FF range.
static const uint8_t KB_ALPHABET_LATIN_ONLY = 0;
static const uint8_t KB_ALPHABET_CYRILLIC = 1;
static const uint8_t KB_ALPHABET_GREEK = 2;
static const uint8_t KB_ALPHABET_EXT_LATIN = 3; // Polish/Czech/Slovak/German/French/etc. diacritics
static const uint8_t KB_ALPHABET_COUNT = 4;
static const char* keyboardAlphabetLabel(uint8_t idx) {
static const char* L[KB_ALPHABET_COUNT] = { "Latin", "Cyrillic", "Greek", "Ext.Latin" };
return L[idx < KB_ALPHABET_COUNT ? idx : 0];
}
// Auto-save the live GPS trail to /trail on shutdown (covers the low-battery // Auto-save the live GPS trail to /trail on shutdown (covers the low-battery
// auto-shutdown, which otherwise loses the whole route). Only writes when the // auto-shutdown, which otherwise loses the whole route). Only writes when the
// trail has points and the toggle is on. 0 = off (default), 1 = on. // trail has points and the toggle is on. 0 = off (default), 1 = on.
// [Tools Trail Settings Auto-save] // [Tools Trail Settings Auto-save]
uint8_t trail_autosave_lowbatt; uint8_t trail_autosave_lowbatt;
// Appended at the tail (see the alarm doc comment above for the field itself
// and the serialization tripwire below for why new fields land here, not
// inline with alarm_on/hour/min).
uint8_t alarm_repeat_mask;
// Appended at the tail (see the keyboard_alt_alphabet doc comment above).
uint8_t keyboard_alt_alphabet;
// Single source of truth for the live-share option tables (shared by the Map // Single source of truth for the live-share option tables (shared by the Map
// UI labels and the auto-send engine in UITask). // UI labels and the auto-send engine in UITask).
static const uint8_t LOC_SHARE_MOVE_COUNT = 4; static const uint8_t LOC_SHARE_MOVE_COUNT = 4;
@@ -357,7 +417,7 @@ struct NodePrefs { // persisted to file
// adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so
// older saves are detected on load and skipped (zero-init defaults kept). // older saves are detected on load and skipped (zero-init defaults kept).
// High 24 bits identify the file format; low byte is the schema revision. // High 24 bits identify the file format; low byte is the schema revision.
static const uint32_t SCHEMA_SENTINEL = 0xC0DE001B; static const uint32_t SCHEMA_SENTINEL = 0xC0DE001D;
// Bit-index for each home page. Used by page_order (entries store bit+1) and // Bit-index for each home page. Used by page_order (entries store bit+1) and
// by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage
@@ -454,7 +514,7 @@ struct NodePrefs { // persisted to file
// 3. clamp it on load (an upgrader's file lacks it → stray bytes) // 3. clamp it on load (an upgrader's file lacks it → stray bytes)
// 4. bump SCHEMA_SENTINEL's low byte // 4. bump SCHEMA_SENTINEL's low byte
// (Padding can also shift sizeof; a "false" trip just means re-check + rebump.) // (Padding can also shift sizeof; a "false" trip just means re-check + rebump.)
static_assert(sizeof(NodePrefs) == 2496, static_assert(sizeof(NodePrefs) == 2504,
"NodePrefs layout changed — sync DataStore save/load + clamp, bump " "NodePrefs layout changed — sync DataStore save/load + clamp, bump "
"SCHEMA_SENTINEL, then update this size (see steps above)."); "SCHEMA_SENTINEL, then update this size (see steps above).");

View File

@@ -145,15 +145,16 @@ class ClockToolsScreen : public UIScreen {
int renderAlarm(DisplayDriver& d) { int renderAlarm(DisplayDriver& d) {
d.drawCenteredHeader("ALARM"); d.drawCenteredHeader("ALARM");
if (!_prefs) return 60000; if (!_prefs) return 60000;
const char* rows[3] = { "Hour", "Minute", "Armed" }; const char* rows[4] = { "Hour", "Minute", "Repeat", "Armed" };
if (_sel > 2) _sel = 2; if (_sel > 3) _sel = 3;
const int valx = d.width() / 2 + 6; const int valx = d.width() / 2 + 6;
drawList(d, 3, _sel, _scroll, [&](int i, int y, bool sel, int reserve) { drawList(d, 4, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
drawRowSelection(d, y, sel, reserve); drawRowSelection(d, y, sel, reserve);
d.setCursor(4, y); d.print(rows[i]); d.setCursor(4, y); d.print(rows[i]);
char b[6]; char b[10];
if (i == 0) snprintf(b, sizeof(b), "%02u", _prefs->alarm_hour); if (i == 0) snprintf(b, sizeof(b), "%02u", _prefs->alarm_hour);
else if (i == 1) snprintf(b, sizeof(b), "%02u", _prefs->alarm_min); else if (i == 1) snprintf(b, sizeof(b), "%02u", _prefs->alarm_min);
else if (i == 2) snprintf(b, sizeof(b), "%s", NodePrefs::alarmRepeatLabel(NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask)));
else snprintf(b, sizeof(b), "%s", _prefs->alarm_on ? "ON" : "OFF"); else snprintf(b, sizeof(b), "%s", _prefs->alarm_on ? "ON" : "OFF");
EditKind k = (i == 0) ? EK_A_HOUR : (i == 1) ? EK_A_MIN : EK_NONE; EditKind k = (i == 0) ? EK_A_HOUR : (i == 1) ? EK_A_MIN : EK_NONE;
drawValue(d, y, valx, reserve, sel, k, b); drawValue(d, y, valx, reserve, sel, k, b);
@@ -226,12 +227,16 @@ class ClockToolsScreen : public UIScreen {
_view = V_MENU; _sel = 0; _scroll = 0; _view = V_MENU; _sel = 0; _scroll = 0;
return true; return true;
} }
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; } if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 3; return true; }
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; } if (c == KEY_DOWN) { _sel = (_sel < 3) ? _sel + 1 : 0; return true; }
if (c == KEY_ENTER) { if (c == KEY_ENTER) {
if (_sel == 0) editField(EK_A_HOUR, _prefs->alarm_hour, 23); if (_sel == 0) editField(EK_A_HOUR, _prefs->alarm_hour, 23);
else if (_sel == 1) editField(EK_A_MIN, _prefs->alarm_min, 59); else if (_sel == 1) editField(EK_A_MIN, _prefs->alarm_min, 59);
else { _prefs->alarm_on ^= 1; _alarm_dirty = true; _task->onAlarmChanged(); } else if (_sel == 2) { // Repeat: cycle Off -> Daily -> Weekdays -> Weekends -> Off
uint8_t idx = (NodePrefs::alarmRepeatIdxForMask(_prefs->alarm_repeat_mask) + 1) % NodePrefs::ALARM_REPEAT_COUNT;
_prefs->alarm_repeat_mask = NodePrefs::alarmRepeatMaskForIdx(idx);
_alarm_dirty = true; _task->onAlarmChanged();
} else { _prefs->alarm_on ^= 1; _alarm_dirty = true; _task->onAlarmChanged(); }
return true; return true;
} }
return true; return true;

View File

@@ -42,6 +42,71 @@ static const char* const KB_T9_GROUPS[KB_PAGES][9] = {
{ ".,!?'-", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" }, // page 0 — letters { ".,!?'-", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" }, // page 0 — letters
{ "@#&", "*()", "-_+", "=/\\", ":;'\"", "<>[]", "{}|~", "^$%`", ",." }, // page 1 — symbols { "@#&", "*()", "-_+", "=/\\", ":;'\"", "<>[]", "{}|~", "^$%`", ",." }, // page 1 — symbols
}; };
// Additional (non-Latin) keyboard alphabets — NodePrefs::keyboard_alt_alphabet
// picks which one (if any) joins the Latin/symbols page cycle. Every alphabet
// here must fall inside Lemon's U+0020-04FF range (src/helpers/ui/LemonFont.h)
// since that's what actually draws these glyphs on-screen.
//
// Unlike KB_CHARS (single ASCII byte per cell), these hold UTF-8 strings —
// Cyrillic is 2 bytes/codepoint — so cells are `const char*`, not `char`.
// KeyboardWidget's insertion/backspace/T9-cycle logic works in codepoints via
// the kbUtf8*() helpers below, not raw bytes, to stay correct for either.
// Cyrillic ABC grid: rows 0-2 hold the 30 letters that fit alphabetically
// (а-э); row 3 holds the remaining 3 (ё, ю, я) plus basic punctuation. No
// digit row on this page (digits are already reachable via the Symbols page,
// same as the Latin page's own row 3 duplicates them there).
static const char* const KB_CYRILLIC_CHARS[4][10] = {
{ "а","б","в","г","д","е","ж","з","и","й" },
{ "к","л","м","н","о","п","р","с","т","у" },
{ "ф","х","ц","ч","ш","щ","ъ","ы","ь","э" },
{ "ю","я","ё",".",",","!","?","-","'","\"" },
};
// Cyrillic T9 groups: the classic Russian phone-keypad distribution. Cell 0
// (digit '1') is punctuation, matching KB_T9_GROUPS' page-0 convention;
// cells 1-8 (digits '2'-'9') hold the 33 letters, 3-5 per key.
static const char* const KB_T9_GROUPS_CYRILLIC[9] = {
".,!?'-", "абвг", "деёжз", "ийкл", "мноп", "рсту", "фхцч", "шщъыь", "эюя"
};
// Greek ABC grid: the 24-letter modern alphabet plus final sigma (ς, used
// only at the end of a word — σ is the regular form) = 25 letters, fitting
// rows 0-2 with 5 basic punctuation marks to spare; row 3 keeps the digit row
// (unlike Cyrillic/Ext.Latin, Greek has room left over).
// NOTE: monotonic Modern Greek normally marks stress with a tonos accent
// (ά έ ή ί ό ύ ώ) — omitted here to keep this a single, simple page. Fine for
// informal/transliteration-style typing; flag if proper accented Greek
// composition turns out to matter and we can add a second Greek page for them.
static const char* const KB_GREEK_CHARS[4][10] = {
{ "α","β","γ","δ","ε","ζ","η","θ","ι","κ" },
{ "λ","μ","ν","ξ","ο","π","ρ","σ","ς","τ" },
{ "υ","φ","χ","ψ","ω",".",",","!","?","'" },
{ "1","2","3","4","5","6","7","8","9","0" },
};
// Greek T9 groups: cell 0 (digit '1') is punctuation; cells 1-8 (digits
// '2'-'9') split the 25 letters roughly evenly, final sigma grouped with the
// regular sigma it's a variant of.
static const char* const KB_T9_GROUPS_GREEK[9] = {
".,!?'-", "αβγ", "δεζ", "ηθι", "κλμ", "νξο", "πρσς", "τυφ", "χψω"
};
// Extended Latin ABC grid: diacritics for Polish, Czech/Slovak, German,
// French, Spanish/Portuguese and Nordic — the common non-ASCII Latin letters,
// not full Unicode coverage. 34 letters + 6 basic punctuation marks fill all
// 40 cells; no digit row on this page (digits are reachable via the Symbols
// page, same as Cyrillic).
static const char* const KB_EXTLATIN_CHARS[4][10] = {
{ "ą","ć","ę","ł","ń","ó","ś","ź","ż","ö" }, // Polish (9) + German ö
{ "ü","ä","ß","č","ď","ě","ň","ř","š","ť" }, // German (3) + Czech/Slovak (7)
{ "ů","ž","é","è","ê","ë","à","ç","ù","ñ" }, // Czech/Slovak (2) + French (6) + Spanish ñ
{ "å","ø","æ","õ",".",",","!","?","-","'" }, // Nordic (3) + Portuguese õ + punctuation
};
// Extended Latin T9 groups: cell 0 (digit '1') is punctuation; cells 1-8
// (digits '2'-'9') cluster by language for memorability.
static const char* const KB_T9_GROUPS_EXTLATIN[9] = {
".,!?'-", "ąćęł", "ńóśźż", "äöüß", "čďěň", "řšťůž", "éèêë", "àçùñ", "åøæõ"
};
// Buffer cap for typed text, in bytes. Matches MeshCore's MAX_TEXT_LEN // Buffer cap for typed text, in bytes. Matches MeshCore's MAX_TEXT_LEN
// (10*CIPHER_BLOCK_SIZE = 160) so a full-length message can be composed; each // (10*CIPHER_BLOCK_SIZE = 160) so a full-length message can be composed; each
// field passes its own smaller max to begin() where its store is smaller. // field passes its own smaller max to begin() where its store is smaller.
@@ -51,11 +116,94 @@ static const int KB_MAX_LEN = 160;
// very wide display (small font → many chars per line) can't overrun them. // very wide display (small font → many chars per line) can't overrun them.
static const int KB_PREVIEW_CAP = 46; static const int KB_PREVIEW_CAP = 46;
// Uppercase one char when Shift (caps) is on. The keyboard applies this at every // ── UTF-8 helpers for the alt-alphabet pages ─────────────────────────────────
// draw and commit site — a-z only, everything else (digits, punctuation) passes // KB_CHARS/KB_T9_GROUPS content is ASCII (1 byte/char); KB_CYRILLIC_CHARS and
// through unchanged. // KB_T9_GROUPS_CYRILLIC are UTF-8 (2 bytes/char in this range). These helpers
static inline char kbApplyCaps(char ch, bool caps) { // let insertion, backspace and T9 cycling work in codepoints for either,
return (caps && ch >= 'a' && ch <= 'z') ? (char)(ch - 'a' + 'A') : ch; // instead of assuming 1 byte == 1 character.
// Apply Shift/caps to every codepoint in a UTF-8 string, writing the result
// (same codepoint count, each independently shifted) into `out`. Every script
// here pairs lower/uppercase differently, so each gets its own rule:
// - ASCII a-z and Cyrillic а-я: flat -0x20 codepoint offset. ё/Ё (U+0451/
// U+0401) break the Cyrillic pattern by 0x50 and are special-cased.
// - Latin-1 Supplement à-þ (U+00E0-00FE, Ext.Latin's ö/ü/é/etc.): also a flat
// -0x20 offset, same as ASCII — the block was designed as parallel case
// pairs. U+00F7 (÷, division sign) sits in that numeric range but isn't a
// letter; excluded. ß (U+00DF) has no simple uppercase in this range
// (its uppercase ẞ is U+1E9E, outside Lemon's U+0020-04FF) — left as-is.
// - Latin Extended-A (Ext.Latin's ą/č/etc., U+0100-017F): NOT a flat offset
// like Latin-1 — this block alternates even=uppercase/odd=lowercase in
// adjacent pairs, so lowercase - 1 = uppercase. Verified true for every
// character actually in KB_EXTLATIN_CHARS/KB_T9_GROUPS_EXTLATIN; NOT a
// universal rule for the whole block (it has a handful of unpaired/
// irregular codepoints elsewhere) — recheck before adding more from it.
// - Greek α-ω (U+03B1-03C9): flat -0x20 offset, same shape as Cyrillic/ASCII.
// Final sigma ς (U+03C2) is the one exception — it has no uppercase of its
// own; -0x20 would land on U+03A2, which is unassigned. It capitalizes to
// regular Σ (U+03A3) instead, same as σ, and is special-cased before the
// general range rule (03C2 falls inside 03B1-03C9, so order matters here).
// Used both for a single cell (one codepoint) and a whole T9 group label/string.
static void kbApplyCapsUtf8(const char* in, bool caps, char* out, size_t out_size) {
size_t o = 0;
const uint8_t* p = (const uint8_t*)in;
while (*p && o + 2 < out_size) {
uint32_t cp = DisplayDriver::decodeCodepoint(p);
if (caps) {
if (cp == 0x0451) cp = 0x0401; // ё -> Ё
else if (cp == 0x03C2) cp = 0x03A3; // ς -> Σ
else if (cp >= 0x0430 && cp <= 0x044F) cp -= 0x20; // а-я -> А
else if (cp >= 0x03B1 && cp <= 0x03C9) cp -= 0x20; // α-ω -> Α
else if (cp >= 0x00E0 && cp <= 0x00FE && cp != 0x00F7) cp -= 0x20; // à-þ -> À-Þ
else if (cp >= 0x0100 && cp < 0x0180 && (cp & 1) == 1) cp -= 1; // ą-ż (odd=lower) -> Ą-Ż
else if (cp >= 'a' && cp <= 'z') cp -= 0x20; // a-z -> A-Z
}
if (cp < 0x80) {
out[o++] = (char)cp;
} else {
out[o++] = (char)(0xC0 | (cp >> 6));
out[o++] = (char)(0x80 | (cp & 0x3F));
}
}
out[o] = '\0';
}
// Codepoint count of a UTF-8 string (byte length overcounts once a 2-byte
// alphabet is involved — this is what T9 cycling needs instead of strlen()).
static int kbUtf8Len(const char* s) {
int n = 0;
const uint8_t* p = (const uint8_t*)s;
while (*p) { DisplayDriver::decodeCodepoint(p); n++; }
return n;
}
// Extract the idx-th codepoint of a UTF-8 string as its own NUL-terminated
// UTF-8 bytes in `out` (>= 5 bytes). Empty string if idx is out of range.
static void kbUtf8CharAt(const char* s, int idx, char* out) {
const uint8_t* p = (const uint8_t*)s;
for (int i = 0; *p; i++) {
const uint8_t* start = p;
DisplayDriver::decodeCodepoint(p);
if (i == idx) {
int n = (int)(p - start);
memcpy(out, start, n);
out[n] = '\0';
return;
}
}
out[0] = '\0';
}
// Byte width of the LAST codepoint in buf[0..len) — for backspace and the T9
// in-place replace, which must remove/overwrite a whole codepoint, not one
// byte (a lone trailing continuation byte would otherwise corrupt a 2-byte
// character). Capped at 4 (UTF-8's max), though this codebase's alphabets are
// all <= 2 bytes today.
static int kbUtf8LastCharBytes(const char* buf, int len) {
if (len <= 0) return 0;
int n = 1;
while (n < len && n < 4 && ((uint8_t)buf[len - n] & 0xC0) == 0x80) n++;
return n;
} }
static const int KB_PH_MAX = 12; // max placeholders in list static const int KB_PH_MAX = 12; // max placeholders in list
@@ -67,7 +215,7 @@ struct KeyboardWidget {
int len; int len;
int max_len; int max_len;
int row, col; int row, col;
int page; // 0 = letters, 1 = symbols int page; // see totalPages()/pageIsAltAlphabet()/pageIsSymbols() below
bool caps; bool caps;
char _ph_buf[KB_PH_MAX][KB_PH_LEN]; char _ph_buf[KB_PH_MAX][KB_PH_LEN];
int _ph_count; int _ph_count;
@@ -75,9 +223,12 @@ struct KeyboardWidget {
// Live setting lookup — set once by UITask::begin(). NULL only in tests/tools // Live setting lookup — set once by UITask::begin(). NULL only in tests/tools
// that construct a KeyboardWidget standalone, in which case isT9() defaults // that construct a KeyboardWidget standalone, in which case isT9() defaults
// to ABC. // to ABC and hasAltAlphabet() defaults to Latin-only.
NodePrefs* prefs = nullptr; NodePrefs* prefs = nullptr;
bool isT9() const { return prefs && prefs->keyboard_type == 1; } bool isT9() const { return prefs && prefs->keyboard_type == 1; }
bool hasAltAlphabet() const {
return prefs && prefs->keyboard_alt_alphabet != NodePrefs::KB_ALPHABET_LATIN_ONLY;
}
// T9 multi-tap state: which grid cell is mid-cycle (-1 = none), its cycle // T9 multi-tap state: which grid cell is mid-cycle (-1 = none), its cycle
// position, and when the last Enter landed on it (for the timeout). // position, and when the last Enter landed on it (for the timeout).
@@ -88,6 +239,61 @@ struct KeyboardWidget {
int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; } int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; }
int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; } int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; }
// ── Page model ────────────────────────────────────────────────────────────
// Logical page order: 0 = Latin letters, [1 = the enabled alt alphabet],
// last = symbols. Without an alt alphabet this is exactly the original
// 2-page Latin/symbols cycle; enabling one inserts its page in the middle,
// so the #@/abc key's existing cycle (case 4 below) reaches it for free.
int totalPages() const { return hasAltAlphabet() ? 3 : 2; }
bool pageIsAltAlphabet(int pg) const { return hasAltAlphabet() && pg == 1; }
bool pageIsSymbols(int pg) const { return pg == totalPages() - 1; }
// ABC-grid cell content as a NUL-terminated UTF-8 string (1 codepoint).
// Latin/symbols cells come back through a small scratch buffer since
// KB_CHARS stores single ASCII bytes, not strings; alt-alphabet cells are
// literal string-table entries, returned directly.
const char* cellStr(int r, int c) const {
if (pageIsAltAlphabet(page)) {
switch (prefs->keyboard_alt_alphabet) {
case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_CYRILLIC_CHARS[r][c];
case NodePrefs::KB_ALPHABET_GREEK: return KB_GREEK_CHARS[r][c];
case NodePrefs::KB_ALPHABET_EXT_LATIN: return KB_EXTLATIN_CHARS[r][c];
default: return "?";
}
}
static char single[2];
single[0] = KB_CHARS[pageIsSymbols(page) ? 1 : 0][r][c];
single[1] = '\0';
return single;
}
// T9 group string (UTF-8) for the given cell (0-8), page-aware like cellStr.
const char* t9GroupStr(int cell) const {
if (pageIsAltAlphabet(page)) {
switch (prefs->keyboard_alt_alphabet) {
case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_T9_GROUPS_CYRILLIC[cell];
case NodePrefs::KB_ALPHABET_GREEK: return KB_T9_GROUPS_GREEK[cell];
case NodePrefs::KB_ALPHABET_EXT_LATIN: return KB_T9_GROUPS_EXTLATIN[cell];
default: return "?";
}
}
return KB_T9_GROUPS[pageIsSymbols(page) ? 1 : 0][cell];
}
// Compact ASCII hint for the #@/abc key when it's about to switch to the
// alt-alphabet page. Deliberately ASCII (not the alphabet's own script) so
// it renders correctly even when Lemon isn't the user's current font choice
// — unlike the alphabet's own grid, this hint is visible from the Latin/
// symbols pages too, where render() doesn't force Lemon on (see render()).
static const char* altAlphabetHint(uint8_t alt) {
switch (alt) {
case NodePrefs::KB_ALPHABET_CYRILLIC: return "CY";
case NodePrefs::KB_ALPHABET_GREEK: return "GR";
case NodePrefs::KB_ALPHABET_EXT_LATIN: return "EL";
default: return "?";
}
}
enum Result { NONE, DONE, CANCELLED }; enum Result { NONE, DONE, CANCELLED };
void begin(const char* initial = "", int max = KB_MAX_LEN) { void begin(const char* initial = "", int max = KB_MAX_LEN) {
@@ -123,6 +329,17 @@ struct KeyboardWidget {
// on the same cell from being treated as a continued cycle. // on the same cell from being treated as a continued cycle.
if (t9_cell >= 0 && millis() - t9_last_ms > KB_T9_TIMEOUT_MS) t9_cell = -1; if (t9_cell >= 0 && millis() - t9_last_ms > KB_T9_TIMEOUT_MS) t9_cell = -1;
// Lemon is the only font that can draw the alt-alphabet page (or any
// already-typed non-ASCII bytes sitting in buf, e.g. Cyrillic composed
// earlier, now viewed from the Latin/symbols page) — force it on for just
// this render() call if the user's own font choice wouldn't otherwise show
// it, then restore exactly the state found on entry. Self-contained: never
// leaks a forced font into whatever renders next frame.
bool want_lemon = pageIsAltAlphabet(page);
if (!want_lemon) for (int i = 0; i < len; i++) if ((uint8_t)buf[i] >= 0x80) { want_lemon = true; break; }
bool had_lemon = display.isLemonFont();
if (want_lemon && !had_lemon) display.setLemonFont(true);
display.setTextSize(1); display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
@@ -179,10 +396,12 @@ struct KeyboardWidget {
// Label the cell "<digit><group>" so it reads like a phone keypad. The // Label the cell "<digit><group>" so it reads like a phone keypad. The
// digit is what the multi-tap cycle lands on after the letters (see // digit is what the multi-tap cycle lands on after the letters (see
// handleInput: '1'+cell). No separator space — the widest group // handleInput: '1'+cell). No separator space — the widest group
// (".,!?'-", 6 chars) + digit already fills a narrow OLED cell. // (Cyrillic "деёжз"/"шщъыь", 5 letters x up to 2 UTF-8 bytes) + digit
char label[10]; // still fits with room to spare.
snprintf(label, sizeof(label), "%c%s", (char)('1' + cell), KB_T9_GROUPS[page][cell]); char group_shown[12];
if (caps) for (char* p = label; *p; p++) *p = kbApplyCaps(*p, caps); kbApplyCapsUtf8(t9GroupStr(cell), caps, group_shown, sizeof(group_shown));
char label[14];
snprintf(label, sizeof(label), "%c%s", (char)('1' + cell), group_shown);
int cx = c * cell_w; int cx = c * cell_w;
display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel);
int tw = display.getTextWidth(label); int tw = display.getTextWidth(label);
@@ -195,11 +414,13 @@ struct KeyboardWidget {
int y = chars_y + r * cell_h; int y = chars_y + r * cell_h;
for (int c = 0; c < cols; c++) { for (int c = 0; c < cols; c++) {
bool sel = (row == r && col == c); bool sel = (row == r && col == c);
char ch = kbApplyCaps(KB_CHARS[page][r][c], caps); char ch_buf[3];
char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; kbApplyCapsUtf8(cellStr(r, c), caps, ch_buf, sizeof(ch_buf));
if (ch_buf[0] == ' ' && ch_buf[1] == '\0') ch_buf[0] = '_';
int cx = c * cell_w; int cx = c * cell_w;
display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel);
display.setCursor(cx + (cell_w - cw) / 2, y); int tw = display.getTextWidth(ch_buf);
display.setCursor(cx + (cell_w - tw) / 2, y);
display.print(ch_buf); display.print(ch_buf);
} }
} }
@@ -214,7 +435,18 @@ struct KeyboardWidget {
int sx = i * spec_w; int sx = i * spec_w;
display.drawSelectionRow(sx, spec_y - 1, spec_w - 1, cell_h, sel || active); display.drawSelectionRow(sx, spec_y - 1, spec_w - 1, cell_h, sel || active);
if (i == 3 || i == 4) { // text keys: {} picker, page toggle if (i == 3 || i == 4) { // text keys: {} picker, page toggle
const char* lbl = (i == 3) ? "{}" : (page == 0 ? "#@" : "abc"); // Shows what pressing it lands on next, same "reads as the
// destination" convention as the original 2-page abc<->#@ toggle,
// generalized to however many pages are in the cycle right now.
const char* lbl;
if (i == 3) {
lbl = "{}";
} else {
int next = (page + 1) % totalPages();
lbl = pageIsSymbols(next) ? "#@"
: pageIsAltAlphabet(next) ? altAlphabetHint(prefs->keyboard_alt_alphabet)
: "abc";
}
int tw = display.getTextWidth(lbl); int tw = display.getTextWidth(lbl);
display.setCursor(sx + (spec_w - tw) / 2, spec_y); display.setCursor(sx + (spec_w - tw) / 2, spec_y);
display.print(lbl); display.print(lbl);
@@ -236,6 +468,7 @@ struct KeyboardWidget {
// placeholder picker overlay (drawn on top of keyboard) // placeholder picker overlay (drawn on top of keyboard)
if (_ph_menu.active) _ph_menu.render(display); if (_ph_menu.active) _ph_menu.render(display);
if (want_lemon && !had_lemon) display.setLemonFont(false); // restore exactly what we found
return 50; return 50;
} }
@@ -300,28 +533,47 @@ struct KeyboardWidget {
if (c == KEY_ENTER) { if (c == KEY_ENTER) {
if (row < rows && isT9()) { if (row < rows && isT9()) {
int cell = row * cols + col; int cell = row * cols + col;
const char* group = KB_T9_GROUPS[page][cell]; const char* group = t9GroupStr(cell);
int glen = (int)strlen(group); int glen = kbUtf8Len(group); // codepoint count, not byte length
int total = glen + 1; // + the cell's own digit, at the end of the cycle int total = glen + 1; // + the cell's own digit, at the end of the cycle
bool cycling = (t9_cell == cell) && (millis() - t9_last_ms < KB_T9_TIMEOUT_MS); bool cycling = (t9_cell == cell) && (millis() - t9_last_ms < KB_T9_TIMEOUT_MS);
if (cycling) { if (cycling) {
t9_cycle = (t9_cycle + 1) % total; t9_cycle = (t9_cycle + 1) % total;
if (len > 0) { if (len > 0) {
char ch = kbApplyCaps((t9_cycle < glen) ? group[t9_cycle] : (char)('1' + cell), caps); char one[5];
buf[len - 1] = ch; if (t9_cycle < glen) kbUtf8CharAt(group, t9_cycle, one);
else { one[0] = (char)('1' + cell); one[1] = '\0'; }
char shown[5];
kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
int old_n = kbUtf8LastCharBytes(buf, len);
int new_len = len - old_n;
int n = (int)strlen(shown);
if (new_len + n <= max_len) {
memcpy(buf + new_len, shown, n);
len = new_len + n;
buf[len] = '\0';
}
} }
} else if (len < max_len) { } else if (len < max_len) {
char ch = kbApplyCaps(group[0], caps); char one[5]; kbUtf8CharAt(group, 0, one);
buf[len++] = ch; char shown[5]; kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
buf[len] = '\0'; int n = (int)strlen(shown);
t9_cell = cell; if (len + n <= max_len) {
t9_cycle = 0; memcpy(buf + len, shown, n);
len += n;
buf[len] = '\0';
t9_cell = cell;
t9_cycle = 0;
}
} }
t9_last_ms = millis(); t9_last_ms = millis();
} else if (row < rows) { } else if (row < rows) {
if (len < max_len) { char shown[3];
char ch = kbApplyCaps(KB_CHARS[page][row][col], caps); kbApplyCapsUtf8(cellStr(row, col), caps, shown, sizeof(shown));
buf[len++] = ch; int n = (int)strlen(shown);
if (len + n <= max_len) {
memcpy(buf + len, shown, n);
len += n;
buf[len] = '\0'; buf[len] = '\0';
} }
} else { } else {
@@ -332,14 +584,14 @@ struct KeyboardWidget {
if (len < max_len) { buf[len++] = ' '; buf[len] = '\0'; } if (len < max_len) { buf[len++] = ' '; buf[len] = '\0'; }
break; break;
case 2: case 2:
if (len > 0) buf[--len] = '\0'; if (len > 0) { len -= kbUtf8LastCharBytes(buf, len); buf[len] = '\0'; }
break; break;
case 3: case 3:
_ph_menu.begin("Placeholder:", KB_PH_VISIBLE); _ph_menu.begin("Placeholder:", KB_PH_VISIBLE);
for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]); for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]);
break; break;
case 4: case 4:
page ^= 1; // toggle letters symbols page = (page + 1) % totalPages(); // cycle letters -> [alt alphabet] -> symbols
break; break;
case 5: case 5:
return DONE; return DONE;

View File

@@ -70,6 +70,7 @@ class SettingsScreen : public UIScreen {
// Keyboard section // Keyboard section
SECTION_KEYBOARD, SECTION_KEYBOARD,
KEYBOARD_TYPE, KEYBOARD_TYPE,
KEYBOARD_ALPHABET,
// Contacts section // Contacts section
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER,
// Messages section // Messages section
@@ -557,6 +558,10 @@ class SettingsScreen : public UIScreen {
display.print("Type"); display.print("Type");
display.setCursor(valCol(display), y); display.setCursor(valCol(display), y);
display.print((p && p->keyboard_type) ? "T9" : "ABC"); display.print((p && p->keyboard_type) ? "T9" : "ABC");
} else if (item == KEYBOARD_ALPHABET) {
display.print("Alphabet");
display.setCursor(valCol(display), y);
display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0));
} else if (item == BATT_DISPLAY) { } else if (item == BATT_DISPLAY) {
display.print("BattDisp"); display.print("BattDisp");
display.setCursor(valCol(display), y); display.setCursor(valCol(display), y);
@@ -886,6 +891,14 @@ public:
_dirty = true; _dirty = true;
return true; return true;
} }
if (_selected == KEYBOARD_ALPHABET && p && (left || right || enter)) {
int idx = p->keyboard_alt_alphabet;
if (right || enter) idx = (idx + 1) % NodePrefs::KB_ALPHABET_COUNT;
else if (left) idx = (idx + NodePrefs::KB_ALPHABET_COUNT - 1) % NodePrefs::KB_ALPHABET_COUNT;
p->keyboard_alt_alphabet = (uint8_t)idx;
_dirty = true;
return true;
}
if (_selected == DM_RESEND && p) { if (_selected == DM_RESEND && p) {
int n = p->dm_resend_count; int n = p->dm_resend_count;
if (right || enter) n = (n + 1) % 6; // 0..5, wraps if (right || enter) n = (n + 1) % 6; // 0..5, wraps

View File

@@ -1496,14 +1496,30 @@ void UITask::wakeForAlarm() {
// Next absolute wall instant matching alarm_hour:alarm_min in local time, // Next absolute wall instant matching alarm_hour:alarm_min in local time,
// strictly after now_wall (an alarm set to the current minute waits a day). // strictly after now_wall (an alarm set to the current minute waits a day).
// With alarm_repeat_mask == 0 that's just tomorrow's occurrence (one-shot).
// With a repeat mask set, scan today..+6 days for the next weekday whose bit
// is set (struct tm's tm_wday convention, same as the mask) — today counts
// only if its time hasn't already passed.
uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const { uint32_t UITask::computeAlarmNextFire(uint32_t now_wall) const {
int tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; int tz = _node_prefs ? _node_prefs->tz_offset_hours : 0;
int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600; int64_t now_local = (int64_t)now_wall + (int64_t)tz * 3600;
time_t t = (time_t)now_local; time_t t = (time_t)now_local;
struct tm* ti = gmtime(&t); struct tm* ti = gmtime(&t);
int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight int64_t sod = ti->tm_hour * 3600 + ti->tm_min * 60 + ti->tm_sec; // secs since local midnight
int64_t target = (now_local - sod) int64_t midnight = now_local - sod;
+ (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60; int64_t time_of_day = (int64_t)_node_prefs->alarm_hour * 3600 + (int64_t)_node_prefs->alarm_min * 60;
uint8_t mask = _node_prefs->alarm_repeat_mask;
if (mask != 0) {
for (int d = 0; d < 7; d++) {
if (mask & (1 << ((ti->tm_wday + d) % 7))) {
int64_t target = midnight + (int64_t)d * 86400 + time_of_day;
if (target > now_local) return (uint32_t)(target - (int64_t)tz * 3600);
}
}
// Mask had no bit set (shouldn't happen — the UI only offers non-empty
// presets) — fall through to the one-shot calculation so it still fires.
}
int64_t target = midnight + time_of_day;
if (target <= now_local) target += 86400; if (target <= now_local) target += 86400;
return (uint32_t)(target - (int64_t)tz * 3600); return (uint32_t)(target - (int64_t)tz * 3600);
} }
@@ -1529,8 +1545,12 @@ void UITask::evaluateAlarm() {
if (now_wall - _alarm_next_fire < CLOCK_ALARM_CATCHUP_SECS) { if (now_wall - _alarm_next_fire < CLOCK_ALARM_CATCHUP_SECS) {
char lbl[20]; char lbl[20];
snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min); snprintf(lbl, sizeof(lbl), "Alarm %02d:%02d", _node_prefs->alarm_hour, _node_prefs->alarm_min);
_node_prefs->alarm_on = 0; // one-shot if (_node_prefs->alarm_repeat_mask == 0) {
bool dirty = true; savePrefsIfDirty(dirty); _node_prefs->alarm_on = 0; // one-shot
bool dirty = true; savePrefsIfDirty(dirty);
}
// Repeating: alarm_on stays set: computeAlarmNextFire() re-arms it for the
// next matching weekday below.
_alarm_next_fire = 0; _alarm_next_fire = 0;
fireClockAlert(lbl); fireClockAlert(lbl);
} else { } else {