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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user