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})