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._modActions then buildModActionsModal(imp, m) return true end
if imp._findEntry then buildFindEntryModal(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 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 return false
end end
@@ -5787,6 +5795,10 @@ end
local function drawPadCursor(imp) local function drawPadCursor(imp)
if not imp._padCursorActive then return end 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 -- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
-- framebuffer when the stick advances by fractional pixels each frame. -- framebuffer when the stick advances by fractional pixels each frame.
local x, y = imp._padCursor.x, imp._padCursor.y local x, y = imp._padCursor.x, imp._padCursor.y
+70 -2
View File
@@ -2056,7 +2056,22 @@ function RomImporter:chooseMod()
return return
end end
local path = chooseZip() 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 end
local function requiredManifest(self, modId) local function requiredManifest(self, modId)
@@ -2480,7 +2495,22 @@ function RomImporter:chooseSaveImport(version)
return return
end end
local path = chooseSav() 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 end
-- "Export save" button: write the active slot back out to a raw .sav in the save -- "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) self:startPath(path)
return return
end 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 -- 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 -- 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 -- used on Android, which works when the game is launched as an unpacked
@@ -2998,6 +3040,14 @@ function RomImporter:_cycleTab(delta)
end end
function RomImporter:_updatePadCursor(dt) 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 if self.isNX then
self:_ensureNxPointerBridge() self:_ensureNxPointerBridge()
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling -- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
@@ -3074,6 +3124,15 @@ function RomImporter:_updatePadCursor(dt)
end end
function RomImporter:gamepadpressed(_, button) 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() self:_activatePadCursor()
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B. -- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
local action = GamepadMap.mapGamepadButton(button) local action = GamepadMap.mapGamepadButton(button)
@@ -4062,6 +4121,15 @@ function RomImporter:fileUrl(path)
end end
function RomImporter:keypressed(key) 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 self._profileSavePrompt then
if key == "backspace" then if key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "") 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 Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL local PAL = Theme.PAL
local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard")
local FileBrowser = require("src.ui.kit.FileBrowser")
local Kit = {} local Kit = {
Kit.Theme = Theme scale = 1,
Kit.PAL = PAL 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.mouseX, Kit.mouseY = 0, 0
Kit.mouseClicked = false -- left button pressed this frame Kit.mouseClicked = false -- left button pressed this frame
Kit.mouseDown = false -- held, polled (drag / press-and-hold) Kit.mouseDown = false -- held, polled (drag / press-and-hold)
Kit.wheelY = 0 -- wheel notches queued since the last frame 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.scale = 1
Kit.blockClicks = false Kit.blockClicks = false
Kit.audit = nil Kit.audit = nil
@@ -446,6 +449,9 @@ end
-- ------------------------------------------------------------ input plumbing -- ------------------------------------------------------------ input plumbing
function Kit.textinput(text) function Kit.textinput(text)
if VirtualKeyboard.active then
return VirtualKeyboard.textinput(text)
end
if not Kit.focus then return false end if not Kit.focus then return false end
edits[#edits + 1] = text edits[#edits + 1] = text
return true return true
@@ -454,6 +460,12 @@ end
-- Returns true when the key was consumed, so the host can leave its own -- 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. -- shortcuts alone while the user is typing or driving the ring.
function Kit.keypressed(key) 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 Kit.focus then
if key == "backspace" then edits[#edits + 1] = "\b" return true if key == "backspace" then edits[#edits + 1] = "\b" return true
elseif key == "return" or key == "kpenter" or key == "escape" then 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. -- Gamepad d-pad / stick, routed by the host's pad handling.
function Kit.gamepadpressed(button) 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 if button == "dpup" then Kit.navigate("up") return true
elseif button == "dpdown" then Kit.navigate("down") return true elseif button == "dpdown" then Kit.navigate("down") return true
elseif button == "dpleft" then Kit.navigate("left") 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) audit("control", x, y, w, h, id)
local focusRing = Kit.focusable(id, x, y, w, h) local focusRing = Kit.focusable(id, x, y, w, h)
value = tostring(value or "") 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 if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end
local focused = (Kit.focus == id) local focused = (Kit.focus == id)
if focused then 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