mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
feat(launcher): add in-launcher File Browser and Virtual Keyboard for handhelds
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
Executable
+366
@@ -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
@@ -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
|
||||
|
||||
Executable
+265
@@ -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
|
||||
Reference in New Issue
Block a user