From 592daafa42a14ddd938d8d87843ec69d221c36a1 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Wed, 26 Aug 2026 11:53:01 +0100 Subject: [PATCH] feat(launcher): add in-launcher File Browser and Virtual Keyboard for handhelds --- src/import/LauncherView.lua | 12 ++ src/import/RomImporter.lua | 72 ++++++- src/ui/kit/FileBrowser.lua | 366 +++++++++++++++++++++++++++++++++ src/ui/kit/Kit.lua | 50 ++++- src/ui/kit/VirtualKeyboard.lua | 265 ++++++++++++++++++++++++ 5 files changed, 756 insertions(+), 9 deletions(-) create mode 100755 src/ui/kit/FileBrowser.lua create mode 100755 src/ui/kit/VirtualKeyboard.lua diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 1c54d58b..2d8f9026 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -5762,6 +5762,14 @@ local function buildModals(imp, m) if imp._modActions then buildModActionsModal(imp, m) return true end if imp._findEntry then buildFindEntryModal(imp, m) return true end if imp._gameManage then buildGameManageModal(imp, m) return true end + if Kit.FileBrowser and Kit.FileBrowser.active then + Kit.FileBrowser.draw(m) + return true + end + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then + Kit.VirtualKeyboard.draw(m) + return true + end return false end @@ -5787,6 +5795,10 @@ end local function drawPadCursor(imp) if not imp._padCursorActive then return end + if (Kit.FileBrowser and Kit.FileBrowser.active) + or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then + return + end -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch -- framebuffer when the stick advances by fractional pixels each frame. local x, y = imp._padCursor.x, imp._padCursor.y diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 364de276..695ee4d9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2056,7 +2056,22 @@ function RomImporter:chooseMod() return end local path = chooseZip() - if path then self:_installMod(path) end + if path then + self:_installMod(path) + return + end + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select Mod (.zip)", + mode = "mod", + onSelect = function(pickedPath) + self:_installMod(pickedPath) + end, + }) + return + end end local function requiredManifest(self, modId) @@ -2480,7 +2495,22 @@ function RomImporter:chooseSaveImport(version) return end local path = chooseSav() - if path then self:_importSave(version, path) end + if path then + self:_importSave(version, path) + return + end + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select Save (.sav)", + mode = "save", + onSelect = function(pickedPath) + self:_importSave(version, pickedPath) + end, + }) + return + end end -- "Export save" button: write the active slot back out to a raw .sav in the save @@ -2626,6 +2656,18 @@ function RomImporter:choose(version) self:startPath(path) return end + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit and Kit.FileBrowser then + self._padCursorActive = false + Kit.FileBrowser.open({ + title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"), + mode = "rom", + onSelect = function(pickedPath) + self:startPath(pickedPath) + end, + }) + return + end -- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or -- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan -- used on Android, which works when the game is launched as an unpacked @@ -2998,6 +3040,14 @@ function RomImporter:_cycleTab(delta) end function RomImporter:_updatePadCursor(dt) + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then + if (Kit.FileBrowser and Kit.FileBrowser.active) + or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then + return + end + end + if self.isNX then self:_ensureNxPointerBridge() -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling @@ -3074,6 +3124,15 @@ function RomImporter:_updatePadCursor(dt) end function RomImporter:gamepadpressed(_, button) + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then + if Kit.FileBrowser and Kit.FileBrowser.active then + if Kit.FileBrowser.gamepadpressed(button) then return end + end + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then + if Kit.VirtualKeyboard.gamepadpressed(button) then return end + end + end self:_activatePadCursor() -- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B. local action = GamepadMap.mapGamepadButton(button) @@ -4062,6 +4121,15 @@ function RomImporter:fileUrl(path) end function RomImporter:keypressed(key) + local okKit, Kit = pcall(require, "src.ui.kit.Kit") + if okKit then + if Kit.FileBrowser and Kit.FileBrowser.active then + if Kit.FileBrowser.keypressed(key) then return end + end + if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then + if Kit.VirtualKeyboard.keypressed(key) then return end + end + end if self._profileSavePrompt then if key == "backspace" then self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "") diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua new file mode 100755 index 00000000..11c4f85d --- /dev/null +++ b/src/ui/kit/FileBrowser.lua @@ -0,0 +1,366 @@ +-- Immediate-mode in-launcher File Browser for handhelds and gamepads. +-- Allows navigating directories to pick ROM files (.gb/.gbc), import save files (.sav), +-- and select mod/skin archives (.zip) without requiring host desktop GUI pickers (zenity/kdialog). + +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local Kit = nil +local function getKit() + if not Kit then Kit = require("src.ui.kit.Kit") end + return Kit +end + +local FileBrowser = { + active = false, + title = "Select File", + mode = "rom", -- "rom", "save", "mod", "all" + currentDir = "/", + entries = {}, + selectedIdx = 1, + page = 1, + perPage = 8, + onSelect = nil, + onCancel = nil, + shortcutIdx = 1, +} + +local SHORTCUTS = { + { label = "SD Card", path = "/mnt/SDCARD" }, + { label = "GB ROMs", path = "/roms/gb" }, + { label = "GBC ROMs", path = "/roms/gbc" }, + { label = "Ports", path = "/roms/ports" }, + { label = "Game Dir", path = "." }, + { label = "Storage", path = "/storage" }, + { label = "Userdata", path = "/userdata/roms" }, + { label = "Root /", path = "/" }, +} + +local function findSdCardRoot() + local candidates = { + "/mnt/SDCARD", + "/mnt/sdcard", + "/mnt/mmc", + "/sdcard", + "/roms", + "/storage/roms", + "/userdata/roms", + "/storage", + "/userdata", + } + for _, path in ipairs(candidates) do + local ok, h = pcall(io.popen, string.format('test -d "%s" && echo "yes"', path)) + if ok and h then + local res = h:read("*a") + h:close() + if res and res:match("yes") then + return path + end + end + end + return "/" +end + +local function normalizePath(p) + if not p or p == "" then return "/" end + p = p:gsub("\\", "/") + if p:sub(1, 1) ~= "/" and p:sub(1, 2) ~= "./" and p:sub(1, 3) ~= "../" then + p = "/" .. p + end + p = p:gsub("/+", "/") + return p +end + +local function parentDir(p) + p = normalizePath(p) + if p == "/" or p == "." then return "/" end + local parent = p:match("^(.*)/[^/]+/?$") + if not parent or parent == "" then return "/" end + return parent +end + +local function isMatchingFilter(name, isDir, mode) + if isDir then return true end + local ext = name:match("%.([^.]+)$") + if not ext then return (mode == "all") end + ext = ext:lower() + if mode == "rom" then + return (ext == "gb" or ext == "gbc" or ext == "zip") + elseif mode == "save" then + return (ext == "sav") + elseif mode == "mod" then + return (ext == "zip") + end + return true +end + +local function scanDirectory(dir, mode) + dir = normalizePath(dir) + local entries = {} + + local ok, handle = pcall(io.popen, string.format('ls -1ap "%s" 2>/dev/null', dir:gsub('"', '\\"'))) + if ok and handle then + local output = handle:read("*a") + handle:close() + if output and #output > 0 then + for line in output:gmatch("[^\r\n]+") do + if line ~= "./" and line ~= "../" and line ~= "." and line ~= ".." then + local isDir = (line:sub(-1) == "/") + local cleanName = isDir and line:sub(1, -2) or line + if isMatchingFilter(cleanName, isDir, mode) then + table.insert(entries, { + name = cleanName, + isDir = isDir, + path = (dir == "/" and "/" .. cleanName) or (dir .. "/" .. cleanName), + }) + end + end + end + end + end + + if #entries == 0 and love and love.filesystem and love.filesystem.getDirectoryItems then + local okFs, items = pcall(love.filesystem.getDirectoryItems, dir) + if okFs and items then + for _, name in ipairs(items) do + local full = (dir == "/" and "/" .. name) or (dir .. "/" .. name) + local info = love.filesystem.getInfo and love.filesystem.getInfo(full) + local isDir = info and info.type == "directory" + if isMatchingFilter(name, isDir, mode) then + table.insert(entries, { + name = name, + isDir = isDir, + path = full, + }) + end + end + end + end + + table.sort(entries, function(a, b) + if a.isDir ~= b.isDir then + return a.isDir + end + return a.name:lower() < b.name:lower() + end) + + return entries +end + +function FileBrowser.open(opts) + opts = opts or {} + FileBrowser.active = true + FileBrowser.title = opts.title or "Select File" + FileBrowser.mode = opts.mode or "rom" + FileBrowser.onSelect = opts.onSelect + FileBrowser.onCancel = opts.onCancel + FileBrowser.selectedIdx = 1 + FileBrowser.page = 1 + + local initPath = opts.initialPath or findSdCardRoot() + FileBrowser.currentDir = initPath + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + if #FileBrowser.entries == 0 and initPath ~= "/" then + FileBrowser.currentDir = "/" + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + end +end + +function FileBrowser.close(path) + if not FileBrowser.active then return end + local onSelect = FileBrowser.onSelect + local onCancel = FileBrowser.onCancel + FileBrowser.active = false + FileBrowser.onSelect = nil + FileBrowser.onCancel = nil + if path and onSelect then + onSelect(path) + elseif not path and onCancel then + onCancel() + end +end + +function FileBrowser.setDirectory(dir) + FileBrowser.currentDir = normalizePath(dir) + FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode) + FileBrowser.selectedIdx = 1 + FileBrowser.page = 1 +end + +function FileBrowser.cycleShortcut() + FileBrowser.shortcutIdx = (FileBrowser.shortcutIdx % #SHORTCUTS) + 1 + local target = SHORTCUTS[FileBrowser.shortcutIdx] + FileBrowser.setDirectory(target.path) +end + +function FileBrowser.gamepadpressed(button) + if not FileBrowser.active then return false end + + local total = #FileBrowser.entries + local perPage = FileBrowser.perPage + local maxPage = math.max(1, math.ceil(total / perPage)) + + if button == "dpup" then + if FileBrowser.selectedIdx > 1 then + FileBrowser.selectedIdx = FileBrowser.selectedIdx - 1 + FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1 + end + return true + elseif button == "dpdown" then + if FileBrowser.selectedIdx < total then + FileBrowser.selectedIdx = FileBrowser.selectedIdx + 1 + FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1 + end + return true + elseif button == "dpleft" or button == "leftshoulder" or button == "triggerleft" or button == "lefttrigger" or button == "l2" then + if FileBrowser.page > 1 then + FileBrowser.page = FileBrowser.page - 1 + FileBrowser.selectedIdx = (FileBrowser.page - 1) * perPage + 1 + end + return true + elseif button == "dpright" or button == "rightshoulder" or button == "triggerright" or button == "righttrigger" or button == "r2" then + if FileBrowser.page < maxPage then + FileBrowser.page = FileBrowser.page + 1 + FileBrowser.selectedIdx = math.min(total, (FileBrowser.page - 1) * perPage + 1) + end + return true + elseif button == "a" or button == "start" then + local entry = FileBrowser.entries[FileBrowser.selectedIdx] + if entry then + if entry.isDir then + FileBrowser.setDirectory(entry.path) + else + FileBrowser.close(entry.path) + end + end + return true + elseif button == "b" then + if FileBrowser.currentDir ~= "/" and FileBrowser.currentDir ~= "." then + FileBrowser.setDirectory(parentDir(FileBrowser.currentDir)) + else + FileBrowser.close(nil) + end + return true + elseif button == "x" then + FileBrowser.cycleShortcut() + return true + elseif button == "back" or button == "select" then + FileBrowser.close(nil) + return true + end + + return true +end + +function FileBrowser.keypressed(key) + if not FileBrowser.active then return false end + if key == "escape" then + FileBrowser.close(nil) + return true + elseif key == "up" then + return FileBrowser.gamepadpressed("dpup") + elseif key == "down" then + return FileBrowser.gamepadpressed("dpdown") + elseif key == "left" then + return FileBrowser.gamepadpressed("dpleft") + elseif key == "right" then + return FileBrowser.gamepadpressed("dpright") + elseif key == "return" then + return FileBrowser.gamepadpressed("a") + elseif key == "backspace" then + return FileBrowser.gamepadpressed("b") + end + return false +end + +function FileBrowser.draw(m) + if not FileBrowser.active then return end + + local Kit = getKit() + local s = m and m.s or Kit.scale or 1 + local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640 + local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480 + + Theme.fill(0, 0, W, H, PAL.bg, 0.94) + Kit.blockClicks = true + + local pad = math.floor(14 * s) + local modalW = math.floor(math.min(560 * s, W - 20 * s)) + local modalH = math.floor(math.min(420 * s, H - 20 * s)) + local px = math.floor((W - modalW) / 2) + local py = math.floor((H - modalH) / 2) + + Kit.card(px, py, modalW, modalH, true) + + local cy = py + pad + -- Header + Kit.text("button", FileBrowser.title, px + pad, cy, PAL.heading) + Kit.tag(px + modalW - pad - math.floor(60 * s), cy, math.floor(60 * s), math.floor(18 * s), + FileBrowser.mode:upper(), PAL.blue) + + cy = cy + Kit.textHeight("button") + math.floor(6 * s) + + -- Breadcrumb / Current directory bar + local pathBarH = math.floor(24 * s) + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.bg, 1) + Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.line, Theme.A.hairline, 1) + local pathStr = Kit.ellipsizeLeft("small", FileBrowser.currentDir, modalW - 4 * pad) + Kit.text("small", pathStr, px + pad + math.floor(6 * s), cy + math.floor(4 * s), PAL.heading) + + cy = cy + pathBarH + math.floor(8 * s) + + -- File / Directory List + local total = #FileBrowser.entries + local perPage = FileBrowser.perPage + local startIdx = (FileBrowser.page - 1) * perPage + 1 + local endIdx = math.min(total, startIdx + perPage - 1) + local rowH = math.floor(26 * s) + local rowGap = math.floor(3 * s) + + if total == 0 then + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, rowH * 4, PAL.bg, 1) + Kit.textCenter("small", "No files found in this folder.", px + pad, cy + rowH * 1.5, modalW - 2 * pad, PAL.muted) + cy = cy + rowH * 4 + math.floor(8 * s) + else + for i = startIdx, endIdx do + local entry = FileBrowser.entries[i] + local isSel = (FileBrowser.selectedIdx == i) + local rx = px + pad + local rw = modalW - 2 * pad + + if Kit.press(rx, cy, rw, rowH) then + FileBrowser.selectedIdx = i + if entry.isDir then + FileBrowser.setDirectory(entry.path) + else + FileBrowser.close(entry.path) + end + end + + if isSel then + Theme.fillRounded(rx, cy, rw, rowH, PAL.ink, 1) + local icon = entry.isDir and "[DIR] " or "[FILE] " + Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), PAL.inverse) + else + Theme.fillRounded(rx, cy, rw, rowH, PAL.surface, 1) + Theme.strokeRounded(rx, cy, rw, rowH, PAL.line, Theme.A.hairline, 1) + local icon = entry.isDir and "[DIR] " or "[FILE] " + local col = entry.isDir and PAL.blue or PAL.text + Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), col) + end + + cy = cy + rowH + rowGap + end + end + + -- Pager info & Controls guide + local bottomY = py + modalH - pad - math.floor(18 * s) + local pageInfo = string.format("Page %d of %d (%d items)", FileBrowser.page, math.max(1, math.ceil(total / perPage)), total) + Kit.text("micro", pageInfo, px + pad, bottomY, PAL.muted) + + local guide = "A: Open/Select B: Parent X: Shortcut Start: Confirm Select: Cancel" + Kit.textRight("micro", guide, px + modalW - pad, bottomY, PAL.muted) + + Kit.blockClicks = false +end + +return FileBrowser diff --git a/src/ui/kit/Kit.lua b/src/ui/kit/Kit.lua index 56adc505..3eb57b28 100644 --- a/src/ui/kit/Kit.lua +++ b/src/ui/kit/Kit.lua @@ -43,19 +43,22 @@ local Theme = require("src.ui.kit.Theme") local PAL = Theme.PAL +local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard") +local FileBrowser = require("src.ui.kit.FileBrowser") -local Kit = {} -Kit.Theme = Theme -Kit.PAL = PAL +local Kit = { + scale = 1, + time = 0, + focus = nil, -- active text-input id (nil = no focused field) + focusId = nil, -- spatial-nav ring id (nil = nothing selected by pad/arrows) + VirtualKeyboard = VirtualKeyboard, + FileBrowser = FileBrowser, +} Kit.mouseX, Kit.mouseY = 0, 0 Kit.mouseClicked = false -- left button pressed this frame Kit.mouseDown = false -- held, polled (drag / press-and-hold) Kit.wheelY = 0 -- wheel notches queued since the last frame -Kit.focus = nil -- id of the text field receiving keystrokes -Kit.focusId = nil -- id of the keyboard/gamepad focus ring target -Kit.time = 0 -Kit.fonts = {} Kit.scale = 1 Kit.blockClicks = false Kit.audit = nil @@ -446,6 +449,9 @@ end -- ------------------------------------------------------------ input plumbing function Kit.textinput(text) + if VirtualKeyboard.active then + return VirtualKeyboard.textinput(text) + end if not Kit.focus then return false end edits[#edits + 1] = text return true @@ -454,6 +460,12 @@ end -- Returns true when the key was consumed, so the host can leave its own -- shortcuts alone while the user is typing or driving the ring. function Kit.keypressed(key) + if VirtualKeyboard.active then + return VirtualKeyboard.keypressed(key) + end + if FileBrowser.active then + return FileBrowser.keypressed(key) + end if Kit.focus then if key == "backspace" then edits[#edits + 1] = "\b" return true elseif key == "return" or key == "kpenter" or key == "escape" then @@ -474,6 +486,12 @@ end -- Gamepad d-pad / stick, routed by the host's pad handling. function Kit.gamepadpressed(button) + if VirtualKeyboard.active then + return VirtualKeyboard.gamepadpressed(button) + end + if FileBrowser.active then + return FileBrowser.gamepadpressed(button) + end if button == "dpup" then Kit.navigate("up") return true elseif button == "dpdown" then Kit.navigate("down") return true elseif button == "dpleft" then Kit.navigate("left") return true @@ -829,6 +847,24 @@ function Kit.textfield(id, x, y, w, h, value, placeholder) audit("control", x, y, w, h, id) local focusRing = Kit.focusable(id, x, y, w, h) value = tostring(value or "") + + if Kit._activateId == id and not mobile() then + VirtualKeyboard.open({ + text = value, + targetId = id, + title = placeholder or "Enter Text", + onDone = function(newText, confirmed) + if confirmed then + edits[#edits + 1] = "\r" + end + end + }) + end + + if VirtualKeyboard.active and VirtualKeyboard.targetId == id then + value = VirtualKeyboard.text + end + if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end local focused = (Kit.focus == id) if focused then diff --git a/src/ui/kit/VirtualKeyboard.lua b/src/ui/kit/VirtualKeyboard.lua new file mode 100755 index 00000000..669e3fa2 --- /dev/null +++ b/src/ui/kit/VirtualKeyboard.lua @@ -0,0 +1,265 @@ +-- Controller-driven on-screen keyboard (OSK) for handhelds and gamepads. +-- Allows typing URLs (mod index, repositories), mod search queries, and custom names +-- without needing a physical keyboard. + +local Theme = require("src.ui.kit.Theme") +local PAL = Theme.PAL +local Kit = nil +local function getKit() + if not Kit then Kit = require("src.ui.kit.Kit") end + return Kit +end + +local VirtualKeyboard = { + active = false, + text = "", + title = "Enter Text", + targetId = nil, + onDone = nil, + row = 1, + col = 1, + mode = 1, -- 1: lower, 2: upper, 3: symbols +} + +local ROWS_LOWER = { + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" }, + { "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "/", ":" }, + { "a", "s", "d", "f", "g", "h", "j", "k", "l", ".", "_", "@" }, + { "z", "x", "c", "v", "b", "n", "m", "?", "!", "&", "%", "#" }, + { { id = "shift", label = "Shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_UPPER = { + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" }, + { "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "/", ":" }, + { "A", "S", "D", "F", "G", "H", "J", "K", "L", ".", "_", "@" }, + { "Z", "X", "C", "V", "B", "N", "M", "?", "!", "&", "%", "#" }, + { { id = "shift", label = "shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +local ROWS_SYM = { + { "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+" }, + { "~", "`", "{", "}", "[", "]", "|", "\\", ";", ":", "'", "\"" }, + { "<", ">", ",", ".", "/", "?", "=", "-", "*", "/", "\\", "~" }, + { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", "=" }, + { { id = "shift", label = "ABC", w = 2 }, { id = "sym", label = "123", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } }, + { { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } }, +} + +function VirtualKeyboard.open(opts) + opts = opts or {} + VirtualKeyboard.active = true + VirtualKeyboard.text = tostring(opts.text or "") + VirtualKeyboard.title = opts.title or "Enter Text" + VirtualKeyboard.targetId = opts.targetId + VirtualKeyboard.onDone = opts.onDone + VirtualKeyboard.row = 1 + VirtualKeyboard.col = 1 + VirtualKeyboard.mode = 1 +end + +function VirtualKeyboard.close(confirmed) + if not VirtualKeyboard.active then return end + local txt = VirtualKeyboard.text + local cb = VirtualKeyboard.onDone + VirtualKeyboard.active = false + VirtualKeyboard.targetId = nil + VirtualKeyboard.onDone = nil + if cb then + cb(txt, confirmed == true) + end +end + +local function currentRows() + if VirtualKeyboard.mode == 2 then return ROWS_UPPER end + if VirtualKeyboard.mode == 3 then return ROWS_SYM end + return ROWS_LOWER +end + +local function triggerKey(key) + if type(key) == "string" then + VirtualKeyboard.text = VirtualKeyboard.text .. key + elseif type(key) == "table" then + if key.id == "shift" then + VirtualKeyboard.mode = (VirtualKeyboard.mode == 2) and 1 or 2 + elseif key.id == "sym" then + VirtualKeyboard.mode = (VirtualKeyboard.mode == 3) and 1 or 3 + elseif key.id == "space" then + VirtualKeyboard.text = VirtualKeyboard.text .. " " + elseif key.id == "back" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + elseif key.id == "clear" then + VirtualKeyboard.text = "" + elseif key.id == "done" then + VirtualKeyboard.close(true) + elseif key.id == "url_https" then + VirtualKeyboard.text = VirtualKeyboard.text .. "https://" + elseif key.id == "url_http" then + VirtualKeyboard.text = VirtualKeyboard.text .. "http://" + elseif key.id == "url_json" then + VirtualKeyboard.text = VirtualKeyboard.text .. ".json" + elseif key.id == "url_zip" then + VirtualKeyboard.text = VirtualKeyboard.text .. ".zip" + end + end +end + +function VirtualKeyboard.gamepadpressed(button) + if not VirtualKeyboard.active then return false end + + local rows = currentRows() + local r = VirtualKeyboard.row + local c = VirtualKeyboard.col + + if button == "dpup" then + VirtualKeyboard.row = math.max(1, r - 1) + local maxC = #rows[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "dpdown" then + VirtualKeyboard.row = math.min(#rows, r + 1) + local maxC = #rows[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "dpleft" then + VirtualKeyboard.col = math.max(1, c - 1) + return true + elseif button == "dpright" then + VirtualKeyboard.col = math.min(#rows[r], c + 1) + return true + elseif button == "a" then + local key = rows[r] and rows[r][c] + if key then triggerKey(key) end + return true + elseif button == "b" then + VirtualKeyboard.close(false) + return true + elseif button == "x" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + return true + elseif button == "y" then + VirtualKeyboard.text = VirtualKeyboard.text .. " " + return true + elseif button == "leftshoulder" or button == "rightshoulder" then + VirtualKeyboard.mode = (VirtualKeyboard.mode % 3) + 1 + local maxC = #currentRows()[VirtualKeyboard.row] + if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end + return true + elseif button == "start" then + VirtualKeyboard.close(true) + return true + end + + return true -- consume all input while keyboard modal is up +end + +function VirtualKeyboard.keypressed(key) + if not VirtualKeyboard.active then return false end + if key == "escape" then + VirtualKeyboard.close(false) + return true + elseif key == "return" then + VirtualKeyboard.close(true) + return true + elseif key == "backspace" then + VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2) + return true + end + return false +end + +function VirtualKeyboard.textinput(text) + if not VirtualKeyboard.active then return false end + VirtualKeyboard.text = VirtualKeyboard.text .. text + return true +end + +function VirtualKeyboard.draw(m) + if not VirtualKeyboard.active then return end + + local Kit = getKit() + local s = m and m.s or Kit.scale or 1 + local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640 + local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480 + + -- Scrim background + Theme.fill(0, 0, W, H, PAL.bg, 0.92) + Kit.blockClicks = true + + local pad = math.floor(12 * s) + local modalW = math.floor(math.min(540 * s, W - 20 * s)) + local modalH = math.floor(math.min(360 * s, H - 20 * s)) + local px = math.floor((W - modalW) / 2) + local py = math.floor((H - modalH) / 2) + + Kit.card(px, py, modalW, modalH, true) + + local cy = py + pad + -- Title & hint + Kit.text("button", VirtualKeyboard.title, px + pad, cy, PAL.heading) + Kit.textRight("micro", "L1/R1: Mode X: Bksp Y: Space Start: Done B: Cancel", + px + modalW - pad, cy + 2 * s, PAL.muted) + + cy = cy + Kit.textHeight("button") + math.floor(8 * s) + + -- Preview textfield box + local fieldH = math.floor(32 * s) + Theme.fillRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.bg, 1) + Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.lineStrong, 1, 2) + + local shown = Kit.ellipsizeLeft("mono", VirtualKeyboard.text .. "_", modalW - 4 * pad) + Kit.text("mono", shown, px + pad + math.floor(8 * s), cy + math.floor(7 * s), PAL.heading) + + cy = cy + fieldH + math.floor(10 * s) + + -- Keyboard Rows + local rows = currentRows() + local rowH = math.floor(30 * s) + local gap = math.floor(4 * s) + local availableW = modalW - 2 * pad + + for rIdx, rData in ipairs(rows) do + local totalUnits = 0 + for _, key in ipairs(rData) do + totalUnits = totalUnits + (type(key) == "table" and (key.w or 1) or 1) + end + + local unitW = (availableW - (gap * (#rData - 1))) / totalUnits + local kx = px + pad + + for cIdx, key in ipairs(rData) do + local units = type(key) == "table" and (key.w or 1) or 1 + local kw = math.floor(unitW * units) + local isSelected = (VirtualKeyboard.row == rIdx and VirtualKeyboard.col == cIdx) + + local label = type(key) == "table" and key.label or key + local isSpecial = type(key) == "table" + + -- Click / touch detection + if Kit.press(kx, cy, kw, rowH) then + VirtualKeyboard.row = rIdx + VirtualKeyboard.col = cIdx + triggerKey(key) + end + + if isSelected then + Theme.fillRounded(kx, cy, kw, rowH, PAL.ink, 1) + Kit.textCenterBold("small", label, kx, cy + math.floor(6 * s), kw, PAL.inverse) + else + Theme.fillRounded(kx, cy, kw, rowH, isSpecial and PAL.raised or PAL.surface, 1) + Theme.strokeRounded(kx, cy, kw, rowH, PAL.line, Theme.A.hairline, 1) + Kit.textCenter("small", label, kx, cy + math.floor(6 * s), kw, isSpecial and PAL.heading or PAL.text) + end + + kx = kx + kw + gap + end + + cy = cy + rowH + gap + end + + Kit.blockClicks = false +end + +return VirtualKeyboard