From fea0e9c291bd51d88e5a4df9953b9a6cbd8ae4f7 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sat, 1 Aug 2026 21:43:17 -0400 Subject: [PATCH] fix(settings): reduce keyboard memory usage --- sdcard/apps/settings/main.lua | 147 +++++++++++++++++++++++++--------- sdcard/lib/ui.lua | 92 ++++++++++++--------- test/settings_calibration.lua | 37 +++++---- test/ui_layout.lua | 7 ++ 4 files changed, 194 insertions(+), 89 deletions(-) diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index 8d3c46b..980fcdd 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -2,13 +2,14 @@ local ui = require("ui") local INSET = 30 -local screen, message +local screen, message, passwordLabel, passwordRow local mode = "menu" local samples, pending, armed = {}, nil, false -local scanRequested, selectedNetwork, password = false, nil, "" +local scanRequested, keyboardRequested = false, false +local selectedNetwork, password = nil, "" local keyboardPage = "lower" -local buildMenu, buildWifi, buildNetworks, buildKeyboard, startCalibration, cycleRotation +local buildMenu, buildWifi, buildNetworks, buildKeyboard, startCalibration, cycleRotation, updatePassword -- Two inset targets give a raw-per-pixel slope; extrapolate it to the screen edges. -- Exposed as a global so test/settings_calibration.lua can exercise it. @@ -93,13 +94,17 @@ local function zoneLabel() return current end -local function cycleTimezone() +local function cycleTimezone(button) 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) + if ok and not message then + button:setText("timezone: " .. zoneLabel()) + return + end message = ok and "timezone saved" or "save failed" buildMenu() end @@ -184,8 +189,18 @@ local function connectSelected() end local function chooseNetwork(network) - selectedNetwork, password = network, "" - if network.secure then buildKeyboard() else connectSelected() end + selectedNetwork, password = {ssid = network.ssid}, "" + if not network.secure then + connectSelected() + return + end + keyboardRequested = true + mode = "password" + themed{pad = 12, ui.text("opening keyboard...")} +end + +local function selectNetwork(button) + chooseNetwork(button.network) end function buildNetworks(networks) @@ -207,33 +222,96 @@ function buildNetworks(networks) local lock = network.secure and " *" or "" items[#items + 1] = ui.button{ label = network.ssid .. lock .. " " .. network.rssi, - on_press = function() chooseNetwork(network) end, + network = network, + on_press = selectNetwork, } end + log.info("wifi scan found " .. #list .. " networks") if #list == 0 then items[#items + 1] = ui.text("no networks found", {color = ui.theme.muted}) end items[#items + 1] = ui.button{label = "rescan", on_press = requestScan} items[#items + 1] = ui.button{label = "back", on_press = buildWifi} themed(items) end -local function keyButton(label, value, width) - return ui.button{label = label, w = width or 24, h = 28, pad = 4, - on_press = function() - if #password < 64 then password = password .. value end - buildKeyboard() - end, - } +function updatePassword() + passwordLabel:setText("password: " .. password) + passwordRow:invalidate() end -local function keyRow(chars) - local row = {row = true, h = 28, gap = 4} - for char in chars:gmatch(".") do row[#row + 1] = keyButton(char, char) end - return ui.box(row) +local KEY_W, KEY_H, KEY_GAP, KEY_ROW_GAP = 24, 28, 4, 5 +local KEY_CONTROLS = { + {label = "page", action = "page", w = 48}, + {label = "backspace", action = "backspace", w = 68}, + {label = "space", action = "space", w = 40}, + {label = "connect", action = "connect", w = 60}, + {label = "cancel", action = "cancel", w = 52}, +} + +local function drawKey(node, label, x, y, w) + local gradient = node.gradient + local top, bottom = gradient[1], gradient[2] + gui.roundRect(x, y, w, KEY_H, node.radius, node.bg, top, bottom, node.color) + gui.drawText(label, x + math.floor((w - gui.textWidth(label)) / 2), + y + math.floor((KEY_H - gui.fontHeight()) / 2), node.color, bottom) +end + +local function paintKeyboard(node) + local y = node.rect.y + for _, chars in ipairs(node.keyRows) do + local x = node.rect.x + for char in chars:gmatch(".") do + drawKey(node, char, x, y, KEY_W) + x = x + KEY_W + KEY_GAP + end + y = y + KEY_H + KEY_ROW_GAP + end + local x = node.rect.x + for _, control in ipairs(KEY_CONTROLS) do + drawKey(node, control.action == "page" and keyboardPage or control.label, x, y, control.w) + x = x + control.w + KEY_GAP + end +end + +local function pressKeyboard(node, x, y) + local row = math.floor((y - node.rect.y) / (KEY_H + KEY_ROW_GAP)) + 1 + if (y - node.rect.y) % (KEY_H + KEY_ROW_GAP) >= KEY_H then return end + if row <= #node.keyRows then + local offset = x - node.rect.x + local column = math.floor(offset / (KEY_W + KEY_GAP)) + 1 + if offset % (KEY_W + KEY_GAP) >= KEY_W then return end + local char = node.keyRows[row]:sub(column, column) + if char ~= "" and #password < 64 then password = password .. char; updatePassword() end + return + end + if row ~= #node.keyRows + 1 then return end + local offset = x - node.rect.x + for _, control in ipairs(KEY_CONTROLS) do + if offset < control.w then + if control.action == "page" then + keyboardPage = keyboardPage == "lower" and "upper" or + (keyboardPage == "upper" and "symbols" or "lower") + keyboardRequested = true + elseif control.action == "backspace" then + password = password:sub(1, -2) + updatePassword() + elseif control.action == "space" and #password < 64 then + password = password .. " " + updatePassword() + elseif control.action == "connect" then + connectSelected() + elseif control.action == "cancel" then + buildWifi() + end + return + end + offset = offset - control.w - KEY_GAP + end end function buildKeyboard() + screen = nil + collectgarbage() mode = "password" - local shown = string.rep("*", #password) local rows if keyboardPage == "symbols" then rows = {"!@#$%^&*()", "-_=+[]{}", "`~;:'\",.?", "/\\|<>"} @@ -243,30 +321,20 @@ function buildKeyboard() for i, row in ipairs(rows) do rows[i] = row:upper() end end end - local controls = {row = true, h = 28, gap = 4, - ui.button{label = keyboardPage, w = 48, h = 28, pad = 4, - on_press = function() - keyboardPage = keyboardPage == "lower" and "upper" or - (keyboardPage == "upper" and "symbols" or "lower") - buildKeyboard() - end}, - ui.button{label = "backspace", w = 68, h = 28, pad = 4, - on_press = function() password = password:sub(1, -2); buildKeyboard() end}, - keyButton("space", " ", 40), - ui.button{label = "connect", w = 60, h = 28, pad = 4, on_press = connectSelected}, - ui.button{label = "cancel", w = 52, h = 28, pad = 4, on_press = buildWifi}, - } - themed{pad = 8, gap = 5, - ui.text(selectedNetwork.ssid), - ui.text("password: " .. shown), - keyRow(rows[1]), keyRow(rows[2]), keyRow(rows[3]), keyRow(rows[4]), - ui.box(controls), + passwordLabel = ui.text("password: " .. password) + passwordRow = ui.box{passwordLabel} + themed{pad = 8, gap = KEY_ROW_GAP, + ui.text(selectedNetwork.ssid), passwordRow, + ui.box{h = 5 * KEY_H + 4 * KEY_ROW_GAP, keyRows = rows, press_style = false, + paint = paintKeyboard, on_press = pressKeyboard}, } + log.info("wifi keyboard ready") end function init() app.setTickInterval(50) -- calibration samples the raw panel between draws buildMenu() + log.info("settings ready") end function draw() @@ -282,6 +350,11 @@ function on_touch_up(x, y) end function on_tick() + if keyboardRequested then + keyboardRequested = false + buildKeyboard() + return + end if scanRequested then scanRequested = false buildNetworks(wifi.scan()) diff --git a/sdcard/lib/ui.lua b/sdcard/lib/ui.lua index 8850302..9abbf8f 100644 --- a/sdcard/lib/ui.lua +++ b/sdcard/lib/ui.lua @@ -315,54 +315,68 @@ function ui.spacer(spec) return component{w = spec.w, h = spec.h} end +local function measureText(self, available) + self.mw = resolve(self.w, available.w, "width") or gui.textWidth(self.label) + self.mh = resolve(self.h, available.h, "height") or gui.fontHeight() + return self.mw, self.mh +end + +local function paintText(self) + gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg) +end + +local function setText(self, text) + if text == self.label then return end + self.label = text + self:invalidate() +end + function ui.text(label, spec) spec = spec or {} spec.label = label local node = component(spec) - node.measure = function(self, available) - self.mw = resolve(self.w, available.w, "width") or gui.textWidth(self.label) - self.mh = resolve(self.h, available.h, "height") or gui.fontHeight() - return self.mw, self.mh - end - node.paint = function(self) - gui.drawText(self.label, self.rect.x, self.rect.y, self.color, self.bg) - end - -- Setting .label alone repaints nothing, which fails silently. Unchanged text also - -- costs nothing, so a caller can push a value every tick without thinking about it. - node.setText = function(self, text) - if text == self.label then return end - self.label = text - self:invalidate() - end + node.measure = measureText + node.paint = paintText + node.setText = setText return node end +local function paintButton(self) + local r = self.rect + local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient) + local top, bottom + if gradient then + top, bottom = gradient[1], gradient[2] + else + top = self.pressed and (self.press_bg or self.color) or self.button_bg + bottom = top + end + gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, top, bottom, self.color) + for _, child in ipairs(self.children) do + child.bg = bottom or self.bg + child.color = self.pressed and (self.press_color or self.bg) or self.color + child.dirty = true + end +end + +local function setButtonText(self, text) + local label = self.children[1] + if text == label.label then return end + label:setText(text) + self:invalidate() +end + function ui.button(spec) spec.pad = spec.pad or 8 spec.align = spec.align or "center" - if spec.label then + local hasLabel = spec.label ~= nil + if hasLabel then spec.children = {ui.text(spec.label)} spec.label = nil end local node = component(spec) - node.paint = function(self) - local r = self.rect - local gradient = self.pressed and self.press_gradient or (not self.pressed and self.gradient) - local top, bottom - if gradient then - top, bottom = gradient[1], gradient[2] - else - top = self.pressed and (self.press_bg or self.color) or self.button_bg - bottom = top - end - -- self.bg is the surface this button sits on, which the anti-aliased edge blends into. - gui.roundRect(r.x, r.y, r.w, r.h, self.radius or ui.theme.radius, self.bg, top, bottom, self.color) - for _, child in ipairs(self.children) do - child.bg = bottom or self.bg -- text blends against the bottom stop - child.color = self.pressed and (self.press_color or self.bg) or self.color - child.dirty = true - end - end + node.paint = paintButton + if hasLabel then node.setText = setButtonText end return node end @@ -437,17 +451,19 @@ function Screen:down(x, y) if not target then return end self.captured = target self.pressedAt = sys.millis() - target.pressed = true - target:invalidate() + if target.press_style ~= false then + target.pressed = true + target:invalidate() + end end function Screen:up(x, y) local target = self.captured self.captured = nil if not target then return end - self.released = target + if target.press_style ~= false then self.released = target end local inside = contains(target.rect, x, y) - if inside and target.on_press then target.on_press(target) end + if inside and target.on_press then target.on_press(target, x, y) end end return ui diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index 9245cb4..dcf213e 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -13,18 +13,22 @@ 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 targets = {} local function tapRow(prefix) device.painted = {} draw() + local target 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 + targets[item.label] = item + if item.label:sub(1, #prefix) == prefix then target = item end + end + if not target then + for label, item in pairs(targets) do + if label:sub(1, #prefix) == prefix then target = item break end end end - error("no row labelled '" .. prefix .. "'") + if not target then error("no row labelled '" .. prefix .. "'") end + tap{x = target.x + 2, y = target.y + 2} end -- A perfectly linear panel spanning raw 200..3800 over 320x480 must round-trip @@ -46,6 +50,16 @@ local s4 = {x = 3800 - raw(w - inset, w) + 200, y = s2.y} local fx0, _, fx1 = computeCalibration(s3, s4, w, h, inset) assert(fx0 > fx1, "flipped axis should descend") +-- Timezone cycles through the picker list and redraws only its button. +local zones = require("timezones") +init() +tapRow("timezone:") +assert(sys.getTimezone() == zones[2].tz, "timezone " .. sys.getTimezone()) +tapRow("timezone:") +assert(#device.painted == 1 and device.painted[1].label == "timezone: " .. zones[2].name, + "timezone change repainted the settings list") +assert(sys.getTimezone() == zones[3].tz, "timezone " .. sys.getTimezone()) + -- Rotation cycles through the four quarter turns and wraps back to 0. init() for _, expected in ipairs({90, 180, 270, 0}) do @@ -53,14 +67,6 @@ for _, expected in ipairs({90, 180, 270, 0}) do 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") -init() -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. init() tapRow("calibrate") @@ -94,8 +100,11 @@ tapRow("wifi:") tapRow("scan networks") on_tick() tapRow("secure") +on_tick() tapRow("q") -- keyboard key tapRow("connect") +assert(#device.painted == 1, + "typing rebuilt the keyboard instead of repainting the password") 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 c179b09..182470c 100644 --- a/test/ui_layout.lua +++ b/test/ui_layout.lua @@ -20,6 +20,13 @@ assert(rect(title) == "12,12 296x8", rect(title)) assert(rect(first) == "12,28 296x24", rect(first)) assert(rect(second) == "12,60 296x24", rect(second)) +screen:draw() +device.painted = {} +first:setText("a longer label") +screen:draw() +assert(#device.painted == 1 and device.painted[1].label == "a longer label", + "changing a button label repainted other components") + -- Fractions resolve against the parent content box, absolutes stay absolute. local half = ui.box{w = 0.5, h = 40} local fixed = ui.box{w = 100, h = 40}