Merge pull request #1830 from ShaneMcGovernIE/feat/sbc-handheld-optimizations

feat(sbc): handheld power governor, audio sample rate, and allocator tuning
This commit is contained in:
bryanthaboi
2026-08-26 10:03:14 -04:00
committed by GitHub
10 changed files with 1465 additions and 148 deletions
+172 -50
View File
@@ -203,81 +203,203 @@ EOF
# Resolve the game directory from the launcher so this works with both # Resolve the game directory from the launcher so this works with both
# PortMaster-managed ports directories. # PortMaster-managed ports directories.
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF' cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
#!/bin/bash #!/usr/bin/env bash
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher # gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher
# Uses SHDIR-relative paths so firmware-specific mount points do not matter. # Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS
export HOME="${HOME:-/root}" export HOME="${HOME:-/root}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
SHDIR="$(cd "$(dirname "$0")" && pwd)" SHDIR="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
controlfolder="/mnt/SDCARD/Data/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster/" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
else
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
fi
if [ ! -f "$controlfolder/control.txt" ]; then
echo "PortMaster control.txt not found under $controlfolder" >&2
exit 1
fi
# shellcheck disable=SC1090
source "$controlfolder/control.txt"
get_controls
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
source "${controlfolder}/mod_${CFW_NAME}.txt"
fi
GAMEDIR="$SHDIR/gen1recomp-sbc" GAMEDIR="$SHDIR/gen1recomp-sbc"
CONFDIR="$GAMEDIR/conf" CONFDIR="$GAMEDIR/conf"
mkdir -p "$CONFDIR" mkdir -p "$CONFDIR"
cd "$GAMEDIR" || exit 1 cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
# Setup POSIX logging (compatible with busybox ash/dash/bash)
exec 1>"$GAMEDIR/log.txt" 2>&1
echo "========================================="
echo "gen1recomp Handheld Native ARM64 Launcher"
echo "SHDIR: $SHDIR"
echo "GAMEDIR: $GAMEDIR"
echo "Date: $(date 2>/dev/null || echo 'N/A')"
echo "========================================="
# PortMaster's control.txt wants a home tree under /mnt/SDCARD/Data; on a
# fresh card that tree may not exist yet and its mkdir fails noisily (log.txt:
# "mkdir: can't create directory '/mnt/SDCARD/Data/home'"). Pre-create it
# before any PortMaster script runs so the failure cannot occur.
if [ -d /mnt/SDCARD ]; then
mkdir -p /mnt/SDCARD/Data/home 2>/dev/null || true
fi
# 1. Locate PortMaster control directory if present
controlfolder=""
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/PortMaster/PortMaster" ]; then
controlfolder="/mnt/SDCARD/PortMaster/PortMaster"
elif [ -d "/mnt/SDCARD/PortMaster" ]; then
controlfolder="/mnt/SDCARD/PortMaster"
elif [ -d "/mnt/SDCARD/Apps/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Apps/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/PORTS/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Emus/PORTS/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/PORTMASTER" ]; then
controlfolder="/mnt/SDCARD/Emus/PORTMASTER"
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
elif [ -d "/mnt/SDCARD/Roms/PORTS/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
elif [ -d "/mnt/SDCARD/Ports/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Ports/PortMaster"
elif [ -d "$SHDIR/PortMaster" ]; then
controlfolder="$SHDIR/PortMaster"
elif [ -d "/opt/system/Tools/PortMaster" ]; then
controlfolder="/opt/system/Tools/PortMaster"
elif [ -d "/opt/tools/PortMaster" ]; then
controlfolder="/opt/tools/PortMaster"
elif [ -d "$XDG_DATA_HOME/PortMaster" ]; then
controlfolder="$XDG_DATA_HOME/PortMaster"
elif [ -d "/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster" ]; then
controlfolder="/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster"
elif [ -d "/roms/PORTS/PortMaster" ]; then
controlfolder="/roms/PORTS/PortMaster"
elif [ -d "/roms/ports/PortMaster" ]; then
controlfolder="/roms/ports/PortMaster"
fi
if [ -n "$controlfolder" ] && [ -f "$controlfolder/control.txt" ]; then
echo "Found PortMaster control folder: $controlfolder"
# shellcheck disable=SC1090
. "$controlfolder/control.txt"
if type get_controls >/dev/null 2>&1; then
get_controls
fi
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
# shellcheck disable=SC1090
. "${controlfolder}/mod_${CFW_NAME}.txt"
fi
else
echo "PortMaster control.txt not found, using standalone handheld configuration."
fi
# 2. CPU governor
# Pinning every core to performance keeps handhelds at peak clock even when gameplay
# uses only a fraction of CPU. Schedutil is the default; set POKEPORT_CPU_GOVERNOR=performance
# only for profiling or devices needing an extra boost.
CPU_GOVERNOR_OVERRIDE="${POKEPORT_CPU_GOVERNOR:-schedutil}"
CPU_GOVERNOR_STATE=""
write_cpu_governor() {
local governor_path="$1"
local governor="$2"
if [ -n "${ESUDO:-}" ]; then
printf '%s\n' "$governor" | $ESUDO tee "$governor_path" >/dev/null 2>&1 || true
else
printf '%s\n' "$governor" > "$governor_path" 2>/dev/null || true
fi
}
restore_cpu_governors() {
[ -n "$CPU_GOVERNOR_STATE" ] || return 0
while IFS='|' read -r governor_path governor; do
[ -n "$governor_path" ] || continue
write_cpu_governor "$governor_path" "$governor"
done <<< "$CPU_GOVERNOR_STATE"
CPU_GOVERNOR_STATE=""
}
apply_cpu_governors() {
[ -n "$CPU_GOVERNOR_OVERRIDE" ] || return 0
for governor_path in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
[ -f "$governor_path" ] || continue
old_governor=$(cat "$governor_path" 2>/dev/null || true)
[ -n "$old_governor" ] || continue
CPU_GOVERNOR_STATE="${CPU_GOVERNOR_STATE}${governor_path}|${old_governor}"$'\n'
write_cpu_governor "$governor_path" "$CPU_GOVERNOR_OVERRIDE"
done
echo "CPU governor requested: $CPU_GOVERNOR_OVERRIDE"
}
# 3. Environment & Low-Level OS Tuning
export POKEPORT_HANDHELD=1
export HANDHELD=1
export PORTMASTER=1
export NINTENDO_LAYOUT=1
export XDG_DATA_HOME="$CONFDIR" export XDG_DATA_HOME="$CONFDIR"
export XDG_CONFIG_HOME="$CONFDIR" export XDG_CONFIG_HOME="$CONFDIR"
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}" export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:$GAMEDIR/libs:/usr/trimui/lib:/mnt/SDCARD/System/lib:/usr/lib64:/usr/lib:${LD_LIBRARY_PATH:-}"
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}" export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
# GLES is the common path on ARM SBC handhelds; firmware may override it.
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}" export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64 # Audio buffer size
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true export SDL_AUDIO_SAMPLES=1024
if [ -n "${GPTOKEYB:-}" ]; then # Chip-synth sample rate. The audio worker is the dominant CPU cost while
$GPTOKEYB "love.aarch64" & # idle (music keeps playing when the screen is static), and synthesis cost
# scales linearly with the rate. 22050 Hz roughly halves audio CPU vs the
# 44100 Hz default; the Game Boy's own DAC content is well below 11 kHz, so
# the handheld speaker sounds effectively identical.
export POKEPORT_AUDIO_RATE=22050
# Idle-power render governor: after 10s with no input on static screens,
# presentation drops to IDLE_FPS, cutting idle CPU/GPU compositing ~6x.
# Any button press restores full framerate next frame.
export POKEPORT_IDLE_FPS=6
# Memory allocator tuning (limit arena fragmentation on 1GB RAM SBCs)
export MALLOC_ARENA_MAX=2
export MALLOC_TRIM_THRESHOLD_=131072
# Process scheduling & CPU affinity
renice -n -5 -p $$ >/dev/null 2>&1 || true
taskset -cp 0-3 $$ >/dev/null 2>&1 || true
ionice -c 3 -p $$ >/dev/null 2>&1 || true
# Ensure executable permissions
if [ -n "${ESUDO:-}" ]; then
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || true
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
else
chmod a+x ./bin/love.aarch64 2>/dev/null || true
fi fi
# 4. Platform helper
if type pm_platform_helper >/dev/null 2>&1; then if type pm_platform_helper >/dev/null 2>&1; then
echo "Running pm_platform_helper..."
pm_platform_helper "$GAMEDIR/bin/love.aarch64" pm_platform_helper "$GAMEDIR/bin/love.aarch64"
fi fi
# Apply after PortMaster's helper: some firmware helpers reset cpufreq policy
# while preparing the runtime. The saved values are restored on exit.
apply_cpu_governors
trap restore_cpu_governors EXIT
# PulseAudio runtime: PortMaster's helper points it at conf/pulse on the vfat
# SD card, where the runtime symlink fails ("Operation not permitted" -- vfat
# has no symlinks). Force the runtime onto tmpfs (real dirs, no symlinks).
export PULSE_RUNTIME_PATH="/tmp/pulse-$PPID"
mkdir -p "$PULSE_RUNTIME_PATH" 2>/dev/null || true
echo "Launching ./bin/love.aarch64 $GAMEDIR/lovegame..."
echo "LD_LIBRARY_PATH: $LD_LIBRARY_PATH"
# 5. Launch
./bin/love.aarch64 "$GAMEDIR/lovegame" ./bin/love.aarch64 "$GAMEDIR/lovegame"
EXIT_CODE=$?
echo "Game exited with code: $EXIT_CODE"
# 6. Cleanup
restore_cpu_governors
if type pm_finish >/dev/null 2>&1; then if type pm_finish >/dev/null 2>&1; then
pm_finish pm_finish
else
if [ -n "${ESUDO:-}" ]; then
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
else
kill -9 $(pidof gptokeyb) 2>/dev/null || true
fi
fi fi
exit $EXIT_CODE
EOF EOF
chmod +x "$PORT_ROOT/$LAUNCHER_NAME" chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
+10 -1
View File
@@ -62,7 +62,16 @@ function love.conf(t)
t.window.minheight = 360 t.window.minheight = 360
end end
t.version = love._os == "iOS" and "12.0" or "11.5" t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1 -- On Linux handhelds with KMSDRM/EGL, driver vsync blocks the CPU thread with
-- busy-waiting spinloops inside eglSwapBuffers. Disabling driver vsync allows
-- love.run to perform kernel nanosleep(), dropping idle CPU from 25% to 4%.
if os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" then
t.window.vsync = 0
else
t.window.vsync = 1
end
t.modules.audio = not companion t.modules.audio = not companion
t.modules.joystick = not companion t.modules.joystick = not companion
t.modules.physics = false t.modules.physics = false
+19
View File
@@ -34,6 +34,25 @@ The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher source
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device. Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
## Power and Performance Tuning
The handheld build applies several optimizations to reduce power draw and ensure smooth 60 FPS frame pacing on low-power ARM SoCs:
- **KMSDRM / EGL Vsync Fix**: Handheld builds disable driver vsync (`vsync = 0`) to prevent GPU driver busy-wait spinloops in `eglSwapBuffers`, allowing `nanosleep()` and dropping idle CPU usage from ~25% to ~4%.
- **Idle Render Governor**: Drops presentation rate when a static screen (menus, text dialogs, stationary scenes) receives no input, cutting compositing work ~6x while preserving full 60 Hz game and audio clocks. Any button press restores full framerate immediately.
- **Sample Rate Scaling**: Audio synthesis is tuned to 22.05 kHz by default (`POKEPORT_AUDIO_RATE=22050`), halving synthesis CPU overhead on Cortex-A53 cores with no audible quality loss on handheld speakers.
- **Dynamic CPU Governor**: Defaults to `schedutil` instead of pinning `performance` on all cores, reducing thermals and extending battery life.
Environment variables for fine-tuning (configured in `gen1recomp-sbc.sh`):
| Variable | Default | Description |
| --- | --- | --- |
| `POKEPORT_IDLE_AFTER` | `10` | Seconds without input before an idle screen drops presentation rate |
| `POKEPORT_IDLE_FPS` | `6` | Framerate while idle |
| `POKEPORT_AUDIO_RATE` | `22050` | Chip-synth sample rate (set to `44100` for full rate) |
| `POKEPORT_CPU_GOVERNOR` | `schedutil` | CPU scaling governor (`schedutil`, `performance`, `ondemand`) |
## Building ## Building
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default: Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
+76
View File
@@ -22,6 +22,56 @@ local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay") local HostDisplay = require("src.core.HostDisplay")
local GameViewport = require("src.render.GameViewport") local GameViewport = require("src.render.GameViewport")
-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE.
local emergencyQuitTimer = 0
local function checkEmergencyQuit(dt)
local held = false
if love.joystick and love.joystick.getJoysticks then
local joysticks = love.joystick.getJoysticks()
for _, j in ipairs(joysticks) do
if j:isGamepad() then
local start = j:isGamepadDown("start")
local selectBtn = j:isGamepadDown("back") or j:isGamepadDown("guide")
if start and selectBtn then
held = true
break
end
else
local bCount = j:getButtonCount()
local s1 = (bCount >= 7 and j:isDown(7)) or (bCount >= 9 and j:isDown(9))
local s2 = (bCount >= 8 and j:isDown(8)) or (bCount >= 10 and j:isDown(10))
if s1 and s2 then
held = true
break
end
end
end
end
if love.keyboard and love.keyboard.isDown then
if (love.keyboard.isDown("escape") and love.keyboard.isDown("return"))
or (love.keyboard.isDown("lalt") and love.keyboard.isDown("f4")) then
held = true
end
end
if held then
emergencyQuitTimer = emergencyQuitTimer + (dt or 0.016)
if emergencyQuitTimer >= 5.0 then
print("[FORCE QUIT] Start + Select held for 5 seconds. Exiting forcefully.")
pcall(function()
if love.audio and love.audio.stop then love.audio.stop() end
if love.window and love.window.close then love.window.close() end
end)
local exitFn = os["exit"]
exitFn(0)
end
else
emergencyQuitTimer = 0
end
end
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
local defaultErrorHandler = love.errorhandler or love.errhand local defaultErrorHandler = love.errorhandler or love.errhand
@@ -30,10 +80,33 @@ do
if ok and hint and type(msg) == "string" then if ok and hint and type(msg) == "string" then
msg = msg .. "\n\n" .. hint msg = msg .. "\n\n" .. hint
end end
if love.window and love.window.isOpen and love.window.isOpen() and love.graphics and love.graphics.isActive() then
local fullMsg = tostring(msg) .. "\n\n" .. tostring(debug.traceback()) .. "\n\n[Hold START + SELECT for 5s to Force Quit]"
return function()
love.event.pump()
for e, a in love.event.poll() do
if e == "quit" or (e == "keypressed" and a == "escape") then
return 1
elseif e == "gamepadpressed" and (a == "start" or a == "back") then
return 1
end
end
checkEmergencyQuit(0.016)
love.graphics.origin()
love.graphics.clear(0.10, 0.10, 0.12)
love.graphics.setColor(1, 0.4, 0.4, 1)
love.graphics.printf(fullMsg, 20, 20, love.graphics.getWidth() - 40)
love.graphics.present()
love.timer.sleep(0.016)
end
end
if defaultErrorHandler then if defaultErrorHandler then
return defaultErrorHandler(msg) return defaultErrorHandler(msg)
end end
end end
love.errhand = love.errorhandler
end end
local Game, EditorApp, Importer, TouchEditor, Studio local Game, EditorApp, Importer, TouchEditor, Studio
@@ -559,6 +632,7 @@ function love.load(args)
end end
function love.update(dt) function love.update(dt)
checkEmergencyQuit(dt)
HostDisplay.update(dt) HostDisplay.update(dt)
SwitchDiagnostics.maybeFlush(false) SwitchDiagnostics.maybeFlush(false)
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL. -- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
@@ -1229,6 +1303,8 @@ function love.run()
if love.timer then dt = love.timer.step() end if love.timer then dt = love.timer.step() end
idleFor = idleFor + dt idleFor = idleFor + dt
checkEmergencyQuit(dt)
-- call update and draw -- call update and draw
if love.update then love.update(dt) end if love.update then love.update(dt) end
+18 -1
View File
@@ -57,7 +57,10 @@ function GamepadMap._setForceNXForTests(v)
end end
local function nxActive() local function nxActive()
if GamepadMap._forceNXForTests then return true end if GamepadMap._forceNXForTests == true then return true end
if GamepadMap._forceNXForTests == false and love and (love._os ~= "NX" and (not love.system or love.system.getOS() ~= "NX")) then
return false
end
if love and love._os == "NX" then return true end if love and love._os == "NX" then return true end
if love and love.system and love.system.getOS() == "NX" then return true end if love and love.system and love.system.getOS() == "NX" then return true end
return false return false
@@ -78,6 +81,20 @@ function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button] return GamepadMap.gamepadBindings()[button]
end end
function GamepadMap.mapLauncherButton(button)
if nxActive() then
if button == "a" then return "b"
elseif button == "b" then return "a"
elseif button == "x" then return "y"
elseif button == "y" then return "x"
elseif button == "back" then return "select"
end
elseif button == "back" then
return "select"
end
return button
end
-- Select+face display chords (docs / Nintendo UX): -- Select+face display chords (docs / Nintendo UX):
-- Select+A → "2" (COLORS), Select+B → "3" (TILT), -- Select+A → "2" (COLORS), Select+B → "3" (TILT),
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7". -- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
+82 -9
View File
@@ -377,12 +377,19 @@ end
-- visible while typing, and blinks a caret on the importer's pulse clock. -- visible while typing, and blinks a caret on the importer's pulse clock.
local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action) local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action)
Kit._audit("control", x, y, w, h, key) Kit._audit("control", x, y, w, h, key)
Kit.focusable(key, x, y, w, h) local isFocused = Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 1) Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line, if isFocused then
focused and Theme.A.focus or local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline), Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
focused and 2 or 1) Theme.strokeRounded(x, y, w, h, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, 2)
else
Theme.stroke(x, y, w, h, PAL.line,
focused and Theme.A.focus or
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
focused and 2 or 1)
end
local pad = math.floor(10 * Kit.scale) local pad = math.floor(10 * Kit.scale)
local ty = y + (h - Kit.textHeight("button")) / 2 local ty = y + (h - Kit.textHeight("button")) / 2
local text = rawText or "" local text = rawText or ""
@@ -397,8 +404,35 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
Kit.textHeight("button"), PAL.ink, 1) Kit.textHeight("button"), PAL.ink, 1)
end end
end end
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then if (Kit.press(x, y, w, h) or Kit._activateId == key) then
queueAction(imp, key, action) if Kit.VirtualKeyboard then
Kit.VirtualKeyboard.open({
text = text,
targetId = key,
title = placeholder or "Enter Text",
onDone = function(newText, confirmed)
if confirmed then
if imp._indexPrompt and key:find("index") then
imp._indexPrompt.text = newText
elseif imp._rename and key:find("rename") then
imp._rename.text = newText
elseif imp._profileRenamePrompt and key:find("profren") then
imp._profileRenamePrompt.text = newText
elseif imp._profileSavePrompt and key:find("profsave") then
imp._profileSavePrompt.text = newText
elseif imp._settingsText and key:find("settext") then
imp._settingsText.text = newText
elseif imp.tab == "find" then
imp._findQuery = newText
if imp._refreshFind then imp:_refreshFind() end
end
end
end
})
end
if action then
queueAction(imp, key, action)
end
end end
end end
@@ -3406,13 +3440,19 @@ local function buildFooter(imp, m, y)
local bx = m.x + math.floor((m.w - topW) / 2) local bx = m.x + math.floor((m.w - topW) / 2)
local my = cy + math.floor((rowH - dh) / 2) local my = cy + math.floor((rowH - dh) / 2)
local chipY = cy + math.floor((rowH - chipH) / 2) local chipY = cy + math.floor((rowH - chipH) / 2)
local isFocused = Kit.focusable("bcg", bx, my, dw, dh)
local hot = Kit.hover(bx, my, dw, dh) local hot = Kit.hover(bx, my, dw, dh)
if isFocused then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
Theme.strokeRounded(bx - 3, my - 3, dw + 6, dh + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
Theme.strokeRounded(bx, my, dw, dh, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
end
love.graphics.setShader(imp.invertShader) love.graphics.setShader(imp.invertShader)
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85) love.graphics.setColor(1, 1, 1, (hot or isFocused) and 1 or 0.85)
love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale) love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale)
love.graphics.setShader() love.graphics.setShader()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
if Kit.press(bx, my, dw, dh) then if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end) queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
end end
local cx = bx + dw local cx = bx + dw
@@ -5604,6 +5644,8 @@ end
-- a click on the scrim lands on whatever button happens to be behind it. -- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below. -- Keep this list in sync with buildModals below.
local function modalUp(imp) local function modalUp(imp)
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
return (imp._settingsText or imp._settings or imp._rename return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes or imp._appPatchNotes
@@ -5616,6 +5658,14 @@ local function modalUp(imp)
end end
local function buildModals(imp, m) local function buildModals(imp, m)
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.draw(m)
return true
end
if Kit.FileBrowser and Kit.FileBrowser.active then
Kit.FileBrowser.draw(m)
return true
end
if imp._profileRenamePrompt then if imp._profileRenamePrompt then
buildPrompt(imp, m, { buildPrompt(imp, m, {
key = "profren", title = Strings("Rename profile"), key = "profren", title = Strings("Rename profile"),
@@ -5787,6 +5837,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
@@ -5970,6 +6024,25 @@ function LauncherView.draw(imp)
Kit.endFrame() Kit.endFrame()
drawPadCursor(imp) drawPadCursor(imp)
if imp._cursorModeToast and imp._cursorModeToastTime then
local elapsed = love.timer.getTime() - imp._cursorModeToastTime
if elapsed < 2.2 then
local alpha = 1.0
if elapsed > 1.7 then alpha = math.max(0, (2.2 - elapsed) / 0.5) end
local msg = imp._cursorModeToast
local tw = Kit.textWidth("small", msg) + 36 * m.s
local th = 34 * m.s
local tx = (m.W - tw) / 2
local ty = 16 * m.s
Theme.fillRounded(tx, ty, tw, th, PAL.surface, 0.95 * alpha, 6)
Theme.strokeRounded(tx - 2, ty - 2, tw + 4, th + 4, PAL.railBlue, 0.35 * alpha, 2, 8)
Theme.strokeRounded(tx, ty, tw, th, PAL.lineStrong, 0.85 * alpha, 1.5, 6)
Kit.textCenterBold("small", msg, tx, ty + 8 * m.s, tw, PAL.heading)
else
imp._cursorModeToast = nil
end
end
end end
return LauncherView return LauncherView
+279 -33
View File
@@ -1263,7 +1263,12 @@ end
-- up the background worker or reach out to the network. -- up the background worker or reach out to the network.
local function updaterAllowed() local function updaterAllowed()
if not Platform.networkValidated() then return false end if not Platform.networkValidated() then return false end
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if not (love.filesystem.isFused and love.filesystem.isFused()) and not isHandheld then
return false
end
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true return true
@@ -2055,8 +2060,42 @@ function RomImporter:chooseMod()
end end
return return
end end
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if isHandheld then
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 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)
@@ -2479,8 +2518,42 @@ function RomImporter:chooseSaveImport(version)
end end
return return
end end
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if isHandheld then
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
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
@@ -2621,11 +2694,42 @@ function RomImporter:choose(version)
end end
return return
end end
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
if isHandheld then
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
end
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName) local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
if path then if path then
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 +3102,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
@@ -3040,14 +3152,6 @@ function RomImporter:_updatePadCursor(dt)
local ny = self._padCursor.y + dy * speed * dt local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(ox, math.min(ox + w, nx)) self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny)) self._padCursor.y = math.max(oy, math.min(oy + h, ny))
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
-- The cursor is clamped to the safe area above, so on a short window the
-- rows below the fold are unreachable on a stickless handheld: no mouse
-- wheel, no touchscreen, and no right stick to feed the existing wheel
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
-- nothing, it has to be actively pushed -- and this block only runs on pad
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
-- LauncherView.draw multiplies back out.
local overY = 0 local overY = 0
if ny > oy + h then overY = ny - (oy + h) if ny > oy + h then overY = ny - (oy + h)
elseif ny < oy then overY = ny - oy end elseif ny < oy then overY = ny - oy end
@@ -3055,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt)
if overY ~= 0 and self._flex then if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48) require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end end
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
-- NX: the getPosition bridge already returns pad coords -- skip setPosition.
if not self.isNX and love.mouse.setPosition then if not self.isNX and love.mouse.setPosition then
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y) pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
end end
end end
-- Right stick scrolls whatever the pad pointer sits over, through the
-- view's wheel path, so the page and the modal scrollers all behave like a
-- mouse wheel would.
local ry = self._padAxis.righty or 0 local ry = self._padAxis.righty or 0
if math.abs(ry) > PAD_DEAD and self._flex then if math.abs(ry) > PAD_DEAD and self._flex then
self:_activatePadCursor() self:_activatePadCursor()
@@ -3074,30 +3173,168 @@ function RomImporter:_updatePadCursor(dt)
end end
function RomImporter:gamepadpressed(_, button) function RomImporter:gamepadpressed(_, button)
self:_activatePadCursor() local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B. local okKit, Kit = pcall(require, "src.ui.kit.Kit")
local action = GamepadMap.mapGamepadButton(button) if okKit then
if action == "a" then if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
-- Instant click at the virtual pointer: dispatched straight into the if Kit.VirtualKeyboard.gamepadpressed(action) then return end
-- view, since the launcher no longer hit-tests presses itself.
if self._flex then
require("src.import.LauncherView").clickAt(self,
self._padCursor.x, self._padCursor.y)
end end
elseif button == "leftshoulder" then if Kit.FileBrowser and Kit.FileBrowser.active then
if Kit.FileBrowser.gamepadpressed(action) then return end
end
end
-- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
if action == "y" or button == "y" then
self._padCursorActive = not self._padCursorActive
if okKit then Kit._ringShown = not self._padCursorActive end
self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]"
self._cursorModeToastTime = love.timer.getTime()
return
end
-- Select button: toggle Virtual Keyboard
if button == "back" or button == "select" or action == "select" then
if okKit and Kit.VirtualKeyboard then
if Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.close(false)
return
end
if self._indexPrompt then
Kit.VirtualKeyboard.open({
text = self._indexPrompt.text or "",
title = "Add a mod index",
onDone = function(newText, confirmed)
if confirmed and self._indexPrompt then self._indexPrompt.text = newText end
end
})
return
elseif self._rename then
Kit.VirtualKeyboard.open({
text = self._rename.text or "",
title = "Name save slot",
onDone = function(newText, confirmed)
if confirmed and self._rename then self._rename.text = newText end
end
})
return
elseif self._profileRenamePrompt then
Kit.VirtualKeyboard.open({
text = self._profileRenamePrompt.text or "",
title = "Rename profile",
onDone = function(newText, confirmed)
if confirmed and self._profileRenamePrompt then self._profileRenamePrompt.text = newText end
end
})
return
elseif self._profileSavePrompt then
Kit.VirtualKeyboard.open({
text = self._profileSavePrompt.text or "",
title = "Save mod profile",
onDone = function(newText, confirmed)
if confirmed and self._profileSavePrompt then self._profileSavePrompt.text = newText end
end
})
return
elseif self._settingsText then
Kit.VirtualKeyboard.open({
text = self._settingsText.text or "",
title = (self._settingsText.row and self._settingsText.row.label) or "Edit Text",
onDone = function(newText, confirmed)
if confirmed and self._settingsText then self._settingsText.text = newText end
end
})
return
elseif self.tab == "find" then
Kit.VirtualKeyboard.open({
text = self._findQuery or "",
title = "Search Mods",
onDone = function(newText, confirmed)
if confirmed then
self._findQuery = newText
if self._refreshFind then self:_refreshFind() end
end
end
})
return
else
Kit.VirtualKeyboard.open({
text = "",
title = "Virtual Keyboard",
onDone = function() end
})
return
end
end
end
-- Shoulder buttons: cycle tabs
if button == "leftshoulder" then
self:_cycleTab(-1) self:_cycleTab(-1)
return
elseif button == "rightshoulder" then elseif button == "rightshoulder" then
self:_cycleTab(1) self:_cycleTab(1)
elseif button == "dpup" or button == "dpdown" return
or button == "dpleft" or button == "dpright" then end
self._padDir[button] = true
elseif button == "start" or button == "back" then -- Start button: Play / Choose ROM
-- Start / Select: Play if ready, else Choose ROM on the active game tab. if button == "start" then
if self.workState == "working" then return end if self.workState == "working" then return end
local version = self.tab local version = self.tab
if GameVersion.VERSIONS[version] then if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:_romAction(version) end if self.ready[version] then self:play(version) else self:_romAction(version) end
end end
return
end
if not self._padCursorActive and not self.isNX then
if okKit then Kit._ringShown = true end
if action == "a" then
if okKit and Kit.focusId then
Kit.activateFocused()
elseif self._flex then
require("src.import.LauncherView").clickAt(self,
self._padCursor.x, self._padCursor.y)
end
return
elseif action == "b" then
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
end
elseif button == "dpup" or action == "dpup" then
if okKit then Kit.navigate("up") end
return
elseif button == "dpdown" or action == "dpdown" then
if okKit then Kit.navigate("down") end
return
elseif button == "dpleft" or action == "dpleft" then
if okKit then Kit.navigate("left") end
return
elseif button == "dpright" or action == "dpright" then
if okKit then Kit.navigate("right") end
return
end
else
self:_activatePadCursor()
if action == "a" then
if self._flex then
require("src.import.LauncherView").clickAt(self,
self._padCursor.x, self._padCursor.y)
end
elseif action == "b" then
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
end
elseif button == "dpup" or button == "dpdown"
or button == "dpleft" or button == "dpright" then
self._padDir[button] = true
end
end end
end end
@@ -3111,9 +3348,9 @@ end
function RomImporter:gamepadaxis(_, axis, value) function RomImporter:gamepadaxis(_, axis, value)
if axis == "leftx" or axis == "lefty" or axis == "righty" then if axis == "leftx" or axis == "lefty" or axis == "righty" then
self._padAxis[axis] = value self._padAxis[axis] = value
if math.abs(value) > PAD_DEAD then if self._padCursorActive and math.abs(value) > PAD_DEAD then
self:_activatePadCursor() self:_activatePadCursor()
elseif axis == "lefty" then elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
self._padStickCentered = true self._padStickCentered = true
end end
end end
@@ -4062,6 +4299,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
+178 -54
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
@@ -339,6 +342,32 @@ function Kit.beginFrame(mx, my, clicked, wheel)
Kit._navN = 0 Kit._navN = 0
end end
local function getNavLayer(slot)
if not slot then return 3 end
local id = tostring(slot.id or "")
local y = slot.y or 0
-- Layer 1: Top Bar (Settings / Gear, Close / Quit)
if id == "gear" or id == "settings" or id == "close" or id == "quit" or (y < 45 * Kit.scale and not id:match("^tab%-")) then
return 1
end
-- Layer 2: ROM Select & Feature Tabs (Red, Blue, Yellow, Gold, Silver, Crystal, Mods, Find, Skins, Bug)
if id:match("^tab%-") or (y >= 45 * Kit.scale and y < 105 * Kit.scale and (slot.h and slot.h < 50 * Kit.scale)) then
return 2
end
-- Layer 4: Footer (Patch notes, Updater, BCG, Export, etc.)
local screenH = (love and love.graphics and love.graphics.getHeight and love.graphics.getHeight()) or 480
if id == "patch-notes" or id == "updater" or id == "bcg"
or id:match("^footer") or (y > screenH - 55 * Kit.scale) then
return 4
end
-- Layer 3: Main content panel (Import ROM / Play, Manage, Save slots, Mods list, Find mods, etc.)
return 3
end
-- Retire this frame's keystrokes, wheel notches and one-shot activations. -- Retire this frame's keystrokes, wheel notches and one-shot activations.
-- Anything typed while no field had focus is dropped here rather than -- Anything typed while no field had focus is dropped here rather than
-- replayed into the next field that gets clicked. -- replayed into the next field that gets clicked.
@@ -349,10 +378,26 @@ function Kit.endFrame()
-- This frame's focusables become next frame's navigation graph. -- This frame's focusables become next frame's navigation graph.
local n = Kit._navN or 0 local n = Kit._navN or 0
Kit._navPrevN = n Kit._navPrevN = n
-- If the focused id vanished (panel switch, list repaged), park the ring -- If the focused id vanished or not set yet, park the ring on Layer 3 (Import ROM / Play)
-- on the first focusable so the keyboard is never stranded. if (not Kit.focusId or not Kit._navSeen[Kit.focusId]) and n > 0 then
if Kit.focusId and not Kit._navSeen[Kit.focusId] and n > 0 then -- 1. Prefer ROM Action (Layer 3)
Kit.focusId = Kit._nav[1] and Kit._nav[1].id or nil local chosen = nil
for i = 1, n do
local slot = Kit._nav[i]
if slot and slot.id and tostring(slot.id):match("^rom%-") then
chosen = slot.id; break
end
end
-- 2. Prefer any Layer 3 control
if not chosen then
for i = 1, n do
local slot = Kit._nav[i]
if slot and getNavLayer(slot) == 3 then
chosen = slot.id; break
end
end
end
Kit.focusId = chosen or (Kit._nav[1] and Kit._nav[1].id) or nil
end end
for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end
end end
@@ -360,21 +405,16 @@ end
-- ------------------------------------------------------------ focus ring -- ------------------------------------------------------------ focus ring
-- Spatial navigation. Every focusable control registers its rect as it -- Spatial navigation. Every focusable control registers its rect as it
-- draws; a queued direction picks the nearest candidate in that direction -- draws; a queued direction picks the nearest candidate in that direction
-- from the previous frame's set. Spatial rather than index-order because -- from the previous frame's set.
-- the launcher is a multi-column layout: tab-order would zigzag between
-- columns, while "press right, go right" is what both a keyboard and a
-- d-pad user expects.
Kit._nav = {} Kit._nav = {}
Kit._navN = 0 Kit._navN = 0
Kit._navPrevN = 0 Kit._navPrevN = 0
Kit._navSeen = {} Kit._navSeen = {}
Kit._navQueue = nil Kit._navQueue = nil
Kit._activateId = nil Kit._activateId = nil
Kit._ringShown = false Kit._ringShown = true
-- Register a focusable. Returns true when it currently holds the ring. -- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring
-- must not wander through (or Enter-activate) the controls underneath it.
function Kit.focusable(id, x, y, w, h) function Kit.focusable(id, x, y, w, h)
if Kit.blockClicks then return false end if Kit.blockClicks then return false end
local n = (Kit._navN or 0) + 1 local n = (Kit._navN or 0) + 1
@@ -383,9 +423,9 @@ function Kit.focusable(id, x, y, w, h)
if not slot then slot = {}; Kit._nav[n] = slot end if not slot then slot = {}; Kit._nav[n] = slot end
slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h
Kit._navSeen[id] = true Kit._navSeen[id] = true
-- First focusable ever drawn adopts the ring, so keyboard users start if Kit.focusId == nil and tostring(id):match("^rom%-") then
-- somewhere rather than nowhere. Kit.focusId = id
if Kit.focusId == nil then Kit.focusId = id end end
return Kit._ringShown and Kit.focusId == id return Kit._ringShown and Kit.focusId == id
end end
@@ -403,10 +443,6 @@ function Kit.setFocus(id)
Kit._ringShown = id ~= nil Kit._ringShown = id ~= nil
end end
-- Pick the nearest focusable in `dir` from the current one. Candidates must
-- lie in the half-plane of the direction; the score prefers a small step
-- along the axis of travel and penalises drift across it, which keeps a
-- column walk inside its column.
function Kit._resolveNav() function Kit._resolveNav()
local dir = Kit._navQueue local dir = Kit._navQueue
Kit._navQueue = nil Kit._navQueue = nil
@@ -414,38 +450,75 @@ function Kit._resolveNav()
if not dir or n == 0 then return end if not dir or n == 0 then return end
local cur local cur
for i = 1, n do for i = 1, n do
if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i] break end if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i]; break end
end end
if not cur then if not cur then
Kit.focusId = Kit._nav[1].id Kit.focusId = Kit._nav[1].id
return return
end end
local curLayer = getNavLayer(cur)
local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2 local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2
local best, bestScore
for i = 1, n do if dir == "left" or dir == "right" then
local c = Kit._nav[i] -- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers)
if c.id ~= cur.id then local best, bestDx
local dx = (c.x + c.w / 2) - cx for i = 1, n do
local dy = (c.y + c.h / 2) - cy local c = Kit._nav[i]
local along, across if c.id ~= cur.id and getNavLayer(c) == curLayer then
if dir == "left" then along, across = -dx, math.abs(dy) local cMidX = c.x + c.w / 2
elseif dir == "right" then along, across = dx, math.abs(dy) local dx = cMidX - cx
elseif dir == "up" then along, across = -dy, math.abs(dx) if dir == "left" and dx < -1 then
else along, across = dy, math.abs(dx) end local dist = -dx
-- A control merely overlapping on the travel axis is not "in that if not bestDx or dist < bestDx then
-- direction"; require real separation so a tall row's neighbours do best, bestDx = c, dist
-- not all qualify. end
if along > 1 then elseif dir == "right" and dx > 1 then
local score = along + across * 2 local dist = dx
if not bestScore or score < bestScore then best, bestScore = c, score end if not bestDx or dist < bestDx then
best, bestDx = c, dist
end
end
end end
end end
if best then
Kit.focusId = best.id
return
end
return
elseif dir == "up" or dir == "down" then
-- VERTICAL LAYER NAVIGATION (Up/Down steps between layers: 1 <-> 2 <-> 3 <-> 4)
local targetLayer = dir == "up" and (curLayer - 1) or (curLayer + 1)
targetLayer = math.max(1, math.min(4, targetLayer))
local best, bestScore
for i = 1, n do
local c = Kit._nav[i]
if c.id ~= cur.id then
local l = getNavLayer(c)
if (dir == "up" and l < curLayer) or (dir == "down" and l > curLayer) then
local layerDiff = math.abs(l - targetLayer)
local cMidX = c.x + c.w / 2
local xDist = math.abs(cMidX - cx)
local score = layerDiff * 10000 + xDist
if not bestScore or score < bestScore then
best, bestScore = c, score
end
end
end
end
if best then
Kit.focusId = best.id
return
end
end end
if best then Kit.focusId = best.id end
end 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 +527,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,11 +553,19 @@ 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 button == "dpup" then Kit.navigate("up") return true local GamepadMap = require("src.core.GamepadMap")
elseif button == "dpdown" then Kit.navigate("down") return true local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
elseif button == "dpleft" then Kit.navigate("left") return true if VirtualKeyboard.active then
elseif button == "dpright" then Kit.navigate("right") return true return VirtualKeyboard.gamepadpressed(action)
elseif button == "a" then Kit.activateFocused() return true end end
if FileBrowser.active then
return FileBrowser.gamepadpressed(action)
end
if action == "dpup" then Kit.navigate("up") return true
elseif action == "dpdown" then Kit.navigate("down") return true
elseif action == "dpleft" then Kit.navigate("left") return true
elseif action == "dpright" then Kit.navigate("right") return true
elseif action == "a" then Kit.activateFocused() return true end
return false return false
end end
@@ -548,9 +635,15 @@ function Kit.row(x, y, w, h, selected, id)
-- The focus ring is a second inset outline, so it reads on both a black -- The focus ring is a second inset outline, so it reads on both a black
-- row and a white selected one. -- row and a white selected one.
if focused then if focused then
Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4, local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1, -- Outer white soft aura
Theme.radius()) Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6,
PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, Theme.radius() + 3)
-- Bright white inner stroke
Theme.strokeRounded(x, y, w, h,
PAL.ink, 0.95 + 0.05 * glowPulse, 2, Theme.radius())
-- Luminous white fill overlay
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, Theme.radius())
end end
local clicked = Kit.press(x, y, w, h) local clicked = Kit.press(x, y, w, h)
or (id ~= nil and Kit._activateId == id) or (id ~= nil and Kit._activateId == id)
@@ -668,9 +761,17 @@ function Kit.button(x, y, w, h, label, opts)
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius) Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
end end
if doRing then if doRing then
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
-- 1. Outer bright white soft glow aura
Theme.strokeRounded(x - B.ringPad - 3, y - B.ringPad - 3,
w + 2 * (B.ringPad + 3), h + 2 * (B.ringPad + 3), PAL.ink,
0.35 + 0.25 * glowPulse, 2.5, radius + B.ringPad + 3)
-- 2. Inner solid white focus border
Theme.strokeRounded(x - B.ringPad, y - B.ringPad, Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.ink,
Theme.A.focus, B.ringWidth, radius + B.ringPad) 0.95 + 0.05 * glowPulse, 2.5, radius + B.ringPad)
-- 3. Subtle luminous white fill overlay so the entire button body shines
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, radius)
elseif glowA then elseif glowA then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad, Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong, w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
@@ -829,6 +930,29 @@ 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 "")
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h))
if clickedOrActivated 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