From 2cdb7b3702a3822afc0aa3efc012dd796a9f0f16 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 1 Aug 2026 17:03:55 -0400 Subject: [PATCH] feat: timezone setting and main-axis justify in the ui toolkit The launcher clock read UTC and sat next to the title because the toolkit could only stack children from the start of an axis. justify adds the CSS main-axis modes that had a caller -- start, end, center, between -- so a header keeps its title left and its clock right without any app doing arithmetic. Timezones are stored as POSIX TZ rules rather than offsets, so newlib applies DST changeovers and os.date() in Lua reports local time with no binding of its own. /lib/timezones.lua is only the picker list: a zone missing from it still works if its rule is written into settings, the same split themes already use. settings_calibration.lua now finds rows by label. Adding the timezone row shifted every hardcoded y coordinate in it, which is the failure that had been predicted and would have silently retargeted taps at the wrong control. --- sdcard/apps/launcher/main.lua | 2 +- sdcard/apps/settings/main.lua | 24 ++++++++++++++++++ sdcard/lib/timezones.lua | 27 ++++++++++++++++++++ sdcard/lib/ui.lua | 23 ++++++++++++++--- src/lua/bindings/sys.cpp | 22 ++++++++++++++++ src/net.cpp | 6 +++++ src/net.h | 3 +++ src/settings.cpp | 2 ++ src/settings.h | 3 +++ test/fake_device.lua | 3 +++ test/settings_calibration.lua | 48 +++++++++++++++++++++++++---------- test/ui_layout.lua | 20 +++++++++++++++ 12 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 sdcard/lib/timezones.lua diff --git a/sdcard/apps/launcher/main.lua b/sdcard/apps/launcher/main.lua index d51c736..55ff9ee 100644 --- a/sdcard/apps/launcher/main.lua +++ b/sdcard/apps/launcher/main.lua @@ -23,7 +23,7 @@ function setup() -- Fixed width: drawText paints an opaque background only under its own glyphs, so a -- shorter label would leave the tail of the previous one on screen. clock = ui.text(PLACEHOLDER, {w = gui.textWidth(PLACEHOLDER), color = ui.theme.muted}) - local header = ui.box{row = true, gap = 12, ui.text("esp32-lcd"), clock} + local header = ui.box{row = true, gap = 12, justify = "between", ui.text("esp32-lcd"), clock} local items = {pad = 12, gap = 8, header} local names = fs.listDirs("/apps") diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index 5e5a3c9..9ceedc1 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -74,6 +74,29 @@ local function cycleTheme() buildMenu() end +local zones = require("timezones") + +-- The stored value is a POSIX rule, so a zone set by hand and missing from the list +-- shows its rule rather than pretending to be the first entry. +local function zoneLabel() + local current = sys.getTimezone() + for _, zone in ipairs(zones) do + if zone.tz == current then return zone.name end + end + return current +end + +local function cycleTimezone() + local current = sys.getTimezone() + local next_index = 1 + for index, zone in ipairs(zones) do + if zone.tz == current then next_index = index % #zones + 1 end + end + local ok = sys.setTimezone(zones[next_index].tz) + message = ok and "timezone saved" or "save failed" + buildMenu() +end + local function statusLabel(status) if status.state == "connected" then return "wifi: " .. status.ssid end if status.ssid ~= "" then return "wifi: " .. status.state end @@ -90,6 +113,7 @@ function buildMenu() ui.button{label = "rotation: " .. sys.getRotation() .. " deg", on_press = cycleRotation}, ui.button{label = statusLabel(wifi.status()), on_press = buildWifi}, ui.button{label = "theme: " .. ui.themeName, on_press = cycleTheme}, + ui.button{label = "timezone: " .. zoneLabel(), on_press = cycleTimezone}, ui.button{label = "exit", on_press = sys.exit}, } if message then items[#items + 1] = ui.text(message) end diff --git a/sdcard/lib/timezones.lua b/sdcard/lib/timezones.lua new file mode 100644 index 0000000..8835278 --- /dev/null +++ b/sdcard/lib/timezones.lua @@ -0,0 +1,27 @@ +-- Picker list for the settings app. The firmware stores the POSIX TZ rule itself, so +-- this file is a convenience, not a lookup table: add a row and the zone appears, and +-- a zone that is not here can still be set by writing its rule into /settings.lua. +-- +-- Rules carry their own DST changeover dates, which is why they are spelled out rather +-- than reduced to an offset. + +return { + {name = "UTC", tz = "UTC0"}, + {name = "London", tz = "GMT0BST,M3.5.0/1,M10.5.0"}, + {name = "Berlin", tz = "CET-1CEST,M3.5.0,M10.5.0/3"}, + {name = "Athens", tz = "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {name = "Dubai", tz = "<+04>-4"}, + {name = "Kolkata", tz = "IST-5:30"}, + {name = "Shanghai", tz = "CST-8"}, + {name = "Tokyo", tz = "JST-9"}, + {name = "Sydney", tz = "AEST-10AEDT,M10.1.0,M4.1.0/3"}, + {name = "Auckland", tz = "NZST-12NZDT,M9.5.0,M4.1.0/3"}, + {name = "Sao Paulo", tz = "<-03>3"}, + {name = "New York", tz = "EST5EDT,M3.2.0,M11.1.0"}, + {name = "Chicago", tz = "CST6CDT,M3.2.0,M11.1.0"}, + {name = "Denver", tz = "MST7MDT,M3.2.0,M11.1.0"}, + {name = "Phoenix", tz = "MST7"}, + {name = "Los Angeles", tz = "PST8PDT,M3.2.0,M11.1.0"}, + {name = "Anchorage", tz = "AKST9AKDT,M3.2.0,M11.1.0"}, + {name = "Honolulu", tz = "HST10"}, +} diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index 1281da7..851af23 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -160,7 +160,24 @@ function Component:place(rect) h = rect.h - pad.t - pad.b, } - local offset = 0 + -- Main-axis distribution, CSS justify-content minus the modes nothing here asks for. + -- Absolutely placed children take no part in the flow, so they are excluded. + local flowing, used = 0, 0 + for _, child in ipairs(self.children) do + if not child.at then + flowing = flowing + 1 + used = used + (self.row and child.mw or child.mh) + end + end + used = used + math.max(0, flowing - 1) * self.gap + local free = math.max(0, (self.row and content.w or content.h) - used) + + local offset, spread = 0, 0 + if self.justify == "end" then offset = free + elseif self.justify == "center" then offset = math.floor(free / 2) + elseif self.justify == "between" and flowing > 1 then spread = math.floor(free / (flowing - 1)) + end + for _, child in ipairs(self.children) do local cw, ch = child.mw, child.mh -- Cross axis fills the parent unless the child asked for a size, like CSS blocks. @@ -178,12 +195,12 @@ function Component:place(rect) x, y = content.x + offset, content.y if self.align == "center" then y = y + math.floor((content.h - ch) / 2) elseif self.align == "end" then y = y + content.h - ch end - offset = offset + cw + self.gap + offset = offset + cw + self.gap + spread else x, y = content.x, content.y + offset if self.align == "center" then x = x + math.floor((content.w - cw) / 2) elseif self.align == "end" then x = x + content.w - cw end - offset = offset + ch + self.gap + offset = offset + ch + self.gap + spread end child:place{x = x, y = y, w = cw, h = ch} end diff --git a/src/lua/bindings/sys.cpp b/src/lua/bindings/sys.cpp index cebc361..63733eb 100644 --- a/src/lua/bindings/sys.cpp +++ b/src/lua/bindings/sys.cpp @@ -55,6 +55,26 @@ static int l_sys_setTheme(lua_State* L) { return 1; } +static int l_sys_getTimezone(lua_State* L) { + lua_pushstring(L, settings.timezone.c_str()); + return 1; +} + +// Takes a POSIX TZ rule, not a zone name: /lib/timezones.lua is only a picker, so a +// zone missing from that list is still reachable by writing the rule. +static int l_sys_setTimezone(lua_State* L) { + size_t length; + const char* tz = luaL_checklstring(L, 1, &length); + if (!length || length > 48) { + lua_pushboolean(L, false); + return 1; + } + settings.timezone = String(tz, length); + net::applyTimezone(); + lua_pushboolean(L, settings.save()); + return 1; +} + static int l_sys_setCalibration(lua_State* L) { settings.touchX0 = luaL_checkinteger(L, 1); settings.touchY0 = luaL_checkinteger(L, 2); @@ -86,6 +106,8 @@ void registerSys(lua_State* L) { {"setTheme", l_sys_setTheme}, {"setCalibration", l_sys_setCalibration}, {"clockSynced", l_sys_clockSynced}, + {"getTimezone", l_sys_getTimezone}, + {"setTimezone", l_sys_setTimezone}, {nullptr, nullptr}}; luaL_newlib(L, lib); lua_setglobal(L, "sys"); diff --git a/src/net.cpp b/src/net.cpp index c1b67de..34e23f4 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -38,7 +38,13 @@ void onTimeSync(struct timeval*) { } // namespace +void net::applyTimezone() { + setenv("TZ", settings.timezone.c_str(), 1); + tzset(); +} + void net::begin() { + applyTimezone(); time_t floor = buildTime(); if (time(nullptr) < floor) { struct timeval seed = {.tv_sec = floor, .tv_usec = 0}; diff --git a/src/net.h b/src/net.h index 4c908ad..a6d093e 100644 --- a/src/net.h +++ b/src/net.h @@ -8,6 +8,9 @@ namespace net { void begin(); void loop(); +// Pushes settings.timezone into libc, so os.date() in Lua reports local time. +void applyTimezone(); + // True once SNTP has actually answered. Certificate validity checks and any UI that // shows a wall clock need this; the seeded build-time clock is only a floor. bool clockSynced(); diff --git a/src/settings.cpp b/src/settings.cpp index 8d40800..48d76a3 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -65,6 +65,7 @@ bool Settings::load() { if (ok) { rotation = fieldOr(L, "rotation", rotation); theme = stringFieldOr(L, "theme", theme); + timezone = stringFieldOr(L, "timezone", timezone); lua_getfield(L, -1, "touch"); if (lua_istable(L, -1)) { touchX0 = fieldOr(L, "x0", touchX0); @@ -87,6 +88,7 @@ bool Settings::load() { bool Settings::save() const { String source = "return {\n rotation = " + String(rotation) + ",\n"; source += " theme = " + luaString(theme) + ",\n"; + source += " timezone = " + luaString(timezone) + ",\n"; source += " touch = { x0 = " + String(touchX0) + ", y0 = " + String(touchY0) + ", x1 = " + String(touchX1) + ", y1 = " + String(touchY1) + " },\n"; source += " wifi = { ssid = " + luaString(wifiSsid) + diff --git a/src/settings.h b/src/settings.h index 02e28a1..0284320 100644 --- a/src/settings.h +++ b/src/settings.h @@ -14,6 +14,9 @@ struct Settings { // Name of a palette in /lib/theme.lua; the colors themselves live on the card, // so improving a theme never has to migrate saved settings. String theme = "light"; + // POSIX TZ string, applied with setenv("TZ")/tzset(). Storing the rule rather than a + // zone name means newlib handles DST changeovers and an unlisted zone still works. + String timezone = "UTC0"; uint8_t rotationIndex() const { return (rotation / 90) & 3; } bool setRotation(int16_t degrees); diff --git a/test/fake_device.lua b/test/fake_device.lua index 8d2e185..f5bbd72 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -13,6 +13,7 @@ local device = { rotation = 0, theme = "light", clockSynced = false, + timezone = "UTC0", raw = nil, -- pending raw touch reading, {x, y} or nil networks = {}, -- what wifi.scan() returns status = {state = "disconnected", ssid = "", ip = "", rssi = 0}, @@ -64,6 +65,8 @@ function device.install() setTheme = function(name) device.theme = name return saved() end, setCalibration = function(...) device.calibration = {...} return saved() end, clockSynced = function() return device.clockSynced end, + getTimezone = function() return device.timezone end, + setTimezone = function(tz) device.timezone = tz return saved() end, } input = { diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index f50f576..52b57ce 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -6,15 +6,27 @@ local device = require("fake_device").install() dofile("sdcard/apps/settings/main.lua") --- Menu rows: pad 12, gap 8, title 8 tall, buttons 24 tall. -local ROTATION_ROW = {x = 100, y = 70} -local CALIBRATE_ROW = {x = 100, y = 38} - local function tap(point) on_touch_down(point.x, point.y) on_touch_up(point.x, point.y) end +-- Rows are found by their label rather than by a pixel row, because every hardcoded +-- coordinate silently retargets to the wrong control the day a row is inserted. +-- Each state change rebuilds the screen, so a draw repaints every label with its +-- placed position. +local function tapRow(prefix) + device.painted = {} + draw() + for _, item in ipairs(device.painted) do + if item.label:sub(1, #prefix) == prefix then + tap{x = item.x + 2, y = item.y + 2} + return + end + end + error("no row labelled '" .. prefix .. "'") +end + -- A perfectly linear panel spanning raw 200..3800 over 320x480 must round-trip -- to those same extremes from the two inset samples. local inset, w, h = 30, 320, 480 @@ -37,13 +49,21 @@ assert(fx0 > fx1, "flipped axis should descend") -- Rotation cycles through the four quarter turns and wraps back to 0. setup() for _, expected in ipairs({90, 180, 270, 0}) do - tap(ROTATION_ROW) + tapRow("rotation:") assert(sys.getRotation() == expected, "rotation " .. sys.getRotation()) end +-- Timezone cycles through the picker list and stores the POSIX rule, not the name. +local zones = require("timezones") +setup() +tapRow("timezone:") +assert(sys.getTimezone() == zones[2].tz, "timezone " .. sys.getTimezone()) +tapRow("timezone:") +assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone()) + -- Calibration collects one sample per target and saves on the second release. setup() -tap(CALIBRATE_ROW) +tapRow("calibrate") on_tick() -- release after the menu tap arms sampling device.raw = {s1.x, s1.y} on_tick() @@ -60,22 +80,22 @@ assert(math.abs(saved[1] - 200) <= 1, "saved x0 " .. saved[1]) -- Open networks connect directly from scan results. setup() device.networks = {{ssid = "qemu", rssi = -25, secure = false}} -tap{x = 100, y = 104} -- wifi -tap{x = 100, y = 56} -- scan +tapRow("wifi:") +tapRow("scan networks") on_tick() -tap{x = 100, y = 38} -- qemu +tapRow("qemu") assert(device.connected, "open network should connect without a keyboard") assert(device.connected[1] == "qemu" and device.connected[2] == "", "open wifi connect") -- Secure networks route through the keyboard and preserve typed punctuation. setup() device.networks = {{ssid = "secure", rssi = -40, secure = true}} -tap{x = 100, y = 104} -tap{x = 100, y = 56} +tapRow("wifi:") +tapRow("scan networks") on_tick() -tap{x = 100, y = 38} -tap{x = 12, y = 80} -- q -tap{x = 200, y = 180} -- connect +tapRow("secure") +tapRow("q") -- keyboard key +tapRow("connect") assert(device.connected[1] == "secure" and device.connected[2] == "q", "secure wifi password") print("ok") diff --git a/test/ui_layout.lua b/test/ui_layout.lua index a18aacd..a4b0142 100644 --- a/test/ui_layout.lua +++ b/test/ui_layout.lua @@ -35,6 +35,26 @@ ui.screen(ui.box{row}) assert(rect(tall) == "0,0 40x40", rect(tall)) assert(rect(short) == "46,15 40x10", rect(short)) +-- justify distributes the main axis: "between" pushes the last child to the far edge, +-- which is how a header keeps a title left and a clock right without arithmetic. +local left = ui.box{w = 40, h = 10} +local right = ui.box{w = 60, h = 10} +ui.screen(ui.box{pad = 10, ui.box{row = true, justify = "between", left, right}}) +assert(rect(left) == "10,10 40x10", rect(left)) +assert(rect(right) == "250,10 60x10", rect(right)) + +-- The other modes shift the whole run rather than spreading it. +local a = ui.box{w = 40, h = 10} +local b = ui.box{w = 60, h = 10} +ui.screen(ui.box{ui.box{row = true, gap = 10, justify = "end", a, b}}) +assert(rect(a) == "210,0 40x10", rect(a)) +assert(rect(b) == "260,0 60x10", rect(b)) + +-- One child cannot be spread against anything, so "between" degrades to "start". +local only = ui.box{w = 40, h = 10} +ui.screen(ui.box{ui.box{row = true, justify = "between", only}}) +assert(rect(only) == "0,0 40x10", rect(only)) + -- The root always fills the screen, so alignment there spans the whole panel. local lone = ui.box{w = 40, h = 40} ui.screen(ui.box{align = "center", lone})