mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 08:51:27 +02:00
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:
+172
-50
@@ -203,81 +203,203 @@ EOF
|
||||
# Resolve the game directory from the launcher so this works with both
|
||||
# PortMaster-managed ports directories.
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
|
||||
#!/usr/bin/env bash
|
||||
# gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher
|
||||
# Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
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"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
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_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:-}"
|
||||
# GLES is the common path on ARM SBC handhelds; firmware may override it.
|
||||
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
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
# Audio buffer size
|
||||
export SDL_AUDIO_SAMPLES=1024
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
# Chip-synth sample rate. The audio worker is the dominant CPU cost while
|
||||
# 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
|
||||
|
||||
# 4. Platform helper
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
echo "Running pm_platform_helper..."
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
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"
|
||||
EXIT_CODE=$?
|
||||
|
||||
echo "Game exited with code: $EXIT_CODE"
|
||||
|
||||
# 6. Cleanup
|
||||
restore_cpu_governors
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
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
|
||||
|
||||
exit $EXIT_CODE
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
|
||||
@@ -62,7 +62,16 @@ function love.conf(t)
|
||||
t.window.minheight = 360
|
||||
end
|
||||
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.joystick = not companion
|
||||
t.modules.physics = false
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
|
||||
|
||||
@@ -22,6 +22,56 @@ local PlatformHooks = require("src.core.PlatformHooks")
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
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.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||
@@ -30,10 +80,33 @@ do
|
||||
if ok and hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
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
|
||||
return defaultErrorHandler(msg)
|
||||
end
|
||||
end
|
||||
love.errhand = love.errorhandler
|
||||
end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor, Studio
|
||||
@@ -559,6 +632,7 @@ function love.load(args)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
checkEmergencyQuit(dt)
|
||||
HostDisplay.update(dt)
|
||||
SwitchDiagnostics.maybeFlush(false)
|
||||
-- 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
|
||||
idleFor = idleFor + dt
|
||||
|
||||
checkEmergencyQuit(dt)
|
||||
|
||||
-- call update and draw
|
||||
if love.update then love.update(dt) end
|
||||
|
||||
|
||||
+18
-1
@@ -57,7 +57,10 @@ function GamepadMap._setForceNXForTests(v)
|
||||
end
|
||||
|
||||
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.system and love.system.getOS() == "NX" then return true end
|
||||
return false
|
||||
@@ -78,6 +81,20 @@ function GamepadMap.mapGamepadButton(button)
|
||||
return GamepadMap.gamepadBindings()[button]
|
||||
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+A → "2" (COLORS), Select+B → "3" (TILT),
|
||||
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
|
||||
|
||||
@@ -377,12 +377,19 @@ end
|
||||
-- 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)
|
||||
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.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)
|
||||
if isFocused then
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
|
||||
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 ty = y + (h - Kit.textHeight("button")) / 2
|
||||
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)
|
||||
end
|
||||
end
|
||||
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then
|
||||
queueAction(imp, key, action)
|
||||
if (Kit.press(x, y, w, h) or Kit._activateId == key) then
|
||||
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
|
||||
|
||||
@@ -3406,13 +3440,19 @@ local function buildFooter(imp, m, y)
|
||||
local bx = m.x + math.floor((m.w - topW) / 2)
|
||||
local my = cy + math.floor((rowH - dh) / 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)
|
||||
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.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.setShader()
|
||||
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)
|
||||
end
|
||||
local cx = bx + dw
|
||||
@@ -5604,6 +5644,8 @@ end
|
||||
-- a click on the scrim lands on whatever button happens to be behind it.
|
||||
-- Keep this list in sync with buildModals below.
|
||||
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
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._appPatchNotes
|
||||
@@ -5616,6 +5658,14 @@ local function modalUp(imp)
|
||||
end
|
||||
|
||||
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
|
||||
buildPrompt(imp, m, {
|
||||
key = "profren", title = Strings("Rename profile"),
|
||||
@@ -5787,6 +5837,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
|
||||
@@ -5970,6 +6024,25 @@ function LauncherView.draw(imp)
|
||||
|
||||
Kit.endFrame()
|
||||
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
|
||||
|
||||
return LauncherView
|
||||
|
||||
+279
-33
@@ -1263,7 +1263,12 @@ end
|
||||
-- up the background worker or reach out to the network.
|
||||
local function updaterAllowed()
|
||||
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_IMPORT_ONLY") == "1" then return false end
|
||||
return true
|
||||
@@ -2055,8 +2060,42 @@ function RomImporter:chooseMod()
|
||||
end
|
||||
return
|
||||
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()
|
||||
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)
|
||||
@@ -2479,8 +2518,42 @@ function RomImporter:chooseSaveImport(version)
|
||||
end
|
||||
return
|
||||
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()
|
||||
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
|
||||
@@ -2621,11 +2694,42 @@ function RomImporter:choose(version)
|
||||
end
|
||||
return
|
||||
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)
|
||||
if path then
|
||||
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 +3102,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
|
||||
@@ -3040,14 +3152,6 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
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
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
@@ -3055,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
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
|
||||
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
|
||||
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
|
||||
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
|
||||
if math.abs(ry) > PAD_DEAD and self._flex then
|
||||
self:_activatePadCursor()
|
||||
@@ -3074,30 +3173,168 @@ function RomImporter:_updatePadCursor(dt)
|
||||
end
|
||||
|
||||
function RomImporter:gamepadpressed(_, button)
|
||||
self:_activatePadCursor()
|
||||
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
|
||||
local action = GamepadMap.mapGamepadButton(button)
|
||||
if action == "a" then
|
||||
-- Instant click at the virtual pointer: dispatched straight into the
|
||||
-- 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)
|
||||
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
|
||||
if Kit.VirtualKeyboard.gamepadpressed(action) then return 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)
|
||||
return
|
||||
elseif button == "rightshoulder" then
|
||||
self:_cycleTab(1)
|
||||
elseif button == "dpup" or button == "dpdown"
|
||||
or button == "dpleft" or button == "dpright" then
|
||||
self._padDir[button] = true
|
||||
elseif button == "start" or button == "back" then
|
||||
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
|
||||
return
|
||||
end
|
||||
|
||||
-- Start button: Play / Choose ROM
|
||||
if button == "start" then
|
||||
if self.workState == "working" then return end
|
||||
local version = self.tab
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:_romAction(version) 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
|
||||
|
||||
@@ -3111,9 +3348,9 @@ end
|
||||
function RomImporter:gamepadaxis(_, axis, value)
|
||||
if axis == "leftx" or axis == "lefty" or axis == "righty" then
|
||||
self._padAxis[axis] = value
|
||||
if math.abs(value) > PAD_DEAD then
|
||||
if self._padCursorActive and math.abs(value) > PAD_DEAD then
|
||||
self:_activatePadCursor()
|
||||
elseif axis == "lefty" then
|
||||
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
|
||||
self._padStickCentered = true
|
||||
end
|
||||
end
|
||||
@@ -4062,6 +4299,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
|
||||
+178
-54
@@ -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
|
||||
@@ -339,6 +342,32 @@ function Kit.beginFrame(mx, my, clicked, wheel)
|
||||
Kit._navN = 0
|
||||
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.
|
||||
-- Anything typed while no field had focus is dropped here rather than
|
||||
-- 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.
|
||||
local n = Kit._navN or 0
|
||||
Kit._navPrevN = n
|
||||
-- If the focused id vanished (panel switch, list repaged), park the ring
|
||||
-- on the first focusable so the keyboard is never stranded.
|
||||
if Kit.focusId and not Kit._navSeen[Kit.focusId] and n > 0 then
|
||||
Kit.focusId = Kit._nav[1] and Kit._nav[1].id or nil
|
||||
-- If the focused id vanished or not set yet, park the ring on Layer 3 (Import ROM / Play)
|
||||
if (not Kit.focusId or not Kit._navSeen[Kit.focusId]) and n > 0 then
|
||||
-- 1. Prefer ROM Action (Layer 3)
|
||||
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
|
||||
for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end
|
||||
end
|
||||
@@ -360,21 +405,16 @@ end
|
||||
-- ------------------------------------------------------------ focus ring
|
||||
-- Spatial navigation. Every focusable control registers its rect as it
|
||||
-- draws; a queued direction picks the nearest candidate in that direction
|
||||
-- from the previous frame's set. Spatial rather than index-order because
|
||||
-- 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.
|
||||
-- from the previous frame's set.
|
||||
Kit._nav = {}
|
||||
Kit._navN = 0
|
||||
Kit._navPrevN = 0
|
||||
Kit._navSeen = {}
|
||||
Kit._navQueue = nil
|
||||
Kit._activateId = nil
|
||||
Kit._ringShown = false
|
||||
Kit._ringShown = true
|
||||
|
||||
-- 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)
|
||||
if Kit.blockClicks then return false end
|
||||
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
|
||||
slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h
|
||||
Kit._navSeen[id] = true
|
||||
-- First focusable ever drawn adopts the ring, so keyboard users start
|
||||
-- somewhere rather than nowhere.
|
||||
if Kit.focusId == nil then Kit.focusId = id end
|
||||
if Kit.focusId == nil and tostring(id):match("^rom%-") then
|
||||
Kit.focusId = id
|
||||
end
|
||||
return Kit._ringShown and Kit.focusId == id
|
||||
end
|
||||
|
||||
@@ -403,10 +443,6 @@ function Kit.setFocus(id)
|
||||
Kit._ringShown = id ~= nil
|
||||
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()
|
||||
local dir = Kit._navQueue
|
||||
Kit._navQueue = nil
|
||||
@@ -414,38 +450,75 @@ function Kit._resolveNav()
|
||||
if not dir or n == 0 then return end
|
||||
local cur
|
||||
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
|
||||
if not cur then
|
||||
Kit.focusId = Kit._nav[1].id
|
||||
return
|
||||
end
|
||||
|
||||
local curLayer = getNavLayer(cur)
|
||||
local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2
|
||||
local best, bestScore
|
||||
for i = 1, n do
|
||||
local c = Kit._nav[i]
|
||||
if c.id ~= cur.id then
|
||||
local dx = (c.x + c.w / 2) - cx
|
||||
local dy = (c.y + c.h / 2) - cy
|
||||
local along, across
|
||||
if dir == "left" then along, across = -dx, math.abs(dy)
|
||||
elseif dir == "right" then along, across = dx, math.abs(dy)
|
||||
elseif dir == "up" then along, across = -dy, math.abs(dx)
|
||||
else along, across = dy, math.abs(dx) end
|
||||
-- A control merely overlapping on the travel axis is not "in that
|
||||
-- direction"; require real separation so a tall row's neighbours do
|
||||
-- not all qualify.
|
||||
if along > 1 then
|
||||
local score = along + across * 2
|
||||
if not bestScore or score < bestScore then best, bestScore = c, score end
|
||||
|
||||
if dir == "left" or dir == "right" then
|
||||
-- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers)
|
||||
local best, bestDx
|
||||
for i = 1, n do
|
||||
local c = Kit._nav[i]
|
||||
if c.id ~= cur.id and getNavLayer(c) == curLayer then
|
||||
local cMidX = c.x + c.w / 2
|
||||
local dx = cMidX - cx
|
||||
if dir == "left" and dx < -1 then
|
||||
local dist = -dx
|
||||
if not bestDx or dist < bestDx then
|
||||
best, bestDx = c, dist
|
||||
end
|
||||
elseif dir == "right" and dx > 1 then
|
||||
local dist = dx
|
||||
if not bestDx or dist < bestDx then
|
||||
best, bestDx = c, dist
|
||||
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
|
||||
if best then Kit.focusId = best.id end
|
||||
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 +527,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,11 +553,19 @@ end
|
||||
|
||||
-- Gamepad d-pad / stick, routed by the host's pad handling.
|
||||
function Kit.gamepadpressed(button)
|
||||
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
|
||||
elseif button == "dpright" then Kit.navigate("right") return true
|
||||
elseif button == "a" then Kit.activateFocused() return true end
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
|
||||
if VirtualKeyboard.active then
|
||||
return VirtualKeyboard.gamepadpressed(action)
|
||||
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
|
||||
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
|
||||
-- row and a white selected one.
|
||||
if focused then
|
||||
Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4,
|
||||
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1,
|
||||
Theme.radius())
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
-- Outer white soft aura
|
||||
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
|
||||
local clicked = Kit.press(x, y, w, h)
|
||||
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)
|
||||
end
|
||||
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,
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
|
||||
Theme.A.focus, B.ringWidth, radius + B.ringPad)
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.ink,
|
||||
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
|
||||
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
|
||||
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)
|
||||
local focusRing = Kit.focusable(id, x, y, w, h)
|
||||
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
|
||||
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