Merge PR #324
This commit is contained in:
commit
159d5bb709
14 files changed
+517
-64
No files matched your search
@@ -33,7 +33,7 @@
|
||||
{
|
||||
"id": "wardrive",
|
||||
"name": "Wardrive",
|
||||
"ver": "1.0",
|
||||
"ver": "1.1",
|
||||
"desc": "LoRa coverage survey. Probes every 20s and logs each reply with GPS position, altitude and BOTH link directions (how well you heard them, how well they heard you) to a CSV. Reference app for wada.mesh.discover."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"wardrive","name":"Wardrive","ver":"1.1","desc":"LoRa coverage survey. Probes every 20s, logs each reply with GPS position, altitude and BOTH link directions to a CSV you can pull off the device. Reference app for wada.mesh.discover."}
|
||||
@@ -0,0 +1,216 @@
|
||||
-- Wardrive (Lua) — a LoRa coverage survey that logs to a CSV you can pull off
|
||||
-- the device afterwards.
|
||||
--
|
||||
-- Reference app for the discovery half of the SDK. It works the way a survey
|
||||
-- has to work: it PROBES rather than listens. A probe is a zero-hop request
|
||||
-- that every node in earshot answers, so a reply proves the link works from
|
||||
-- exactly where you are standing. Listening only ever tells you what happened
|
||||
-- to transmit while you were there, which is a different and much weaker claim.
|
||||
--
|
||||
-- Each reply carries both directions of the link: how well we heard them, and
|
||||
-- how well they heard us. They are rarely equal, and the asymmetry is the point
|
||||
-- -- "I can hear the repeater but it cannot hear me" is not the same fact as
|
||||
-- "no coverage", and only a probe reveals it.
|
||||
local ui, sys, mesh, fs, timer = wada.ui, wada.sys, wada.mesh, wada.fs, wada.timer
|
||||
local C = ui.colors
|
||||
local app = {}
|
||||
|
||||
local SWEEP_MS = 20000 -- above the 15 s floor the firmware enforces on probes
|
||||
local HARVEST_MS = 4000 -- replies land over the few seconds after a probe
|
||||
local TYPE = { [1]="chat", [2]="repeater", [3]="room", [4]="sensor" }
|
||||
|
||||
local run, running, samples, sweeps, last_err = "run", false, 0, 0, nil
|
||||
local node_count = 0
|
||||
local phase, phase_at = "idle", 0
|
||||
local hdr, gps_lbl, stat_lbl, rows = nil, nil, nil, {}
|
||||
local nodes = {} -- pubkey -> { name, type, best, worst, seen }
|
||||
local pending = {} -- lines waiting on the 1 write/sec limit
|
||||
|
||||
local function logname() return run .. ".csv" end
|
||||
|
||||
-- Return at most max_bytes without splitting a UTF-8 sequence. The row's
|
||||
-- fixed-width columns are byte-budgeted, not character-budgeted; a raw
|
||||
-- string.sub(1, 14) cut Ouderkerk + sun + VS16 inside the final codepoint and
|
||||
-- left LVGL unable to advance through the label (#323, same class as #223).
|
||||
local function utf8_prefix_bytes(text, max_bytes)
|
||||
local offset, last, length = 1, 0, #text
|
||||
while offset <= length and offset <= max_bytes do
|
||||
local first = text:byte(offset)
|
||||
local width = first <= 0x7F and 1
|
||||
or (first >= 0xC2 and first <= 0xDF and 2)
|
||||
or (first >= 0xE0 and first <= 0xEF and 3)
|
||||
or (first >= 0xF0 and first <= 0xF4 and 4) or 0
|
||||
if width == 0 or offset + width - 1 > length or offset + width - 1 > max_bytes then break end
|
||||
local valid = true
|
||||
for i = 2, width do
|
||||
local byte = text:byte(offset + i - 1)
|
||||
if byte < 0x80 or byte > 0xBF then valid = false; break end
|
||||
end
|
||||
local second = width > 1 and text:byte(offset + 1) or 0
|
||||
if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
|
||||
(first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then valid = false end
|
||||
if not valid then break end
|
||||
last = offset + width - 1
|
||||
offset = last + 1
|
||||
end
|
||||
return text:sub(1, last)
|
||||
end
|
||||
|
||||
-- Lua here is built with 32-bit floats, so fix.lat is good to about a metre and
|
||||
-- no better. fix.lat_e6 is the same reading as an exact integer in
|
||||
-- micro-degrees, which is what belongs in a log: a survey you plot months later
|
||||
-- should not carry rounding the device never had.
|
||||
local HEADER = "epoch,lat_e6,lon_e6,alt_m,pubkey,name,type,rssi,snr,their_snr,hops"
|
||||
local wrote_header = false
|
||||
|
||||
-- The filesystem allows one write a second. Sweeps produce a burst of rows, so
|
||||
-- they queue here and drain a chunk per tick instead of being dropped.
|
||||
local function flush()
|
||||
if #pending == 0 then return end
|
||||
local chunk = table.concat(pending, "\n") .. "\n"
|
||||
if not wrote_header then chunk = HEADER .. "\n" .. chunk end
|
||||
local ok = fs.append(logname(), chunk)
|
||||
if ok then pending, wrote_header = {}, true end
|
||||
end
|
||||
|
||||
local function record(fix, hit)
|
||||
local key = hit.pubkey
|
||||
local n = nodes[key]
|
||||
if not n then
|
||||
n = { name = hit.name or key, type = hit.type, best = hit.snr, worst = hit.snr, seen = 0 }
|
||||
nodes[key] = n
|
||||
node_count = node_count + 1
|
||||
end
|
||||
if hit.snr > n.best then n.best = hit.snr end
|
||||
if hit.snr < n.worst then n.worst = hit.snr end
|
||||
n.seen = n.seen + 1
|
||||
if hit.name then n.name = hit.name end
|
||||
|
||||
samples = samples + 1
|
||||
pending[#pending + 1] = string.format("%d,%d,%d,%d,%s,%s,%d,%d,%.2f,%.2f,%d",
|
||||
fix.time or sys.epoch(), fix.lat_e6, fix.lon_e6, fix.alt_m or 0,
|
||||
key, (hit.name or ""):gsub(",", " "), hit.type,
|
||||
hit.rssi, hit.snr, hit.their_snr, hit.hops)
|
||||
end
|
||||
|
||||
local function sweep()
|
||||
local tag, err = mesh.discover() -- every node type
|
||||
if not tag then last_err = err; return false end
|
||||
last_err = nil
|
||||
sweeps = sweeps + 1
|
||||
return true
|
||||
end
|
||||
|
||||
local function harvest()
|
||||
local fix = sys.gps()
|
||||
if not fix then
|
||||
-- No fix means the sample cannot be placed, so it is discarded rather than
|
||||
-- logged at 0,0. A survey file with phantom points at Null Island is worse
|
||||
-- than a shorter one.
|
||||
mesh.discover_clear()
|
||||
return
|
||||
end
|
||||
for _, hit in ipairs(mesh.discovered()) do record(fix, hit) end
|
||||
mesh.discover_clear() -- next sample must not inherit this one
|
||||
end
|
||||
|
||||
local function redraw()
|
||||
local fix = sys.gps()
|
||||
if fix then
|
||||
gps_lbl:set(string.format("%.5f, %.5f %dm %d sats", fix.lat, fix.lon, fix.alt_m or 0, fix.sats))
|
||||
gps_lbl:color(C.good)
|
||||
else
|
||||
gps_lbl:set("waiting for a GPS fix - samples are discarded until then")
|
||||
gps_lbl:color(C.bad)
|
||||
end
|
||||
|
||||
local state = running and (phase == "probe" and "listening..." or "sweeping") or "stopped"
|
||||
stat_lbl:set(string.format("%s | %s | %d sweeps, %d samples, %d nodes%s",
|
||||
run, state, sweeps, samples, node_count,
|
||||
last_err and (" [" .. last_err .. "]") or ""))
|
||||
stat_lbl:color(last_err and C.bad or C.accent)
|
||||
|
||||
local list = {}
|
||||
for key, n in pairs(nodes) do list[#list + 1] = { key = key, n = n } end
|
||||
table.sort(list, function(a, b) return a.n.best > b.n.best end)
|
||||
for i = 1, #rows do
|
||||
local e = list[i]
|
||||
if e then
|
||||
rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d",
|
||||
utf8_prefix_bytes(e.n.name, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
|
||||
rows[i]:color(e.n.best > 0 and C.good or C.text)
|
||||
else
|
||||
rows[i]:set(i == 1 and "nothing has answered a probe yet" or "")
|
||||
rows[i]:color(C.sub)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function app.on_open(w, h)
|
||||
if not sys.caps().discover then
|
||||
ui.label("This board does not carry the extended SDK,", 6, 8, 12, C.bad)
|
||||
ui.label("so it cannot send discovery probes.", 6, 26, 12, C.bad)
|
||||
return
|
||||
end
|
||||
ui.scroll(true)
|
||||
local LH = ui.text_h(12)
|
||||
local y = 4
|
||||
|
||||
hdr = ui.label("LoRa coverage survey", 4, y, 12, C.accent); hdr:width(w - 10); y = y + LH + 3
|
||||
gps_lbl = ui.label("", 4, y, 12, C.sub); gps_lbl:width(w - 10); y = y + LH + 3
|
||||
stat_lbl = ui.label("", 4, y, 12, C.text); stat_lbl:width(w - 10); y = y + LH * 2 + 5
|
||||
|
||||
local bw = math.min(96, (w - 20) // 3)
|
||||
ui.button("Start", 4, y, bw, 32, function()
|
||||
running = not running
|
||||
if running then phase, phase_at = "idle", 0 end
|
||||
sys.toast(running and "Survey running" or "Survey stopped", 1200)
|
||||
end)
|
||||
ui.button("Name", 8 + bw, y, bw, 32, function()
|
||||
ui.input("Name this run", run, function(text)
|
||||
if text then
|
||||
run = text:gsub("[^%w%-_]", "_")
|
||||
wrote_header = false -- a new file needs its own header row
|
||||
sys.toast("Logging to " .. logname(), 1500)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
ui.button("Reset", 12 + bw * 2, y, bw, 32, function()
|
||||
nodes, samples, sweeps, pending, node_count = {}, 0, 0, {}, 0
|
||||
mesh.discover_clear()
|
||||
fs.remove(logname())
|
||||
wrote_header = false
|
||||
sys.toast("Cleared " .. logname(), 1200)
|
||||
end)
|
||||
y = y + 38
|
||||
|
||||
ui.label("strongest first", 4, y, 12, C.sub); y = y + LH + 2
|
||||
for i = 1, 12 do
|
||||
rows[i] = ui.label("", 4, y, 12, C.text); rows[i]:width(w - 10); y = y + LH + 2
|
||||
end
|
||||
|
||||
redraw()
|
||||
timer.every(1000)
|
||||
end
|
||||
|
||||
function app.on_tick()
|
||||
if running then
|
||||
local now = sys.millis()
|
||||
if phase == "idle" or (phase == "wait" and now - phase_at >= SWEEP_MS) then
|
||||
-- A refused or rate-limited probe backs off a full sweep interval. Retrying
|
||||
-- every tick would re-enter the permission path once a second for nothing.
|
||||
phase, phase_at = sweep() and "probe" or "wait", now
|
||||
elseif phase == "probe" and now - phase_at >= HARVEST_MS then
|
||||
harvest()
|
||||
phase = "wait" -- phase_at stays at the probe time, so sweeps stay on cadence
|
||||
end
|
||||
end
|
||||
flush()
|
||||
redraw()
|
||||
end
|
||||
|
||||
function app.on_close()
|
||||
flush() -- one last drain; anything queued would otherwise be lost
|
||||
end
|
||||
|
||||
return app
|
||||
@@ -5,7 +5,7 @@ it ever touches a device. Not part of the firmware build.
|
||||
|
||||
```sh
|
||||
scripts/lua-harness/run.sh # gpscompass, all scenarios
|
||||
scripts/lua-harness/run.sh deploy/apps/wardrive/1.0/wardrive.lua
|
||||
scripts/lua-harness/run.sh deploy/apps/wardrive/1.1/wardrive.lua
|
||||
SCENARIO=declination scripts/lua-harness/run.sh # one scenario
|
||||
```
|
||||
|
||||
|
||||
@@ -10,6 +10,27 @@ local function checkint(v, what) assert(math.tointeger(v) ~= nil, what .. ": not
|
||||
local function checkcol(v, what) if v ~= nil then checkint(v, what .. " color") end end
|
||||
local function checkstr(v, what) assert(type(v) == "string" or type(v) == "number", what .. ": not a string") end
|
||||
|
||||
local function valid_utf8(text)
|
||||
local i, n = 1, #text
|
||||
while i <= n do
|
||||
local first = text:byte(i)
|
||||
local width = first <= 0x7F and 1
|
||||
or (first >= 0xC2 and first <= 0xDF and 2)
|
||||
or (first >= 0xE0 and first <= 0xEF and 3)
|
||||
or (first >= 0xF0 and first <= 0xF4 and 4) or 0
|
||||
if width == 0 or i + width - 1 > n then return false end
|
||||
for j = 2, width do
|
||||
local byte = text:byte(i + j - 1)
|
||||
if byte < 0x80 or byte > 0xBF then return false end
|
||||
end
|
||||
local second = width > 1 and text:byte(i + 1) or 0
|
||||
if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
|
||||
(first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then return false end
|
||||
i = i + width
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- instruction budget like guardedCall(): error() out of the hook, pcall catches
|
||||
local function guarded(budget, fn, ...)
|
||||
local count = 0
|
||||
@@ -77,7 +98,7 @@ local function mkbutton(text, x, y, w, h, fn)
|
||||
end
|
||||
|
||||
local function build_wada()
|
||||
local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {} }
|
||||
local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {}, fs = {} }
|
||||
wada.ui.colors = { accent = 0x15B6A6, text = 0xE6E9ED, sub = 0x7A7F87, bg = 0x000000, panel = 0x15181B, bad = 0xD7574E, good = 0x53C06B }
|
||||
wada.ui.canvas = mkcanvas
|
||||
wada.ui.label = mklabel
|
||||
@@ -102,7 +123,7 @@ local function build_wada()
|
||||
wada.sys.beep = function() return false end
|
||||
wada.sys.caps = function() return { sdk_ext = cfg.caps.sdk_ext, keyboard = cfg.caps.keyboard,
|
||||
touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass,
|
||||
accel = cfg.caps.accel } end
|
||||
accel = cfg.caps.accel, discover = cfg.caps.discover } end
|
||||
if cfg.caps.sdk_ext then
|
||||
wada.sys.battery = function() return { mv = 3900, pct = 70, charging = false } end
|
||||
wada.sys.gps = function() return cfg.gps and cfg.gps() or nil end
|
||||
@@ -118,6 +139,12 @@ local function build_wada()
|
||||
wada.mesh.self = function() return { name = "me", lat = cfg.self_lat or 0, lon = cfg.self_lon or 0 } end
|
||||
wada.mesh.stats = function() return {} end
|
||||
wada.mesh.rx_log = function() return {} end
|
||||
wada.mesh.discover = function() return "probe-1" end
|
||||
wada.mesh.discovered = function() return cfg.discovered or {} end
|
||||
wada.mesh.discover_clear = function() cfg.discovered = {} end
|
||||
|
||||
wada.fs.append = function(name, data) checkstr(name, "fs.append name"); checkstr(data, "fs.append data"); return true end
|
||||
wada.fs.remove = function(name) checkstr(name, "fs.remove name"); return true end
|
||||
|
||||
wada.store.get = function(k, d) assert(type(k) == "string"); local v = storekv[k]; if v == nil then return d end return v end
|
||||
wada.store.set = function(k, v) assert(type(k) == "string", "store key must be a string")
|
||||
@@ -204,6 +231,36 @@ local contacts_fixture = {
|
||||
|
||||
local scenarios = {}
|
||||
|
||||
scenarios.wardrive_utf8 = function()
|
||||
cfg = {
|
||||
w = 320, h = 196,
|
||||
caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false, discover = true },
|
||||
discovered = {
|
||||
{ pubkey = "01020304", name = "Ouderkerk☀️", type = 2,
|
||||
rssi = -72, snr = 7.25, their_snr = 6.5, hops = 0 }
|
||||
}
|
||||
}
|
||||
cfg.gps = function()
|
||||
return { lat = 52.295, lon = 4.907, lat_e6 = 52295000, lon_e6 = 4907000,
|
||||
sats = 9, alt_m = 3, time = 1787620000 }
|
||||
end
|
||||
storekv = {}
|
||||
wada = build_wada()
|
||||
local app = load_app()
|
||||
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
|
||||
assert(buttons[1] and buttons[1].fn, "Wardrive Start button missing")
|
||||
assert(guarded(BUDGET, buttons[1].fn))
|
||||
tick(app, 1, 100)
|
||||
tick(app, 1, 4000)
|
||||
local found = false
|
||||
for _, label in ipairs(labels) do
|
||||
assert(valid_utf8(label.text), "Wardrive rendered invalid UTF-8")
|
||||
if label.text:find("Ouderkerk☀", 1, true) then found = true end
|
||||
end
|
||||
assert(found, "emoji-bearing repeater name was not rendered")
|
||||
if app.on_close then guarded(BUDGET, app.on_close) end
|
||||
end
|
||||
|
||||
-- The declination model, as it actually ships. Rather than testing a copy in
|
||||
-- out/wmm, this pulls the do-block straight out of the app file that gets
|
||||
-- sideloaded, so an inlining mistake fails here instead of on the device.
|
||||
@@ -711,7 +768,9 @@ scenarios.cost = function()
|
||||
assert(worst < BUDGET / 4, "tick too expensive")
|
||||
end
|
||||
|
||||
local order = { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" }
|
||||
local order = APP_PATH:find("/wardrive/", 1, true)
|
||||
and { "wardrive_utf8" }
|
||||
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" }
|
||||
for _, name in ipairs(order) do
|
||||
if SCENARIO == "all" or SCENARIO == name then
|
||||
print("== " .. name)
|
||||
|
||||
@@ -183,6 +183,14 @@ bool pagerKeyboardAltHeld() { return s_alt; }
|
||||
|
||||
void pagerKeyboardMarkAltUsed() { s_alt_used = true; }
|
||||
|
||||
void pagerKeyboardDiscardAlt() {
|
||||
s_alt = false;
|
||||
s_alt_used = true;
|
||||
s_alt_tap_pending = false;
|
||||
s_alt_shift_chord_pending = false;
|
||||
s_alt_backspace_chord_pending = false;
|
||||
}
|
||||
|
||||
bool pagerKeyboardConsumeAltTap() {
|
||||
if (!s_alt_tap_pending) return false;
|
||||
s_alt_tap_pending = false;
|
||||
|
||||
@@ -50,6 +50,10 @@ bool pagerKeyboardAltHeld();
|
||||
* pagerKeyboardConsumeAltTap(). */
|
||||
void pagerKeyboardMarkAltUsed();
|
||||
|
||||
/** Clear held/tapped Alt and pending Alt chords when keyboard input is
|
||||
* intentionally discarded during a UI mode transition. */
|
||||
void pagerKeyboardDiscardAlt();
|
||||
|
||||
/** One-shot: true exactly once if Alt was pressed and released without being
|
||||
* used as a modifier for anything else in between (no key typed, no
|
||||
* pagerKeyboardMarkAltUsed() call) — a "solo tap", distinct from a
|
||||
|
||||
@@ -66,4 +66,8 @@ int tdeckKeyboardReadKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
void tdeckKeyboardDiscardModifiers() {}
|
||||
|
||||
void tdeckKeyboardAllowModifiers() {}
|
||||
|
||||
#endif
|
||||
@@ -23,6 +23,12 @@ void tdeckKeyboardPoll();
|
||||
/** Pop the next buffered key (ASCII), or 0 if none. Safe from the UI thread. */
|
||||
int tdeckKeyboardReadKey();
|
||||
|
||||
/** Compatibility hooks for UI mode transitions. Modifier state belongs to
|
||||
* the keyboard's C3 firmware in this legacy ASCII driver, so there is no
|
||||
* host-side state to clear or re-enable. */
|
||||
void tdeckKeyboardDiscardModifiers();
|
||||
void tdeckKeyboardAllowModifiers();
|
||||
|
||||
/** Request a keyboard-backlight level (0 = off, 0xFF = on). Safe from the UI
|
||||
* thread — the I2C write happens inside tdeckKeyboardPoll() (core 0), which
|
||||
* owns the bus. Only re-sent when the value changes. */
|
||||
|
||||
+54
-16
@@ -18,6 +18,7 @@
|
||||
#include "AppPage.h"
|
||||
#include "device_caps.h"
|
||||
#include "i18n.h" // wada.sys.tr: apps can use the same translation table the UI does
|
||||
#include "Utf8Text.h"
|
||||
#include "helpers/esp32/WdtHeavyGuard.h" // wada.fs writes can trigger SPIFFS GC
|
||||
|
||||
extern "C" {
|
||||
@@ -246,6 +247,19 @@ uint32_t argColor(lua_State* L, int idx, uint32_t def = 0xFFFFFF) {
|
||||
return (uint32_t)luaL_optinteger(L, idx, (lua_Integer)def) & 0xFFFFFF;
|
||||
}
|
||||
|
||||
// LVGL assumes structurally valid UTF-8 and can stop advancing when an app
|
||||
// hands it a partial codepoint. Keep one reusable repair buffer: valid strings
|
||||
// stay zero-copy, while malformed runs become one ASCII '?'. Every call below
|
||||
// is synchronous and LVGL copies label text before this buffer can be reused.
|
||||
const char* safeUiText(const char* text, size_t length, size_t* safe_length = nullptr,
|
||||
unsigned scratch_slot = 0) {
|
||||
static std::string scratch_slots[2];
|
||||
std::string& scratch = scratch_slots[scratch_slot & 1U];
|
||||
const char* safe = Utf8Text::sanitize(text, length, scratch);
|
||||
if (safe_length) *safe_length = safe == text ? length : scratch.size();
|
||||
return safe;
|
||||
}
|
||||
|
||||
// ---- canvas userdata ----
|
||||
struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; };
|
||||
|
||||
@@ -298,11 +312,14 @@ int cvCircle(lua_State* L) {
|
||||
int cvText(lua_State* L) {
|
||||
CanvasUd* c = checkCanvas(L);
|
||||
if (!c->obj) return 0;
|
||||
const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2);
|
||||
const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3);
|
||||
size_t text_length = 0;
|
||||
const char* text = luaL_checklstring(L, 4, &text_length);
|
||||
lv_draw_label_dsc_t d; lv_draw_label_dsc_init(&d);
|
||||
d.color = lv_color_hex(argColor(L, 5));
|
||||
d.font = luaHostFontForSize((int)luaL_optinteger(L, 6, 14));
|
||||
lv_canvas_draw_text(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3),
|
||||
c->w, &d, luaL_checkstring(L, 4));
|
||||
lv_canvas_draw_text(c->obj, x, y, c->w, &d, safeUiText(text, text_length));
|
||||
return 0;
|
||||
}
|
||||
int cvPos(lua_State* L) {
|
||||
@@ -357,7 +374,9 @@ WidgetUd* checkLabel(lua_State* L) { return (WidgetUd*)luaL_checkudata(L, 1, "wa
|
||||
|
||||
int lbSet(lua_State* L) {
|
||||
WidgetUd* u = checkLabel(L);
|
||||
if (u->obj) lv_label_set_text(u->obj, luaL_checkstring(L, 2));
|
||||
size_t length = 0;
|
||||
const char* text = luaL_checklstring(L, 2, &length);
|
||||
if (u->obj) lv_label_set_text(u->obj, safeUiText(text, length));
|
||||
return 0;
|
||||
}
|
||||
int lbPos(lua_State* L) {
|
||||
@@ -390,11 +409,17 @@ int lbWidth(lua_State* L) { // label:width(px [, "left"|"center"|"right"]) —
|
||||
|
||||
int uiLabel(lua_State* L) {
|
||||
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
|
||||
size_t length = 0;
|
||||
const char* text = luaL_checklstring(L, 1, &length);
|
||||
const lv_coord_t x = (lv_coord_t)luaL_optinteger(L, 2, 0);
|
||||
const lv_coord_t y = (lv_coord_t)luaL_optinteger(L, 3, 0);
|
||||
const lv_font_t* font = luaHostFontForSize((int)luaL_optinteger(L, 4, 14));
|
||||
const uint32_t color = argColor(L, 5, 0xE6E9ED);
|
||||
lv_obj_t* l = lv_label_create(s_h->body);
|
||||
lv_label_set_text(l, luaL_checkstring(L, 1));
|
||||
lv_obj_set_pos(l, (lv_coord_t)luaL_optinteger(L, 2, 0), (lv_coord_t)luaL_optinteger(L, 3, 0));
|
||||
lv_obj_set_style_text_font(l, luaHostFontForSize((int)luaL_optinteger(L, 4, 14)), LV_PART_MAIN);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(argColor(L, 5, 0xE6E9ED)), LV_PART_MAIN);
|
||||
lv_label_set_text(l, safeUiText(text, length));
|
||||
lv_obj_set_pos(l, x, y);
|
||||
lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN);
|
||||
WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0);
|
||||
ud->obj = l;
|
||||
luaL_setmetatable(L, "wada.label");
|
||||
@@ -497,12 +522,17 @@ void btnEventCb(lv_event_t* e) {
|
||||
|
||||
int uiButton(lua_State* L) {
|
||||
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
|
||||
const char* txt = luaL_checkstring(L, 1);
|
||||
size_t text_length = 0;
|
||||
const char* text = luaL_checklstring(L, 1, &text_length);
|
||||
const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2);
|
||||
const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3);
|
||||
const lv_coord_t w = (lv_coord_t)luaL_optinteger(L, 4, 90);
|
||||
const lv_coord_t h = (lv_coord_t)luaL_optinteger(L, 5, 34);
|
||||
lv_obj_t* b = lv_btn_create(s_h->body);
|
||||
lv_obj_set_pos(b, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3));
|
||||
lv_obj_set_size(b, (lv_coord_t)luaL_optinteger(L, 4, 90), (lv_coord_t)luaL_optinteger(L, 5, 34));
|
||||
lv_obj_set_pos(b, x, y);
|
||||
lv_obj_set_size(b, w, h);
|
||||
lv_obj_t* bl = lv_label_create(b);
|
||||
lv_label_set_text(bl, txt);
|
||||
lv_label_set_text(bl, safeUiText(text, text_length));
|
||||
lv_obj_center(bl);
|
||||
if (lua_isfunction(L, 6)) {
|
||||
lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb);
|
||||
@@ -554,6 +584,7 @@ int uiTextW(lua_State* L) {
|
||||
size_t len = 0;
|
||||
const char* txt = luaL_checklstring(L, 1, &len);
|
||||
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 2, 14));
|
||||
txt = safeUiText(txt, len, &len);
|
||||
lua_pushinteger(L, (lua_Integer)lv_txt_get_width(txt, (uint32_t)len, f, 0, LV_TEXT_FLAG_NONE));
|
||||
return 1;
|
||||
}
|
||||
@@ -566,6 +597,7 @@ int uiTextLines(lua_State* L) {
|
||||
const char* txt = luaL_checklstring(L, 1, &len);
|
||||
const int w = (int)luaL_checkinteger(L, 2);
|
||||
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 3, 14));
|
||||
txt = safeUiText(txt, len, &len);
|
||||
if (w <= 0) { lua_pushinteger(L, 1); return 1; }
|
||||
int lines = 0;
|
||||
const char* p = txt;
|
||||
@@ -605,7 +637,8 @@ int uiList(lua_State* L) {
|
||||
// list:add(text [, fn]) -> index of the new row
|
||||
int lsAdd(lua_State* L) {
|
||||
ListUd* u = checkList(L);
|
||||
const char* txt = luaL_checkstring(L, 2);
|
||||
size_t text_length = 0;
|
||||
const char* text = luaL_checklstring(L, 2, &text_length);
|
||||
if (!u->obj || !s_h) return 0;
|
||||
lv_obj_t* row = lv_btn_create(u->obj);
|
||||
lv_obj_set_width(row, LV_PCT(100));
|
||||
@@ -614,7 +647,7 @@ int lsAdd(lua_State* L) {
|
||||
lv_obj_set_style_pad_all(row, 6, LV_PART_MAIN);
|
||||
listPaintRow(row, false);
|
||||
lv_obj_t* lb = lv_label_create(row);
|
||||
lv_label_set_text(lb, txt);
|
||||
lv_label_set_text(lb, safeUiText(text, text_length));
|
||||
lv_label_set_long_mode(lb, LV_LABEL_LONG_DOT); // a long name truncates instead of reflowing the row
|
||||
lv_obj_set_width(lb, LV_PCT(100));
|
||||
lv_obj_set_style_text_font(lb, luaHostFontForSize(12), LV_PART_MAIN);
|
||||
@@ -633,9 +666,11 @@ int lsAdd(lua_State* L) {
|
||||
int lsSet(lua_State* L) {
|
||||
ListUd* u = checkList(L);
|
||||
lv_obj_t* row = listRowAt(u, (int)luaL_checkinteger(L, 2));
|
||||
size_t text_length = 0;
|
||||
const char* text = luaL_checklstring(L, 3, &text_length);
|
||||
if (!row) return 0;
|
||||
lv_obj_t* lb = lv_obj_get_child(row, 0);
|
||||
if (lb) lv_label_set_text(lb, luaL_checkstring(L, 3));
|
||||
if (lb) lv_label_set_text(lb, safeUiText(text, text_length));
|
||||
return 0;
|
||||
}
|
||||
int lsColor(lua_State* L) {
|
||||
@@ -1939,11 +1974,14 @@ void promptDeliver(const char* text) {
|
||||
}
|
||||
|
||||
int uiInput(lua_State* L) {
|
||||
const char* title = luaL_optstring(L, 1, "");
|
||||
const char* initial = luaL_optstring(L, 2, "");
|
||||
size_t title_length = 0, initial_length = 0;
|
||||
const char* title_raw = luaL_optlstring(L, 1, "", &title_length);
|
||||
const char* initial_raw = luaL_optlstring(L, 2, "", &initial_length);
|
||||
luaL_checktype(L, 3, LUA_TFUNCTION);
|
||||
if (!s_h) return luaL_error(L, "no app");
|
||||
if (s_h->prompt_cb != LUA_NOREF) return luaL_error(L, "an input prompt is already open");
|
||||
const char* title = safeUiText(title_raw, title_length, nullptr, 0);
|
||||
const char* initial = safeUiText(initial_raw, initial_length, nullptr, 1);
|
||||
lua_pushvalue(L, 3);
|
||||
s_h->prompt_cb = luaL_ref(L, LUA_REGISTRYINDEX);
|
||||
luaHostTextPrompt(title, initial, promptDeliver);
|
||||
|
||||
+20
-41
@@ -119,7 +119,7 @@
|
||||
#include <helpers/input/TDeckTrackball.h>
|
||||
#endif
|
||||
#if defined(HAS_TDECK_KEYBOARD)
|
||||
#include <helpers/input/TDeckKeyboard.h>
|
||||
#include "../helpers/input/TDeckKeyboard.h"
|
||||
#elif defined(HAS_M9_KEYBOARD)
|
||||
#include <M9Keyboard.h>
|
||||
#endif
|
||||
@@ -130,7 +130,7 @@
|
||||
#include <M9Imu.h> // wada.sys.accel() source (luaHostAccel)
|
||||
#endif
|
||||
#if defined(HAS_PAGER_KEYBOARD)
|
||||
#include <helpers/input/PagerKeyboard.h>
|
||||
#include "../helpers/input/PagerKeyboard.h"
|
||||
#endif
|
||||
#if defined(HAS_PAGER_ENCODER)
|
||||
#include <helpers/input/PagerEncoder.h>
|
||||
@@ -36216,30 +36216,6 @@ static void updatePagerBackspaceHold(unsigned long now) {
|
||||
s_was_held = held;
|
||||
}
|
||||
|
||||
// Orange/Alt key, tapped alone (not held as a symbol-layer modifier or for the
|
||||
// encoder's Alt+turn). In an open chat it takes over the old Backspace role:
|
||||
// jump to the latest message (when scrolled up in history) and drop focus in
|
||||
// the composer, ready to type — the natural "done reading, reply now" motion,
|
||||
// and the deliberate way OUT of the message list now that plain encoder turns
|
||||
// hold focus inside it while history loads (pagerEncoderChatEdgeScroll).
|
||||
// Everywhere else it stays the same "next field" as one rotary NEXT detent.
|
||||
// Call once per loop tick while the screen is on; screen-off handling discards
|
||||
// any pending tap instead (see loop()'s HAS_PAGER_KEYBOARD branch) so a stray
|
||||
// tap picked up while idle-dimmed can't fire the moment the screen wakes.
|
||||
static void updatePagerAltTapNext() {
|
||||
if (!pagerKeyboardConsumeAltTap()) return;
|
||||
LvChatPanel* cp = navOpenChatPanel();
|
||||
if (cp && cp->msgs && cp->composer_ta && lv_obj_is_valid(cp->composer_ta)) {
|
||||
if (chatVirtAwayFromBottom(cp)) chatVirtJumpToLatest(cp);
|
||||
lv_group_focus_obj(cp->composer_ta);
|
||||
s_nav_show = true;
|
||||
if (g_lv.task) g_lv.task->noteUserInput();
|
||||
return;
|
||||
}
|
||||
navPushTap(LV_KEY_NEXT);
|
||||
if (g_lv.task) g_lv.task->noteUserInput();
|
||||
}
|
||||
|
||||
// Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has
|
||||
// no UI visibility): toggles Caps Lock while actually editing a text field
|
||||
// (the field is where "Caps Lock" means anything) and is a deliberate no-op
|
||||
@@ -36454,9 +36430,7 @@ static bool pagerEncoderScrollOversizedFocused(bool up) {
|
||||
// the list instead (same navScrollFocused the Alt+turn branch uses): the
|
||||
// scroll fires the virtualization render, the neighbor bubble joins the nav
|
||||
// group via the tree-signature rebuild, and the next detent walks onto it.
|
||||
// Focus only leaves the list at the TRUE oldest/newest message. The orange-key
|
||||
// solo tap (updatePagerAltTapNext) pushes LV_KEY_NEXT directly and never comes
|
||||
// through here, so it stays the deliberate "leave the messages now" exit.
|
||||
// Focus only leaves the list at the TRUE oldest/newest message.
|
||||
// Returns true when the detent was consumed as a scroll.
|
||||
static bool pagerEncoderChatEdgeScroll(bool up) {
|
||||
LvChatPanel* cp = navOpenChatPanel();
|
||||
@@ -36627,8 +36601,8 @@ static void updatePagerEncoder(unsigned long now) {
|
||||
if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput();
|
||||
if (delta != 0 || held) noteKbActivity(); // same activity counts for the keyboard-backlight auto mode
|
||||
|
||||
// Alt+turn is a modifier combo, not a solo Alt tap -- mark it used so a
|
||||
// release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT.
|
||||
// Alt+turn is a modifier combo, not a solo tap -- mark it used so releasing
|
||||
// Alt afterward does not also arm the one-shot symbol layer.
|
||||
if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed();
|
||||
|
||||
if (s_mentionnav_active) {
|
||||
@@ -38479,9 +38453,9 @@ if (g_lv.task && g_lv.task->isManualLock()) {
|
||||
// below the "NEW ----" unread divider) and focus it, so the user reads the
|
||||
// new messages chronologically with plain encoder turns from there; with
|
||||
// no divider (nothing unread) it jumps to the NEWEST message instead.
|
||||
// (The old Backspace role -- jump to latest + focus the composer -- moved
|
||||
// to the solo orange/Alt tap, see updatePagerAltTapNext().) The divider
|
||||
// jump reuses the exact scroll recipe refreshChatDetail's open-to-divider
|
||||
// The former solo-Alt shortcut now latches the symbol layer; at the true
|
||||
// newest message, the next encoder detent leaves the list for the composer.
|
||||
// The divider jump reuses the exact scroll recipe refreshChatDetail's open-to-divider
|
||||
// path uses; both cases use the pending-focus re-aim so the recreated row
|
||||
// for the target message ends up focused after the virtualization render.
|
||||
// Outside a chat, Backspace keeps its normal no-op / hold-to-back
|
||||
@@ -52959,6 +52933,8 @@ void UITask::loop() {
|
||||
// started above.
|
||||
bool con_activity = false;
|
||||
#if defined(HAS_TDECK_KEYBOARD)
|
||||
if (_screen_off || _manual_lock) tdeckKeyboardDiscardModifiers();
|
||||
else tdeckKeyboardAllowModifiers();
|
||||
for (int kbi = 0; kbi < 12; ++kbi) {
|
||||
int key = tdeckKeyboardReadKey();
|
||||
if (key <= 0) break;
|
||||
@@ -52967,7 +52943,7 @@ void UITask::loop() {
|
||||
// A key pressed on a dark screen WAKES rather than types, the same as a
|
||||
// touch does in the UI. Otherwise the character you used to see the screen
|
||||
// ends up in the command you are typing.
|
||||
if (_screen_off) { wakeScreen(); continue; }
|
||||
if (_screen_off) { tdeckKeyboardDiscardModifiers(); wakeScreen(); continue; }
|
||||
consoleKey(key);
|
||||
}
|
||||
#endif
|
||||
@@ -53571,12 +53547,15 @@ void UITask::loop() {
|
||||
updatePagerEncoder(now);
|
||||
#endif
|
||||
#if defined(HAS_TDECK_KEYBOARD)
|
||||
if (_screen_off || _manual_lock || s_remote_mode) tdeckKeyboardDiscardModifiers();
|
||||
else tdeckKeyboardAllowModifiers();
|
||||
// Drain physical-keyboard presses buffered by the touch task into the field.
|
||||
for (int kbi = 0; kbi < 12; ++kbi) {
|
||||
int key = tdeckKeyboardReadKey();
|
||||
if (key <= 0) break;
|
||||
if (!_screen_off) s_kb_last_key_ms = now; // a keypress while locked must not light the kb
|
||||
if (s_remote_mode) { remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit
|
||||
if (s_remote_mode) { tdeckKeyboardDiscardModifiers(); remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit
|
||||
if (_screen_off) tdeckKeyboardDiscardModifiers();
|
||||
handleHwKey(key);
|
||||
}
|
||||
// Focusing a text field (cursor starts blinking — tapped or auto-focused)
|
||||
@@ -53618,6 +53597,7 @@ void UITask::loop() {
|
||||
// working while the screen is dark. updatePagerKbBacklight() follows the
|
||||
// same rule. Remote Mode pauses both because it owns the lit placeholder.
|
||||
pagerKeyboardPoll();
|
||||
if (g_lv.task && g_lv.task->isManualLock()) pagerKeyboardDiscardAlt();
|
||||
// Remote Mode keeps the placeholder lit and reserves all keys for its local
|
||||
// escape path, so normal lock/backlight state machines stay paused there.
|
||||
if (!s_remote_mode) {
|
||||
@@ -53633,7 +53613,7 @@ void UITask::loop() {
|
||||
if (key <= 0) break;
|
||||
remotePhysicalKey(key);
|
||||
}
|
||||
pagerKeyboardConsumeAltTap();
|
||||
pagerKeyboardDiscardAlt();
|
||||
pagerKeyboardConsumeAltShiftChord();
|
||||
pagerKeyboardConsumeAltBackspaceChord();
|
||||
} else if (g_lv.task && g_lv.task->isScreenOff()) {
|
||||
@@ -53652,10 +53632,10 @@ void UITask::loop() {
|
||||
any = true;
|
||||
if (k == 0x08) saw_backspace = true;
|
||||
}
|
||||
// Discard any Alt tap / Alt+Shift / Alt+Backspace chord picked up while
|
||||
// idle-dimmed -- none of them may fire (NEXT / Caps toggle / jump Home)
|
||||
// Discard any Alt latch / Alt+Shift / Alt+Backspace chord picked up while
|
||||
// idle-dimmed -- none of them may fire (symbol layer / Caps / jump Home)
|
||||
// the instant the screen wakes.
|
||||
pagerKeyboardConsumeAltTap();
|
||||
pagerKeyboardDiscardAlt();
|
||||
pagerKeyboardConsumeAltShiftChord();
|
||||
pagerKeyboardConsumeAltBackspaceChord();
|
||||
// Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding
|
||||
@@ -53680,7 +53660,6 @@ void UITask::loop() {
|
||||
if (key <= 0) break;
|
||||
handleHwKey(key);
|
||||
}
|
||||
updatePagerAltTapNext();
|
||||
updatePagerAltShiftChord();
|
||||
updatePagerAltBackspaceChord();
|
||||
updatePagerBackspaceHold(now);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string>
|
||||
|
||||
namespace Utf8Text {
|
||||
|
||||
inline bool continuation(uint8_t byte) {
|
||||
return (byte & 0xC0U) == 0x80U;
|
||||
}
|
||||
|
||||
inline size_t sequenceLength(const uint8_t* text, size_t remaining) {
|
||||
if (!remaining) return 0;
|
||||
const uint8_t first = text[0];
|
||||
if (first <= 0x7F) return 1;
|
||||
if (first >= 0xC2 && first <= 0xDF) {
|
||||
return remaining >= 2 && continuation(text[1]) ? 2 : 0;
|
||||
}
|
||||
if (first == 0xE0) {
|
||||
return remaining >= 3 && text[1] >= 0xA0 && text[1] <= 0xBF && continuation(text[2]) ? 3 : 0;
|
||||
}
|
||||
if ((first >= 0xE1 && first <= 0xEC) || (first >= 0xEE && first <= 0xEF)) {
|
||||
return remaining >= 3 && continuation(text[1]) && continuation(text[2]) ? 3 : 0;
|
||||
}
|
||||
if (first == 0xED) {
|
||||
return remaining >= 3 && text[1] >= 0x80 && text[1] <= 0x9F && continuation(text[2]) ? 3 : 0;
|
||||
}
|
||||
if (first == 0xF0) {
|
||||
return remaining >= 4 && text[1] >= 0x90 && text[1] <= 0xBF &&
|
||||
continuation(text[2]) && continuation(text[3]) ? 4 : 0;
|
||||
}
|
||||
if (first >= 0xF1 && first <= 0xF3) {
|
||||
return remaining >= 4 && continuation(text[1]) && continuation(text[2]) && continuation(text[3]) ? 4 : 0;
|
||||
}
|
||||
if (first == 0xF4) {
|
||||
return remaining >= 4 && text[1] >= 0x80 && text[1] <= 0x8F &&
|
||||
continuation(text[2]) && continuation(text[3]) ? 4 : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
inline bool valid(const char* text, size_t length) {
|
||||
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(text);
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
const size_t sequence = sequenceLength(bytes + offset, length - offset);
|
||||
if (!sequence) return false;
|
||||
offset += sequence;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns the original pointer when it is already valid. Malformed runs are
|
||||
// collapsed to one ASCII '?' in scratch so LVGL never receives a partial codepoint.
|
||||
inline const char* sanitize(const char* text, size_t length, std::string& scratch) {
|
||||
scratch.clear();
|
||||
if (valid(text, length)) return text;
|
||||
|
||||
scratch.reserve(length);
|
||||
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(text);
|
||||
size_t offset = 0;
|
||||
while (offset < length) {
|
||||
const size_t sequence = sequenceLength(bytes + offset, length - offset);
|
||||
if (sequence) {
|
||||
scratch.append(text + offset, sequence);
|
||||
offset += sequence;
|
||||
continue;
|
||||
}
|
||||
scratch.push_back('?');
|
||||
++offset;
|
||||
while (offset < length && continuation(bytes[offset])) ++offset;
|
||||
}
|
||||
return scratch.c_str();
|
||||
}
|
||||
|
||||
} // namespace Utf8Text
|
||||
@@ -1473,6 +1473,34 @@ local pending = {} -- lines waiting on the 1 write/sec limit
|
||||
|
||||
local function logname() return run .. ".csv" end
|
||||
|
||||
-- Return at most max_bytes without splitting a UTF-8 sequence. The row's
|
||||
-- fixed-width columns are byte-budgeted, not character-budgeted; a raw
|
||||
-- string.sub(1, 14) cut Ouderkerk + sun + VS16 inside the final codepoint and
|
||||
-- left LVGL unable to advance through the label (#323, same class as #223).
|
||||
local function utf8_prefix_bytes(text, max_bytes)
|
||||
local offset, last, length = 1, 0, #text
|
||||
while offset <= length and offset <= max_bytes do
|
||||
local first = text:byte(offset)
|
||||
local width = first <= 0x7F and 1
|
||||
or (first >= 0xC2 and first <= 0xDF and 2)
|
||||
or (first >= 0xE0 and first <= 0xEF and 3)
|
||||
or (first >= 0xF0 and first <= 0xF4 and 4) or 0
|
||||
if width == 0 or offset + width - 1 > length or offset + width - 1 > max_bytes then break end
|
||||
local valid = true
|
||||
for i = 2, width do
|
||||
local byte = text:byte(offset + i - 1)
|
||||
if byte < 0x80 or byte > 0xBF then valid = false; break end
|
||||
end
|
||||
local second = width > 1 and text:byte(offset + 1) or 0
|
||||
if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
|
||||
(first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then valid = false end
|
||||
if not valid then break end
|
||||
last = offset + width - 1
|
||||
offset = last + 1
|
||||
end
|
||||
return text:sub(1, last)
|
||||
end
|
||||
|
||||
-- Lua here is built with 32-bit floats, so fix.lat is good to about a metre and
|
||||
-- no better. fix.lat_e6 is the same reading as an exact integer in
|
||||
-- micro-degrees, which is what belongs in a log: a survey you plot months later
|
||||
@@ -1554,7 +1582,7 @@ local function redraw()
|
||||
local e = list[i]
|
||||
if e then
|
||||
rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d",
|
||||
e.n.name:sub(1, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
|
||||
utf8_prefix_bytes(e.n.name, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
|
||||
rows[i]:color(e.n.best > 0 and C.good or C.text)
|
||||
else
|
||||
rows[i]:set(i == 1 and "nothing has answered a probe yet" or "")
|
||||
@@ -1777,7 +1805,7 @@ static const LuaBuiltinApp kLuaBuiltin[] = {
|
||||
{ "snake", "Snake", "1.0", kLuaSrc_snake },
|
||||
{ "sdktest", "SDK Test", "1.6", kLuaSrc_sdktest },
|
||||
{ "2048", "2048", "1.2", kLuaSrc_2048 },
|
||||
{ "wardrive", "Wardrive", "1.0", kLuaSrc_wardrive },
|
||||
{ "wardrive", "Wardrive", "1.1", kLuaSrc_wardrive },
|
||||
{ "nearby", "Nearby", "1.0", kLuaSrc_nearby },
|
||||
};
|
||||
static const int kLuaBuiltinCount = (int)(sizeof(kLuaBuiltin)/sizeof(kLuaBuiltin[0]));
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
|
||||
#include "ui-touch/Utf8Text.h"
|
||||
|
||||
int main() {
|
||||
const char full[] = "Ouderkerk\xE2\x98\x80\xEF\xB8\x8F";
|
||||
assert(strlen(full) == 15);
|
||||
assert(Utf8Text::valid(full, strlen(full)));
|
||||
|
||||
std::string broken(full, 14); // Wardrive's former name:sub(1, 14)
|
||||
assert(!Utf8Text::valid(broken.data(), broken.size()));
|
||||
|
||||
std::string scratch;
|
||||
const char* clean = Utf8Text::sanitize(broken.data(), broken.size(), scratch);
|
||||
assert(scratch == "Ouderkerk\xE2\x98\x80?");
|
||||
assert(Utf8Text::valid(clean, scratch.size()));
|
||||
|
||||
scratch = "allocated";
|
||||
const char* unchanged = Utf8Text::sanitize(full, strlen(full), scratch);
|
||||
assert(unchanged == full);
|
||||
assert(scratch.empty());
|
||||
|
||||
const char overlong[] = { (char)0xC0, (char)0xAF };
|
||||
assert(!Utf8Text::valid(overlong, sizeof overlong));
|
||||
Utf8Text::sanitize(overlong, sizeof overlong, scratch);
|
||||
assert(scratch == "?");
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user