feat(launcher): add in-launcher File Browser and Virtual Keyboard for handhelds

This commit is contained in:
Shane McGovern
2026-08-26 11:53:01 +01:00
parent b78ab6fb50
commit 592daafa42
5 changed files with 756 additions and 9 deletions
+12
View File
@@ -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
+70 -2
View File
@@ -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 "")
+366
View File
@@ -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
+43 -7
View File
@@ -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
+265
View File
@@ -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