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.
This commit is contained in:
2026-08-01 17:03:55 -04:00
parent 2b0355a148
commit 2cdb7b3702
12 changed files with 165 additions and 18 deletions
+1 -1
View File
@@ -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")
+24
View File
@@ -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
+27
View File
@@ -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"},
}
+20 -3
View File
@@ -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
+22
View File
@@ -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");
+6
View File
@@ -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};
+3
View File
@@ -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();
+2
View File
@@ -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) +
+3
View File
@@ -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);
+3
View File
@@ -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 = {
+34 -14
View File
@@ -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")
+20
View File
@@ -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})