mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b63936055a
|
|||
|
beb48ab0cb
|
|||
|
6ff89b9227
|
|||
|
c9d5f6b6e3
|
|||
|
6569eaa0ac
|
|||
|
9cbc3fc335
|
|||
|
ae44477a69
|
|||
|
aa569272db
|
|||
|
504793eaed
|
|||
|
a64af69365
|
|||
|
3ee71139a6
|
@@ -1,127 +0,0 @@
|
|||||||
---
|
|
||||||
name: "subminer-change-verification"
|
|
||||||
description: "Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation."
|
|
||||||
---
|
|
||||||
|
|
||||||
# SubMiner Change Verification
|
|
||||||
|
|
||||||
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
- `scripts/classify_subminer_diff.sh`
|
|
||||||
- Emits suggested lanes and flags from explicit paths or current git changes.
|
|
||||||
- `scripts/verify_subminer_change.sh`
|
|
||||||
- Runs selected lanes, captures artifacts, and writes a compact summary.
|
|
||||||
|
|
||||||
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SUBMINER_VERIFY_SKILL="<path-to-skill>"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Default workflow
|
|
||||||
|
|
||||||
1. Inspect the changed files or user-requested area.
|
|
||||||
2. Run the classifier unless you already know the right lane.
|
|
||||||
3. Run the verifier with the cheapest sufficient lane set.
|
|
||||||
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
|
|
||||||
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
|
|
||||||
6. Return:
|
|
||||||
- verification summary
|
|
||||||
- exact commands run
|
|
||||||
- artifact paths
|
|
||||||
- skipped lanes and blockers
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Repo-source quick start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Installed-skill quick start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
Classify explicit files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
|
||||||
launcher/main.ts \
|
|
||||||
plugin/subminer/lifecycle.lua \
|
|
||||||
src/main/runtime/mpv-client-runtime-service.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Run automatic lane selection:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Installed-skill form:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
|
|
||||||
```
|
|
||||||
|
|
||||||
Run targeted lanes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
|
||||||
--lane launcher-plugin \
|
|
||||||
--lane runtime-compat
|
|
||||||
```
|
|
||||||
|
|
||||||
Dry-run to inspect planned commands and artifact layout:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
|
||||||
--dry-run \
|
|
||||||
launcher/main.ts \
|
|
||||||
src/main.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lane guidance
|
|
||||||
|
|
||||||
- `docs`
|
|
||||||
- For `docs-site/`, `docs/`, and doc-only edits.
|
|
||||||
- `config`
|
|
||||||
- For `src/config/` and config-template-sensitive edits.
|
|
||||||
- `core`
|
|
||||||
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
|
||||||
- `launcher-plugin`
|
|
||||||
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
|
||||||
- `runtime-compat`
|
|
||||||
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
|
|
||||||
- `real-runtime`
|
|
||||||
- Only after deliberate escalation.
|
|
||||||
|
|
||||||
## Real Runtime Escalation
|
|
||||||
|
|
||||||
Escalate only when the change claim depends on actual runtime behavior, for example:
|
|
||||||
|
|
||||||
- overlay appears, hides, or tracks a real mpv window
|
|
||||||
- mpv launch flags or pause-until-ready behavior
|
|
||||||
- plugin/socket/auto-start handshake under a real player
|
|
||||||
- macOS/window-tracker/focus-sensitive behavior
|
|
||||||
|
|
||||||
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
|
|
||||||
|
|
||||||
## Artifact contract
|
|
||||||
|
|
||||||
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
|
|
||||||
|
|
||||||
- `summary.json`
|
|
||||||
- `summary.txt`
|
|
||||||
- `classification.txt`
|
|
||||||
- `env.txt`
|
|
||||||
- `lanes.txt`
|
|
||||||
- `steps.tsv`
|
|
||||||
- `steps/*.stdout.log`
|
|
||||||
- `steps/*.stderr.log`
|
|
||||||
|
|
||||||
On failure, quote the exact failing command and point at the artifact directory.
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: classify_subminer_diff.sh [path ...]
|
|
||||||
|
|
||||||
Emit suggested verification lanes for explicit paths or current local git changes.
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
lane:<name>
|
|
||||||
flag:<name>
|
|
||||||
reason:<text>
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
has_item() {
|
|
||||||
local needle=$1
|
|
||||||
shift || true
|
|
||||||
local item
|
|
||||||
for item in "$@"; do
|
|
||||||
if [[ "$item" == "$needle" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
add_lane() {
|
|
||||||
local lane=$1
|
|
||||||
if ! has_item "$lane" "${LANES[@]:-}"; then
|
|
||||||
LANES+=("$lane")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_flag() {
|
|
||||||
local flag=$1
|
|
||||||
if ! has_item "$flag" "${FLAGS[@]:-}"; then
|
|
||||||
FLAGS+=("$flag")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_reason() {
|
|
||||||
REASONS+=("$1")
|
|
||||||
}
|
|
||||||
|
|
||||||
collect_git_paths() {
|
|
||||||
local top_level
|
|
||||||
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
|
||||||
cd "$top_level"
|
|
||||||
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
|
||||||
git diff --name-only --relative HEAD --
|
|
||||||
git diff --name-only --relative --cached --
|
|
||||||
else
|
|
||||||
git diff --name-only --relative --
|
|
||||||
git diff --name-only --relative --cached --
|
|
||||||
fi
|
|
||||||
git ls-files --others --exclude-standard
|
|
||||||
) | awk 'NF' | sort -u
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -a PATHS=()
|
|
||||||
declare -a LANES=()
|
|
||||||
declare -a FLAGS=()
|
|
||||||
declare -a REASONS=()
|
|
||||||
|
|
||||||
if [[ $# -gt 0 ]]; then
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
PATHS+=("$1")
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
else
|
|
||||||
while IFS= read -r line; do
|
|
||||||
[[ -n "$line" ]] && PATHS+=("$line")
|
|
||||||
done < <(collect_git_paths)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ${#PATHS[@]} -eq 0 ]]; then
|
|
||||||
add_lane "core"
|
|
||||||
add_reason "no changed paths detected -> default to core"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for path in "${PATHS[@]}"; do
|
|
||||||
specialized=0
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
docs-site/*|docs/*|changes/*|README.md)
|
|
||||||
add_lane "docs"
|
|
||||||
add_reason "$path -> docs"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
|
|
||||||
add_lane "config"
|
|
||||||
add_reason "$path -> config"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
|
||||||
add_lane "launcher-plugin"
|
|
||||||
add_reason "$path -> launcher-plugin"
|
|
||||||
add_flag "real-runtime-candidate"
|
|
||||||
add_reason "$path -> real-runtime-candidate"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
|
|
||||||
add_lane "runtime-compat"
|
|
||||||
add_reason "$path -> runtime-compat"
|
|
||||||
add_flag "real-runtime-candidate"
|
|
||||||
add_reason "$path -> real-runtime-candidate"
|
|
||||||
specialized=1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ "$specialized" == "0" ]]; then
|
|
||||||
case "$path" in
|
|
||||||
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
|
|
||||||
add_lane "core"
|
|
||||||
add_reason "$path -> core"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$path" in
|
|
||||||
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
|
|
||||||
add_flag "broad-impact"
|
|
||||||
add_reason "$path -> broad-impact"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ ${#LANES[@]} -eq 0 ]]; then
|
|
||||||
add_lane "core"
|
|
||||||
add_reason "no lane-specific matches -> default to core"
|
|
||||||
fi
|
|
||||||
|
|
||||||
for lane in "${LANES[@]}"; do
|
|
||||||
printf 'lane:%s\n' "$lane"
|
|
||||||
done
|
|
||||||
|
|
||||||
for flag in "${FLAGS[@]}"; do
|
|
||||||
printf 'flag:%s\n' "$flag"
|
|
||||||
done
|
|
||||||
|
|
||||||
for reason in "${REASONS[@]}"; do
|
|
||||||
printf 'reason:%s\n' "$reason"
|
|
||||||
done
|
|
||||||
@@ -1,566 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: verify_subminer_change.sh [options] [path ...]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--lane <name> Force a verification lane. Repeatable.
|
|
||||||
--artifact-dir <dir> Use an explicit artifact directory.
|
|
||||||
--allow-real-runtime Allow explicit real-runtime execution.
|
|
||||||
--allow-real-gui Deprecated alias for --allow-real-runtime.
|
|
||||||
--dry-run Record planned steps without executing commands.
|
|
||||||
--help Show this help text.
|
|
||||||
|
|
||||||
If no lanes are supplied, the script classifies the provided paths. If no paths are
|
|
||||||
provided, it classifies the current local git changes.
|
|
||||||
|
|
||||||
Authoritative real-runtime verification should be requested with explicit path
|
|
||||||
arguments instead of relying on inferred local git changes.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp() {
|
|
||||||
date +%Y%m%d-%H%M%S
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp_iso() {
|
|
||||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_session_id() {
|
|
||||||
local tmp_dir
|
|
||||||
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
|
|
||||||
basename "$tmp_dir"
|
|
||||||
rmdir "$tmp_dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
has_item() {
|
|
||||||
local needle=$1
|
|
||||||
shift || true
|
|
||||||
local item
|
|
||||||
for item in "$@"; do
|
|
||||||
if [[ "$item" == "$needle" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
normalize_lane_name() {
|
|
||||||
case "$1" in
|
|
||||||
real-gui)
|
|
||||||
printf '%s' "real-runtime"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
printf '%s' "$1"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
add_lane() {
|
|
||||||
local lane
|
|
||||||
lane=$(normalize_lane_name "$1")
|
|
||||||
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
|
|
||||||
SELECTED_LANES+=("$lane")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_blocker() {
|
|
||||||
BLOCKERS+=("$1")
|
|
||||||
BLOCKED=1
|
|
||||||
}
|
|
||||||
|
|
||||||
append_step_record() {
|
|
||||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
||||||
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_env() {
|
|
||||||
{
|
|
||||||
printf 'repo_root=%s\n' "$REPO_ROOT"
|
|
||||||
printf 'session_id=%s\n' "$SESSION_ID"
|
|
||||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
|
||||||
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
|
|
||||||
printf 'dry_run=%s\n' "$DRY_RUN"
|
|
||||||
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
|
|
||||||
printf 'session_home=%s\n' "$SESSION_HOME"
|
|
||||||
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
|
|
||||||
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
|
|
||||||
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
|
|
||||||
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
|
|
||||||
printf 'pwd=%s\n' "$(pwd)"
|
|
||||||
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
|
|
||||||
git status --short 2>/dev/null || true
|
|
||||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
|
||||||
printf 'requested_paths=\n'
|
|
||||||
printf ' %s\n' "${PATH_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
} >"$ARTIFACT_DIR/env.txt"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_step() {
|
|
||||||
local lane=$1
|
|
||||||
local name=$2
|
|
||||||
local command=$3
|
|
||||||
local note=${4:-}
|
|
||||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
|
||||||
local stdout_rel="steps/${slug}.stdout.log"
|
|
||||||
local stderr_rel="steps/${slug}.stderr.log"
|
|
||||||
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
|
||||||
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
|
||||||
local status exit_code
|
|
||||||
|
|
||||||
COMMANDS_RUN+=("$command")
|
|
||||||
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${slug}.command.txt"
|
|
||||||
|
|
||||||
if [[ "$DRY_RUN" == "1" ]]; then
|
|
||||||
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
|
||||||
: >"$stderr_path"
|
|
||||||
status="dry-run"
|
|
||||||
exit_code=0
|
|
||||||
else
|
|
||||||
if bash -lc "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
|
||||||
status="passed"
|
|
||||||
exit_code=0
|
|
||||||
EXECUTED_REAL_STEPS=1
|
|
||||||
else
|
|
||||||
exit_code=$?
|
|
||||||
status="failed"
|
|
||||||
FAILED=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
|
|
||||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
|
||||||
|
|
||||||
if [[ "$status" == "failed" ]]; then
|
|
||||||
FAILURE_STEP="$name"
|
|
||||||
FAILURE_COMMAND="$command"
|
|
||||||
FAILURE_STDOUT="$stdout_rel"
|
|
||||||
FAILURE_STDERR="$stderr_rel"
|
|
||||||
return "$exit_code"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
record_nonpassing_step() {
|
|
||||||
local lane=$1
|
|
||||||
local name=$2
|
|
||||||
local status=$3
|
|
||||||
local note=$4
|
|
||||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
|
||||||
local stdout_rel="steps/${slug}.stdout.log"
|
|
||||||
local stderr_rel="steps/${slug}.stderr.log"
|
|
||||||
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
|
||||||
: >"$ARTIFACT_DIR/$stderr_rel"
|
|
||||||
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
|
||||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_skipped_step() {
|
|
||||||
record_nonpassing_step "$1" "$2" "skipped" "$3"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_blocked_step() {
|
|
||||||
add_blocker "$3"
|
|
||||||
record_nonpassing_step "$1" "$2" "blocked" "$3"
|
|
||||||
}
|
|
||||||
|
|
||||||
record_failed_step() {
|
|
||||||
FAILED=1
|
|
||||||
FAILURE_STEP=$2
|
|
||||||
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
|
||||||
FAILURE_STDOUT="steps/${2//[^a-zA-Z0-9_-]/-}.stdout.log"
|
|
||||||
FAILURE_STDERR="steps/${2//[^a-zA-Z0-9_-]/-}.stderr.log"
|
|
||||||
add_blocker "$3"
|
|
||||||
record_nonpassing_step "$1" "$2" "failed" "$3"
|
|
||||||
}
|
|
||||||
|
|
||||||
find_real_runtime_helper() {
|
|
||||||
local candidate
|
|
||||||
for candidate in \
|
|
||||||
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
|
|
||||||
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
|
|
||||||
if [[ -x "$candidate" ]]; then
|
|
||||||
printf '%s' "$candidate"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
acquire_real_runtime_lease() {
|
|
||||||
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
|
|
||||||
local lease_dir="$lease_root/exclusive-real-runtime"
|
|
||||||
mkdir -p "$lease_root"
|
|
||||||
if mkdir "$lease_dir" 2>/dev/null; then
|
|
||||||
REAL_RUNTIME_LEASE_DIR="$lease_dir"
|
|
||||||
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local owner=""
|
|
||||||
if [[ -f "$lease_dir/session_id" ]]; then
|
|
||||||
owner=$(cat "$lease_dir/session_id")
|
|
||||||
fi
|
|
||||||
add_blocker "real-runtime lease already held${owner:+ by $owner}"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
release_real_runtime_lease() {
|
|
||||||
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
|
|
||||||
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
|
|
||||||
local owner
|
|
||||||
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
|
|
||||||
if [[ "$owner" != "$SESSION_ID" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
rm -rf "$REAL_RUNTIME_LEASE_DIR"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
compute_final_status() {
|
|
||||||
if [[ "$FAILED" == "1" ]]; then
|
|
||||||
FINAL_STATUS="failed"
|
|
||||||
elif [[ "$BLOCKED" == "1" ]]; then
|
|
||||||
FINAL_STATUS="blocked"
|
|
||||||
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
|
|
||||||
FINAL_STATUS="passed"
|
|
||||||
else
|
|
||||||
FINAL_STATUS="skipped"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
write_summary_files() {
|
|
||||||
local lane_lines
|
|
||||||
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
|
||||||
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
|
||||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
|
||||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
|
||||||
|
|
||||||
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
|
||||||
SESSION_ID_ENV="$SESSION_ID" \
|
|
||||||
FINAL_STATUS_ENV="$FINAL_STATUS" \
|
|
||||||
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
|
|
||||||
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
|
|
||||||
SESSION_HOME_ENV="$SESSION_HOME" \
|
|
||||||
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
|
|
||||||
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
|
|
||||||
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
|
|
||||||
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
|
|
||||||
STARTED_AT_ENV="$STARTED_AT" \
|
|
||||||
FINISHED_AT_ENV="$FINISHED_AT" \
|
|
||||||
FAILED_ENV="$FAILED" \
|
|
||||||
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
|
|
||||||
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
|
|
||||||
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
|
|
||||||
bun -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
function readLines(filePath) {
|
|
||||||
if (!fs.existsSync(filePath)) return [];
|
|
||||||
return fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
const artifactDir = process.env.ARTIFACT_DIR_ENV;
|
|
||||||
const reportsDir = path.join(artifactDir, "reports");
|
|
||||||
const lanes = readLines(path.join(artifactDir, "lanes.txt"));
|
|
||||||
const blockers = readLines(path.join(artifactDir, "blockers.txt"));
|
|
||||||
const requestedPaths = readLines(path.join(artifactDir, "requested-paths.txt"));
|
|
||||||
const steps = readLines(path.join(artifactDir, "steps.tsv")).map((line) => {
|
|
||||||
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
|
|
||||||
return {
|
|
||||||
lane,
|
|
||||||
name,
|
|
||||||
status,
|
|
||||||
exitCode: Number(exitCode || 0),
|
|
||||||
command,
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
note,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const summary = {
|
|
||||||
sessionId: process.env.SESSION_ID_ENV || "",
|
|
||||||
artifactDir,
|
|
||||||
reportsDir,
|
|
||||||
status: process.env.FINAL_STATUS_ENV || "failed",
|
|
||||||
selectedLanes: lanes,
|
|
||||||
failed: process.env.FAILED_ENV === "1",
|
|
||||||
failure:
|
|
||||||
process.env.FAILED_ENV === "1"
|
|
||||||
? {
|
|
||||||
command: process.env.FAILURE_COMMAND_ENV || "",
|
|
||||||
stdout: process.env.FAILURE_STDOUT_ENV || "",
|
|
||||||
stderr: process.env.FAILURE_STDERR_ENV || "",
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
blockers,
|
|
||||||
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV || "git-inferred",
|
|
||||||
requestedPaths,
|
|
||||||
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
|
|
||||||
startedAt: process.env.STARTED_AT_ENV || "",
|
|
||||||
finishedAt: process.env.FINISHED_AT_ENV || "",
|
|
||||||
env: {
|
|
||||||
home: process.env.SESSION_HOME_ENV || "",
|
|
||||||
xdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV || "",
|
|
||||||
mpvDir: process.env.SESSION_MPV_DIR_ENV || "",
|
|
||||||
logsDir: process.env.SESSION_LOGS_DIR_ENV || "",
|
|
||||||
mpvLog: process.env.SESSION_MPV_LOG_ENV || "",
|
|
||||||
},
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
|
|
||||||
const summaryJson = JSON.stringify(summary, null, 2) + "\n";
|
|
||||||
fs.writeFileSync(path.join(artifactDir, "summary.json"), summaryJson);
|
|
||||||
fs.writeFileSync(path.join(reportsDir, "summary.json"), summaryJson);
|
|
||||||
|
|
||||||
const lines = [];
|
|
||||||
lines.push(`session_id: ${summary.sessionId}`);
|
|
||||||
lines.push(`artifact_dir: ${artifactDir}`);
|
|
||||||
lines.push(`selected_lanes: ${lanes.join(", ") || "(none)"}`);
|
|
||||||
lines.push(`status: ${summary.status}`);
|
|
||||||
lines.push(`path_selection_mode: ${summary.pathSelectionMode}`);
|
|
||||||
if (requestedPaths.length > 0) {
|
|
||||||
lines.push(`requested_paths: ${requestedPaths.join(", ")}`);
|
|
||||||
}
|
|
||||||
if (blockers.length > 0) {
|
|
||||||
lines.push(`blockers: ${blockers.join(" | ")}`);
|
|
||||||
}
|
|
||||||
for (const step of steps) {
|
|
||||||
lines.push(`${step.lane}/${step.name}: ${step.status}`);
|
|
||||||
if (step.command) lines.push(` command: ${step.command}`);
|
|
||||||
lines.push(` stdout: ${step.stdout}`);
|
|
||||||
lines.push(` stderr: ${step.stderr}`);
|
|
||||||
if (step.note) lines.push(` note: ${step.note}`);
|
|
||||||
}
|
|
||||||
if (summary.failed) {
|
|
||||||
lines.push(`failure_command: ${process.env.FAILURE_COMMAND_ENV || ""}`);
|
|
||||||
}
|
|
||||||
const summaryText = lines.join("\n") + "\n";
|
|
||||||
fs.writeFileSync(path.join(artifactDir, "summary.txt"), summaryText);
|
|
||||||
fs.writeFileSync(path.join(reportsDir, "summary.txt"), summaryText);
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
release_real_runtime_lease
|
|
||||||
}
|
|
||||||
|
|
||||||
CLASSIFIER_OUTPUT=""
|
|
||||||
ARTIFACT_DIR=""
|
|
||||||
ALLOW_REAL_RUNTIME=0
|
|
||||||
DRY_RUN=0
|
|
||||||
FAILED=0
|
|
||||||
BLOCKED=0
|
|
||||||
EXECUTED_REAL_STEPS=0
|
|
||||||
FINAL_STATUS=""
|
|
||||||
FAILURE_STEP=""
|
|
||||||
FAILURE_COMMAND=""
|
|
||||||
FAILURE_STDOUT=""
|
|
||||||
FAILURE_STDERR=""
|
|
||||||
REAL_RUNTIME_LEASE_DIR=""
|
|
||||||
STARTED_AT=""
|
|
||||||
FINISHED_AT=""
|
|
||||||
|
|
||||||
declare -a EXPLICIT_LANES=()
|
|
||||||
declare -a SELECTED_LANES=()
|
|
||||||
declare -a PATH_ARGS=()
|
|
||||||
declare -a COMMANDS_RUN=()
|
|
||||||
declare -a BLOCKERS=()
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--lane)
|
|
||||||
EXPLICIT_LANES+=("$(normalize_lane_name "$2")")
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--artifact-dir)
|
|
||||||
ARTIFACT_DIR=$2
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--allow-real-runtime|--allow-real-gui)
|
|
||||||
ALLOW_REAL_RUNTIME=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dry-run)
|
|
||||||
DRY_RUN=1
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
--)
|
|
||||||
shift
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
PATH_ARGS+=("$1")
|
|
||||||
shift
|
|
||||||
done
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
PATH_ARGS+=("$1")
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
||||||
SESSION_ID=$(generate_session_id)
|
|
||||||
PATH_SELECTION_MODE="git-inferred"
|
|
||||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
|
||||||
PATH_SELECTION_MODE="explicit"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$ARTIFACT_DIR" ]]; then
|
|
||||||
mkdir -p "$REPO_ROOT/.tmp/skill-verification"
|
|
||||||
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
SESSION_HOME="$ARTIFACT_DIR/home"
|
|
||||||
SESSION_XDG_CONFIG_HOME="$ARTIFACT_DIR/xdg"
|
|
||||||
SESSION_MPV_DIR="$ARTIFACT_DIR/mpv"
|
|
||||||
SESSION_LOGS_DIR="$ARTIFACT_DIR/logs"
|
|
||||||
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
|
|
||||||
|
|
||||||
mkdir -p "$ARTIFACT_DIR/steps" "$ARTIFACT_DIR/reports" "$SESSION_HOME" "$SESSION_XDG_CONFIG_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
|
|
||||||
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
|
|
||||||
: >"$STEPS_TSV"
|
|
||||||
|
|
||||||
trap cleanup EXIT
|
|
||||||
STARTED_AT=$(timestamp_iso)
|
|
||||||
|
|
||||||
if [[ ${#EXPLICIT_LANES[@]} -gt 0 ]]; then
|
|
||||||
local_lane=""
|
|
||||||
for local_lane in "${EXPLICIT_LANES[@]}"; do
|
|
||||||
add_lane "$local_lane"
|
|
||||||
done
|
|
||||||
printf 'reason:explicit lanes supplied\n' >"$ARTIFACT_DIR/classification.txt"
|
|
||||||
else
|
|
||||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
|
||||||
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}")
|
|
||||||
else
|
|
||||||
CLASSIFIER_OUTPUT=$(bash "$SCRIPT_DIR/classify_subminer_diff.sh")
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$CLASSIFIER_OUTPUT" >"$ARTIFACT_DIR/classification.txt"
|
|
||||||
while IFS= read -r line; do
|
|
||||||
case "$line" in
|
|
||||||
lane:*)
|
|
||||||
add_lane "${line#lane:}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done <<<"$CLASSIFIER_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
record_env
|
|
||||||
|
|
||||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
|
||||||
printf 'selected_lanes=%s\n' "$(IFS=,; echo "${SELECTED_LANES[*]}")"
|
|
||||||
|
|
||||||
for lane in "${SELECTED_LANES[@]}"; do
|
|
||||||
case "$lane" in
|
|
||||||
docs)
|
|
||||||
run_step "$lane" "docs-test" "bun run docs:test" || break
|
|
||||||
[[ "$FAILED" == "1" ]] && break
|
|
||||||
run_step "$lane" "docs-build" "bun run docs:build" || break
|
|
||||||
;;
|
|
||||||
config)
|
|
||||||
run_step "$lane" "test-config" "bun run test:config" || break
|
|
||||||
;;
|
|
||||||
core)
|
|
||||||
run_step "$lane" "typecheck" "bun run typecheck" || break
|
|
||||||
[[ "$FAILED" == "1" ]] && break
|
|
||||||
run_step "$lane" "test-fast" "bun run test:fast" || break
|
|
||||||
;;
|
|
||||||
launcher-plugin)
|
|
||||||
run_step "$lane" "launcher-smoke-src" "bun run test:launcher:smoke:src" || break
|
|
||||||
[[ "$FAILED" == "1" ]] && break
|
|
||||||
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
|
|
||||||
;;
|
|
||||||
runtime-compat)
|
|
||||||
run_step "$lane" "build" "bun run build" || break
|
|
||||||
[[ "$FAILED" == "1" ]] && break
|
|
||||||
run_step "$lane" "test-runtime-compat" "bun run test:runtime:compat" || break
|
|
||||||
[[ "$FAILED" == "1" ]] && break
|
|
||||||
run_step "$lane" "test-smoke-dist" "bun run test:smoke:dist" || break
|
|
||||||
;;
|
|
||||||
real-runtime)
|
|
||||||
if [[ "$PATH_SELECTION_MODE" != "explicit" ]]; then
|
|
||||||
record_blocked_step \
|
|
||||||
"$lane" \
|
|
||||||
"real-runtime-guard" \
|
|
||||||
"real-runtime lane requires explicit paths; inferred local git changes are non-authoritative"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
|
|
||||||
record_blocked_step \
|
|
||||||
"$lane" \
|
|
||||||
"real-runtime-guard" \
|
|
||||||
"real-runtime lane requested but --allow-real-runtime was not supplied"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! acquire_real_runtime_lease; then
|
|
||||||
record_blocked_step \
|
|
||||||
"$lane" \
|
|
||||||
"real-runtime-lease" \
|
|
||||||
"real-runtime lease already held; rerun after the active runtime verification finishes"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! REAL_RUNTIME_HELPER=$(find_real_runtime_helper); then
|
|
||||||
record_blocked_step \
|
|
||||||
"$lane" \
|
|
||||||
"real-runtime-helper" \
|
|
||||||
"real-runtime helper not implemented yet"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf -v REAL_RUNTIME_COMMAND \
|
|
||||||
'SESSION_ID=%q HOME=%q XDG_CONFIG_HOME=%q SUBMINER_MPV_LOG=%q bash %q' \
|
|
||||||
"$SESSION_ID" \
|
|
||||||
"$SESSION_HOME" \
|
|
||||||
"$SESSION_XDG_CONFIG_HOME" \
|
|
||||||
"$SESSION_MPV_LOG" \
|
|
||||||
"$REAL_RUNTIME_HELPER"
|
|
||||||
|
|
||||||
run_step "$lane" "real-runtime-smoke" "$REAL_RUNTIME_COMMAND" || break
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
record_failed_step "$lane" "lane-validation" "unknown lane: $lane"
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [[ "$FAILED" == "1" || "$BLOCKED" == "1" ]]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
FINISHED_AT=$(timestamp_iso)
|
|
||||||
compute_final_status
|
|
||||||
write_summary_files
|
|
||||||
|
|
||||||
printf 'status=%s\n' "$FINAL_STATUS"
|
|
||||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
|
||||||
|
|
||||||
case "$FINAL_STATUS" in
|
|
||||||
failed)
|
|
||||||
printf 'result=failed\n'
|
|
||||||
printf 'failure_command=%s\n' "$FAILURE_COMMAND"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
blocked)
|
|
||||||
printf 'result=blocked\n'
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
printf 'result=ok\n'
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
---
|
|
||||||
name: "subminer-scrum-master"
|
|
||||||
description: "Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Assesses whether backlog tracking is warranted, creates or updates tasks when needed, records a plan, dispatches one or more subagents, and requires verification before handoff."
|
|
||||||
---
|
|
||||||
|
|
||||||
# SubMiner Scrum Master
|
|
||||||
|
|
||||||
Own workflow, not code by default.
|
|
||||||
|
|
||||||
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, backlog hygiene, worker dispatch, and verification through completion.
|
|
||||||
|
|
||||||
## Core Rules
|
|
||||||
|
|
||||||
1. Decide first whether backlog tracking is warranted.
|
|
||||||
2. If backlog is needed, search first. Update existing work when it clearly matches.
|
|
||||||
3. If backlog is not needed, keep the process light. Do not invent ticket ceremony.
|
|
||||||
4. Record a plan before dispatching coding work.
|
|
||||||
5. Use parent + subtasks for multi-part work when backlog is used.
|
|
||||||
6. Dispatch conservatively. Parallelize only disjoint write scopes.
|
|
||||||
7. Require verification before handoff, typically via `subminer-change-verification`.
|
|
||||||
8. Report backlog actions, dispatched workers, verification, blockers, and remaining risks.
|
|
||||||
|
|
||||||
## Backlog Decision
|
|
||||||
|
|
||||||
Skip backlog when the request is:
|
|
||||||
- question only
|
|
||||||
- obvious mechanical edit
|
|
||||||
- tiny isolated change with no real planning
|
|
||||||
|
|
||||||
Use backlog when the work:
|
|
||||||
- needs planning or scope decisions
|
|
||||||
- spans multiple phases or subsystems
|
|
||||||
- is likely to need subagent dispatch
|
|
||||||
- should remain traceable for handoff/resume
|
|
||||||
|
|
||||||
If backlog is used:
|
|
||||||
- search existing tasks first
|
|
||||||
- create/update a standalone task for one focused deliverable
|
|
||||||
- create/update a parent task plus subtasks for multi-part work
|
|
||||||
- record the implementation plan in the task before implementation begins
|
|
||||||
|
|
||||||
## Intake Workflow
|
|
||||||
|
|
||||||
1. Parse the request.
|
|
||||||
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
|
|
||||||
2. Decide whether backlog is needed.
|
|
||||||
3. If backlog is needed:
|
|
||||||
- search first
|
|
||||||
- update existing task if clearly relevant
|
|
||||||
- otherwise create the right structure
|
|
||||||
- write the implementation plan before dispatch
|
|
||||||
4. If backlog is skipped:
|
|
||||||
- write a short working plan in-thread
|
|
||||||
- proceed without fake ticketing
|
|
||||||
5. Choose execution mode:
|
|
||||||
- no subagents for trivial work
|
|
||||||
- one worker for focused work
|
|
||||||
- parallel workers only for disjoint scopes
|
|
||||||
6. Run verification before handoff.
|
|
||||||
|
|
||||||
## Dispatch Rules
|
|
||||||
|
|
||||||
The scrum master orchestrates. Workers implement.
|
|
||||||
|
|
||||||
- Do not become the default implementer unless delegation is unnecessary.
|
|
||||||
- Do not parallelize overlapping files or tightly coupled runtime work.
|
|
||||||
- Give every worker explicit ownership of files/modules.
|
|
||||||
- Tell every worker other agents may be active and they must not revert unrelated edits.
|
|
||||||
- Require each worker to report:
|
|
||||||
- changed files
|
|
||||||
- tests run
|
|
||||||
- blockers
|
|
||||||
|
|
||||||
Use worker agents for implementation and explorer agents only for bounded codebase questions.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Every nontrivial code task gets verification.
|
|
||||||
|
|
||||||
Preferred flow:
|
|
||||||
1. use `subminer-change-verification`
|
|
||||||
2. start with the cheapest sufficient lane
|
|
||||||
3. escalate only when needed
|
|
||||||
4. if worker verification is sufficient, accept it or run one final consolidating pass
|
|
||||||
|
|
||||||
Never hand off nontrivial work without stating what was verified and what was skipped.
|
|
||||||
|
|
||||||
## Pre-Handoff Policy Checks (Required)
|
|
||||||
|
|
||||||
Before handoff, always ask and answer both of these questions explicitly:
|
|
||||||
|
|
||||||
1. **Docs update required?**
|
|
||||||
2. **Changelog fragment required?**
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Do not assume silence implies "no." Record an explicit yes/no decision for each item.
|
|
||||||
- If the answer is yes, either complete the update or report the blocker before handoff.
|
|
||||||
- Include the final answers in the handoff summary even when both answers are "no."
|
|
||||||
|
|
||||||
## Failure / Scope Handling
|
|
||||||
|
|
||||||
- If a worker hits ambiguity, pause and ask the user.
|
|
||||||
- If verification fails, either:
|
|
||||||
- send the worker back with exact failure context, or
|
|
||||||
- fix it directly if it is tiny and clearly in scope
|
|
||||||
- If new scope appears, revisit backlog structure before silently expanding work.
|
|
||||||
|
|
||||||
## Representative Flows
|
|
||||||
|
|
||||||
### Trivial no-ticket work
|
|
||||||
|
|
||||||
- decide backlog is unnecessary
|
|
||||||
- keep a short plan
|
|
||||||
- implement directly or with one worker if helpful
|
|
||||||
- run targeted verification
|
|
||||||
- report outcome concisely
|
|
||||||
|
|
||||||
### Single-task implementation
|
|
||||||
|
|
||||||
- search/create/update one task
|
|
||||||
- record plan
|
|
||||||
- dispatch one worker
|
|
||||||
- integrate
|
|
||||||
- verify
|
|
||||||
- update task and report outcome
|
|
||||||
|
|
||||||
### Parent + subtasks execution
|
|
||||||
|
|
||||||
- search/create/update parent task
|
|
||||||
- create subtasks for distinct deliverables/phases
|
|
||||||
- record sequencing in the plan
|
|
||||||
- dispatch workers only where scopes are disjoint
|
|
||||||
- integrate
|
|
||||||
- run consolidated verification
|
|
||||||
- update task state and report outcome
|
|
||||||
|
|
||||||
## Output Expectations
|
|
||||||
|
|
||||||
At the end, report:
|
|
||||||
- whether backlog was used and what changed
|
|
||||||
- which workers were dispatched and what they owned
|
|
||||||
- what verification ran
|
|
||||||
- explicit answers to:
|
|
||||||
- docs update required?
|
|
||||||
- changelog fragment required?
|
|
||||||
- blockers, skips, and risks
|
|
||||||
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
@@ -9,6 +9,9 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality-gate:
|
quality-gate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -241,8 +244,6 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
needs: [build-linux, build-macos, build-windows]
|
needs: [build-linux, build-macos, build-windows]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -317,7 +318,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Get version from tag
|
- name: Get version from tag
|
||||||
id: version
|
id: version
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Verify changelog is ready for tagged release
|
- name: Verify changelog is ready for tagged release
|
||||||
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
|
||||||
@@ -362,84 +363,3 @@ jobs:
|
|||||||
for asset in "${artifacts[@]}"; do
|
for asset in "${artifacts[@]}"; do
|
||||||
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
aur-publish:
|
|
||||||
needs: [release]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Validate AUR SSH secret
|
|
||||||
env:
|
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
|
||||||
echo "Missing required secret: AUR_SSH_PRIVATE_KEY"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Configure SSH for AUR
|
|
||||||
env:
|
|
||||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
install -dm700 ~/.ssh
|
|
||||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
|
||||||
chmod 600 ~/.ssh/aur
|
|
||||||
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
|
|
||||||
chmod 644 ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Clone AUR repo
|
|
||||||
env:
|
|
||||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
|
||||||
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin
|
|
||||||
|
|
||||||
- name: Download release assets for AUR
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version="${{ steps.version.outputs.VERSION }}"
|
|
||||||
install -dm755 .tmp/aur-release-assets
|
|
||||||
gh release download "$version" \
|
|
||||||
--dir .tmp/aur-release-assets \
|
|
||||||
--pattern "SubMiner-${version#v}.AppImage" \
|
|
||||||
--pattern "subminer" \
|
|
||||||
--pattern "subminer-assets.tar.gz"
|
|
||||||
|
|
||||||
- name: Update AUR packaging metadata
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version_no_v="${{ steps.version.outputs.VERSION }}"
|
|
||||||
version_no_v="${version_no_v#v}"
|
|
||||||
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
|
|
||||||
bash scripts/update-aur-package.sh \
|
|
||||||
--pkg-dir aur-subminer-bin \
|
|
||||||
--version "${{ steps.version.outputs.VERSION }}" \
|
|
||||||
--appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \
|
|
||||||
--wrapper ".tmp/aur-release-assets/subminer" \
|
|
||||||
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
|
|
||||||
|
|
||||||
- name: Commit and push AUR update
|
|
||||||
working-directory: aur-subminer-bin
|
|
||||||
env:
|
|
||||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if git diff --quiet -- PKGBUILD .SRCINFO; then
|
|
||||||
echo "AUR packaging already up to date."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add PKGBUILD .SRCINFO
|
|
||||||
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
|
|
||||||
git push origin HEAD:master
|
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -35,20 +35,6 @@ docs/.vitepress/cache/
|
|||||||
docs/.vitepress/dist/
|
docs/.vitepress/dist/
|
||||||
tests/*
|
tests/*
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.tmp/
|
|
||||||
.codex/*
|
.codex/*
|
||||||
.agents/*
|
.agents/*
|
||||||
!.agents/skills/
|
|
||||||
.agents/skills/*
|
|
||||||
!.agents/skills/subminer-change-verification/
|
|
||||||
!.agents/skills/subminer-scrum-master/
|
|
||||||
.agents/skills/subminer-change-verification/*
|
|
||||||
!.agents/skills/subminer-change-verification/SKILL.md
|
|
||||||
!.agents/skills/subminer-change-verification/scripts/
|
|
||||||
.agents/skills/subminer-change-verification/scripts/*
|
|
||||||
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
|
||||||
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
|
||||||
.agents/skills/subminer-scrum-master/*
|
|
||||||
!.agents/skills/subminer-scrum-master/SKILL.md
|
|
||||||
favicon.png
|
favicon.png
|
||||||
.claude/*
|
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,19 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.6.4 (2026-03-15)
|
|
||||||
|
|
||||||
### Internal
|
|
||||||
- Release: Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
|
||||||
|
|
||||||
## v0.6.3 (2026-03-15)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Overlay: Expanded the `Alt+C` controller modal into an inline config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
|
|
||||||
|
|
||||||
### Internal
|
|
||||||
- Workflow: Hardened the `subminer-scrum-master` skill to explicitly answer whether docs updates and changelog fragments are required before handoff.
|
|
||||||
- Release: Automate `subminer-bin` AUR package updates from the tagged release workflow.
|
|
||||||
|
|
||||||
## v0.6.2 (2026-03-12)
|
## v0.6.2 (2026-03-12)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-159
|
|
||||||
title: Add overlay controller support for keyboard-only mode
|
|
||||||
status: Done
|
|
||||||
assignee:
|
|
||||||
- codex
|
|
||||||
created_date: '2026-03-11 00:30'
|
|
||||||
updated_date: '2026-03-11 04:05'
|
|
||||||
labels:
|
|
||||||
- enhancement
|
|
||||||
- renderer
|
|
||||||
- overlay
|
|
||||||
- input
|
|
||||||
dependencies:
|
|
||||||
- TASK-86
|
|
||||||
references:
|
|
||||||
- src/renderer/handlers/keyboard.ts
|
|
||||||
- src/renderer/renderer.ts
|
|
||||||
- src/renderer/state.ts
|
|
||||||
- src/renderer/index.html
|
|
||||||
- src/renderer/style.css
|
|
||||||
- src/preload.ts
|
|
||||||
- src/types.ts
|
|
||||||
- src/config/definitions/defaults-core.ts
|
|
||||||
- src/config/definitions/options-core.ts
|
|
||||||
- src/config/definitions/template-sections.ts
|
|
||||||
- config.example.jsonc
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
|
|
||||||
Add Chrome Gamepad API support to the visible overlay as a supplement to keyboard-only mode. By default SubMiner should bind to the first available controller, allow the user to pick and persist a preferred controller, expose a raw-input debug modal, and map controller actions onto the existing keyboard-only/Yomitan flow without breaking keyboard input. Also fix the current keyboard-only cleanup bug so the selected-token highlight clears when keyboard-only mode turns off or when the Yomitan popup closes.
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
|
|
||||||
- [x] #1 Controller input is ignored unless keyboard-only mode is enabled, except the controller binding for toggling keyboard-only mode itself.
|
|
||||||
- [x] #2 Default logical mappings work: smooth popup scroll, token selection, lookup toggle/close, mining, Yomitan audio navigation/play, and mpv play/pause.
|
|
||||||
- [x] #3 Controller config supports named logical bindings plus tuning knobs (preferred controller, deadzones, smooth-scroll speed/repeat), not raw axis/button maps.
|
|
||||||
- [x] #4 `Alt+C` opens a controller selection modal listing connected controllers; saving a choice persists the preferred controller for next launch.
|
|
||||||
- [x] #5 `Alt+Shift+C` opens a debug modal showing live raw controller axes/buttons as seen by SubMiner.
|
|
||||||
- [x] #6 Keyboard-only selection highlight clears immediately when keyboard-only mode is disabled or the Yomitan popup closes.
|
|
||||||
- [x] #7 Renderer/config regression tests cover controller gating, mappings, modal behavior, persisted selection, and highlight cleanup.
|
|
||||||
- [x] #8 Docs/config example describe the controller feature and new shortcuts.
|
|
||||||
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
- Added renderer-side gamepad polling and logical action mapping in `src/renderer/handlers/gamepad-controller.ts`.
|
|
||||||
- Added controller select/debug modals, persisted preferred-controller IPC, and top-level `controller` config defaults/schema/template output.
|
|
||||||
- Added a transient in-overlay controller status indicator when a controller is first detected.
|
|
||||||
- Tuned controller defaults and routing after live testing: d-pad fallback navigation, slower repeat timing, DOM-backed popup-open detection, and direct pixel scroll/audio-source popup bridge commands.
|
|
||||||
- Reused existing keyboard-only lookup/mining/navigation flows so controller input stays a supplement to keyboard-only mode instead of a parallel input path.
|
|
||||||
- Verified keyboard-only highlight cleanup on mode-off and popup-close paths with renderer tests.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `bun test src/config/config.test.ts src/config/definitions/domain-registry.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/handlers/gamepad-controller.test.ts src/renderer/modals/controller-select.test.ts src/renderer/modals/controller-debug.test.ts src/core/services/ipc.test.ts`
|
|
||||||
- `bun test src/main/runtime/composers/ipc-runtime-composer.test.ts`
|
|
||||||
- `bun run generate:config-example`
|
|
||||||
- `bun run typecheck`
|
|
||||||
- `bun run docs:test`
|
|
||||||
- `bun run test:fast`
|
|
||||||
- `bun run test:env`
|
|
||||||
- `bun run build`
|
|
||||||
- `bun run docs:build`
|
|
||||||
- `bun run test:smoke:dist`
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-165
|
|
||||||
title: Automate AUR publish on tagged releases
|
|
||||||
status: Done
|
|
||||||
assignee:
|
|
||||||
- codex
|
|
||||||
created_date: '2026-03-14 15:55'
|
|
||||||
updated_date: '2026-03-14 18:40'
|
|
||||||
labels:
|
|
||||||
- release
|
|
||||||
- packaging
|
|
||||||
- linux
|
|
||||||
dependencies:
|
|
||||||
- TASK-161
|
|
||||||
references:
|
|
||||||
- .github/workflows/release.yml
|
|
||||||
- src/release-workflow.test.ts
|
|
||||||
- docs/RELEASING.md
|
|
||||||
- packaging/aur/subminer-bin/PKGBUILD
|
|
||||||
documentation:
|
|
||||||
- docs/plans/2026-03-14-aur-release-sync-design.md
|
|
||||||
- docs/plans/2026-03-14-aur-release-sync.md
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Extend the tagged release workflow so a successful GitHub release automatically updates the `subminer-bin` AUR package over SSH. Keep the PKGBUILD source-of-truth in this repo so release automation is reviewable and testable instead of depending on an external maintainer checkout.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [x] #1 Repo-tracked AUR packaging source exists for `subminer-bin` and matches the current release artifact layout.
|
|
||||||
- [x] #2 The release workflow clones `ssh://aur@aur.archlinux.org/subminer-bin.git` with a dedicated secret-backed SSH key only after release artifacts are ready.
|
|
||||||
- [x] #3 The workflow updates `pkgver`, regenerates `sha256sums` from the built release artifacts, regenerates `.SRCINFO`, and pushes only when packaging files changed.
|
|
||||||
- [x] #4 Regression coverage fails if the AUR publish job, secret contract, or update steps are removed from the release workflow.
|
|
||||||
- [x] #5 Release docs mention the required `AUR_SSH_PRIVATE_KEY` setup and the new tagged-release side effect.
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
|
||||||
1. Record the approved design and implementation plan for direct AUR publishing from the release workflow.
|
|
||||||
2. Add failing release workflow regression tests covering the new AUR publish job, SSH secret, and PKGBUILD/.SRCINFO regeneration steps.
|
|
||||||
3. Reintroduce repo-tracked `packaging/aur/subminer-bin` source files as the maintained AUR template.
|
|
||||||
4. Add a small helper script that updates `pkgver`, computes checksums from release artifacts, and regenerates `.SRCINFO` deterministically.
|
|
||||||
5. Extend `.github/workflows/release.yml` with an AUR publish job that clones the AUR repo over SSH, runs the helper, commits only when needed, and pushes to `aur`.
|
|
||||||
6. Update release docs for the new secret/setup requirements and tagged-release behavior.
|
|
||||||
7. Run targeted workflow tests plus the SubMiner verification lane needed for workflow/docs changes, then update this task with results.
|
|
||||||
<!-- SECTION:PLAN:END -->
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
|
||||||
Added repo-tracked AUR packaging source under `packaging/aur/subminer-bin/` plus `scripts/update-aur-package.sh` to stamp `pkgver`, compute SHA-256 sums from release assets, and regenerate `.SRCINFO` with `makepkg --printsrcinfo`.
|
|
||||||
|
|
||||||
Extended `.github/workflows/release.yml` with a terminal `aur-publish` job that runs after `release`, validates `AUR_SSH_PRIVATE_KEY`, installs `makepkg`, configures SSH/known_hosts, clones `ssh://aur@aur.archlinux.org/subminer-bin.git`, downloads the just-published `SubMiner-<version>.AppImage`, `subminer`, and `subminer-assets.tar.gz` assets, updates packaging metadata, and pushes only when `PKGBUILD` or `.SRCINFO` changed.
|
|
||||||
|
|
||||||
Updated `src/release-workflow.test.ts` with regression assertions for the AUR publish contract and updated `docs/RELEASING.md` with the new secret/setup requirement.
|
|
||||||
|
|
||||||
Verification run:
|
|
||||||
- `bun test src/release-workflow.test.ts src/ci-workflow.test.ts`
|
|
||||||
- `bash -n scripts/update-aur-package.sh && bash -n packaging/aur/subminer-bin/PKGBUILD`
|
|
||||||
- `cd packaging/aur/subminer-bin && makepkg --printsrcinfo > .SRCINFO`
|
|
||||||
- updater smoke via temp package dir with fake assets and `v9.9.9`
|
|
||||||
- `bun run typecheck`
|
|
||||||
- `bun run test:fast`
|
|
||||||
- `bun run test:env`
|
|
||||||
- `git submodule update --init --recursive` (required because the worktree lacked release submodules)
|
|
||||||
- `bun run build`
|
|
||||||
- `bun run test:smoke:dist`
|
|
||||||
|
|
||||||
Docs update required: yes, completed in `docs/RELEASING.md`.
|
|
||||||
Changelog fragment required: no; internal release automation only.
|
|
||||||
<!-- SECTION:NOTES:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
Tagged releases now attempt a direct AUR sync for `subminer-bin` using a dedicated SSH private key stored in `AUR_SSH_PRIVATE_KEY`. The release workflow clones the AUR repo after GitHub Release publication, rewrites `PKGBUILD` and `.SRCINFO` from the published release assets, and skips empty pushes. Repo-owned packaging source and workflow regression coverage were added so the automation remains reviewable and testable.
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-165
|
|
||||||
title: Make controller configuration easier with inline remapping modal
|
|
||||||
status: To Do
|
|
||||||
assignee:
|
|
||||||
- Codex
|
|
||||||
created_date: '2026-03-13 00:10'
|
|
||||||
updated_date: '2026-03-13 00:10'
|
|
||||||
labels:
|
|
||||||
- enhancement
|
|
||||||
- renderer
|
|
||||||
- overlay
|
|
||||||
- input
|
|
||||||
- config
|
|
||||||
dependencies:
|
|
||||||
- TASK-159
|
|
||||||
references:
|
|
||||||
- src/renderer/modals/controller-select.ts
|
|
||||||
- src/renderer/modals/controller-debug.ts
|
|
||||||
- src/renderer/handlers/gamepad-controller.ts
|
|
||||||
- src/renderer/index.html
|
|
||||||
- src/renderer/style.css
|
|
||||||
- src/renderer/utils/dom.ts
|
|
||||||
- src/preload.ts
|
|
||||||
- src/core/services/ipc.ts
|
|
||||||
- src/main.ts
|
|
||||||
- src/types.ts
|
|
||||||
- src/config/definitions/defaults-core.ts
|
|
||||||
- src/config/definitions/options-core.ts
|
|
||||||
- config.example.jsonc
|
|
||||||
- docs/plans/2026-03-13-overlay-controller-config-remap-design.md
|
|
||||||
- docs/plans/2026-03-13-overlay-controller-config-remap.md
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Replace the current controller-selection-only modal with a denser controller configuration surface that keeps device selection and adds inline controller remapping. The new flow should feel like emulator configuration: pick an overlay action, arm capture, then press the matching controller button, trigger, d-pad direction, or stick direction to bind it. Keep the current overlay-local renderer architecture, preserve controller gating to keyboard-only mode, and retain the separate raw debug modal for troubleshooting.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 `Alt+C` opens a controller modal that includes both preferred-controller selection and controller-config editing in one surface.
|
|
||||||
- [ ] #2 Controller device selection uses a compact dropdown or equivalent compact picker instead of the current full-height device list.
|
|
||||||
- [ ] #3 Each remappable controller action shows its current binding and supports learn/capture, clear, and reset-to-default flows.
|
|
||||||
- [ ] #4 Learn mode captures the next fresh controller input edge or stick/d-pad direction, not a held/stale input.
|
|
||||||
- [ ] #5 Captured bindings can represent non-standard controllers without depending only on the browser's standard semantic button names.
|
|
||||||
- [ ] #6 Updated bindings persist through the existing config pipeline and take effect in the renderer without restart unless a field explicitly requires reopen/reload.
|
|
||||||
- [ ] #7 Existing controller behavior remains gated to keyboard-only mode except for the controller action that toggles keyboard-only mode itself.
|
|
||||||
- [ ] #8 Renderer/config/IPC regression tests cover the new modal layout, capture flow, persistence, and runtime mapping behavior.
|
|
||||||
- [ ] #9 Docs/config example explain the new controller-config flow and when to use the debug modal.
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
|
||||||
1. Add the design doc and implementation plan for inline controller remapping, tied to a new backlog task instead of reopening the already-completed base controller-support task.
|
|
||||||
2. Expand controller config types/defaults/template output so action bindings can store captured input descriptors, not only semantic button-name enums.
|
|
||||||
3. Extend preload/main/IPC write paths from preferred-controller-only saves to full controller-config patching needed by the modal.
|
|
||||||
4. Redesign the controller modal UI into a compact device picker plus action-binding editor with learn, clear, and reset affordances.
|
|
||||||
5. Add renderer capture state and a learn-mode runtime that waits for neutral-to-active transitions before saving a binding.
|
|
||||||
6. Update the gamepad runtime to resolve the new stored descriptors into actions while preserving current gating and repeat/deadzone behavior.
|
|
||||||
7. Keep the raw debug modal as a separate advanced surface; optionally expose copyable input-descriptor text for troubleshooting.
|
|
||||||
8. Add focused regression tests first, then run the maintained gate needed for docs/config/renderer/main changes.
|
|
||||||
<!-- SECTION:PLAN:END -->
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
|
||||||
Planning only in this pass.
|
|
||||||
|
|
||||||
Current-state findings:
|
|
||||||
|
|
||||||
- `src/renderer/modals/controller-select.ts` only persists `preferredGamepadId` / `preferredGamepadLabel`.
|
|
||||||
- `src/preload.ts`, `src/core/services/ipc.ts`, and `src/main.ts` only expose a narrow save path for preferred controller, not general controller config writes.
|
|
||||||
- `src/renderer/handlers/gamepad-controller.ts` currently resolves actions from semantic button bindings plus a few axis slots; this is fine for defaults but too narrow for emulator-style learn mode on non-standard controllers.
|
|
||||||
- `src/renderer/modals/controller-debug.ts` already provides the raw input surface needed for troubleshooting and for validating capture behavior.
|
|
||||||
|
|
||||||
Recommended direction:
|
|
||||||
|
|
||||||
- keep `Alt+C` as the single controller-config entrypoint
|
|
||||||
- keep `Alt+Shift+C` as raw debug
|
|
||||||
- introduce stored input descriptors for discrete bindings so learn mode can capture buttons, triggers, d-pad directions, and stick directions directly
|
|
||||||
- defer per-controller profiles; keep one global binding set plus preferred-controller selection for this pass
|
|
||||||
<!-- SECTION:NOTES:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
Planned follow-up work to make controller configuration materially easier than the current “pick preferred device” modal. The proposed change keeps existing controller runtime/debug foundations, but upgrades the selection modal into a compact controller-config surface with inline learn-mode remapping and persistent binding storage.
|
|
||||||
|
|
||||||
Main architectural change in scope: move from semantic-button-only binding storage toward captured input descriptors so the UI can reliably learn from buttons, triggers, d-pad directions, and stick directions on non-standard controllers.
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -50,97 +50,6 @@
|
|||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Controller Support
|
|
||||||
// Gamepad support for the visible overlay while keyboard-only mode is active.
|
|
||||||
// Use Alt+C to pick a preferred controller and remap actions inline with learn mode.
|
|
||||||
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
|
|
||||||
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
|
||||||
// ==========================================
|
|
||||||
"controller": {
|
|
||||||
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
|
||||||
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
|
|
||||||
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
|
||||||
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
|
||||||
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
|
|
||||||
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
|
||||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
|
||||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
|
||||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
|
||||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
|
||||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
|
||||||
"buttonIndices": {
|
|
||||||
"select": 6, // Raw button index used for the controller select/minus/back button.
|
|
||||||
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
|
||||||
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
|
||||||
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
|
||||||
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
|
||||||
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
|
||||||
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
|
||||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
|
||||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
|
||||||
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
|
||||||
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
|
||||||
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
|
||||||
"bindings": {
|
|
||||||
"toggleLookup": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"closeLookup": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"toggleKeyboardOnlyMode": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"mineCard": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"quitMpv": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"previousAudio": {
|
|
||||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"nextAudio": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"playCurrentAudio": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"toggleMpvPause": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"leftStickHorizontal": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"leftStickVertical": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"rightStickHorizontal": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"rightStickVertical": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Startup Warmups
|
// Startup Warmups
|
||||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.6.4 (2026-03-15)
|
|
||||||
- Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
|
||||||
|
|
||||||
## v0.6.3 (2026-03-15)
|
|
||||||
- Expanded `Alt+C` into an inline controller config/remap flow with preferred-controller saving and per-action learn mode for buttons, triggers, and stick directions.
|
|
||||||
- Automated `subminer-bin` AUR package updates from the tagged release workflow.
|
|
||||||
|
|
||||||
## v0.6.2 (2026-03-12)
|
## v0.6.2 (2026-03-12)
|
||||||
- Added `yomitan.externalProfilePath` so SubMiner can reuse another Electron app's Yomitan profile in read-only mode.
|
- Added `yomitan.externalProfilePath` so SubMiner can reuse another Electron app's Yomitan profile in read-only mode.
|
||||||
- Reused external Yomitan dictionaries/settings without writing back to that profile.
|
- Reused external Yomitan dictionaries/settings without writing back to that profile.
|
||||||
@@ -14,11 +7,11 @@
|
|||||||
- Seeded `config.jsonc` even when the default config directory already exists.
|
- Seeded `config.jsonc` even when the default config directory already exists.
|
||||||
- Let first-run setup complete without internal dictionaries while external Yomitan is configured, then require an internal dictionary again only if that external profile is later removed.
|
- Let first-run setup complete without internal dictionaries while external Yomitan is configured, then require an internal dictionary again only if that external profile is later removed.
|
||||||
|
|
||||||
## v0.6.1 (2026-03-12)
|
## v0.6.0 (2026-03-12)
|
||||||
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
- Added Chrome Gamepad API controller support for keyboard-only overlay mode.
|
||||||
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
- Added configurable controller bindings for lookup, mining, popup navigation, Yomitan audio, mpv pause, and d-pad fallback navigation.
|
||||||
- Added smooth, slower popup scrolling for controller navigation.
|
- Added smooth, slower popup scrolling for controller navigation.
|
||||||
- Expanded `Alt+C` into a controller config/remap modal with preferred-controller saving, inline learn mode, and kept `Alt+Shift+C` for raw input debugging.
|
- Added `Alt+C` controller selection and `Alt+Shift+C` controller debug modals, with preferred controller persistence and live raw input inspection.
|
||||||
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
- Added a transient in-overlay controller-detected indicator when a controller is first found.
|
||||||
- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes.
|
- Fixed cleanup of stale keyboard-only token highlights when keyboard-only mode is disabled or when the Yomitan popup closes.
|
||||||
- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.
|
- Added an enforced `verify:config-example` gate so checked-in example config artifacts cannot drift silently.
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ The configuration file includes several main sections:
|
|||||||
|
|
||||||
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
- [**Keybindings**](#keybindings) - MPV command shortcuts
|
||||||
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts
|
||||||
- [**Controller Support**](#controller-support) - Gamepad support for keyboard-only mode
|
|
||||||
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
- [**Manual Card Update Shortcuts**](#manual-card-update-shortcuts) - Shortcuts for manual Anki card workflows
|
||||||
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
- [**Session Help Modal**](#session-help-modal) - In-overlay shortcut reference
|
||||||
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
|
||||||
@@ -505,108 +504,6 @@ Set any shortcut to `null` to disable it.
|
|||||||
|
|
||||||
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled.
|
||||||
|
|
||||||
### Controller Support
|
|
||||||
|
|
||||||
SubMiner can read controllers through the Chrome Gamepad API and map them onto the existing keyboard-only overlay workflow.
|
|
||||||
|
|
||||||
Important behavior:
|
|
||||||
|
|
||||||
- Controller input is only active while keyboard-only mode is enabled.
|
|
||||||
- Keyboard-only mode continues to work normally without a controller.
|
|
||||||
- By default SubMiner uses the first connected controller.
|
|
||||||
- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline.
|
|
||||||
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
|
||||||
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
|
||||||
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
|
||||||
- Turning keyboard-only mode off clears the keyboard-only token highlight state.
|
|
||||||
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"controller": {
|
|
||||||
"enabled": true,
|
|
||||||
"preferredGamepadId": "",
|
|
||||||
"preferredGamepadLabel": "",
|
|
||||||
"smoothScroll": true,
|
|
||||||
"scrollPixelsPerSecond": 900,
|
|
||||||
"horizontalJumpPixels": 160,
|
|
||||||
"stickDeadzone": 0.2,
|
|
||||||
"triggerInputMode": "auto",
|
|
||||||
"triggerDeadzone": 0.5,
|
|
||||||
"repeatDelayMs": 320,
|
|
||||||
"repeatIntervalMs": 120,
|
|
||||||
"buttonIndices": {
|
|
||||||
"select": 6,
|
|
||||||
"buttonSouth": 0,
|
|
||||||
"buttonEast": 1,
|
|
||||||
"buttonWest": 2,
|
|
||||||
"buttonNorth": 3,
|
|
||||||
"leftShoulder": 4,
|
|
||||||
"rightShoulder": 5,
|
|
||||||
"leftStickPress": 9,
|
|
||||||
"rightStickPress": 10,
|
|
||||||
"leftTrigger": 6,
|
|
||||||
"rightTrigger": 7
|
|
||||||
},
|
|
||||||
"bindings": {
|
|
||||||
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
|
||||||
"closeLookup": { "kind": "button", "buttonIndex": 1 },
|
|
||||||
"toggleKeyboardOnlyMode": { "kind": "button", "buttonIndex": 3 },
|
|
||||||
"mineCard": { "kind": "button", "buttonIndex": 2 },
|
|
||||||
"quitMpv": { "kind": "button", "buttonIndex": 6 },
|
|
||||||
"previousAudio": { "kind": "none" },
|
|
||||||
"nextAudio": { "kind": "button", "buttonIndex": 5 },
|
|
||||||
"playCurrentAudio": { "kind": "button", "buttonIndex": 4 },
|
|
||||||
"toggleMpvPause": { "kind": "button", "buttonIndex": 9 },
|
|
||||||
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
|
|
||||||
"leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
|
|
||||||
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
|
||||||
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Default logical mapping:
|
|
||||||
|
|
||||||
- Left stick up/down: scroll Yomitan popup
|
|
||||||
- Left stick left/right: move subtitle token selection
|
|
||||||
- Right stick up/down: page-jump through Yomitan popup
|
|
||||||
- Right stick left/right: unused by default
|
|
||||||
- `A`: toggle lookup
|
|
||||||
- `B`: close lookup
|
|
||||||
- `Y`: toggle keyboard-only mode
|
|
||||||
- `X`: mine card
|
|
||||||
- `Minus` / `Select`: quit mpv
|
|
||||||
- `L1`: play current Yomitan audio (falls back to the first available track)
|
|
||||||
- `R1`: move to the next available Yomitan audio track
|
|
||||||
- `L3`: toggle mpv pause
|
|
||||||
- `L2` / `R2`: unbound by default
|
|
||||||
|
|
||||||
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
|
|
||||||
|
|
||||||
If you bind a discrete action to an axis manually, include `direction`:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"controller": {
|
|
||||||
"bindings": {
|
|
||||||
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
|
||||||
|
|
||||||
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
|
||||||
|
|
||||||
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
|
|
||||||
|
|
||||||
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
|
||||||
|
|
||||||
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
|
||||||
|
|
||||||
### Manual Card Update Shortcuts
|
### Manual Card Update Shortcuts
|
||||||
|
|
||||||
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { expect, test } from 'bun:test';
|
import { expect, test } from 'bun:test';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
const rootChangelogContents = readFileSync(new URL('../CHANGELOG.md', import.meta.url), 'utf8');
|
|
||||||
const readmeContents = readFileSync(new URL('./README.md', import.meta.url), 'utf8');
|
const readmeContents = readFileSync(new URL('./README.md', import.meta.url), 'utf8');
|
||||||
const usageContents = readFileSync(new URL('./usage.md', import.meta.url), 'utf8');
|
const usageContents = readFileSync(new URL('./usage.md', import.meta.url), 'utf8');
|
||||||
const installationContents = readFileSync(new URL('./installation.md', import.meta.url), 'utf8');
|
const installationContents = readFileSync(new URL('./installation.md', import.meta.url), 'utf8');
|
||||||
@@ -11,12 +10,6 @@ const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url
|
|||||||
const ankiIntegrationContents = readFileSync(new URL('./anki-integration.md', import.meta.url), 'utf8');
|
const ankiIntegrationContents = readFileSync(new URL('./anki-integration.md', import.meta.url), 'utf8');
|
||||||
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
||||||
|
|
||||||
function extractReleaseHeadings(content: string, count: number): string[] {
|
|
||||||
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
|
||||||
.map(([heading]) => heading)
|
|
||||||
.slice(0, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
test('docs reflect current launcher and release surfaces', () => {
|
test('docs reflect current launcher and release surfaces', () => {
|
||||||
expect(usageContents).not.toContain('--mode preprocess');
|
expect(usageContents).not.toContain('--mode preprocess');
|
||||||
expect(usageContents).not.toContain('"automatic" (default)');
|
expect(usageContents).not.toContain('"automatic" (default)');
|
||||||
@@ -44,7 +37,3 @@ test('docs reflect current launcher and release surfaces', () => {
|
|||||||
|
|
||||||
expect(changelogContents).toContain('## v0.5.1 (2026-03-09)');
|
expect(changelogContents).toContain('## v0.5.1 (2026-03-09)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('docs changelog keeps the newest release headings aligned with the root changelog', () => {
|
|
||||||
expect(extractReleaseHeadings(changelogContents, 3)).toEqual(extractReleaseHeadings(rootChangelogContents, 3));
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -59,22 +59,6 @@ Jimaku search, field-grouping, runtime options, and manual subsync open as modal
|
|||||||
3. Yomitan detects the selection and opens its lookup popup.
|
3. Yomitan detects the selection and opens its lookup popup.
|
||||||
4. From the popup, add the word to Anki.
|
4. From the popup, add the word to Anki.
|
||||||
|
|
||||||
### Controller Workflow
|
|
||||||
|
|
||||||
With a gamepad connected and keyboard-only mode enabled, the full mining loop works without a mouse or keyboard:
|
|
||||||
|
|
||||||
1. **Navigate** — push the left stick left/right to move the token highlight across subtitle words.
|
|
||||||
2. **Look up** — press `A` to trigger Yomitan lookup on the highlighted word.
|
|
||||||
3. **Browse the popup** — push the left stick up/down to smooth-scroll through the Yomitan popup, or use the right stick for larger jumps.
|
|
||||||
4. **Cycle audio** — press `R1` to move to the next dictionary audio entry, `L1` to play the current one.
|
|
||||||
5. **Mine** — press `X` to create an Anki card for the current sentence (same as `Ctrl+S`).
|
|
||||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
|
||||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
|
||||||
|
|
||||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
|
||||||
|
|
||||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
|
||||||
|
|
||||||
## Creating Anki Cards
|
## Creating Anki Cards
|
||||||
|
|
||||||
There are three ways to create cards, depending on your workflow.
|
There are three ways to create cards, depending on your workflow.
|
||||||
|
|||||||
@@ -50,97 +50,6 @@
|
|||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
}, // Controls logging verbosity.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Controller Support
|
|
||||||
// Gamepad support for the visible overlay while keyboard-only mode is active.
|
|
||||||
// Use Alt+C to pick a preferred controller and remap actions inline with learn mode.
|
|
||||||
// Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.
|
|
||||||
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
|
|
||||||
// ==========================================
|
|
||||||
"controller": {
|
|
||||||
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
|
|
||||||
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
|
|
||||||
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
|
|
||||||
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
|
|
||||||
"scrollPixelsPerSecond": 900, // Base popup scroll speed for controller stick input.
|
|
||||||
"horizontalJumpPixels": 160, // Popup page-jump distance for controller jump input.
|
|
||||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
|
||||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
|
||||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
|
||||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
|
||||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
|
||||||
"buttonIndices": {
|
|
||||||
"select": 6, // Raw button index used for the controller select/minus/back button.
|
|
||||||
"buttonSouth": 0, // Raw button index used for controller south/A button input.
|
|
||||||
"buttonEast": 1, // Raw button index used for controller east/B button input.
|
|
||||||
"buttonWest": 2, // Raw button index used for controller west/X button input.
|
|
||||||
"buttonNorth": 3, // Raw button index used for controller north/Y button input.
|
|
||||||
"leftShoulder": 4, // Raw button index used for controller left shoulder input.
|
|
||||||
"rightShoulder": 5, // Raw button index used for controller right shoulder input.
|
|
||||||
"leftStickPress": 9, // Raw button index used for controller L3 input.
|
|
||||||
"rightStickPress": 10, // Raw button index used for controller R3 input.
|
|
||||||
"leftTrigger": 6, // Raw button index used for controller L2 input.
|
|
||||||
"rightTrigger": 7 // Raw button index used for controller R2 input.
|
|
||||||
}, // Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.
|
|
||||||
"bindings": {
|
|
||||||
"toggleLookup": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 0 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for toggling lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"closeLookup": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 1 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for closing lookup. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"toggleKeyboardOnlyMode": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 3 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for toggling keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"mineCard": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 2 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for mining the active card. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"quitMpv": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 6 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for quitting mpv. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"previousAudio": {
|
|
||||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
}, // Controller binding descriptor for previous Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"nextAudio": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 5 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for next Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"playCurrentAudio": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 4 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for playing the current Yomitan audio. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"toggleMpvPause": {
|
|
||||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
|
||||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
|
||||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
|
||||||
"leftStickHorizontal": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "horizontal" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor used for left/right token selection. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"leftStickVertical": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 1, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "vertical" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor used for primary popup scrolling. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"rightStickHorizontal": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 3, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
}, // Axis binding descriptor reserved for alternate right-stick mappings. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
"rightStickVertical": {
|
|
||||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
|
||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Startup Warmups
|
// Startup Warmups
|
||||||
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
// Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|||||||
@@ -69,17 +69,6 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
|
|
||||||
## Controller Shortcuts
|
|
||||||
|
|
||||||
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration.
|
|
||||||
|
|
||||||
| Shortcut | Action | Configurable |
|
|
||||||
| ------------- | ------------------------------ | ------------ |
|
|
||||||
| `Alt+C` | Open controller config + remap modal | Fixed |
|
|
||||||
| `Alt+Shift+C` | Open controller debug modal | Fixed |
|
|
||||||
|
|
||||||
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
|
|
||||||
|
|
||||||
## MPV Plugin Chords
|
## MPV Plugin Chords
|
||||||
|
|
||||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||||
|
|||||||
@@ -246,48 +246,6 @@ Notes:
|
|||||||
- `--whisper-threads`
|
- `--whisper-threads`
|
||||||
- `--yt-subgen-audio-format`
|
- `--yt-subgen-audio-format`
|
||||||
|
|
||||||
## Controller Support
|
|
||||||
|
|
||||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
|
||||||
|
|
||||||
### Getting Started
|
|
||||||
|
|
||||||
1. Connect a controller before or after launching SubMiner.
|
|
||||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
|
||||||
3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline.
|
|
||||||
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
|
||||||
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
|
||||||
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
|
||||||
|
|
||||||
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads.
|
|
||||||
|
|
||||||
### Default Button Mapping
|
|
||||||
|
|
||||||
| Button | Action |
|
|
||||||
| ------ | ------ |
|
|
||||||
| `A` (South) | Toggle lookup |
|
|
||||||
| `B` (East) | Close lookup |
|
|
||||||
| `Y` (North) | Toggle keyboard-only mode |
|
|
||||||
| `X` (West) | Mine card |
|
|
||||||
| `L1` | Play current Yomitan audio |
|
|
||||||
| `R1` | Next Yomitan audio track |
|
|
||||||
| `L3` (left stick press) | Toggle mpv pause |
|
|
||||||
| `Select` / `Minus` | Quit mpv |
|
|
||||||
| `L2` / `R2` | Unbound (available for custom bindings) |
|
|
||||||
|
|
||||||
### Analog Controls
|
|
||||||
|
|
||||||
| Input | Action |
|
|
||||||
| ----- | ------ |
|
|
||||||
| Left stick horizontal | Move token selection left/right |
|
|
||||||
| Left stick vertical | Scroll Yomitan popup |
|
|
||||||
| Right stick vertical | Jump through Yomitan popup |
|
|
||||||
| D-pad | Fallback for stick navigation when configured |
|
|
||||||
|
|
||||||
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
|
||||||
|
|
||||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining shortcuts, overlay controls, and customization.
|
||||||
|
|||||||
@@ -20,5 +20,3 @@ Notes:
|
|||||||
|
|
||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `changelog:check` now rejects tag/package version mismatches.
|
||||||
- Do not tag while `changes/*.md` fragments still exist.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
|
||||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
|
||||||
|
|||||||
105
docs/plans/2026-03-10-subminer-change-verification-design.md
Normal file
105
docs/plans/2026-03-10-subminer-change-verification-design.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# SubMiner Change Verification Skill Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-10
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create a SubMiner-specific skill that agents can use to verify code changes with automated checks. The skill must support both targeted regression testing during debugging and pre-handoff verification before final response.
|
||||||
|
|
||||||
|
## Skill Contract
|
||||||
|
|
||||||
|
- **Name:** `subminer-change-verification`
|
||||||
|
- **Trigger:** Use when working in the SubMiner repo and you need to verify code changes actually work, especially for launcher, mpv, plugin, overlay, runtime, Electron, or env-sensitive behavior.
|
||||||
|
- **Default posture:** cheap-first; prefer repo-native tests and narrow lanes before broader or GUI-dependent verification.
|
||||||
|
- **Outputs:**
|
||||||
|
- verification summary
|
||||||
|
- exact commands run
|
||||||
|
- artifact paths for logs, captured summaries, and preserved temp state on failures
|
||||||
|
- skipped lanes and blockers
|
||||||
|
- **Non-goals:**
|
||||||
|
- replacing the repo's native tests
|
||||||
|
- launching real GUI apps for every change
|
||||||
|
- default visual regression or pixel-diff workflows
|
||||||
|
|
||||||
|
## Lane Selection
|
||||||
|
|
||||||
|
The skill chooses lanes from the diff or explicit file list.
|
||||||
|
|
||||||
|
- **`docs`**
|
||||||
|
- For `docs-site/`, `docs/`, and similar documentation-only changes.
|
||||||
|
- Prefer `bun run docs:test` and `bun run docs:build`.
|
||||||
|
- **`config`**
|
||||||
|
- For `src/config/`, config example generation/verification paths, and config-template-sensitive changes.
|
||||||
|
- Prefer `bun run test:config`.
|
||||||
|
- **`core`**
|
||||||
|
- For general source-level changes where type safety and the fast maintained lane are the best cheap signal.
|
||||||
|
- Prefer `bun run typecheck` and `bun run test:fast`.
|
||||||
|
- **`launcher-plugin`**
|
||||||
|
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||||
|
- Prefer `bun run test:launcher:smoke:src` and `bun run test:plugin:src`.
|
||||||
|
- **`runtime-compat`**
|
||||||
|
- For runtime/composition/bundled behavior where dist-sensitive validation matters.
|
||||||
|
- Prefer `bun run build`, `bun run test:runtime:compat`, and `bun run test:smoke:dist`.
|
||||||
|
- **`real-gui`**
|
||||||
|
- Reserved for cases where actual Electron/mpv/window behavior must be validated.
|
||||||
|
- Not part of the default lane set; the classifier marks these changes as candidates so the agent can escalate deliberately.
|
||||||
|
|
||||||
|
## Escalation Rules
|
||||||
|
|
||||||
|
1. Start with the narrowest lane that credibly exercises the changed behavior.
|
||||||
|
2. If a narrow lane fails in a way that suggests broader fallout, expand once.
|
||||||
|
3. If a change touches launcher/mpv/plugin/runtime/overlay/window tracking paths, include the relevant specialized lanes before falling back to broad suites.
|
||||||
|
4. Treat real GUI/mpv verification as opt-in escalation:
|
||||||
|
- use only when cheaper evidence is insufficient
|
||||||
|
- allow for platform/display/permission blockers
|
||||||
|
- report skipped/blocker states explicitly
|
||||||
|
|
||||||
|
## Helper Script Design
|
||||||
|
|
||||||
|
The skill uses two small shell helpers:
|
||||||
|
|
||||||
|
- **`scripts/classify_subminer_diff.sh`**
|
||||||
|
- Accepts explicit paths or discovers local changes from git.
|
||||||
|
- Emits lane suggestions and flags in a simple line-oriented format.
|
||||||
|
- Marks real GUI-sensitive paths as `flag:real-gui-candidate` instead of forcing GUI execution.
|
||||||
|
- **`scripts/verify_subminer_change.sh`**
|
||||||
|
- Creates an artifact directory under `.tmp/skill-verification/<timestamp>/`.
|
||||||
|
- Selects lanes from the classifier unless lanes are supplied explicitly.
|
||||||
|
- Runs repo-native commands in a stable order and captures stdout/stderr per step.
|
||||||
|
- Writes a compact `summary.json` and a human-readable `summary.txt`.
|
||||||
|
- Skips real GUI verification unless explicitly enabled.
|
||||||
|
|
||||||
|
## Artifact Contract
|
||||||
|
|
||||||
|
Each invocation should create:
|
||||||
|
|
||||||
|
- `summary.json`
|
||||||
|
- `summary.txt`
|
||||||
|
- `classification.txt`
|
||||||
|
- `env.txt`
|
||||||
|
- `lanes.txt`
|
||||||
|
- `steps.tsv`
|
||||||
|
- `steps/<step>.stdout.log`
|
||||||
|
- `steps/<step>.stderr.log`
|
||||||
|
|
||||||
|
Failures should preserve the artifact directory and identify the exact failing command and log paths.
|
||||||
|
|
||||||
|
## Agent Workflow
|
||||||
|
|
||||||
|
1. Inspect changed files or requested area.
|
||||||
|
2. Classify the change into verification lanes.
|
||||||
|
3. Run the cheapest sufficient lane set.
|
||||||
|
4. Escalate only if evidence is insufficient.
|
||||||
|
5. Escalate to real GUI/mpv only for actual Electron/mpv/window behavior claims.
|
||||||
|
6. Return a short report with:
|
||||||
|
- pass/fail/skipped per lane
|
||||||
|
- exact commands run
|
||||||
|
- artifact paths
|
||||||
|
- blockers/gaps
|
||||||
|
|
||||||
|
## Initial Implementation Scope
|
||||||
|
|
||||||
|
- Ship the skill entrypoint plus the classifier/verifier helpers.
|
||||||
|
- Make real GUI verification an explicit future hook rather than a default workflow.
|
||||||
|
- Verify the new skill locally with representative classifier output and artifact generation.
|
||||||
111
docs/plans/2026-03-10-subminer-scrum-master-design.md
Normal file
111
docs/plans/2026-03-10-subminer-scrum-master-design.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# SubMiner Scrum Master Skill Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-10
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create a repo-local skill that can take incoming requests, bugs, or issues in the SubMiner repo, decide whether backlog tracking is warranted, create or update backlog work when appropriate, plan the implementation, dispatch one or more subagents, and ensure verification happens before handoff.
|
||||||
|
|
||||||
|
## Skill Contract
|
||||||
|
|
||||||
|
- **Name:** `subminer-scrum-master`
|
||||||
|
- **Location:** `.agents/skills/subminer-scrum-master/`
|
||||||
|
- **Use when:** the user gives a feature request, bug report, issue, refactor, or implementation ask in the SubMiner repo and the agent should own intake, planning, backlog hygiene, dispatch, and completion flow.
|
||||||
|
- **Responsibilities:**
|
||||||
|
- assess whether backlog tracking is warranted
|
||||||
|
- if needed, search/update/create proper backlog structure
|
||||||
|
- write a plan before dispatching coding work
|
||||||
|
- choose sequential vs parallel execution
|
||||||
|
- assign explicit ownership to workers
|
||||||
|
- require verification before final handoff
|
||||||
|
- **Limits:**
|
||||||
|
- not the default code implementer unless delegation would be wasteful
|
||||||
|
- no overlapping parallel write scopes
|
||||||
|
- no skipping planning before dispatch
|
||||||
|
- no skipping verification
|
||||||
|
- must pause for ambiguous, risky, or external side-effect work
|
||||||
|
|
||||||
|
## Backlog Decision Rules
|
||||||
|
|
||||||
|
Backlog use is conditional, not mandatory.
|
||||||
|
|
||||||
|
- **Skip backlog when:**
|
||||||
|
- question only
|
||||||
|
- obvious mechanical edit
|
||||||
|
- tiny isolated change with no real planning
|
||||||
|
- **Use backlog when:**
|
||||||
|
- implementation requires planning
|
||||||
|
- scope/approach needs decisions
|
||||||
|
- multiple phases or subsystems
|
||||||
|
- likely subagent dispatch
|
||||||
|
- work should remain traceable
|
||||||
|
|
||||||
|
When backlog is used:
|
||||||
|
- search first
|
||||||
|
- update existing matching work when appropriate
|
||||||
|
- otherwise create standalone task or parent task
|
||||||
|
- use parent + subtasks for multi-part work
|
||||||
|
- record the implementation plan before coding starts
|
||||||
|
|
||||||
|
## Orchestration Policy
|
||||||
|
|
||||||
|
The skill orchestrates; workers implement.
|
||||||
|
|
||||||
|
- **No dispatch** for trivial/mechanical work
|
||||||
|
- **Single worker** for focused single-scope work
|
||||||
|
- **Parallel workers** only for clearly disjoint scopes
|
||||||
|
- **Sequential flow** for shared files, runtime coupling, or unclear boundaries
|
||||||
|
|
||||||
|
Every worker should receive:
|
||||||
|
- owned files/modules
|
||||||
|
- explicit reminder not to revert unrelated edits
|
||||||
|
- requirement to report changed files, tests run, and blockers
|
||||||
|
|
||||||
|
## Verification Policy
|
||||||
|
|
||||||
|
Every nontrivial code task gets verification.
|
||||||
|
|
||||||
|
- prefer `subminer-change-verification`
|
||||||
|
- use cheap-first lanes
|
||||||
|
- escalate only when needed
|
||||||
|
- accept worker-run verification only if it is clearly relevant and sufficient
|
||||||
|
- run a consolidating final verification pass when the scrum master needs stronger evidence
|
||||||
|
|
||||||
|
## Representative Flows
|
||||||
|
|
||||||
|
### Trivial fix, no backlog
|
||||||
|
|
||||||
|
1. assess request as mechanical or narrowly reversible
|
||||||
|
2. skip backlog
|
||||||
|
3. keep a short internal plan
|
||||||
|
4. implement directly or use one worker if helpful
|
||||||
|
5. run targeted verification
|
||||||
|
6. report concise summary
|
||||||
|
|
||||||
|
### Single-task implementation
|
||||||
|
|
||||||
|
1. search backlog
|
||||||
|
2. create/update one task
|
||||||
|
3. record plan
|
||||||
|
4. dispatch one worker
|
||||||
|
5. integrate result
|
||||||
|
6. run verification
|
||||||
|
7. update task and report outcome
|
||||||
|
|
||||||
|
### Multi-part feature
|
||||||
|
|
||||||
|
1. search backlog
|
||||||
|
2. create/update parent task
|
||||||
|
3. create subtasks for distinct deliverables/phases
|
||||||
|
4. record sequencing in the plan
|
||||||
|
5. dispatch workers only where write scopes do not overlap
|
||||||
|
6. integrate
|
||||||
|
7. run consolidated verification
|
||||||
|
8. update task state and report outcome
|
||||||
|
|
||||||
|
## V1 Scope
|
||||||
|
|
||||||
|
- instruction-heavy `SKILL.md`
|
||||||
|
- no helper scripts unless orchestration becomes too repetitive
|
||||||
|
- strong coordination with existing Backlog workflow and `subminer-change-verification`
|
||||||
@@ -17,9 +17,7 @@ import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './confi
|
|||||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||||
|
|
||||||
export function readExternalYomitanProfilePath(
|
export function readExternalYomitanProfilePath(root: Record<string, unknown> | null): string | null {
|
||||||
root: Record<string, unknown> | null,
|
|
||||||
): string | null {
|
|
||||||
const yomitan =
|
const yomitan =
|
||||||
root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan)
|
root?.yomitan && typeof root.yomitan === 'object' && !Array.isArray(root.yomitan)
|
||||||
? (root.yomitan as Record<string, unknown>)
|
? (root.yomitan as Record<string, unknown>)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.6.4",
|
"version": "0.6.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
pkgbase = subminer-bin
|
|
||||||
pkgdesc = All-in-one sentence mining overlay with AnkiConnect and dictionary integration
|
|
||||||
pkgver = 0.6.2
|
|
||||||
pkgrel = 1
|
|
||||||
url = https://github.com/ksyasuda/SubMiner
|
|
||||||
arch = x86_64
|
|
||||||
license = GPL-3.0-or-later
|
|
||||||
depends = bun
|
|
||||||
depends = fuse2
|
|
||||||
depends = glibc
|
|
||||||
depends = mpv
|
|
||||||
depends = zlib-ng-compat
|
|
||||||
optdepends = ffmpeg: media extraction and screenshot generation
|
|
||||||
optdepends = ffmpegthumbnailer: faster thumbnail previews in the launcher
|
|
||||||
optdepends = fzf: terminal media picker in the subminer wrapper
|
|
||||||
optdepends = rofi: GUI media picker in the subminer wrapper
|
|
||||||
optdepends = chafa: image previews in the fzf picker
|
|
||||||
optdepends = yt-dlp: YouTube playback and subtitle extraction
|
|
||||||
optdepends = mecab: optional Japanese metadata enrichment
|
|
||||||
optdepends = mecab-ipadic: dictionary for MeCab metadata enrichment
|
|
||||||
optdepends = python-guessit: improved AniSkip title and episode inference
|
|
||||||
optdepends = alass-git: preferred subtitle synchronization engine
|
|
||||||
optdepends = python-ffsubsync: fallback subtitle synchronization engine
|
|
||||||
provides = subminer=0.6.2
|
|
||||||
conflicts = subminer
|
|
||||||
noextract = SubMiner-0.6.2.AppImage
|
|
||||||
options = !strip
|
|
||||||
options = !debug
|
|
||||||
source = SubMiner-0.6.2.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/SubMiner-0.6.2.AppImage
|
|
||||||
source = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer
|
|
||||||
source = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v0.6.2/subminer-assets.tar.gz
|
|
||||||
sha256sums = c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e
|
|
||||||
sha256sums = 85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5
|
|
||||||
sha256sums = 210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1
|
|
||||||
|
|
||||||
pkgname = subminer-bin
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Maintainer: Kyle Yasuda <suda@sudacode.com>
|
|
||||||
|
|
||||||
pkgname=subminer-bin
|
|
||||||
pkgver=0.6.2
|
|
||||||
pkgrel=1
|
|
||||||
pkgdesc='All-in-one sentence mining overlay with AnkiConnect and dictionary integration'
|
|
||||||
arch=('x86_64')
|
|
||||||
url='https://github.com/ksyasuda/SubMiner'
|
|
||||||
license=('GPL-3.0-or-later')
|
|
||||||
options=('!strip' '!debug')
|
|
||||||
depends=(
|
|
||||||
'bun'
|
|
||||||
'fuse2'
|
|
||||||
'glibc'
|
|
||||||
'mpv'
|
|
||||||
'zlib-ng-compat'
|
|
||||||
)
|
|
||||||
optdepends=(
|
|
||||||
'ffmpeg: media extraction and screenshot generation'
|
|
||||||
'ffmpegthumbnailer: faster thumbnail previews in the launcher'
|
|
||||||
'fzf: terminal media picker in the subminer wrapper'
|
|
||||||
'rofi: GUI media picker in the subminer wrapper'
|
|
||||||
'chafa: image previews in the fzf picker'
|
|
||||||
'yt-dlp: YouTube playback and subtitle extraction'
|
|
||||||
'mecab: optional Japanese metadata enrichment'
|
|
||||||
'mecab-ipadic: dictionary for MeCab metadata enrichment'
|
|
||||||
'python-guessit: improved AniSkip title and episode inference'
|
|
||||||
'alass-git: preferred subtitle synchronization engine'
|
|
||||||
'python-ffsubsync: fallback subtitle synchronization engine'
|
|
||||||
)
|
|
||||||
provides=("subminer=${pkgver}")
|
|
||||||
conflicts=('subminer')
|
|
||||||
source=(
|
|
||||||
"SubMiner-${pkgver}.AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/SubMiner-${pkgver}.AppImage"
|
|
||||||
"subminer::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer"
|
|
||||||
"subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v${pkgver}/subminer-assets.tar.gz"
|
|
||||||
)
|
|
||||||
sha256sums=(
|
|
||||||
'c91667adbbc47a0fba34855358233454a9ea442ab57510546b2219abd1f2461e'
|
|
||||||
'85050918e14cb2512fcd34be83387a2383fa5c206dc1bdc11e8d98f7d37817e5'
|
|
||||||
'210113be64a06840f4dfaebc22a8e6fc802392f1308413aa00d9348c804ab2a1'
|
|
||||||
)
|
|
||||||
noextract=("SubMiner-${pkgver}.AppImage")
|
|
||||||
|
|
||||||
package() {
|
|
||||||
install -dm755 "${pkgdir}/usr/bin"
|
|
||||||
|
|
||||||
install -Dm755 "${srcdir}/SubMiner-${pkgver}.AppImage" \
|
|
||||||
"${pkgdir}/opt/SubMiner/SubMiner.AppImage"
|
|
||||||
install -dm755 "${pkgdir}/opt/SubMiner"
|
|
||||||
ln -s '/opt/SubMiner/SubMiner.AppImage' "${pkgdir}/usr/bin/SubMiner.AppImage"
|
|
||||||
|
|
||||||
install -Dm755 "${srcdir}/subminer" "${pkgdir}/usr/bin/subminer"
|
|
||||||
|
|
||||||
install -Dm644 "${srcdir}/config.example.jsonc" \
|
|
||||||
"${pkgdir}/usr/share/SubMiner/config.example.jsonc"
|
|
||||||
install -Dm644 "${srcdir}/plugin/subminer.conf" \
|
|
||||||
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
|
||||||
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
|
||||||
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
|
||||||
|
|
||||||
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
|
||||||
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
## Highlights
|
|
||||||
### Internal
|
|
||||||
- Release: Reworked AUR metadata generation to update `.SRCINFO` directly instead of depending on runner `makepkg`, fixing tagged release publishing for `subminer-bin`.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
|
||||||
const classifyScript = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
|
|
||||||
);
|
|
||||||
const verifyScript = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
|
|
||||||
);
|
|
||||||
|
|
||||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
|
|
||||||
try {
|
|
||||||
return fn(dir);
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runBash(args: string[]) {
|
|
||||||
return spawnSync('bash', args, {
|
|
||||||
cwd: repoRoot,
|
|
||||||
env: process.env,
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArtifactDir(stdout: string): string {
|
|
||||||
const match = stdout.match(/^artifact_dir=(.+)$/m);
|
|
||||||
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
|
||||||
return match[1] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSummaryJson(artifactDir: string) {
|
|
||||||
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
|
||||||
sessionId: string;
|
|
||||||
status: string;
|
|
||||||
selectedLanes: string[];
|
|
||||||
blockers?: string[];
|
|
||||||
artifactDir: string;
|
|
||||||
pathSelectionMode?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
|
|
||||||
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
|
|
||||||
|
|
||||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
||||||
assert.match(result.stdout, /^lane:launcher-plugin$/m);
|
|
||||||
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
|
|
||||||
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const artifactDir = path.join(root, 'artifacts');
|
|
||||||
const result = runBash([
|
|
||||||
verifyScript,
|
|
||||||
'--dry-run',
|
|
||||||
'--artifact-dir',
|
|
||||||
artifactDir,
|
|
||||||
'--lane',
|
|
||||||
'real-runtime',
|
|
||||||
'launcher/mpv.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.notEqual(result.status, 0, result.stdout);
|
|
||||||
assert.match(result.stdout, /^result=blocked$/m);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
|
||||||
assert.equal(summary.status, 'blocked');
|
|
||||||
assert.deepEqual(summary.selectedLanes, ['real-runtime']);
|
|
||||||
assert.ok(summary.sessionId.length > 0);
|
|
||||||
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
|
||||||
assert.equal(fs.existsSync(path.join(artifactDir, 'reports', 'summary.json')), true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier fails closed for unknown lanes', () => {
|
|
||||||
withTempDir((root) => {
|
|
||||||
const artifactDir = path.join(root, 'artifacts');
|
|
||||||
const result = runBash([
|
|
||||||
verifyScript,
|
|
||||||
'--dry-run',
|
|
||||||
'--artifact-dir',
|
|
||||||
artifactDir,
|
|
||||||
'--lane',
|
|
||||||
'not-a-lane',
|
|
||||||
'src/main.ts',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.notEqual(result.status, 0, result.stdout);
|
|
||||||
assert.match(result.stdout, /^result=failed$/m);
|
|
||||||
|
|
||||||
const summary = readSummaryJson(artifactDir);
|
|
||||||
assert.equal(summary.status, 'failed');
|
|
||||||
assert.deepEqual(summary.selectedLanes, ['not-a-lane']);
|
|
||||||
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('verifier allocates unique session ids and artifact roots by default', () => {
|
|
||||||
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
|
||||||
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
|
||||||
|
|
||||||
assert.equal(first.status, 0, first.stderr || first.stdout);
|
|
||||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
|
||||||
|
|
||||||
const firstArtifactDir = parseArtifactDir(first.stdout);
|
|
||||||
const secondArtifactDir = parseArtifactDir(second.stdout);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firstSummary = readSummaryJson(firstArtifactDir);
|
|
||||||
const secondSummary = readSummaryJson(secondArtifactDir);
|
|
||||||
|
|
||||||
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
|
||||||
assert.notEqual(firstSummary.artifactDir, secondSummary.artifactDir);
|
|
||||||
assert.equal(firstSummary.pathSelectionMode, 'explicit');
|
|
||||||
assert.equal(secondSummary.pathSelectionMode, 'explicit');
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
|
||||||
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Usage: scripts/update-aur-package.sh --pkg-dir <dir> --version <version> --appimage <path> --wrapper <path> --assets <path>
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg_dir=
|
|
||||||
version=
|
|
||||||
appimage=
|
|
||||||
wrapper=
|
|
||||||
assets=
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--pkg-dir)
|
|
||||||
pkg_dir="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--version)
|
|
||||||
version="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--appimage)
|
|
||||||
appimage="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--wrapper)
|
|
||||||
wrapper="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--assets)
|
|
||||||
assets="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown argument: $1" >&2
|
|
||||||
usage >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -z "$pkg_dir" || -z "$version" || -z "$appimage" || -z "$wrapper" || -z "$assets" ]]; then
|
|
||||||
usage >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
version="${version#v}"
|
|
||||||
pkgbuild="${pkg_dir}/PKGBUILD"
|
|
||||||
srcinfo="${pkg_dir}/.SRCINFO"
|
|
||||||
|
|
||||||
if [[ ! -f "$pkgbuild" ]]; then
|
|
||||||
echo "Missing PKGBUILD at $pkgbuild" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for artifact in "$appimage" "$wrapper" "$assets"; do
|
|
||||||
if [[ ! -f "$artifact" ]]; then
|
|
||||||
echo "Missing artifact: $artifact" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
mapfile -t sha256sums < <(sha256sum "$appimage" "$wrapper" "$assets" | awk '{print $1}')
|
|
||||||
|
|
||||||
tmpfile="$(mktemp)"
|
|
||||||
awk \
|
|
||||||
-v version="$version" \
|
|
||||||
-v sum_appimage="${sha256sums[0]}" \
|
|
||||||
-v sum_wrapper="${sha256sums[1]}" \
|
|
||||||
-v sum_assets="${sha256sums[2]}" \
|
|
||||||
'
|
|
||||||
BEGIN {
|
|
||||||
in_sha_block = 0
|
|
||||||
found_pkgver = 0
|
|
||||||
found_sha_block = 0
|
|
||||||
}
|
|
||||||
/^pkgver=/ {
|
|
||||||
print "pkgver=" version
|
|
||||||
found_pkgver = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^sha256sums=\(/ {
|
|
||||||
print "sha256sums=("
|
|
||||||
print "\047" sum_appimage "\047"
|
|
||||||
print "\047" sum_wrapper "\047"
|
|
||||||
print "\047" sum_assets "\047"
|
|
||||||
in_sha_block = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
in_sha_block {
|
|
||||||
if ($0 ~ /^\)/) {
|
|
||||||
print ")"
|
|
||||||
in_sha_block = 0
|
|
||||||
found_sha_block = 1
|
|
||||||
}
|
|
||||||
next
|
|
||||||
}
|
|
||||||
{
|
|
||||||
print
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
if (!found_pkgver) {
|
|
||||||
print "Missing pkgver= line in PKGBUILD" > "/dev/stderr"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (!found_sha_block) {
|
|
||||||
print "Missing sha256sums block in PKGBUILD" > "/dev/stderr"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$pkgbuild" > "$tmpfile"
|
|
||||||
mv "$tmpfile" "$pkgbuild"
|
|
||||||
|
|
||||||
if [[ ! -f "$srcinfo" ]]; then
|
|
||||||
echo "Missing .SRCINFO at $srcinfo" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
tmpfile="$(mktemp)"
|
|
||||||
awk \
|
|
||||||
-v version="$version" \
|
|
||||||
-v sum_appimage="${sha256sums[0]}" \
|
|
||||||
-v sum_wrapper="${sha256sums[1]}" \
|
|
||||||
-v sum_assets="${sha256sums[2]}" \
|
|
||||||
'
|
|
||||||
BEGIN {
|
|
||||||
sha_index = 0
|
|
||||||
found_pkgver = 0
|
|
||||||
found_provides = 0
|
|
||||||
found_noextract = 0
|
|
||||||
found_source_appimage = 0
|
|
||||||
found_source_wrapper = 0
|
|
||||||
found_source_assets = 0
|
|
||||||
}
|
|
||||||
/^\tpkgver = / {
|
|
||||||
print "\tpkgver = " version
|
|
||||||
found_pkgver = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^\tprovides = subminer=/ {
|
|
||||||
print "\tprovides = subminer=" version
|
|
||||||
found_provides = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^\tnoextract = SubMiner-.*\.AppImage$/ {
|
|
||||||
print "\tnoextract = SubMiner-" version ".AppImage"
|
|
||||||
found_noextract = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^\tsource = SubMiner-.*\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/SubMiner-.*\.AppImage$/ {
|
|
||||||
print "\tsource = SubMiner-" version ".AppImage::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/SubMiner-" version ".AppImage"
|
|
||||||
found_source_appimage = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^\tsource = subminer::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer$/ {
|
|
||||||
print "\tsource = subminer::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer"
|
|
||||||
found_source_wrapper = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^\tsource = subminer-assets\.tar\.gz::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v.*\/subminer-assets\.tar\.gz$/ {
|
|
||||||
print "\tsource = subminer-assets.tar.gz::https://github.com/ksyasuda/SubMiner/releases/download/v" version "/subminer-assets.tar.gz"
|
|
||||||
found_source_assets = 1
|
|
||||||
next
|
|
||||||
}
|
|
||||||
/^\tsha256sums = / {
|
|
||||||
sha_index += 1
|
|
||||||
if (sha_index == 1) {
|
|
||||||
print "\tsha256sums = " sum_appimage
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (sha_index == 2) {
|
|
||||||
print "\tsha256sums = " sum_wrapper
|
|
||||||
next
|
|
||||||
}
|
|
||||||
if (sha_index == 3) {
|
|
||||||
print "\tsha256sums = " sum_assets
|
|
||||||
next
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{
|
|
||||||
print
|
|
||||||
}
|
|
||||||
END {
|
|
||||||
if (!found_pkgver) {
|
|
||||||
print "Missing pkgver entry in .SRCINFO" > "/dev/stderr"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (!found_provides) {
|
|
||||||
print "Missing provides entry in .SRCINFO" > "/dev/stderr"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (!found_noextract) {
|
|
||||||
print "Missing noextract entry in .SRCINFO" > "/dev/stderr"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (!found_source_appimage || !found_source_wrapper || !found_source_assets) {
|
|
||||||
print "Missing source entry in .SRCINFO" > "/dev/stderr"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if (sha_index < 3) {
|
|
||||||
print "Missing sha256sums entries in .SRCINFO" > "/dev/stderr"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
' "$srcinfo" > "$tmpfile"
|
|
||||||
mv "$tmpfile" "$srcinfo"
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
function createWorkspace(name: string): string {
|
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
|
|
||||||
}
|
|
||||||
|
|
||||||
test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
|
||||||
const workspace = createWorkspace('subminer-aur-package');
|
|
||||||
const pkgDir = path.join(workspace, 'aur-subminer-bin');
|
|
||||||
const appImagePath = path.join(workspace, 'SubMiner-0.6.3.AppImage');
|
|
||||||
const wrapperPath = path.join(workspace, 'subminer');
|
|
||||||
const assetsPath = path.join(workspace, 'subminer-assets.tar.gz');
|
|
||||||
|
|
||||||
fs.mkdirSync(pkgDir, { recursive: true });
|
|
||||||
fs.copyFileSync('packaging/aur/subminer-bin/PKGBUILD', path.join(pkgDir, 'PKGBUILD'));
|
|
||||||
fs.copyFileSync('packaging/aur/subminer-bin/.SRCINFO', path.join(pkgDir, '.SRCINFO'));
|
|
||||||
fs.writeFileSync(appImagePath, 'appimage');
|
|
||||||
fs.writeFileSync(wrapperPath, 'wrapper');
|
|
||||||
fs.writeFileSync(assetsPath, 'assets');
|
|
||||||
|
|
||||||
try {
|
|
||||||
execFileSync(
|
|
||||||
'bash',
|
|
||||||
[
|
|
||||||
'scripts/update-aur-package.sh',
|
|
||||||
'--pkg-dir',
|
|
||||||
pkgDir,
|
|
||||||
'--version',
|
|
||||||
'v0.6.3',
|
|
||||||
'--appimage',
|
|
||||||
appImagePath,
|
|
||||||
'--wrapper',
|
|
||||||
wrapperPath,
|
|
||||||
'--assets',
|
|
||||||
assetsPath,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
cwd: process.cwd(),
|
|
||||||
encoding: 'utf8',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const pkgbuild = fs.readFileSync(path.join(pkgDir, 'PKGBUILD'), 'utf8');
|
|
||||||
const srcinfo = fs.readFileSync(path.join(pkgDir, '.SRCINFO'), 'utf8');
|
|
||||||
const expectedSums = [appImagePath, wrapperPath, assetsPath].map((filePath) =>
|
|
||||||
execFileSync('sha256sum', [filePath], { encoding: 'utf8' }).split(/\s+/)[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(pkgbuild, /^pkgver=0\.6\.3$/m);
|
|
||||||
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
|
|
||||||
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
|
|
||||||
assert.match(
|
|
||||||
srcinfo,
|
|
||||||
/^\tsource = SubMiner-0\.6\.3\.AppImage::https:\/\/github\.com\/ksyasuda\/SubMiner\/releases\/download\/v0\.6\.3\/SubMiner-0\.6\.3\.AppImage$/m,
|
|
||||||
);
|
|
||||||
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[0]}$`, 'm'));
|
|
||||||
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[1]}$`, 'm'));
|
|
||||||
assert.match(srcinfo, new RegExp(`^\\tsha256sums = ${expectedSums[2]}$`, 'm'));
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(workspace, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1107,250 +1107,6 @@ test('parses global shortcuts and startup settings', () => {
|
|||||||
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
assert.equal(config.youtubeSubgen.fixWithAi, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parses controller settings with logical bindings and tuning knobs', () => {
|
|
||||||
const dir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(dir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"controller": {
|
|
||||||
"enabled": true,
|
|
||||||
"preferredGamepadId": "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
|
|
||||||
"preferredGamepadLabel": "Xbox Wireless Controller",
|
|
||||||
"smoothScroll": false,
|
|
||||||
"scrollPixelsPerSecond": 1440,
|
|
||||||
"horizontalJumpPixels": 180,
|
|
||||||
"stickDeadzone": 0.3,
|
|
||||||
"triggerInputMode": "analog",
|
|
||||||
"triggerDeadzone": 0.4,
|
|
||||||
"repeatDelayMs": 220,
|
|
||||||
"repeatIntervalMs": 70,
|
|
||||||
"buttonIndices": {
|
|
||||||
"select": 6,
|
|
||||||
"leftStickPress": 9,
|
|
||||||
"rightStickPress": 10
|
|
||||||
},
|
|
||||||
"bindings": {
|
|
||||||
"toggleLookup": "buttonWest",
|
|
||||||
"closeLookup": "buttonEast",
|
|
||||||
"toggleKeyboardOnlyMode": "buttonNorth",
|
|
||||||
"mineCard": "buttonSouth",
|
|
||||||
"quitMpv": "select",
|
|
||||||
"previousAudio": "leftShoulder",
|
|
||||||
"nextAudio": "rightShoulder",
|
|
||||||
"playCurrentAudio": "none",
|
|
||||||
"toggleMpvPause": "leftStickPress",
|
|
||||||
"leftStickHorizontal": "rightStickX",
|
|
||||||
"leftStickVertical": "rightStickY",
|
|
||||||
"rightStickHorizontal": "leftStickX",
|
|
||||||
"rightStickVertical": "leftStickY"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new ConfigService(dir);
|
|
||||||
const config = service.getConfig();
|
|
||||||
|
|
||||||
assert.equal(config.controller.enabled, true);
|
|
||||||
assert.equal(
|
|
||||||
config.controller.preferredGamepadId,
|
|
||||||
'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)',
|
|
||||||
);
|
|
||||||
assert.equal(config.controller.preferredGamepadLabel, 'Xbox Wireless Controller');
|
|
||||||
assert.equal(config.controller.smoothScroll, false);
|
|
||||||
assert.equal(config.controller.scrollPixelsPerSecond, 1440);
|
|
||||||
assert.equal(config.controller.horizontalJumpPixels, 180);
|
|
||||||
assert.equal(config.controller.stickDeadzone, 0.3);
|
|
||||||
assert.equal(config.controller.triggerInputMode, 'analog');
|
|
||||||
assert.equal(config.controller.triggerDeadzone, 0.4);
|
|
||||||
assert.equal(config.controller.repeatDelayMs, 220);
|
|
||||||
assert.equal(config.controller.repeatIntervalMs, 70);
|
|
||||||
assert.equal(config.controller.buttonIndices.select, 6);
|
|
||||||
assert.equal(config.controller.buttonIndices.leftStickPress, 9);
|
|
||||||
assert.deepEqual(config.controller.bindings.toggleLookup, { kind: 'button', buttonIndex: 2 });
|
|
||||||
assert.deepEqual(config.controller.bindings.quitMpv, { kind: 'button', buttonIndex: 6 });
|
|
||||||
assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' });
|
|
||||||
assert.deepEqual(config.controller.bindings.toggleMpvPause, { kind: 'button', buttonIndex: 9 });
|
|
||||||
assert.deepEqual(config.controller.bindings.leftStickHorizontal, {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: 3,
|
|
||||||
dpadFallback: 'horizontal',
|
|
||||||
});
|
|
||||||
assert.deepEqual(config.controller.bindings.rightStickVertical, {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: 1,
|
|
||||||
dpadFallback: 'none',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parses descriptor-based controller bindings', () => {
|
|
||||||
const dir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(dir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"controller": {
|
|
||||||
"bindings": {
|
|
||||||
"toggleLookup": { "kind": "button", "buttonIndex": 11 },
|
|
||||||
"closeLookup": { "kind": "axis", "axisIndex": 4, "direction": "negative" },
|
|
||||||
"playCurrentAudio": { "kind": "none" },
|
|
||||||
"leftStickHorizontal": { "kind": "axis", "axisIndex": 7, "dpadFallback": "none" },
|
|
||||||
"leftStickVertical": { "kind": "axis", "axisIndex": 2, "dpadFallback": "vertical" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new ConfigService(dir);
|
|
||||||
const config = service.getConfig();
|
|
||||||
|
|
||||||
assert.deepEqual(config.controller.bindings.toggleLookup, {
|
|
||||||
kind: 'button',
|
|
||||||
buttonIndex: 11,
|
|
||||||
});
|
|
||||||
assert.deepEqual(config.controller.bindings.closeLookup, {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: 4,
|
|
||||||
direction: 'negative',
|
|
||||||
});
|
|
||||||
assert.deepEqual(config.controller.bindings.playCurrentAudio, { kind: 'none' });
|
|
||||||
assert.deepEqual(config.controller.bindings.leftStickHorizontal, {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: 7,
|
|
||||||
dpadFallback: 'none',
|
|
||||||
});
|
|
||||||
assert.deepEqual(config.controller.bindings.leftStickVertical, {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: 2,
|
|
||||||
dpadFallback: 'vertical',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller descriptor config rejects malformed binding objects', () => {
|
|
||||||
const dir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(dir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"controller": {
|
|
||||||
"bindings": {
|
|
||||||
"toggleLookup": { "kind": "button", "buttonIndex": -1 },
|
|
||||||
"closeLookup": { "kind": "axis", "axisIndex": 1, "direction": "sideways" },
|
|
||||||
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "diagonal" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new ConfigService(dir);
|
|
||||||
const config = service.getConfig();
|
|
||||||
const warnings = service.getWarnings();
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
config.controller.bindings.toggleLookup,
|
|
||||||
DEFAULT_CONFIG.controller.bindings.toggleLookup,
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
config.controller.bindings.closeLookup,
|
|
||||||
DEFAULT_CONFIG.controller.bindings.closeLookup,
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
config.controller.bindings.leftStickHorizontal,
|
|
||||||
DEFAULT_CONFIG.controller.bindings.leftStickHorizontal,
|
|
||||||
);
|
|
||||||
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.toggleLookup'), true);
|
|
||||||
assert.equal(warnings.some((warning) => warning.path === 'controller.bindings.closeLookup'), true);
|
|
||||||
assert.equal(
|
|
||||||
warnings.some((warning) => warning.path === 'controller.bindings.leftStickHorizontal'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller positive-number tuning rejects sub-unit values that floor to zero', () => {
|
|
||||||
const dir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(dir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"controller": {
|
|
||||||
"scrollPixelsPerSecond": 0.5,
|
|
||||||
"horizontalJumpPixels": 0.2,
|
|
||||||
"repeatDelayMs": 0.9,
|
|
||||||
"repeatIntervalMs": 0.1
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new ConfigService(dir);
|
|
||||||
const config = service.getConfig();
|
|
||||||
const warnings = service.getWarnings();
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
config.controller.scrollPixelsPerSecond,
|
|
||||||
DEFAULT_CONFIG.controller.scrollPixelsPerSecond,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
config.controller.horizontalJumpPixels,
|
|
||||||
DEFAULT_CONFIG.controller.horizontalJumpPixels,
|
|
||||||
);
|
|
||||||
assert.equal(config.controller.repeatDelayMs, DEFAULT_CONFIG.controller.repeatDelayMs);
|
|
||||||
assert.equal(config.controller.repeatIntervalMs, DEFAULT_CONFIG.controller.repeatIntervalMs);
|
|
||||||
assert.equal(
|
|
||||||
warnings.some((warning) => warning.path === 'controller.scrollPixelsPerSecond'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
warnings.some((warning) => warning.path === 'controller.horizontalJumpPixels'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
warnings.some((warning) => warning.path === 'controller.repeatDelayMs'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
warnings.some((warning) => warning.path === 'controller.repeatIntervalMs'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller button index config rejects fractional values', () => {
|
|
||||||
const dir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(dir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"controller": {
|
|
||||||
"buttonIndices": {
|
|
||||||
"select": 6.5,
|
|
||||||
"leftStickPress": 9.1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new ConfigService(dir);
|
|
||||||
const config = service.getConfig();
|
|
||||||
const warnings = service.getWarnings();
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
config.controller.buttonIndices.select,
|
|
||||||
DEFAULT_CONFIG.controller.buttonIndices.select,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
config.controller.buttonIndices.leftStickPress,
|
|
||||||
DEFAULT_CONFIG.controller.buttonIndices.leftStickPress,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.select'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
warnings.some((warning) => warning.path === 'controller.buttonIndices.leftStickPress'),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('runtime options registry is centralized', () => {
|
test('runtime options registry is centralized', () => {
|
||||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||||
assert.deepEqual(ids, [
|
assert.deepEqual(ids, [
|
||||||
@@ -1883,7 +1639,6 @@ test('template generator includes known keys', () => {
|
|||||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||||
assert.match(output, /"ai":/);
|
assert.match(output, /"ai":/);
|
||||||
assert.match(output, /"ankiConnect":/);
|
assert.match(output, /"ankiConnect":/);
|
||||||
assert.match(output, /"controller":/);
|
|
||||||
assert.match(output, /"logging":/);
|
assert.match(output, /"logging":/);
|
||||||
assert.match(output, /"websocket":/);
|
assert.match(output, /"websocket":/);
|
||||||
assert.match(output, /"discordPresence":/);
|
assert.match(output, /"discordPresence":/);
|
||||||
@@ -1908,32 +1663,6 @@ test('template generator includes known keys', () => {
|
|||||||
output,
|
output,
|
||||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
||||||
);
|
);
|
||||||
assert.match(
|
|
||||||
output,
|
|
||||||
/"scrollPixelsPerSecond": 900,? \/\/ Base popup scroll speed for controller stick input\./,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
output,
|
|
||||||
/"triggerInputMode": "auto",? \/\/ How controller triggers are interpreted: auto, pressed-only, or thresholded analog\. Values: auto \| digital \| analog/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
output,
|
|
||||||
/"preferredGamepadId": "",? \/\/ Preferred controller id saved from the controller config modal\./,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
output,
|
|
||||||
/"toggleLookup": \{\s*"kind": "button"[\s\S]*\},? \/\/ Controller binding descriptor for toggling lookup\. Use Alt\+C learn mode or set a raw button\/axis descriptor manually\./,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
output,
|
|
||||||
/"kind": "button",? \/\/ Discrete binding input source kind\. When kind is "axis", set both axisIndex and direction\. Values: none \| button \| axis/,
|
|
||||||
);
|
|
||||||
assert.match(output, /"toggleLookup": \{\s*"kind": "button"/);
|
|
||||||
assert.match(output, /"leftStickHorizontal": \{\s*"kind": "axis"/);
|
|
||||||
assert.match(
|
|
||||||
output,
|
|
||||||
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
|
||||||
);
|
|
||||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const {
|
|||||||
annotationWebsocket,
|
annotationWebsocket,
|
||||||
logging,
|
logging,
|
||||||
texthooker,
|
texthooker,
|
||||||
controller,
|
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
subsync,
|
subsync,
|
||||||
@@ -44,7 +43,6 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
annotationWebsocket,
|
annotationWebsocket,
|
||||||
logging,
|
logging,
|
||||||
texthooker,
|
texthooker,
|
||||||
controller,
|
|
||||||
ankiConnect,
|
ankiConnect,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
secondarySub,
|
secondarySub,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
| 'annotationWebsocket'
|
| 'annotationWebsocket'
|
||||||
| 'logging'
|
| 'logging'
|
||||||
| 'texthooker'
|
| 'texthooker'
|
||||||
| 'controller'
|
|
||||||
| 'shortcuts'
|
| 'shortcuts'
|
||||||
| 'secondarySub'
|
| 'secondarySub'
|
||||||
| 'subsync'
|
| 'subsync'
|
||||||
@@ -32,47 +31,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
launchAtStartup: true,
|
launchAtStartup: true,
|
||||||
openBrowser: true,
|
openBrowser: true,
|
||||||
},
|
},
|
||||||
controller: {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: '',
|
|
||||||
preferredGamepadLabel: '',
|
|
||||||
smoothScroll: true,
|
|
||||||
scrollPixelsPerSecond: 900,
|
|
||||||
horizontalJumpPixels: 160,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
triggerInputMode: 'auto',
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
repeatDelayMs: 320,
|
|
||||||
repeatIntervalMs: 120,
|
|
||||||
buttonIndices: {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
buttonEast: 1,
|
|
||||||
buttonWest: 2,
|
|
||||||
buttonNorth: 3,
|
|
||||||
leftShoulder: 4,
|
|
||||||
rightShoulder: 5,
|
|
||||||
leftStickPress: 9,
|
|
||||||
rightStickPress: 10,
|
|
||||||
leftTrigger: 6,
|
|
||||||
rightTrigger: 7,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
toggleVisibleOverlayGlobal: 'Alt+Shift+O',
|
||||||
copySubtitle: 'CommandOrControl+C',
|
copySubtitle: 'CommandOrControl+C',
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
for (const requiredPath of [
|
for (const requiredPath of [
|
||||||
'logging.level',
|
'logging.level',
|
||||||
'annotationWebsocket.enabled',
|
'annotationWebsocket.enabled',
|
||||||
'controller.enabled',
|
|
||||||
'controller.scrollPixelsPerSecond',
|
|
||||||
'startupWarmups.lowPowerMode',
|
'startupWarmups.lowPowerMode',
|
||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
@@ -41,7 +39,6 @@ test('config template sections include expected domains and unique keys', () =>
|
|||||||
const requiredKeys: (typeof keys)[number][] = [
|
const requiredKeys: (typeof keys)[number][] = [
|
||||||
'websocket',
|
'websocket',
|
||||||
'annotationWebsocket',
|
'annotationWebsocket',
|
||||||
'controller',
|
|
||||||
'startupWarmups',
|
'startupWarmups',
|
||||||
'subtitleStyle',
|
'subtitleStyle',
|
||||||
'ankiConnect',
|
'ankiConnect',
|
||||||
|
|||||||
@@ -4,77 +4,6 @@ import { ConfigOptionRegistryEntry } from './shared';
|
|||||||
export function buildCoreConfigOptionRegistry(
|
export function buildCoreConfigOptionRegistry(
|
||||||
defaultConfig: ResolvedConfig,
|
defaultConfig: ResolvedConfig,
|
||||||
): ConfigOptionRegistryEntry[] {
|
): ConfigOptionRegistryEntry[] {
|
||||||
const discreteBindings = [
|
|
||||||
{
|
|
||||||
id: 'toggleLookup',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.toggleLookup,
|
|
||||||
description: 'Controller binding descriptor for toggling lookup.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'closeLookup',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.closeLookup,
|
|
||||||
description: 'Controller binding descriptor for closing lookup.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'toggleKeyboardOnlyMode',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.toggleKeyboardOnlyMode,
|
|
||||||
description: 'Controller binding descriptor for toggling keyboard-only mode.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mineCard',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.mineCard,
|
|
||||||
description: 'Controller binding descriptor for mining the active card.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'quitMpv',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.quitMpv,
|
|
||||||
description: 'Controller binding descriptor for quitting mpv.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'previousAudio',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.previousAudio,
|
|
||||||
description: 'Controller binding descriptor for previous Yomitan audio.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nextAudio',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.nextAudio,
|
|
||||||
description: 'Controller binding descriptor for next Yomitan audio.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'playCurrentAudio',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.playCurrentAudio,
|
|
||||||
description: 'Controller binding descriptor for playing the current Yomitan audio.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'toggleMpvPause',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.toggleMpvPause,
|
|
||||||
description: 'Controller binding descriptor for toggling mpv play/pause.',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const axisBindings = [
|
|
||||||
{
|
|
||||||
id: 'leftStickHorizontal',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.leftStickHorizontal,
|
|
||||||
description: 'Axis binding descriptor used for left/right token selection.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'leftStickVertical',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.leftStickVertical,
|
|
||||||
description: 'Axis binding descriptor used for primary popup scrolling.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rightStickHorizontal',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.rightStickHorizontal,
|
|
||||||
description: 'Axis binding descriptor reserved for alternate right-stick mappings.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rightStickVertical',
|
|
||||||
defaultValue: defaultConfig.controller.bindings.rightStickVertical,
|
|
||||||
description: 'Axis binding descriptor used for popup page jumps.',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
path: 'logging.level',
|
path: 'logging.level',
|
||||||
@@ -83,222 +12,6 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.logging.level,
|
defaultValue: defaultConfig.logging.level,
|
||||||
description: 'Minimum log level for runtime logging.',
|
description: 'Minimum log level for runtime logging.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'controller.enabled',
|
|
||||||
kind: 'boolean',
|
|
||||||
defaultValue: defaultConfig.controller.enabled,
|
|
||||||
description: 'Enable overlay controller support through the Chrome Gamepad API.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.preferredGamepadId',
|
|
||||||
kind: 'string',
|
|
||||||
defaultValue: defaultConfig.controller.preferredGamepadId,
|
|
||||||
description: 'Preferred controller id saved from the controller config modal.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.preferredGamepadLabel',
|
|
||||||
kind: 'string',
|
|
||||||
defaultValue: defaultConfig.controller.preferredGamepadLabel,
|
|
||||||
description: 'Preferred controller display label saved for diagnostics.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.smoothScroll',
|
|
||||||
kind: 'boolean',
|
|
||||||
defaultValue: defaultConfig.controller.smoothScroll,
|
|
||||||
description: 'Use smooth scrolling for controller-driven popup scroll input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.scrollPixelsPerSecond',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.scrollPixelsPerSecond,
|
|
||||||
description: 'Base popup scroll speed for controller stick input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.horizontalJumpPixels',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.horizontalJumpPixels,
|
|
||||||
description: 'Popup page-jump distance for controller jump input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.stickDeadzone',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.stickDeadzone,
|
|
||||||
description: 'Deadzone applied to controller stick axes.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.triggerInputMode',
|
|
||||||
kind: 'enum',
|
|
||||||
enumValues: ['auto', 'digital', 'analog'],
|
|
||||||
defaultValue: defaultConfig.controller.triggerInputMode,
|
|
||||||
description:
|
|
||||||
'How controller triggers are interpreted: auto, pressed-only, or thresholded analog.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.triggerDeadzone',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.triggerDeadzone,
|
|
||||||
description:
|
|
||||||
'Minimum analog trigger value required when trigger input uses auto or analog mode.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.repeatDelayMs',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.repeatDelayMs,
|
|
||||||
description: 'Delay before repeating held controller actions.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.repeatIntervalMs',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.repeatIntervalMs,
|
|
||||||
description: 'Repeat interval for held controller actions.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices',
|
|
||||||
kind: 'object',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices,
|
|
||||||
description:
|
|
||||||
'Semantic button-name reference mapping used for legacy configs and debug output. Updating it does not rewrite existing raw binding descriptors.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.select',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.select,
|
|
||||||
description: 'Raw button index used for the controller select/minus/back button.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.buttonSouth',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.buttonSouth,
|
|
||||||
description: 'Raw button index used for controller south/A button input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.buttonEast',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.buttonEast,
|
|
||||||
description: 'Raw button index used for controller east/B button input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.buttonNorth',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.buttonNorth,
|
|
||||||
description: 'Raw button index used for controller north/Y button input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.buttonWest',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.buttonWest,
|
|
||||||
description: 'Raw button index used for controller west/X button input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.leftShoulder',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.leftShoulder,
|
|
||||||
description: 'Raw button index used for controller left shoulder input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.rightShoulder',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.rightShoulder,
|
|
||||||
description: 'Raw button index used for controller right shoulder input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.leftStickPress',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.leftStickPress,
|
|
||||||
description: 'Raw button index used for controller L3 input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.rightStickPress',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.rightStickPress,
|
|
||||||
description: 'Raw button index used for controller R3 input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.leftTrigger',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.leftTrigger,
|
|
||||||
description: 'Raw button index used for controller L2 input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.buttonIndices.rightTrigger',
|
|
||||||
kind: 'number',
|
|
||||||
defaultValue: defaultConfig.controller.buttonIndices.rightTrigger,
|
|
||||||
description: 'Raw button index used for controller R2 input.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'controller.bindings',
|
|
||||||
kind: 'object',
|
|
||||||
defaultValue: defaultConfig.controller.bindings,
|
|
||||||
description:
|
|
||||||
'Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.',
|
|
||||||
},
|
|
||||||
...discreteBindings.flatMap((binding) => [
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}`,
|
|
||||||
kind: 'object' as const,
|
|
||||||
defaultValue: binding.defaultValue,
|
|
||||||
description: `${binding.description} Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}.kind`,
|
|
||||||
kind: 'enum' as const,
|
|
||||||
enumValues: ['none', 'button', 'axis'],
|
|
||||||
defaultValue: binding.defaultValue.kind,
|
|
||||||
description:
|
|
||||||
'Discrete binding input source kind. When kind is "axis", set both axisIndex and direction.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}.buttonIndex`,
|
|
||||||
kind: 'number' as const,
|
|
||||||
defaultValue:
|
|
||||||
binding.defaultValue.kind === 'button' ? binding.defaultValue.buttonIndex : undefined,
|
|
||||||
description: 'Raw button index captured for this discrete controller action.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}.axisIndex`,
|
|
||||||
kind: 'number' as const,
|
|
||||||
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
|
|
||||||
description: 'Raw axis index captured for this discrete controller action.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}.direction`,
|
|
||||||
kind: 'enum' as const,
|
|
||||||
enumValues: ['negative', 'positive'],
|
|
||||||
defaultValue:
|
|
||||||
binding.defaultValue.kind === 'axis' ? binding.defaultValue.direction : undefined,
|
|
||||||
description:
|
|
||||||
'Axis direction captured for this discrete controller action. Required when kind is "axis".',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
...axisBindings.flatMap((binding) => [
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}`,
|
|
||||||
kind: 'object' as const,
|
|
||||||
defaultValue: binding.defaultValue,
|
|
||||||
description: `${binding.description} Use Alt+C learn mode or set a raw axis descriptor manually.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}.kind`,
|
|
||||||
kind: 'enum' as const,
|
|
||||||
enumValues: ['none', 'axis'],
|
|
||||||
defaultValue: binding.defaultValue.kind,
|
|
||||||
description: 'Analog binding input source kind.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}.axisIndex`,
|
|
||||||
kind: 'number' as const,
|
|
||||||
defaultValue: binding.defaultValue.kind === 'axis' ? binding.defaultValue.axisIndex : undefined,
|
|
||||||
description: 'Raw axis index captured for this analog controller action.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: `controller.bindings.${binding.id}.dpadFallback`,
|
|
||||||
kind: 'enum' as const,
|
|
||||||
enumValues: ['none', 'horizontal', 'vertical'],
|
|
||||||
defaultValue:
|
|
||||||
binding.defaultValue.kind === 'axis' ? binding.defaultValue.dpadFallback : undefined,
|
|
||||||
description: 'Optional D-pad fallback used when this analog controller action should also read D-pad input.',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
{
|
{
|
||||||
path: 'texthooker.launchAtStartup',
|
path: 'texthooker.launchAtStartup',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -34,16 +34,6 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||||
key: 'logging',
|
key: 'logging',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Controller Support',
|
|
||||||
description: [
|
|
||||||
'Gamepad support for the visible overlay while keyboard-only mode is active.',
|
|
||||||
'Use Alt+C to pick a preferred controller and remap actions inline with learn mode.',
|
|
||||||
'Trigger input mode can be auto, digital-only, or analog-thresholded depending on the controller.',
|
|
||||||
'Override controller.buttonIndices when your pad reports non-standard raw button numbers.',
|
|
||||||
],
|
|
||||||
key: 'controller',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Startup Warmups',
|
title: 'Startup Warmups',
|
||||||
description: [
|
description: [
|
||||||
|
|||||||
@@ -1,139 +1,6 @@
|
|||||||
import type {
|
|
||||||
ControllerAxisBinding,
|
|
||||||
ControllerAxisBindingConfig,
|
|
||||||
ControllerAxisDirection,
|
|
||||||
ControllerButtonBinding,
|
|
||||||
ControllerButtonIndicesConfig,
|
|
||||||
ControllerDpadFallback,
|
|
||||||
ControllerDiscreteBindingConfig,
|
|
||||||
ResolvedControllerAxisBinding,
|
|
||||||
ResolvedControllerDiscreteBinding,
|
|
||||||
} from '../../types';
|
|
||||||
import { ResolveContext } from './context';
|
import { ResolveContext } from './context';
|
||||||
import { asBoolean, asNumber, asString, isObject } from './shared';
|
import { asBoolean, asNumber, asString, isObject } from './shared';
|
||||||
|
|
||||||
const CONTROLLER_BUTTON_BINDINGS = [
|
|
||||||
'none',
|
|
||||||
'select',
|
|
||||||
'buttonSouth',
|
|
||||||
'buttonEast',
|
|
||||||
'buttonNorth',
|
|
||||||
'buttonWest',
|
|
||||||
'leftShoulder',
|
|
||||||
'rightShoulder',
|
|
||||||
'leftStickPress',
|
|
||||||
'rightStickPress',
|
|
||||||
'leftTrigger',
|
|
||||||
'rightTrigger',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const CONTROLLER_AXIS_BINDINGS = ['leftStickX', 'leftStickY', 'rightStickX', 'rightStickY'] as const;
|
|
||||||
|
|
||||||
const CONTROLLER_AXIS_INDEX_BY_BINDING: Record<ControllerAxisBinding, number> = {
|
|
||||||
leftStickX: 0,
|
|
||||||
leftStickY: 1,
|
|
||||||
rightStickX: 3,
|
|
||||||
rightStickY: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING: Record<
|
|
||||||
Exclude<ControllerButtonBinding, 'none'>,
|
|
||||||
keyof Required<ControllerButtonIndicesConfig>
|
|
||||||
> = {
|
|
||||||
select: 'select',
|
|
||||||
buttonSouth: 'buttonSouth',
|
|
||||||
buttonEast: 'buttonEast',
|
|
||||||
buttonNorth: 'buttonNorth',
|
|
||||||
buttonWest: 'buttonWest',
|
|
||||||
leftShoulder: 'leftShoulder',
|
|
||||||
rightShoulder: 'rightShoulder',
|
|
||||||
leftStickPress: 'leftStickPress',
|
|
||||||
rightStickPress: 'rightStickPress',
|
|
||||||
leftTrigger: 'leftTrigger',
|
|
||||||
rightTrigger: 'rightTrigger',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTROLLER_AXIS_FALLBACK_BY_SLOT = {
|
|
||||||
leftStickHorizontal: 'horizontal',
|
|
||||||
leftStickVertical: 'vertical',
|
|
||||||
rightStickHorizontal: 'none',
|
|
||||||
rightStickVertical: 'none',
|
|
||||||
} as const satisfies Record<string, ControllerDpadFallback>;
|
|
||||||
|
|
||||||
function isControllerAxisDirection(value: unknown): value is ControllerAxisDirection {
|
|
||||||
return value === 'negative' || value === 'positive';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isControllerDpadFallback(value: unknown): value is ControllerDpadFallback {
|
|
||||||
return value === 'none' || value === 'horizontal' || value === 'vertical';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLegacyDiscreteBinding(
|
|
||||||
value: ControllerButtonBinding,
|
|
||||||
buttonIndices: Required<ControllerButtonIndicesConfig>,
|
|
||||||
): ResolvedControllerDiscreteBinding {
|
|
||||||
if (value === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: 'button',
|
|
||||||
buttonIndex: buttonIndices[CONTROLLER_BUTTON_INDEX_KEY_BY_BINDING[value]],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLegacyAxisBinding(
|
|
||||||
value: ControllerAxisBinding,
|
|
||||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
|
||||||
): ResolvedControllerAxisBinding {
|
|
||||||
return {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: CONTROLLER_AXIS_INDEX_BY_BINDING[value],
|
|
||||||
dpadFallback: CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDiscreteBindingObject(value: unknown): ResolvedControllerDiscreteBinding | null {
|
|
||||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
|
||||||
if (value.kind === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
if (value.kind === 'button') {
|
|
||||||
return typeof value.buttonIndex === 'number' && Number.isInteger(value.buttonIndex) && value.buttonIndex >= 0
|
|
||||||
? { kind: 'button', buttonIndex: value.buttonIndex }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
if (value.kind === 'axis') {
|
|
||||||
return typeof value.axisIndex === 'number' &&
|
|
||||||
Number.isInteger(value.axisIndex) &&
|
|
||||||
value.axisIndex >= 0 &&
|
|
||||||
isControllerAxisDirection(value.direction)
|
|
||||||
? { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAxisBindingObject(
|
|
||||||
value: unknown,
|
|
||||||
slot: keyof typeof CONTROLLER_AXIS_FALLBACK_BY_SLOT,
|
|
||||||
): ResolvedControllerAxisBinding | null {
|
|
||||||
if (isObject(value) && value.kind === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
if (!isObject(value) || value.kind !== 'axis') return null;
|
|
||||||
if (typeof value.axisIndex !== 'number' || !Number.isInteger(value.axisIndex) || value.axisIndex < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (value.dpadFallback !== undefined && !isControllerDpadFallback(value.dpadFallback)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: value.axisIndex,
|
|
||||||
dpadFallback: value.dpadFallback ?? CONTROLLER_AXIS_FALLBACK_BY_SLOT[slot],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyCoreDomainConfig(context: ResolveContext): void {
|
export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||||
const { src, resolved, warn } = context;
|
const { src, resolved, warn } = context;
|
||||||
|
|
||||||
@@ -234,200 +101,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isObject(src.controller)) {
|
|
||||||
const enabled = asBoolean(src.controller.enabled);
|
|
||||||
if (enabled !== undefined) {
|
|
||||||
resolved.controller.enabled = enabled;
|
|
||||||
} else if (src.controller.enabled !== undefined) {
|
|
||||||
warn(
|
|
||||||
'controller.enabled',
|
|
||||||
src.controller.enabled,
|
|
||||||
resolved.controller.enabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredGamepadId = asString(src.controller.preferredGamepadId);
|
|
||||||
if (preferredGamepadId !== undefined) {
|
|
||||||
resolved.controller.preferredGamepadId = preferredGamepadId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredGamepadLabel = asString(src.controller.preferredGamepadLabel);
|
|
||||||
if (preferredGamepadLabel !== undefined) {
|
|
||||||
resolved.controller.preferredGamepadLabel = preferredGamepadLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
const smoothScroll = asBoolean(src.controller.smoothScroll);
|
|
||||||
if (smoothScroll !== undefined) {
|
|
||||||
resolved.controller.smoothScroll = smoothScroll;
|
|
||||||
} else if (src.controller.smoothScroll !== undefined) {
|
|
||||||
warn(
|
|
||||||
'controller.smoothScroll',
|
|
||||||
src.controller.smoothScroll,
|
|
||||||
resolved.controller.smoothScroll,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerInputMode = asString(src.controller.triggerInputMode);
|
|
||||||
if (
|
|
||||||
triggerInputMode === 'auto' ||
|
|
||||||
triggerInputMode === 'digital' ||
|
|
||||||
triggerInputMode === 'analog'
|
|
||||||
) {
|
|
||||||
resolved.controller.triggerInputMode = triggerInputMode;
|
|
||||||
} else if (src.controller.triggerInputMode !== undefined) {
|
|
||||||
warn(
|
|
||||||
'controller.triggerInputMode',
|
|
||||||
src.controller.triggerInputMode,
|
|
||||||
resolved.controller.triggerInputMode,
|
|
||||||
"Expected 'auto', 'digital', or 'analog'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const boundedNumberKeys = [
|
|
||||||
'scrollPixelsPerSecond',
|
|
||||||
'horizontalJumpPixels',
|
|
||||||
'repeatDelayMs',
|
|
||||||
'repeatIntervalMs',
|
|
||||||
] as const;
|
|
||||||
for (const key of boundedNumberKeys) {
|
|
||||||
const value = asNumber(src.controller[key]);
|
|
||||||
if (value !== undefined && Math.floor(value) > 0) {
|
|
||||||
resolved.controller[key] = Math.floor(value) as (typeof resolved.controller)[typeof key];
|
|
||||||
} else if (src.controller[key] !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.${key}`,
|
|
||||||
src.controller[key],
|
|
||||||
resolved.controller[key],
|
|
||||||
'Expected positive number.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadzoneKeys = ['stickDeadzone', 'triggerDeadzone'] as const;
|
|
||||||
for (const key of deadzoneKeys) {
|
|
||||||
const value = asNumber(src.controller[key]);
|
|
||||||
if (value !== undefined && value >= 0 && value <= 1) {
|
|
||||||
resolved.controller[key] = value as (typeof resolved.controller)[typeof key];
|
|
||||||
} else if (src.controller[key] !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.${key}`,
|
|
||||||
src.controller[key],
|
|
||||||
resolved.controller[key],
|
|
||||||
'Expected number between 0 and 1.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(src.controller.buttonIndices)) {
|
|
||||||
const buttonIndexKeys = [
|
|
||||||
'select',
|
|
||||||
'buttonSouth',
|
|
||||||
'buttonEast',
|
|
||||||
'buttonNorth',
|
|
||||||
'buttonWest',
|
|
||||||
'leftShoulder',
|
|
||||||
'rightShoulder',
|
|
||||||
'leftStickPress',
|
|
||||||
'rightStickPress',
|
|
||||||
'leftTrigger',
|
|
||||||
'rightTrigger',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const key of buttonIndexKeys) {
|
|
||||||
const value = asNumber(src.controller.buttonIndices[key]);
|
|
||||||
if (value !== undefined && value >= 0 && Number.isInteger(value)) {
|
|
||||||
resolved.controller.buttonIndices[key] = value;
|
|
||||||
} else if (src.controller.buttonIndices[key] !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.buttonIndices.${key}`,
|
|
||||||
src.controller.buttonIndices[key],
|
|
||||||
resolved.controller.buttonIndices[key],
|
|
||||||
'Expected non-negative integer.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isObject(src.controller.bindings)) {
|
|
||||||
const buttonBindingKeys = [
|
|
||||||
'toggleLookup',
|
|
||||||
'closeLookup',
|
|
||||||
'toggleKeyboardOnlyMode',
|
|
||||||
'mineCard',
|
|
||||||
'quitMpv',
|
|
||||||
'previousAudio',
|
|
||||||
'nextAudio',
|
|
||||||
'playCurrentAudio',
|
|
||||||
'toggleMpvPause',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const key of buttonBindingKeys) {
|
|
||||||
const bindingValue = src.controller.bindings[key];
|
|
||||||
const legacyValue = asString(bindingValue);
|
|
||||||
if (
|
|
||||||
legacyValue !== undefined &&
|
|
||||||
CONTROLLER_BUTTON_BINDINGS.includes(legacyValue as (typeof CONTROLLER_BUTTON_BINDINGS)[number])
|
|
||||||
) {
|
|
||||||
resolved.controller.bindings[key] = resolveLegacyDiscreteBinding(
|
|
||||||
legacyValue as ControllerButtonBinding,
|
|
||||||
resolved.controller.buttonIndices,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parsedObject = parseDiscreteBindingObject(bindingValue);
|
|
||||||
if (parsedObject) {
|
|
||||||
resolved.controller.bindings[key] = parsedObject;
|
|
||||||
} else if (bindingValue !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.bindings.${key}`,
|
|
||||||
bindingValue,
|
|
||||||
resolved.controller.bindings[key],
|
|
||||||
"Expected legacy controller button name or binding object with kind 'none', 'button', or 'axis'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const axisBindingKeys = [
|
|
||||||
'leftStickHorizontal',
|
|
||||||
'leftStickVertical',
|
|
||||||
'rightStickHorizontal',
|
|
||||||
'rightStickVertical',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const key of axisBindingKeys) {
|
|
||||||
const bindingValue = src.controller.bindings[key];
|
|
||||||
const legacyValue = asString(bindingValue);
|
|
||||||
if (
|
|
||||||
legacyValue !== undefined &&
|
|
||||||
CONTROLLER_AXIS_BINDINGS.includes(legacyValue as (typeof CONTROLLER_AXIS_BINDINGS)[number])
|
|
||||||
) {
|
|
||||||
resolved.controller.bindings[key] = resolveLegacyAxisBinding(
|
|
||||||
legacyValue as ControllerAxisBinding,
|
|
||||||
key,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (legacyValue === 'none') {
|
|
||||||
resolved.controller.bindings[key] = { kind: 'none' };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parsedObject = parseAxisBindingObject(bindingValue, key);
|
|
||||||
if (parsedObject) {
|
|
||||||
resolved.controller.bindings[key] = parsedObject;
|
|
||||||
} else if (bindingValue !== undefined) {
|
|
||||||
warn(
|
|
||||||
`controller.bindings.${key}`,
|
|
||||||
bindingValue,
|
|
||||||
resolved.controller.bindings[key],
|
|
||||||
"Expected legacy controller axis name ('none' allowed) or binding object with kind 'axis'.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(src.keybindings)) {
|
if (Array.isArray(src.keybindings)) {
|
||||||
resolved.keybindings = src.keybindings.filter(
|
resolved.keybindings = src.keybindings.filter(
|
||||||
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
(entry): entry is { key: string; command: (string | number)[] | null } => {
|
||||||
|
|||||||
@@ -33,50 +33,6 @@ function createFakeIpcRegistrar(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControllerConfigFixture() {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: '',
|
|
||||||
preferredGamepadLabel: '',
|
|
||||||
smoothScroll: true,
|
|
||||||
scrollPixelsPerSecond: 960,
|
|
||||||
horizontalJumpPixels: 160,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
triggerInputMode: 'auto' as const,
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
repeatDelayMs: 220,
|
|
||||||
repeatIntervalMs: 80,
|
|
||||||
buttonIndices: {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
buttonEast: 1,
|
|
||||||
buttonWest: 2,
|
|
||||||
buttonNorth: 3,
|
|
||||||
leftShoulder: 4,
|
|
||||||
rightShoulder: 5,
|
|
||||||
leftStickPress: 9,
|
|
||||||
rightStickPress: 10,
|
|
||||||
leftTrigger: 6,
|
|
||||||
rightTrigger: 7,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button' as const, buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button' as const, buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button' as const, buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button' as const, buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button' as const, buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'button' as const, buttonIndex: 4 },
|
|
||||||
nextAudio: { kind: 'button' as const, buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button' as const, buttonIndex: 7 },
|
|
||||||
toggleMpvPause: { kind: 'button' as const, buttonIndex: 6 },
|
|
||||||
leftStickHorizontal: { kind: 'axis' as const, axisIndex: 0, dpadFallback: 'horizontal' as const },
|
|
||||||
leftStickVertical: { kind: 'axis' as const, axisIndex: 1, dpadFallback: 'vertical' as const },
|
|
||||||
rightStickHorizontal: { kind: 'axis' as const, axisIndex: 3, dpadFallback: 'none' as const },
|
|
||||||
rightStickVertical: { kind: 'axis' as const, axisIndex: 4, dpadFallback: 'none' as const },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const deps = createIpcDepsRuntime({
|
const deps = createIpcDepsRuntime({
|
||||||
@@ -97,9 +53,6 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
|
||||||
saveControllerConfig: () => {},
|
|
||||||
saveControllerPreference: () => {},
|
|
||||||
getSecondarySubMode: () => 'hover',
|
getSecondarySubMode: () => 'hover',
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -164,9 +117,6 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
|
||||||
saveControllerConfig: () => {},
|
|
||||||
saveControllerPreference: () => {},
|
|
||||||
getSecondarySubMode: () => 'hover',
|
getSecondarySubMode: () => 'hover',
|
||||||
getCurrentSecondarySub: () => '',
|
getCurrentSecondarySub: () => '',
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -223,19 +173,11 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
const getPlaybackPausedHandler = handlers.handle.get(IPC_CHANNELS.request.getPlaybackPaused);
|
||||||
assert.ok(getPlaybackPausedHandler);
|
assert.ok(getPlaybackPausedHandler);
|
||||||
assert.equal(getPlaybackPausedHandler!({}), null);
|
assert.equal(getPlaybackPausedHandler!({}), null);
|
||||||
|
|
||||||
const getControllerConfigHandler = handlers.handle.get(IPC_CHANNELS.request.getControllerConfig);
|
|
||||||
assert.ok(getControllerConfigHandler);
|
|
||||||
assert.equal(
|
|
||||||
(getControllerConfigHandler!({}) as { scrollPixelsPerSecond: number }).scrollPixelsPerSecond,
|
|
||||||
960,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const saves: unknown[] = [];
|
const saves: unknown[] = [];
|
||||||
const controllerSaves: unknown[] = [];
|
|
||||||
const closedModals: unknown[] = [];
|
const closedModals: unknown[] = [];
|
||||||
const openedModals: unknown[] = [];
|
const openedModals: unknown[] = [];
|
||||||
registerIpcHandlers(
|
registerIpcHandlers(
|
||||||
@@ -265,13 +207,6 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
|
||||||
saveControllerConfig: (update) => {
|
|
||||||
controllerSaves.push(update);
|
|
||||||
},
|
|
||||||
saveControllerPreference: (update) => {
|
|
||||||
controllerSaves.push(update);
|
|
||||||
},
|
|
||||||
getSecondarySubMode: () => 'hover',
|
getSecondarySubMode: () => 'hover',
|
||||||
getCurrentSecondarySub: () => '',
|
getCurrentSecondarySub: () => '',
|
||||||
focusMainWindow: () => {},
|
focusMainWindow: () => {},
|
||||||
@@ -305,202 +240,3 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'runtime-options');
|
||||||
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
assert.deepEqual(openedModals, ['subsync', 'runtime-options']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers awaits saveControllerPreference through request-response IPC', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const controllerSaves: unknown[] = [];
|
|
||||||
registerIpcHandlers(
|
|
||||||
{
|
|
||||||
onOverlayModalClosed: () => {},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
toggleDevTools: () => {},
|
|
||||||
getVisibleOverlayVisibility: () => false,
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
tokenizeCurrentSubtitle: async () => null,
|
|
||||||
getCurrentSubtitleRaw: () => '',
|
|
||||||
getCurrentSubtitleAss: () => '',
|
|
||||||
getPlaybackPaused: () => false,
|
|
||||||
getSubtitlePosition: () => null,
|
|
||||||
getSubtitleStyle: () => null,
|
|
||||||
saveSubtitlePosition: () => {},
|
|
||||||
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
|
||||||
setMecabEnabled: () => {},
|
|
||||||
handleMpvCommand: () => {},
|
|
||||||
getKeybindings: () => [],
|
|
||||||
getConfiguredShortcuts: () => ({}),
|
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
|
||||||
saveControllerConfig: async () => {},
|
|
||||||
saveControllerPreference: async (update) => {
|
|
||||||
await Promise.resolve();
|
|
||||||
controllerSaves.push(update);
|
|
||||||
},
|
|
||||||
getSecondarySubMode: () => 'hover',
|
|
||||||
getCurrentSecondarySub: () => '',
|
|
||||||
focusMainWindow: () => {},
|
|
||||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
getAnkiConnectStatus: () => false,
|
|
||||||
getRuntimeOptions: () => [],
|
|
||||||
setRuntimeOption: () => ({ ok: true }),
|
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
|
||||||
reportOverlayContentBounds: () => {},
|
|
||||||
getAnilistStatus: () => ({}),
|
|
||||||
clearAnilistToken: () => {},
|
|
||||||
openAnilistSetup: () => {},
|
|
||||||
getAnilistQueueStatus: () => ({}),
|
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
|
||||||
},
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
|
||||||
assert.ok(saveHandler);
|
|
||||||
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
|
||||||
}, /Invalid controller preference payload/);
|
|
||||||
await saveHandler!(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
preferredGamepadId: 'pad-1',
|
|
||||||
preferredGamepadLabel: 'Pad 1',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(controllerSaves, [
|
|
||||||
{
|
|
||||||
preferredGamepadId: 'pad-1',
|
|
||||||
preferredGamepadLabel: 'Pad 1',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers awaits saveControllerConfig through request-response IPC', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const controllerConfigSaves: unknown[] = [];
|
|
||||||
registerIpcHandlers(
|
|
||||||
{
|
|
||||||
onOverlayModalClosed: () => {},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
toggleDevTools: () => {},
|
|
||||||
getVisibleOverlayVisibility: () => false,
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
tokenizeCurrentSubtitle: async () => null,
|
|
||||||
getCurrentSubtitleRaw: () => '',
|
|
||||||
getCurrentSubtitleAss: () => '',
|
|
||||||
getPlaybackPaused: () => false,
|
|
||||||
getSubtitlePosition: () => null,
|
|
||||||
getSubtitleStyle: () => null,
|
|
||||||
saveSubtitlePosition: () => {},
|
|
||||||
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
|
||||||
setMecabEnabled: () => {},
|
|
||||||
handleMpvCommand: () => {},
|
|
||||||
getKeybindings: () => [],
|
|
||||||
getConfiguredShortcuts: () => ({}),
|
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
|
||||||
saveControllerConfig: async (update) => {
|
|
||||||
await Promise.resolve();
|
|
||||||
controllerConfigSaves.push(update);
|
|
||||||
},
|
|
||||||
saveControllerPreference: async () => {},
|
|
||||||
getSecondarySubMode: () => 'hover',
|
|
||||||
getCurrentSecondarySub: () => '',
|
|
||||||
focusMainWindow: () => {},
|
|
||||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
getAnkiConnectStatus: () => false,
|
|
||||||
getRuntimeOptions: () => [],
|
|
||||||
setRuntimeOption: () => ({ ok: true }),
|
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
|
||||||
reportOverlayContentBounds: () => {},
|
|
||||||
getAnilistStatus: () => ({}),
|
|
||||||
clearAnilistToken: () => {},
|
|
||||||
openAnilistSetup: () => {},
|
|
||||||
getAnilistQueueStatus: () => ({}),
|
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
|
||||||
},
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
|
|
||||||
assert.ok(saveHandler);
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
async () => {
|
|
||||||
await saveHandler!({}, { bindings: { toggleLookup: { kind: 'button', buttonIndex: -1 } } });
|
|
||||||
},
|
|
||||||
/Invalid controller config payload/,
|
|
||||||
);
|
|
||||||
|
|
||||||
await saveHandler!({}, {
|
|
||||||
preferredGamepadId: 'pad-2',
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
|
||||||
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(controllerConfigSaves, [
|
|
||||||
{
|
|
||||||
preferredGamepadId: 'pad-2',
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
|
||||||
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
registerIpcHandlers(
|
|
||||||
{
|
|
||||||
onOverlayModalClosed: () => {},
|
|
||||||
openYomitanSettings: () => {},
|
|
||||||
quitApp: () => {},
|
|
||||||
toggleDevTools: () => {},
|
|
||||||
getVisibleOverlayVisibility: () => false,
|
|
||||||
toggleVisibleOverlay: () => {},
|
|
||||||
tokenizeCurrentSubtitle: async () => null,
|
|
||||||
getCurrentSubtitleRaw: () => '',
|
|
||||||
getCurrentSubtitleAss: () => '',
|
|
||||||
getPlaybackPaused: () => false,
|
|
||||||
getSubtitlePosition: () => null,
|
|
||||||
getSubtitleStyle: () => null,
|
|
||||||
saveSubtitlePosition: () => {},
|
|
||||||
getMecabStatus: () => ({ available: false, enabled: false, path: null }),
|
|
||||||
setMecabEnabled: () => {},
|
|
||||||
handleMpvCommand: () => {},
|
|
||||||
getKeybindings: () => [],
|
|
||||||
getConfiguredShortcuts: () => ({}),
|
|
||||||
getControllerConfig: () => createControllerConfigFixture(),
|
|
||||||
saveControllerConfig: async () => {},
|
|
||||||
saveControllerPreference: async () => {},
|
|
||||||
getSecondarySubMode: () => 'hover',
|
|
||||||
getCurrentSecondarySub: () => '',
|
|
||||||
focusMainWindow: () => {},
|
|
||||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
getAnkiConnectStatus: () => false,
|
|
||||||
getRuntimeOptions: () => [],
|
|
||||||
setRuntimeOption: () => ({ ok: true }),
|
|
||||||
cycleRuntimeOption: () => ({ ok: true }),
|
|
||||||
reportOverlayContentBounds: () => {},
|
|
||||||
getAnilistStatus: () => ({}),
|
|
||||||
clearAnilistToken: () => {},
|
|
||||||
openAnilistSetup: () => {},
|
|
||||||
getAnilistQueueStatus: () => ({}),
|
|
||||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
|
||||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
|
||||||
},
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerPreference);
|
|
||||||
await assert.rejects(async () => {
|
|
||||||
await saveHandler!({}, { preferredGamepadId: 12 });
|
|
||||||
}, /Invalid controller preference payload/);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import electron from 'electron';
|
import electron from 'electron';
|
||||||
import type { IpcMainEvent } from 'electron';
|
import type { IpcMainEvent } from 'electron';
|
||||||
import type {
|
import type {
|
||||||
ControllerConfigUpdate,
|
|
||||||
ControllerPreferenceUpdate,
|
|
||||||
ResolvedControllerConfig,
|
|
||||||
RuntimeOptionId,
|
RuntimeOptionId,
|
||||||
RuntimeOptionValue,
|
RuntimeOptionValue,
|
||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
@@ -13,8 +10,6 @@ import type {
|
|||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
parseMpvCommand,
|
parseMpvCommand,
|
||||||
parseControllerConfigUpdate,
|
|
||||||
parseControllerPreferenceUpdate,
|
|
||||||
parseOptionalForwardingOptions,
|
parseOptionalForwardingOptions,
|
||||||
parseOverlayHostedModal,
|
parseOverlayHostedModal,
|
||||||
parseRuntimeOptionDirection,
|
parseRuntimeOptionDirection,
|
||||||
@@ -50,9 +45,6 @@ export interface IpcServiceDeps {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
getControllerConfig: () => ResolvedControllerConfig;
|
|
||||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getCurrentSecondarySub: () => string;
|
getCurrentSecondarySub: () => string;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
@@ -116,9 +108,6 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
getControllerConfig: () => ResolvedControllerConfig;
|
|
||||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
getMpvClient: () => MpvClientLike | null;
|
getMpvClient: () => MpvClientLike | null;
|
||||||
focusMainWindow: () => void;
|
focusMainWindow: () => void;
|
||||||
@@ -170,9 +159,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
handleMpvCommand: options.handleMpvCommand,
|
handleMpvCommand: options.handleMpvCommand,
|
||||||
getKeybindings: options.getKeybindings,
|
getKeybindings: options.getKeybindings,
|
||||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||||
getControllerConfig: options.getControllerConfig,
|
|
||||||
saveControllerConfig: options.saveControllerConfig,
|
|
||||||
saveControllerPreference: options.saveControllerPreference,
|
|
||||||
getSecondarySubMode: options.getSecondarySubMode,
|
getSecondarySubMode: options.getSecondarySubMode,
|
||||||
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
|
||||||
focusMainWindow: () => {
|
focusMainWindow: () => {
|
||||||
@@ -270,25 +256,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
deps.saveSubtitlePosition(parsedPosition);
|
deps.saveSubtitlePosition(parsedPosition);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.handle(
|
|
||||||
IPC_CHANNELS.command.saveControllerPreference,
|
|
||||||
async (_event: unknown, update: unknown) => {
|
|
||||||
const parsedUpdate = parseControllerPreferenceUpdate(update);
|
|
||||||
if (!parsedUpdate) {
|
|
||||||
throw new Error('Invalid controller preference payload');
|
|
||||||
}
|
|
||||||
await deps.saveControllerPreference(parsedUpdate);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.command.saveControllerConfig, async (_event: unknown, update: unknown) => {
|
|
||||||
const parsedUpdate = parseControllerConfigUpdate(update);
|
|
||||||
if (!parsedUpdate) {
|
|
||||||
throw new Error('Invalid controller config payload');
|
|
||||||
}
|
|
||||||
await deps.saveControllerConfig(parsedUpdate);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
ipc.handle(IPC_CHANNELS.request.getMecabStatus, () => {
|
||||||
return deps.getMecabStatus();
|
return deps.getMecabStatus();
|
||||||
});
|
});
|
||||||
@@ -312,10 +279,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.getConfiguredShortcuts();
|
return deps.getConfiguredShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
|
||||||
return deps.getControllerConfig();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
|
||||||
return deps.getSecondarySubMode();
|
return deps.getSecondarySubMode();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,9 +55,8 @@ test('resolveExistingYomitanExtensionPath ignores source tree without built mani
|
|||||||
|
|
||||||
test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => {
|
test('resolveExternalYomitanExtensionPath returns external extension dir when manifest exists', () => {
|
||||||
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
|
const profilePath = path.join('/Users', 'kyle', '.local', 'share', 'gsm-profile');
|
||||||
const resolved = resolveExternalYomitanExtensionPath(
|
const resolved = resolveExternalYomitanExtensionPath(profilePath, (candidate) =>
|
||||||
profilePath,
|
candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'),
|
||||||
(candidate) => candidate === path.join(profilePath, 'extensions', 'yomitan', 'manifest.json'),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan'));
|
assert.equal(resolved, path.join(profilePath, 'extensions', 'yomitan'));
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export function clearYomitanParserRuntimeState(deps: YomitanParserRuntimeStateDe
|
|||||||
deps.setYomitanParserInitPromise(null);
|
deps.setYomitanParserInitPromise(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearYomitanExtensionRuntimeState(deps: YomitanExtensionRuntimeStateDeps): void {
|
export function clearYomitanExtensionRuntimeState(
|
||||||
|
deps: YomitanExtensionRuntimeStateDeps,
|
||||||
|
): void {
|
||||||
clearYomitanParserRuntimeState(deps);
|
clearYomitanParserRuntimeState(deps);
|
||||||
deps.setYomitanExtension(null);
|
deps.setYomitanExtension(null);
|
||||||
deps.setYomitanSession(null);
|
deps.setYomitanSession(null);
|
||||||
|
|||||||
42
src/main.ts
42
src/main.ts
@@ -30,7 +30,6 @@ import {
|
|||||||
dialog,
|
dialog,
|
||||||
screen,
|
screen,
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
|
||||||
|
|
||||||
function getPasswordStoreArg(argv: string[]): string | null {
|
function getPasswordStoreArg(argv: string[]): string | null {
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
@@ -360,8 +359,7 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
|||||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
import { createOverlayModalRuntimeService, type OverlayHostedModal } from './main/overlay-runtime';
|
||||||
import type { OverlayHostedModal } from './shared/ipc/contracts';
|
|
||||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||||
import {
|
import {
|
||||||
createFrequencyDictionaryRuntimeService,
|
createFrequencyDictionaryRuntimeService,
|
||||||
@@ -695,8 +693,7 @@ const firstRunSetupService = createFirstRunSetupService({
|
|||||||
});
|
});
|
||||||
return dictionaries.length;
|
return dictionaries.length;
|
||||||
},
|
},
|
||||||
isExternalYomitanConfigured: () =>
|
isExternalYomitanConfigured: () => getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
||||||
getResolvedConfig().yomitan.externalProfilePath.trim().length > 0,
|
|
||||||
detectPluginInstalled: () => {
|
detectPluginInstalled: () => {
|
||||||
const installPaths = resolveDefaultMpvInstallPaths(
|
const installPaths = resolveDefaultMpvInstallPaths(
|
||||||
process.platform,
|
process.platform,
|
||||||
@@ -3119,7 +3116,8 @@ function initializeOverlayRuntime(): void {
|
|||||||
|
|
||||||
function openYomitanSettings(): boolean {
|
function openYomitanSettings(): boolean {
|
||||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||||
const message = 'Yomitan settings unavailable while using read-only external-profile mode.';
|
const message =
|
||||||
|
'Yomitan settings unavailable while using read-only external-profile mode.';
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
|
'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.',
|
||||||
);
|
);
|
||||||
@@ -3456,21 +3454,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
getKeybindings: () => appState.keybindings,
|
getKeybindings: () => appState.keybindings,
|
||||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||||
getControllerConfig: () => getResolvedConfig().controller,
|
|
||||||
saveControllerConfig: (update) => {
|
|
||||||
const currentRawConfig = configService.getRawConfig();
|
|
||||||
configService.patchRawConfig({
|
|
||||||
controller: applyControllerConfigUpdate(currentRawConfig.controller, update),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
|
|
||||||
configService.patchRawConfig({
|
|
||||||
controller: {
|
|
||||||
preferredGamepadId,
|
|
||||||
preferredGamepadLabel,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getSecondarySubMode: () => appState.secondarySubMode,
|
getSecondarySubMode: () => appState.secondarySubMode,
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
|
||||||
@@ -3579,11 +3562,11 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
onRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
|
||||||
setOverlayDebugVisualizationEnabled: (enabled) =>
|
setOverlayDebugVisualizationEnabled: (enabled) =>
|
||||||
setOverlayDebugVisualizationEnabled(enabled),
|
setOverlayDebugVisualizationEnabled(enabled),
|
||||||
isOverlayVisible: (windowKind) =>
|
isOverlayVisible: (windowKind) =>
|
||||||
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
windowKind === 'visible' ? overlayManager.getVisibleOverlayVisible() : false,
|
||||||
getYomitanSession: () => appState.yomitanSession,
|
getYomitanSession: () => appState.yomitanSession,
|
||||||
tryHandleOverlayShortcutLocalFallback: (input) =>
|
tryHandleOverlayShortcutLocalFallback: (input) =>
|
||||||
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
overlayShortcutsRuntime.tryHandleOverlayShortcutLocalFallback(input),
|
||||||
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
forwardTabToMpv: () => sendMpvCommandRuntime(appState.mpvClient, ['keypress', 'TAB']),
|
||||||
onWindowClosed: (windowKind) => {
|
onWindowClosed: (windowKind) => {
|
||||||
if (windowKind === 'visible') {
|
if (windowKind === 'visible') {
|
||||||
@@ -3711,7 +3694,12 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
|
|||||||
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
|
||||||
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
|
||||||
getYomitanSession: () => appState.yomitanSession,
|
getYomitanSession: () => appState.yomitanSession,
|
||||||
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {
|
openYomitanSettingsWindow: ({
|
||||||
|
yomitanExt,
|
||||||
|
getExistingWindow,
|
||||||
|
setWindow,
|
||||||
|
yomitanSession,
|
||||||
|
}) => {
|
||||||
openYomitanSettingsWindow({
|
openYomitanSettingsWindow({
|
||||||
yomitanExt: yomitanExt as Extension,
|
yomitanExt: yomitanExt as Extension,
|
||||||
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
getExistingWindow: () => getExistingWindow() as BrowserWindow | null,
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { applyControllerConfigUpdate } from './controller-config-update.js';
|
|
||||||
|
|
||||||
test('applyControllerConfigUpdate replaces binding descriptors instead of deep-merging them', () => {
|
|
||||||
const next = applyControllerConfigUpdate(
|
|
||||||
{
|
|
||||||
preferredGamepadId: 'pad-1',
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'axis', axisIndex: 4, direction: 'positive' },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 11 });
|
|
||||||
assert.deepEqual(next.bindings?.closeLookup, { kind: 'button', buttonIndex: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applyControllerConfigUpdate merges buttonIndices while replacing only updated binding leaves', () => {
|
|
||||||
const next = applyControllerConfigUpdate(
|
|
||||||
{
|
|
||||||
buttonIndices: {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
buttonIndices: {
|
|
||||||
buttonSouth: 9,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
closeLookup: { kind: 'none' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(next.buttonIndices, {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 9,
|
|
||||||
});
|
|
||||||
assert.deepEqual(next.bindings?.toggleLookup, { kind: 'button', buttonIndex: 0 });
|
|
||||||
assert.deepEqual(next.bindings?.closeLookup, { kind: 'none' });
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { ControllerConfigUpdate, RawConfig } from '../types';
|
|
||||||
|
|
||||||
type RawControllerConfig = NonNullable<RawConfig['controller']>;
|
|
||||||
type RawControllerBindings = NonNullable<RawControllerConfig['bindings']>;
|
|
||||||
|
|
||||||
export function applyControllerConfigUpdate(
|
|
||||||
currentController: RawConfig['controller'] | undefined,
|
|
||||||
update: ControllerConfigUpdate,
|
|
||||||
): RawControllerConfig {
|
|
||||||
const nextController: RawControllerConfig = {
|
|
||||||
...(currentController ?? {}),
|
|
||||||
...update,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentController?.buttonIndices || update.buttonIndices) {
|
|
||||||
nextController.buttonIndices = {
|
|
||||||
...(currentController?.buttonIndices ?? {}),
|
|
||||||
...(update.buttonIndices ?? {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentController?.bindings || update.bindings) {
|
|
||||||
const nextBindings: RawControllerBindings = {
|
|
||||||
...(currentController?.bindings ?? {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(update.bindings ?? {}) as Array<
|
|
||||||
[keyof RawControllerBindings, RawControllerBindings[keyof RawControllerBindings] | undefined]
|
|
||||||
>) {
|
|
||||||
if (value === undefined) continue;
|
|
||||||
(nextBindings as Record<string, unknown>)[key] = JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
nextController.bindings = nextBindings;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextController;
|
|
||||||
}
|
|
||||||
@@ -72,9 +72,6 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
handleMpvCommand: IpcDepsRuntimeOptions['handleMpvCommand'];
|
||||||
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
getKeybindings: IpcDepsRuntimeOptions['getKeybindings'];
|
||||||
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
getConfiguredShortcuts: IpcDepsRuntimeOptions['getConfiguredShortcuts'];
|
||||||
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
|
|
||||||
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
|
|
||||||
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
|
|
||||||
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
|
||||||
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
|
||||||
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
|
||||||
@@ -216,9 +213,6 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
handleMpvCommand: params.handleMpvCommand,
|
handleMpvCommand: params.handleMpvCommand,
|
||||||
getKeybindings: params.getKeybindings,
|
getKeybindings: params.getKeybindings,
|
||||||
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
getConfiguredShortcuts: params.getConfiguredShortcuts,
|
||||||
getControllerConfig: params.getControllerConfig,
|
|
||||||
saveControllerConfig: params.saveControllerConfig,
|
|
||||||
saveControllerPreference: params.saveControllerPreference,
|
|
||||||
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
focusMainWindow: params.focusMainWindow ?? (() => {}),
|
||||||
getSecondarySubMode: params.getSecondarySubMode,
|
getSecondarySubMode: params.getSecondarySubMode,
|
||||||
getMpvClient: params.getMpvClient,
|
getMpvClient: params.getMpvClient,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||||
|
|
||||||
|
type OverlayHostedModal = 'runtime-options' | 'subsync' | 'jimaku' | 'kiku';
|
||||||
|
|
||||||
export interface OverlayWindowResolver {
|
export interface OverlayWindowResolver {
|
||||||
getMainWindow: () => BrowserWindow | null;
|
getMainWindow: () => BrowserWindow | null;
|
||||||
getModalWindow: () => BrowserWindow | null;
|
getModalWindow: () => BrowserWindow | null;
|
||||||
@@ -293,3 +294,5 @@ export function createOverlayModalRuntimeService(
|
|||||||
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { OverlayHostedModal };
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
getMecabTokenizer: () => null,
|
getMecabTokenizer: () => null,
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}) as never,
|
getConfiguredShortcuts: () => ({}) as never,
|
||||||
getControllerConfig: () => ({}) as never,
|
|
||||||
saveControllerConfig: () => {},
|
|
||||||
saveControllerPreference: () => {},
|
|
||||||
getSecondarySubMode: () => 'hover' as never,
|
getSecondarySubMode: () => 'hover' as never,
|
||||||
getMpvClient: () => null,
|
getMpvClient: () => null,
|
||||||
getAnkiConnectStatus: () => false,
|
getAnkiConnectStatus: () => false,
|
||||||
|
|||||||
@@ -279,11 +279,7 @@ export function createFirstRunSetupService(deps: {
|
|||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
isSetupCompleted(state) &&
|
isSetupCompleted(state) &&
|
||||||
!(
|
!(state.yomitanSetupMode === 'external' && !externalYomitanConfigured && !yomitanSetupSatisfied)
|
||||||
state.yomitanSetupMode === 'external' &&
|
|
||||||
!externalYomitanConfigured &&
|
|
||||||
!yomitanSetupSatisfied
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
completed = true;
|
completed = true;
|
||||||
return refreshWithState(state);
|
return refreshWithState(state);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||||
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
import type { AppendClipboardVideoToQueueRuntimeDeps } from './clipboard-queue';
|
||||||
|
|
||||||
export function createSetOverlayVisibleHandler(deps: {
|
export function createSetOverlayVisibleHandler(deps: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { RuntimeOptionState } from '../../types';
|
import type { RuntimeOptionState } from '../../types';
|
||||||
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../overlay-runtime';
|
||||||
|
|
||||||
type RuntimeOptionsManagerLike = {
|
type RuntimeOptionsManagerLike = {
|
||||||
listOptions: () => RuntimeOptionState[];
|
listOptions: () => RuntimeOptionState[];
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
function redactSkippedYomitanWriteValue(
|
function redactSkippedYomitanWriteValue(
|
||||||
actionName:
|
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||||
| 'importYomitanDictionary'
|
|
||||||
| 'deleteYomitanDictionary'
|
|
||||||
| 'upsertYomitanDictionarySettings',
|
|
||||||
rawValue: string,
|
rawValue: string,
|
||||||
): string {
|
): string {
|
||||||
const trimmed = rawValue.trim();
|
const trimmed = rawValue.trim();
|
||||||
@@ -21,10 +18,7 @@ function redactSkippedYomitanWriteValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatSkippedYomitanWriteAction(
|
export function formatSkippedYomitanWriteAction(
|
||||||
actionName:
|
actionName: 'importYomitanDictionary' | 'deleteYomitanDictionary' | 'upsertYomitanDictionarySettings',
|
||||||
| 'importYomitanDictionary'
|
|
||||||
| 'deleteYomitanDictionary'
|
|
||||||
| 'upsertYomitanDictionarySettings',
|
|
||||||
rawValue: string,
|
rawValue: string,
|
||||||
): string {
|
): string {
|
||||||
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
|
return `${actionName}(${redactSkippedYomitanWriteValue(actionName, rawValue)})`;
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ test('yomitan settings runtime composes opener with built deps', async () => {
|
|||||||
|
|
||||||
const runtime = createYomitanSettingsRuntime({
|
const runtime = createYomitanSettingsRuntime({
|
||||||
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }),
|
||||||
openYomitanSettingsWindow: ({
|
openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => {
|
||||||
getExistingWindow,
|
|
||||||
setWindow,
|
|
||||||
yomitanSession: forwardedSession,
|
|
||||||
}) => {
|
|
||||||
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
|
calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`);
|
||||||
const current = getExistingWindow();
|
const current = getExistingWindow();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -58,7 +54,5 @@ test('yomitan settings runtime warns and does not open when no yomitan session i
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
assert.equal(existingWindow, null);
|
assert.equal(existingWindow, null);
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, ['warn:Unable to open Yomitan settings: Yomitan session is unavailable.']);
|
||||||
'warn:Unable to open Yomitan settings: Yomitan session is unavailable.',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,9 +48,6 @@ import type {
|
|||||||
OverlayContentMeasurement,
|
OverlayContentMeasurement,
|
||||||
ShortcutsConfig,
|
ShortcutsConfig,
|
||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
ControllerConfigUpdate,
|
|
||||||
ControllerPreferenceUpdate,
|
|
||||||
ResolvedControllerConfig,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -208,12 +205,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||||
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
|
||||||
saveControllerConfig: (update: ControllerConfigUpdate): Promise<void> =>
|
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerConfig, update),
|
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.command.saveControllerPreference, update),
|
|
||||||
|
|
||||||
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuGetMediaInfo),
|
||||||
@@ -301,10 +292,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
|
||||||
notifyOverlayModalClosed: (modal) => {
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal);
|
||||||
},
|
},
|
||||||
notifyOverlayModalOpened: (modal) => {
|
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => {
|
||||||
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal);
|
||||||
},
|
},
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||||
|
|||||||
@@ -67,25 +67,6 @@ test('windows release workflow publishes unsigned artifacts directly without Sig
|
|||||||
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
|
assert.ok(!releaseWorkflow.includes('SIGNPATH_'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('release workflow publishes subminer-bin to AUR from tagged release artifacts', () => {
|
|
||||||
assert.match(releaseWorkflow, /aur-publish:/);
|
|
||||||
assert.match(releaseWorkflow, /needs:\s*\[release\]/);
|
|
||||||
assert.match(releaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
|
|
||||||
assert.match(releaseWorkflow, /ssh:\/\/aur@aur\.archlinux\.org\/subminer-bin\.git/);
|
|
||||||
assert.match(releaseWorkflow, /scripts\/update-aur-package\.sh/);
|
|
||||||
assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/);
|
|
||||||
assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/);
|
|
||||||
assert.doesNotMatch(
|
|
||||||
releaseWorkflow,
|
|
||||||
/SubMiner-\$\{\{ steps\.version\.outputs\.VERSION \}\}\.AppImage/,
|
|
||||||
);
|
|
||||||
assert.doesNotMatch(releaseWorkflow, /Install makepkg/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('release workflow skips empty AUR sync commits', () => {
|
|
||||||
assert.match(releaseWorkflow, /if git diff --quiet -- PKGBUILD \.SRCINFO; then/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
|
test('Makefile routes Windows install-plugin setup through bun and documents Windows builds', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
makefile,
|
makefile,
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
|
||||||
|
|
||||||
function createClassList(initialTokens: string[] = []) {
|
|
||||||
const tokens = new Set(initialTokens);
|
|
||||||
return {
|
|
||||||
add: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.add(entry);
|
|
||||||
},
|
|
||||||
remove: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.delete(entry);
|
|
||||||
},
|
|
||||||
contains: (entry: string) => tokens.has(entry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
|
|
||||||
let nextTimerId = 1;
|
|
||||||
const scheduled = new Map<number, () => void>();
|
|
||||||
const classList = createClassList(['hidden']);
|
|
||||||
const toast = {
|
|
||||||
textContent: '',
|
|
||||||
classList,
|
|
||||||
};
|
|
||||||
|
|
||||||
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
|
|
||||||
durationMs: 1500,
|
|
||||||
setTimeout: (callback: () => void) => {
|
|
||||||
const id = nextTimerId++;
|
|
||||||
scheduled.set(id, callback);
|
|
||||||
return id as never;
|
|
||||||
},
|
|
||||||
clearTimeout: (id) => {
|
|
||||||
scheduled.delete(id as never as number);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
indicator.update({
|
|
||||||
connectedGamepads: [],
|
|
||||||
activeGamepadId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(classList.contains('hidden'), true);
|
|
||||||
assert.equal(toast.textContent, '');
|
|
||||||
|
|
||||||
indicator.update({
|
|
||||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
|
||||||
activeGamepadId: 'pad-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(classList.contains('hidden'), false);
|
|
||||||
assert.match(toast.textContent, /controller detected/i);
|
|
||||||
assert.match(toast.textContent, /pad-1/i);
|
|
||||||
assert.equal(scheduled.size, 1);
|
|
||||||
|
|
||||||
indicator.update({
|
|
||||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
|
||||||
activeGamepadId: 'pad-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(scheduled.size, 1);
|
|
||||||
|
|
||||||
const [hide] = scheduled.values();
|
|
||||||
hide?.();
|
|
||||||
|
|
||||||
assert.equal(classList.contains('hidden'), true);
|
|
||||||
assert.equal(toast.textContent, '');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller status indicator announces newly detected controllers after startup', () => {
|
|
||||||
const toast = {
|
|
||||||
textContent: '',
|
|
||||||
classList: createClassList(['hidden']),
|
|
||||||
};
|
|
||||||
|
|
||||||
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
|
|
||||||
setTimeout: () => 1 as never,
|
|
||||||
clearTimeout: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
indicator.update({
|
|
||||||
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
|
|
||||||
activeGamepadId: 'pad-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.classList.add('hidden');
|
|
||||||
toast.textContent = '';
|
|
||||||
|
|
||||||
indicator.update({
|
|
||||||
connectedGamepads: [
|
|
||||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
|
||||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
|
||||||
],
|
|
||||||
activeGamepadId: 'pad-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(toast.classList.contains('hidden'), false);
|
|
||||||
assert.match(toast.textContent, /pad-2/i);
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { ControllerDeviceInfo } from '../types';
|
|
||||||
|
|
||||||
type ControllerSnapshot = {
|
|
||||||
connectedGamepads: ControllerDeviceInfo[];
|
|
||||||
activeGamepadId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ControllerStatusIndicatorOptions = {
|
|
||||||
durationMs?: number;
|
|
||||||
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
|
|
||||||
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
|
|
||||||
if (!device) return 'Controller';
|
|
||||||
return device.id || `Gamepad ${device.index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createControllerStatusIndicator(
|
|
||||||
dom: {
|
|
||||||
controllerStatusToast: {
|
|
||||||
textContent: string;
|
|
||||||
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
|
|
||||||
};
|
|
||||||
},
|
|
||||||
options: ControllerStatusIndicatorOptions = {},
|
|
||||||
) {
|
|
||||||
const durationMs = options.durationMs ?? 2200;
|
|
||||||
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
|
|
||||||
const cancelTimeout =
|
|
||||||
options.clearTimeout ??
|
|
||||||
((timer: ReturnType<typeof setTimeout> | number) =>
|
|
||||||
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
|
|
||||||
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
|
|
||||||
let previousConnectedIds = new Set<string>();
|
|
||||||
|
|
||||||
function show(message: string): void {
|
|
||||||
if (hideTimeout !== null) {
|
|
||||||
cancelTimeout(hideTimeout);
|
|
||||||
hideTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
dom.controllerStatusToast.textContent = message;
|
|
||||||
dom.controllerStatusToast.classList.remove('hidden');
|
|
||||||
hideTimeout = scheduleTimeout(() => {
|
|
||||||
dom.controllerStatusToast.classList.add('hidden');
|
|
||||||
dom.controllerStatusToast.textContent = '';
|
|
||||||
hideTimeout = null;
|
|
||||||
}, durationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function update(snapshot: ControllerSnapshot): void {
|
|
||||||
const newDevices = snapshot.connectedGamepads.filter(
|
|
||||||
(device) => !previousConnectedIds.has(device.id),
|
|
||||||
);
|
|
||||||
if (newDevices.length > 0) {
|
|
||||||
const activeDevice = snapshot.connectedGamepads.find(
|
|
||||||
(device) => device.id === snapshot.activeGamepadId,
|
|
||||||
);
|
|
||||||
const announcedDevice =
|
|
||||||
newDevices.find((device) => device.id === snapshot.activeGamepadId) ??
|
|
||||||
newDevices[0] ??
|
|
||||||
activeDevice;
|
|
||||||
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { update };
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createControllerBindingCapture } from './controller-binding-capture.js';
|
|
||||||
|
|
||||||
function createSnapshot(
|
|
||||||
overrides: {
|
|
||||||
axes?: number[];
|
|
||||||
buttons?: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
axes: overrides.axes ?? [0, 0, 0, 0, 0],
|
|
||||||
buttons:
|
|
||||||
overrides.buttons ??
|
|
||||||
Array.from({ length: 12 }, () => ({
|
|
||||||
value: 0,
|
|
||||||
pressed: false,
|
|
||||||
touched: false,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('controller binding capture waits for neutral-to-active button edge', () => {
|
|
||||||
const capture = createControllerBindingCapture({
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const heldButtons = createSnapshot({
|
|
||||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
|
||||||
});
|
|
||||||
|
|
||||||
capture.arm({ actionId: 'toggleLookup', bindingType: 'discrete' }, heldButtons);
|
|
||||||
|
|
||||||
assert.equal(capture.poll(heldButtons), null);
|
|
||||||
|
|
||||||
const neutralButtons = createSnapshot();
|
|
||||||
assert.equal(capture.poll(neutralButtons), null);
|
|
||||||
|
|
||||||
const freshPress = createSnapshot({
|
|
||||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
|
||||||
});
|
|
||||||
assert.deepEqual(capture.poll(freshPress), {
|
|
||||||
actionId: 'toggleLookup',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
binding: { kind: 'button', buttonIndex: 0 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller binding capture records fresh axis direction for discrete learn mode', () => {
|
|
||||||
const capture = createControllerBindingCapture({
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
capture.arm({ actionId: 'closeLookup', bindingType: 'discrete' }, createSnapshot());
|
|
||||||
|
|
||||||
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, -0.8] })), {
|
|
||||||
actionId: 'closeLookup',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
binding: { kind: 'axis', axisIndex: 3, direction: 'negative' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller binding capture ignores analog drift inside deadzone', () => {
|
|
||||||
const capture = createControllerBindingCapture({
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
stickDeadzone: 0.3,
|
|
||||||
});
|
|
||||||
|
|
||||||
capture.arm({ actionId: 'mineCard', bindingType: 'discrete' }, createSnapshot());
|
|
||||||
|
|
||||||
assert.equal(capture.poll(createSnapshot({ axes: [0.2, 0, 0, 0] })), null);
|
|
||||||
assert.equal(capture.isArmed(), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller binding capture emits axis binding for continuous learn mode', () => {
|
|
||||||
const capture = createControllerBindingCapture({
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
capture.arm(
|
|
||||||
{
|
|
||||||
actionId: 'leftStickHorizontal',
|
|
||||||
bindingType: 'axis',
|
|
||||||
dpadFallback: 'horizontal',
|
|
||||||
},
|
|
||||||
createSnapshot(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0, 0.9] })), {
|
|
||||||
actionId: 'leftStickHorizontal',
|
|
||||||
bindingType: 'axis',
|
|
||||||
binding: { kind: 'axis', axisIndex: 3, dpadFallback: 'horizontal' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller binding capture ignores button presses for continuous learn mode', () => {
|
|
||||||
const capture = createControllerBindingCapture({
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
capture.arm(
|
|
||||||
{
|
|
||||||
actionId: 'leftStickHorizontal',
|
|
||||||
bindingType: 'axis',
|
|
||||||
dpadFallback: 'horizontal',
|
|
||||||
},
|
|
||||||
createSnapshot(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
capture.poll(
|
|
||||||
createSnapshot({
|
|
||||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(capture.poll(createSnapshot({ axes: [0, 0, 0.75, 0, 0] })), {
|
|
||||||
actionId: 'leftStickHorizontal',
|
|
||||||
bindingType: 'axis',
|
|
||||||
binding: { kind: 'axis', axisIndex: 2, dpadFallback: 'horizontal' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import type {
|
|
||||||
ControllerDpadFallback,
|
|
||||||
ResolvedControllerAxisBinding,
|
|
||||||
ResolvedControllerDiscreteBinding,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
type ControllerButtonState = {
|
|
||||||
value: number;
|
|
||||||
pressed?: boolean;
|
|
||||||
touched?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ControllerBindingCaptureSnapshot = {
|
|
||||||
axes: readonly number[];
|
|
||||||
buttons: readonly ControllerButtonState[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ControllerBindingCaptureTarget =
|
|
||||||
| {
|
|
||||||
actionId: string;
|
|
||||||
bindingType: 'discrete';
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
actionId: string;
|
|
||||||
bindingType: 'axis';
|
|
||||||
dpadFallback: ControllerDpadFallback;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
actionId: string;
|
|
||||||
bindingType: 'dpad';
|
|
||||||
};
|
|
||||||
|
|
||||||
type ControllerBindingCaptureResult =
|
|
||||||
| {
|
|
||||||
actionId: string;
|
|
||||||
bindingType: 'discrete';
|
|
||||||
binding: ResolvedControllerDiscreteBinding;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
actionId: string;
|
|
||||||
bindingType: 'axis';
|
|
||||||
binding: ResolvedControllerAxisBinding;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
actionId: string;
|
|
||||||
bindingType: 'dpad';
|
|
||||||
dpadDirection: ControllerDpadFallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isActiveButton(button: ControllerButtonState | undefined, triggerDeadzone: number): boolean {
|
|
||||||
if (!button) return false;
|
|
||||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAxisDirection(
|
|
||||||
value: number | undefined,
|
|
||||||
activationThreshold: number,
|
|
||||||
): 'negative' | 'positive' | null {
|
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
||||||
if (Math.abs(value) < activationThreshold) return null;
|
|
||||||
return value > 0 ? 'positive' : 'negative';
|
|
||||||
}
|
|
||||||
|
|
||||||
const DPAD_BUTTON_INDICES = [12, 13, 14, 15] as const;
|
|
||||||
|
|
||||||
export function createControllerBindingCapture(options: {
|
|
||||||
triggerDeadzone: number;
|
|
||||||
stickDeadzone: number;
|
|
||||||
}) {
|
|
||||||
let target: ControllerBindingCaptureTarget | null = null;
|
|
||||||
const blockedButtons = new Set<number>();
|
|
||||||
const blockedAxisDirections = new Set<string>();
|
|
||||||
|
|
||||||
function resetBlockedState(snapshot: ControllerBindingCaptureSnapshot): void {
|
|
||||||
blockedButtons.clear();
|
|
||||||
blockedAxisDirections.clear();
|
|
||||||
|
|
||||||
snapshot.buttons.forEach((button, index) => {
|
|
||||||
if (isActiveButton(button, options.triggerDeadzone)) {
|
|
||||||
blockedButtons.add(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const activationThreshold = Math.max(options.stickDeadzone, 0.55);
|
|
||||||
snapshot.axes.forEach((value, index) => {
|
|
||||||
const direction = getAxisDirection(value, activationThreshold);
|
|
||||||
if (direction) {
|
|
||||||
blockedAxisDirections.add(`${index}:${direction}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function arm(nextTarget: ControllerBindingCaptureTarget, snapshot: ControllerBindingCaptureSnapshot): void {
|
|
||||||
target = nextTarget;
|
|
||||||
resetBlockedState(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel(): void {
|
|
||||||
target = null;
|
|
||||||
blockedButtons.clear();
|
|
||||||
blockedAxisDirections.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
function poll(snapshot: ControllerBindingCaptureSnapshot): ControllerBindingCaptureResult | null {
|
|
||||||
if (!target) return null;
|
|
||||||
|
|
||||||
snapshot.buttons.forEach((button, index) => {
|
|
||||||
if (!isActiveButton(button, options.triggerDeadzone)) {
|
|
||||||
blockedButtons.delete(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const activationThreshold = Math.max(options.stickDeadzone, 0.55);
|
|
||||||
snapshot.axes.forEach((value, index) => {
|
|
||||||
const negativeKey = `${index}:negative`;
|
|
||||||
const positiveKey = `${index}:positive`;
|
|
||||||
if (getAxisDirection(value, activationThreshold) === null) {
|
|
||||||
blockedAxisDirections.delete(negativeKey);
|
|
||||||
blockedAxisDirections.delete(positiveKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// D-pad capture: only respond to d-pad buttons (12-15)
|
|
||||||
if (target.bindingType === 'dpad') {
|
|
||||||
for (const index of DPAD_BUTTON_INDICES) {
|
|
||||||
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
|
|
||||||
if (blockedButtons.has(index)) continue;
|
|
||||||
|
|
||||||
const dpadDirection: ControllerDpadFallback =
|
|
||||||
index === 12 || index === 13 ? 'vertical' : 'horizontal';
|
|
||||||
cancel();
|
|
||||||
return {
|
|
||||||
actionId: target.actionId,
|
|
||||||
bindingType: 'dpad' as const,
|
|
||||||
dpadDirection,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// After dpad early-return, only 'discrete' | 'axis' remain
|
|
||||||
const narrowedTarget: Extract<ControllerBindingCaptureTarget, { bindingType: 'discrete' | 'axis' }> = target;
|
|
||||||
|
|
||||||
for (let index = 0; index < snapshot.buttons.length; index += 1) {
|
|
||||||
if (!isActiveButton(snapshot.buttons[index], options.triggerDeadzone)) continue;
|
|
||||||
if (blockedButtons.has(index)) continue;
|
|
||||||
if (narrowedTarget.bindingType === 'axis') continue;
|
|
||||||
|
|
||||||
const result: ControllerBindingCaptureResult = {
|
|
||||||
actionId: narrowedTarget.actionId,
|
|
||||||
bindingType: 'discrete',
|
|
||||||
binding: { kind: 'button', buttonIndex: index },
|
|
||||||
};
|
|
||||||
cancel();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < snapshot.axes.length; index += 1) {
|
|
||||||
const direction = getAxisDirection(snapshot.axes[index], activationThreshold);
|
|
||||||
if (!direction) continue;
|
|
||||||
const directionKey = `${index}:${direction}`;
|
|
||||||
if (blockedAxisDirections.has(directionKey)) continue;
|
|
||||||
|
|
||||||
const result: ControllerBindingCaptureResult =
|
|
||||||
narrowedTarget.bindingType === 'discrete'
|
|
||||||
? {
|
|
||||||
actionId: narrowedTarget.actionId,
|
|
||||||
bindingType: 'discrete',
|
|
||||||
binding: { kind: 'axis', axisIndex: index, direction },
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
actionId: narrowedTarget.actionId,
|
|
||||||
bindingType: 'axis',
|
|
||||||
binding: {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: index,
|
|
||||||
dpadFallback: narrowedTarget.dpadFallback,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
cancel();
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
arm,
|
|
||||||
cancel,
|
|
||||||
isArmed: (): boolean => target !== null,
|
|
||||||
getTargetActionId: (): string | null => target?.actionId ?? null,
|
|
||||||
poll,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,843 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import type { ResolvedControllerConfig } from '../../types';
|
|
||||||
import { createGamepadController } from './gamepad-controller.js';
|
|
||||||
|
|
||||||
type TestGamepad = {
|
|
||||||
id: string;
|
|
||||||
index: number;
|
|
||||||
connected: boolean;
|
|
||||||
mapping: string;
|
|
||||||
axes: number[];
|
|
||||||
buttons: Array<{ value: number; pressed?: boolean; touched?: boolean }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_BUTTON_INDICES = {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
buttonEast: 1,
|
|
||||||
buttonWest: 2,
|
|
||||||
buttonNorth: 3,
|
|
||||||
leftShoulder: 4,
|
|
||||||
rightShoulder: 5,
|
|
||||||
leftStickPress: 9,
|
|
||||||
rightStickPress: 10,
|
|
||||||
leftTrigger: 6,
|
|
||||||
rightTrigger: 7,
|
|
||||||
} satisfies ResolvedControllerConfig['buttonIndices'];
|
|
||||||
|
|
||||||
function createGamepad(
|
|
||||||
id: string,
|
|
||||||
options: Partial<Pick<TestGamepad, 'index' | 'axes' | 'buttons'>> = {},
|
|
||||||
): TestGamepad {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
index: options.index ?? 0,
|
|
||||||
connected: true,
|
|
||||||
mapping: 'standard',
|
|
||||||
axes: options.axes ?? [0, 0, 0, 0],
|
|
||||||
buttons:
|
|
||||||
options.buttons ??
|
|
||||||
Array.from({ length: 16 }, () => ({
|
|
||||||
value: 0,
|
|
||||||
pressed: false,
|
|
||||||
touched: false,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createControllerConfig(
|
|
||||||
overrides: Omit<Partial<ResolvedControllerConfig>, 'bindings' | 'buttonIndices'> & {
|
|
||||||
bindings?: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>;
|
|
||||||
buttonIndices?: Partial<ResolvedControllerConfig['buttonIndices']>;
|
|
||||||
} = {},
|
|
||||||
): ResolvedControllerConfig {
|
|
||||||
const {
|
|
||||||
bindings: bindingOverrides,
|
|
||||||
buttonIndices: buttonIndexOverrides,
|
|
||||||
...restOverrides
|
|
||||||
} = overrides;
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: '',
|
|
||||||
preferredGamepadLabel: '',
|
|
||||||
smoothScroll: true,
|
|
||||||
scrollPixelsPerSecond: 900,
|
|
||||||
horizontalJumpPixels: 160,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
triggerInputMode: 'auto',
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
repeatDelayMs: 320,
|
|
||||||
repeatIntervalMs: 120,
|
|
||||||
buttonIndices: {
|
|
||||||
...DEFAULT_BUTTON_INDICES,
|
|
||||||
...(buttonIndexOverrides ?? {}),
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
...normalizeBindingOverrides(bindingOverrides ?? {}, {
|
|
||||||
...DEFAULT_BUTTON_INDICES,
|
|
||||||
...(buttonIndexOverrides ?? {}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
...restOverrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBindingOverrides(
|
|
||||||
overrides: Partial<Record<keyof ResolvedControllerConfig['bindings'], unknown>>,
|
|
||||||
buttonIndices: ResolvedControllerConfig['buttonIndices'],
|
|
||||||
): Partial<ResolvedControllerConfig['bindings']> {
|
|
||||||
const legacyButtonIndices = {
|
|
||||||
select: buttonIndices.select,
|
|
||||||
buttonSouth: buttonIndices.buttonSouth,
|
|
||||||
buttonEast: buttonIndices.buttonEast,
|
|
||||||
buttonWest: buttonIndices.buttonWest,
|
|
||||||
buttonNorth: buttonIndices.buttonNorth,
|
|
||||||
leftShoulder: buttonIndices.leftShoulder,
|
|
||||||
rightShoulder: buttonIndices.rightShoulder,
|
|
||||||
leftStickPress: buttonIndices.leftStickPress,
|
|
||||||
rightStickPress: buttonIndices.rightStickPress,
|
|
||||||
leftTrigger: buttonIndices.leftTrigger,
|
|
||||||
rightTrigger: buttonIndices.rightTrigger,
|
|
||||||
} as const;
|
|
||||||
const legacyAxisIndices = {
|
|
||||||
leftStickX: 0,
|
|
||||||
leftStickY: 1,
|
|
||||||
rightStickX: 3,
|
|
||||||
rightStickY: 4,
|
|
||||||
} as const;
|
|
||||||
const axisFallbackByKey = {
|
|
||||||
leftStickHorizontal: 'horizontal',
|
|
||||||
leftStickVertical: 'vertical',
|
|
||||||
rightStickHorizontal: 'none',
|
|
||||||
rightStickVertical: 'none',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const normalized: Partial<ResolvedControllerConfig['bindings']> = {};
|
|
||||||
for (const [key, value] of Object.entries(overrides) as Array<
|
|
||||||
[keyof ResolvedControllerConfig['bindings'], unknown]
|
|
||||||
>) {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
if (value === 'none') {
|
|
||||||
normalized[key] = { kind: 'none' } as never;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (value in legacyButtonIndices) {
|
|
||||||
normalized[key] = {
|
|
||||||
kind: 'button',
|
|
||||||
buttonIndex: legacyButtonIndices[value as keyof typeof legacyButtonIndices],
|
|
||||||
} as never;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (value in legacyAxisIndices) {
|
|
||||||
normalized[key] = {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: legacyAxisIndices[value as keyof typeof legacyAxisIndices],
|
|
||||||
dpadFallback: axisFallbackByKey[key as keyof typeof axisFallbackByKey] ?? 'none',
|
|
||||||
} as never;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
normalized[key] = value as never;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('gamepad controller selects the first connected controller by default', () => {
|
|
||||||
const updates: string[] = [];
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [
|
|
||||||
null,
|
|
||||||
createGamepad('pad-2', { index: 1 }),
|
|
||||||
createGamepad('pad-3', { index: 2 }),
|
|
||||||
],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => false,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: (state) => {
|
|
||||||
updates.push(state.activeGamepadId ?? 'none');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
|
||||||
assert.deepEqual(updates.at(-1), 'pad-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller prefers saved controller id when connected', () => {
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1'), createGamepad('pad-2', { index: 1 })],
|
|
||||||
getConfig: () => createControllerConfig({ preferredGamepadId: 'pad-2' }),
|
|
||||||
getKeyboardModeEnabled: () => false,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.equal(controller.getActiveGamepadId(), 'pad-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller allows keyboard-mode toggle while other actions stay gated', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
|
||||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => false,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
|
||||||
toggleLookup: () => calls.push('toggle-lookup'),
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['toggle-keyboard-mode']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller re-evaluates interaction gating after toggling keyboard mode', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let keyboardModeEnabled = true;
|
|
||||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
|
||||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => keyboardModeEnabled,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {
|
|
||||||
calls.push('toggle-keyboard-mode');
|
|
||||||
keyboardModeEnabled = false;
|
|
||||||
},
|
|
||||||
toggleLookup: () => calls.push('toggle-lookup'),
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['toggle-keyboard-mode']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller resets edge state when active controller changes', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let currentGamepads = [
|
|
||||||
createGamepad('pad-1', {
|
|
||||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => currentGamepads,
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => calls.push('toggle-lookup'),
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
currentGamepads = [
|
|
||||||
createGamepad('pad-2', {
|
|
||||||
buttons: [{ value: 1, pressed: true, touched: true }],
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
controller.poll(50);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['toggle-lookup', 'toggle-lookup']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller does not toggle keyboard mode when controller support is disabled', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 8 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[3] = { value: 1, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () => createControllerConfig({ enabled: false }),
|
|
||||||
getKeyboardModeEnabled: () => false,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => calls.push('toggle-keyboard-mode'),
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller does not treat blocked held inputs as fresh edges when interaction resumes', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const selectionCalls: number[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
|
||||||
let axes = [0.9, 0, 0, 0];
|
|
||||||
let keyboardModeEnabled = true;
|
|
||||||
let interactionBlocked = true;
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons, axes })],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => keyboardModeEnabled,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => interactionBlocked,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => calls.push('toggle-lookup'),
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: (delta) => selectionCalls.push(delta),
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
interactionBlocked = false;
|
|
||||||
controller.poll(100);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
assert.deepEqual(selectionCalls, []);
|
|
||||||
|
|
||||||
buttons[0] = { value: 0, pressed: false, touched: false };
|
|
||||||
axes = [0, 0, 0, 0];
|
|
||||||
controller.poll(200);
|
|
||||||
|
|
||||||
buttons[0] = { value: 1, pressed: true, touched: true };
|
|
||||||
axes = [0.9, 0, 0, 0];
|
|
||||||
controller.poll(300);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['toggle-lookup']);
|
|
||||||
assert.deepEqual(selectionCalls, [1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller maps left stick horizontal movement to token selection repeats', () => {
|
|
||||||
const calls: number[] = [];
|
|
||||||
let axes = [0.9, 0, 0, 0];
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: (delta) => calls.push(delta),
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
controller.poll(100);
|
|
||||||
controller.poll(260);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [1]);
|
|
||||||
|
|
||||||
controller.poll(340);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [1, 1]);
|
|
||||||
|
|
||||||
axes = [0, 0, 0, 0];
|
|
||||||
controller.poll(360);
|
|
||||||
axes = [-0.9, 0, 0, 0];
|
|
||||||
controller.poll(380);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [1, 1, -1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller maps L1 play-current, R1 next-audio, and popup navigation', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const scrollCalls: number[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[8] = { value: 1, pressed: true, touched: true };
|
|
||||||
buttons[4] = { value: 1, pressed: true, touched: true };
|
|
||||||
buttons[5] = { value: 1, pressed: true, touched: true };
|
|
||||||
buttons[6] = { value: 0.8, pressed: true, touched: true };
|
|
||||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [
|
|
||||||
createGamepad('pad-1', {
|
|
||||||
axes: [0, -0.75, 0.1, 0, 0.8],
|
|
||||||
buttons,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
getConfig: () =>
|
|
||||||
createControllerConfig({
|
|
||||||
bindings: {
|
|
||||||
playCurrentAudio: 'leftShoulder',
|
|
||||||
nextAudio: 'rightShoulder',
|
|
||||||
previousAudio: 'none',
|
|
||||||
toggleMpvPause: 'leftTrigger',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => calls.push('quit-mpv'),
|
|
||||||
previousAudio: () => calls.push('prev-audio'),
|
|
||||||
nextAudio: () => calls.push('next-audio'),
|
|
||||||
playCurrentAudio: () => calls.push('play-audio'),
|
|
||||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
|
||||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
|
||||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
controller.poll(100);
|
|
||||||
|
|
||||||
assert.equal(calls.includes('next-audio'), true);
|
|
||||||
assert.equal(calls.includes('play-audio'), true);
|
|
||||||
assert.equal(calls.includes('prev-audio'), false);
|
|
||||||
assert.equal(calls.includes('toggle-mpv-pause'), true);
|
|
||||||
assert.equal(calls.includes('quit-mpv'), true);
|
|
||||||
assert.deepEqual(
|
|
||||||
scrollCalls.map((value) => Math.round(value)),
|
|
||||||
[-67],
|
|
||||||
);
|
|
||||||
assert.equal(calls.includes('jump:160'), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller maps quit mpv select binding from raw button 6 by default', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[6] = { value: 1, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () => createControllerConfig({ bindings: { quitMpv: 'select' } }),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => calls.push('quit-mpv'),
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['quit-mpv']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller honors configured raw button index overrides', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[11] = { value: 1, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () =>
|
|
||||||
createControllerConfig({
|
|
||||||
buttonIndices: {
|
|
||||||
select: 11,
|
|
||||||
},
|
|
||||||
bindings: { quitMpv: 'select' },
|
|
||||||
}),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => false,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => calls.push('quit-mpv'),
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['quit-mpv']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller maps right stick vertical to popup jump and ignores horizontal movement', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
let axes = [0, 0, 0.85, 0, 0];
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { axes })],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: (delta) => calls.push(`jump:${delta}`),
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
controller.poll(100);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
|
|
||||||
axes = [0, 0, 0.85, 0, -0.85];
|
|
||||||
controller.poll(200);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['jump:-160']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller maps d-pad left/right to selection and d-pad up/down to popup scroll', () => {
|
|
||||||
const selectionCalls: number[] = [];
|
|
||||||
const scrollCalls: number[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[15] = { value: 1, pressed: false, touched: true };
|
|
||||||
buttons[12] = { value: 1, pressed: false, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: (delta) => selectionCalls.push(delta),
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
controller.poll(100);
|
|
||||||
|
|
||||||
assert.deepEqual(selectionCalls, [1]);
|
|
||||||
assert.deepEqual(
|
|
||||||
scrollCalls.map((value) => Math.round(value)),
|
|
||||||
[-90],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller maps d-pad axes 6 and 7 to selection and popup scroll', () => {
|
|
||||||
const selectionCalls: number[] = [];
|
|
||||||
const scrollCalls: number[] = [];
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { axes: [0, 0, 0, 0, 0, 0, 1, -1] })],
|
|
||||||
getConfig: () => createControllerConfig(),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: (delta) => selectionCalls.push(delta),
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => {},
|
|
||||||
toggleMpvPause: () => {},
|
|
||||||
scrollPopup: (delta) => scrollCalls.push(delta),
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
controller.poll(100);
|
|
||||||
|
|
||||||
assert.deepEqual(selectionCalls, [1]);
|
|
||||||
assert.deepEqual(
|
|
||||||
scrollCalls.map((value) => Math.round(value)),
|
|
||||||
[-90],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller trigger analog mode uses trigger values above threshold', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[6] = { value: 0.7, pressed: false, touched: true };
|
|
||||||
buttons[7] = { value: 0.8, pressed: false, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () =>
|
|
||||||
createControllerConfig({
|
|
||||||
triggerInputMode: 'analog',
|
|
||||||
triggerDeadzone: 0.6,
|
|
||||||
bindings: {
|
|
||||||
playCurrentAudio: 'rightTrigger',
|
|
||||||
toggleMpvPause: 'leftTrigger',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => calls.push('play-audio'),
|
|
||||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller trigger digital mode uses pressed state only', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[6] = { value: 0.9, pressed: true, touched: true };
|
|
||||||
buttons[7] = { value: 0.9, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () =>
|
|
||||||
createControllerConfig({
|
|
||||||
triggerInputMode: 'digital',
|
|
||||||
triggerDeadzone: 1,
|
|
||||||
bindings: {
|
|
||||||
playCurrentAudio: 'rightTrigger',
|
|
||||||
toggleMpvPause: 'leftTrigger',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => calls.push('play-audio'),
|
|
||||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['play-audio', 'toggle-mpv-pause']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller digital trigger bindings ignore analog-only trigger values', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[6] = { value: 0.9, pressed: false, touched: true };
|
|
||||||
buttons[7] = { value: 0.9, pressed: false, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () =>
|
|
||||||
createControllerConfig({
|
|
||||||
triggerInputMode: 'digital',
|
|
||||||
triggerDeadzone: 0.6,
|
|
||||||
bindings: {
|
|
||||||
playCurrentAudio: 'rightTrigger',
|
|
||||||
toggleMpvPause: 'leftTrigger',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => calls.push('play-audio'),
|
|
||||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('gamepad controller maps L3 to mpv pause and keeps unbound audio action inactive', () => {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const buttons = Array.from({ length: 16 }, () => ({ value: 0, pressed: false, touched: false }));
|
|
||||||
buttons[9] = { value: 1, pressed: true, touched: true };
|
|
||||||
|
|
||||||
const controller = createGamepadController({
|
|
||||||
getGamepads: () => [createGamepad('pad-1', { buttons })],
|
|
||||||
getConfig: () =>
|
|
||||||
createControllerConfig({
|
|
||||||
bindings: {
|
|
||||||
toggleMpvPause: 'leftStickPress',
|
|
||||||
playCurrentAudio: 'none',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
getKeyboardModeEnabled: () => true,
|
|
||||||
getLookupWindowOpen: () => true,
|
|
||||||
getInteractionBlocked: () => false,
|
|
||||||
toggleKeyboardMode: () => {},
|
|
||||||
toggleLookup: () => {},
|
|
||||||
closeLookup: () => {},
|
|
||||||
moveSelection: () => {},
|
|
||||||
mineCard: () => {},
|
|
||||||
quitMpv: () => {},
|
|
||||||
previousAudio: () => {},
|
|
||||||
nextAudio: () => {},
|
|
||||||
playCurrentAudio: () => calls.push('play-audio'),
|
|
||||||
toggleMpvPause: () => calls.push('toggle-mpv-pause'),
|
|
||||||
scrollPopup: () => {},
|
|
||||||
jumpPopup: () => {},
|
|
||||||
onState: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
controller.poll(0);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, ['toggle-mpv-pause']);
|
|
||||||
});
|
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
import type {
|
|
||||||
ControllerDeviceInfo,
|
|
||||||
ControllerRuntimeSnapshot,
|
|
||||||
ResolvedControllerAxisBinding,
|
|
||||||
ResolvedControllerConfig,
|
|
||||||
ResolvedControllerDiscreteBinding,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
type ControllerButtonState = {
|
|
||||||
value: number;
|
|
||||||
pressed?: boolean;
|
|
||||||
touched?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GamepadLike = {
|
|
||||||
id: string;
|
|
||||||
index: number;
|
|
||||||
connected: boolean;
|
|
||||||
mapping: string;
|
|
||||||
axes: readonly number[];
|
|
||||||
buttons: readonly ControllerButtonState[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type GamepadControllerOptions = {
|
|
||||||
getGamepads: () => Array<GamepadLike | null>;
|
|
||||||
getConfig: () => ResolvedControllerConfig;
|
|
||||||
getKeyboardModeEnabled: () => boolean;
|
|
||||||
getLookupWindowOpen: () => boolean;
|
|
||||||
getInteractionBlocked: () => boolean;
|
|
||||||
toggleKeyboardMode: () => void;
|
|
||||||
toggleLookup: () => void;
|
|
||||||
closeLookup: () => void;
|
|
||||||
moveSelection: (delta: -1 | 1) => void;
|
|
||||||
mineCard: () => void;
|
|
||||||
quitMpv: () => void;
|
|
||||||
previousAudio: () => void;
|
|
||||||
nextAudio: () => void;
|
|
||||||
playCurrentAudio: () => void;
|
|
||||||
toggleMpvPause: () => void;
|
|
||||||
scrollPopup: (deltaPixels: number) => void;
|
|
||||||
jumpPopup: (deltaPixels: number) => void;
|
|
||||||
onState: (state: ControllerRuntimeSnapshot) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type HoldState = {
|
|
||||||
repeatStarted: boolean;
|
|
||||||
direction: -1 | 1 | null;
|
|
||||||
lastFireAt: number;
|
|
||||||
initialFired: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DPAD_BUTTON_INDEX = {
|
|
||||||
up: 12,
|
|
||||||
down: 13,
|
|
||||||
left: 14,
|
|
||||||
right: 15,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const DPAD_AXIS_INDEX = {
|
|
||||||
horizontal: 6,
|
|
||||||
vertical: 7,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function normalizeRawButtonState(
|
|
||||||
button: ControllerButtonState | undefined,
|
|
||||||
triggerDeadzone: number,
|
|
||||||
): boolean {
|
|
||||||
if (!button) return false;
|
|
||||||
return Boolean(button.pressed) || button.value >= triggerDeadzone;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTriggerBindingPressed(
|
|
||||||
button: ControllerButtonState | undefined,
|
|
||||||
config: ResolvedControllerConfig,
|
|
||||||
): boolean {
|
|
||||||
if (!button) return false;
|
|
||||||
if (config.triggerInputMode === 'digital') {
|
|
||||||
return Boolean(button.pressed);
|
|
||||||
}
|
|
||||||
if (config.triggerInputMode === 'analog') {
|
|
||||||
return button.value >= config.triggerDeadzone;
|
|
||||||
}
|
|
||||||
return normalizeRawButtonState(button, config.triggerDeadzone);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveGamepadAxis(gamepad: GamepadLike, axisIndex: number): number {
|
|
||||||
const value = gamepad.axes[axisIndex];
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDpadValue(
|
|
||||||
gamepad: GamepadLike,
|
|
||||||
negativeIndex: number,
|
|
||||||
positiveIndex: number,
|
|
||||||
triggerDeadzone: number,
|
|
||||||
): number {
|
|
||||||
const negative = gamepad.buttons[negativeIndex];
|
|
||||||
const positive = gamepad.buttons[positiveIndex];
|
|
||||||
return (
|
|
||||||
(normalizeRawButtonState(positive, triggerDeadzone) ? 1 : 0) -
|
|
||||||
(normalizeRawButtonState(negative, triggerDeadzone) ? 1 : 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDpadAxisValue(gamepad: GamepadLike, axisIndex: number): number {
|
|
||||||
const value = resolveGamepadAxis(gamepad, axisIndex);
|
|
||||||
if (Math.abs(value) < 0.5) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.sign(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDpadHorizontalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
|
||||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.horizontal);
|
|
||||||
if (axisValue !== 0) {
|
|
||||||
return axisValue;
|
|
||||||
}
|
|
||||||
return resolveDpadValue(
|
|
||||||
gamepad,
|
|
||||||
DPAD_BUTTON_INDEX.left,
|
|
||||||
DPAD_BUTTON_INDEX.right,
|
|
||||||
triggerDeadzone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDpadVerticalValue(gamepad: GamepadLike, triggerDeadzone: number): number {
|
|
||||||
const axisValue = resolveDpadAxisValue(gamepad, DPAD_AXIS_INDEX.vertical);
|
|
||||||
if (axisValue !== 0) {
|
|
||||||
return axisValue;
|
|
||||||
}
|
|
||||||
return resolveDpadValue(gamepad, DPAD_BUTTON_INDEX.up, DPAD_BUTTON_INDEX.down, triggerDeadzone);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConnectedGamepads(gamepads: Array<GamepadLike | null>): GamepadLike[] {
|
|
||||||
return gamepads
|
|
||||||
.filter((gamepad): gamepad is GamepadLike => Boolean(gamepad?.connected))
|
|
||||||
.sort((left, right) => left.index - right.index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHoldState(): HoldState {
|
|
||||||
return {
|
|
||||||
repeatStarted: false,
|
|
||||||
direction: null,
|
|
||||||
lastFireAt: 0,
|
|
||||||
initialFired: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldFireHeldAction(
|
|
||||||
state: HoldState,
|
|
||||||
now: number,
|
|
||||||
repeatDelayMs: number,
|
|
||||||
repeatIntervalMs: number,
|
|
||||||
): boolean {
|
|
||||||
if (!state.initialFired) {
|
|
||||||
state.initialFired = true;
|
|
||||||
state.lastFireAt = now;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = now - state.lastFireAt;
|
|
||||||
const threshold = state.repeatStarted ? repeatIntervalMs : repeatDelayMs;
|
|
||||||
if (elapsed < threshold) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.repeatStarted = true;
|
|
||||||
state.lastFireAt = now;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetHeldAction(state: HoldState): void {
|
|
||||||
state.repeatStarted = false;
|
|
||||||
state.direction = null;
|
|
||||||
state.lastFireAt = 0;
|
|
||||||
state.initialFired = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncHeldActionBlocked(
|
|
||||||
state: HoldState,
|
|
||||||
value: number,
|
|
||||||
now: number,
|
|
||||||
activationThreshold: number,
|
|
||||||
): void {
|
|
||||||
if (Math.abs(value) < activationThreshold) {
|
|
||||||
resetHeldAction(state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const direction = value > 0 ? 1 : -1;
|
|
||||||
state.repeatStarted = false;
|
|
||||||
state.direction = direction;
|
|
||||||
state.lastFireAt = now;
|
|
||||||
state.initialFired = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveDiscreteBindingPressed(
|
|
||||||
gamepad: GamepadLike,
|
|
||||||
binding: ResolvedControllerDiscreteBinding,
|
|
||||||
config: ResolvedControllerConfig,
|
|
||||||
): boolean {
|
|
||||||
if (binding.kind === 'none') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.kind === 'button') {
|
|
||||||
const button = gamepad.buttons[binding.buttonIndex];
|
|
||||||
const isTriggerBinding =
|
|
||||||
binding.buttonIndex === config.buttonIndices.leftTrigger ||
|
|
||||||
binding.buttonIndex === config.buttonIndices.rightTrigger;
|
|
||||||
return isTriggerBinding
|
|
||||||
? resolveTriggerBindingPressed(button, config)
|
|
||||||
: normalizeRawButtonState(button, config.triggerDeadzone);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
|
||||||
const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex);
|
|
||||||
return binding.direction === 'positive'
|
|
||||||
? axisValue >= activationThreshold
|
|
||||||
: axisValue <= -activationThreshold;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAxisBindingValue(
|
|
||||||
gamepad: GamepadLike,
|
|
||||||
binding: ResolvedControllerAxisBinding,
|
|
||||||
triggerDeadzone: number,
|
|
||||||
activationThreshold: number,
|
|
||||||
): number {
|
|
||||||
if (binding.kind === 'none') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const axisValue = resolveGamepadAxis(gamepad, binding.axisIndex);
|
|
||||||
if (Math.abs(axisValue) >= activationThreshold) {
|
|
||||||
return axisValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.dpadFallback === 'horizontal') {
|
|
||||||
return resolveDpadHorizontalValue(gamepad, triggerDeadzone);
|
|
||||||
}
|
|
||||||
if (binding.dpadFallback === 'vertical') {
|
|
||||||
return resolveDpadVerticalValue(gamepad, triggerDeadzone);
|
|
||||||
}
|
|
||||||
return axisValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGamepadController(options: GamepadControllerOptions) {
|
|
||||||
let previousActions = new Map<string, boolean>();
|
|
||||||
let selectionHold = createHoldState();
|
|
||||||
let jumpHold = createHoldState();
|
|
||||||
let activeGamepadId: string | null = null;
|
|
||||||
let lastPollAt: number | null = null;
|
|
||||||
|
|
||||||
function getConnectedGamepads(): GamepadLike[] {
|
|
||||||
return resolveConnectedGamepads(options.getGamepads());
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveActiveGamepad(
|
|
||||||
gamepads: GamepadLike[],
|
|
||||||
config: ResolvedControllerConfig,
|
|
||||||
): GamepadLike | null {
|
|
||||||
if (gamepads.length === 0) return null;
|
|
||||||
if (config.preferredGamepadId.trim().length > 0) {
|
|
||||||
const preferred = gamepads.find((gamepad) => gamepad.id === config.preferredGamepadId);
|
|
||||||
if (preferred) {
|
|
||||||
return preferred;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gamepads[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishState(gamepads: GamepadLike[], activeGamepad: GamepadLike | null): void {
|
|
||||||
activeGamepadId = activeGamepad?.id ?? null;
|
|
||||||
options.onState({
|
|
||||||
connectedGamepads: gamepads.map((gamepad) => ({
|
|
||||||
id: gamepad.id,
|
|
||||||
index: gamepad.index,
|
|
||||||
mapping: gamepad.mapping,
|
|
||||||
connected: gamepad.connected,
|
|
||||||
})) satisfies ControllerDeviceInfo[],
|
|
||||||
activeGamepadId,
|
|
||||||
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
|
||||||
rawButtons: activeGamepad?.buttons
|
|
||||||
? activeGamepad.buttons.map((button) => ({
|
|
||||||
value: button.value,
|
|
||||||
pressed: Boolean(button.pressed),
|
|
||||||
touched: button.touched,
|
|
||||||
}))
|
|
||||||
: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleActionEdge(
|
|
||||||
actionKey: string,
|
|
||||||
binding: ResolvedControllerDiscreteBinding,
|
|
||||||
activeGamepad: GamepadLike,
|
|
||||||
config: ResolvedControllerConfig,
|
|
||||||
action: () => void,
|
|
||||||
): void {
|
|
||||||
const isPressed = resolveDiscreteBindingPressed(activeGamepad, binding, config);
|
|
||||||
const wasPressed = previousActions.get(actionKey) ?? false;
|
|
||||||
previousActions.set(actionKey, isPressed);
|
|
||||||
if (!wasPressed && isPressed) {
|
|
||||||
action();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectionAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
|
||||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
|
||||||
if (Math.abs(value) < activationThreshold) {
|
|
||||||
resetHeldAction(selectionHold);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const direction = value > 0 ? 1 : -1;
|
|
||||||
if (selectionHold.direction !== direction) {
|
|
||||||
resetHeldAction(selectionHold);
|
|
||||||
selectionHold.direction = direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldFireHeldAction(selectionHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
|
||||||
options.moveSelection(direction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleJumpAxis(value: number, now: number, config: ResolvedControllerConfig): void {
|
|
||||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
|
||||||
if (Math.abs(value) < activationThreshold) {
|
|
||||||
resetHeldAction(jumpHold);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const direction = value > 0 ? 1 : -1;
|
|
||||||
if (jumpHold.direction !== direction) {
|
|
||||||
resetHeldAction(jumpHold);
|
|
||||||
jumpHold.direction = direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldFireHeldAction(jumpHold, now, config.repeatDelayMs, config.repeatIntervalMs)) {
|
|
||||||
options.jumpPopup(direction * config.horizontalJumpPixels);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncBlockedInteractionState(
|
|
||||||
activeGamepad: GamepadLike,
|
|
||||||
config: ResolvedControllerConfig,
|
|
||||||
now: number,
|
|
||||||
): void {
|
|
||||||
const discreteActions = [
|
|
||||||
['toggleKeyboardOnlyMode', config.bindings.toggleKeyboardOnlyMode],
|
|
||||||
['toggleLookup', config.bindings.toggleLookup],
|
|
||||||
['closeLookup', config.bindings.closeLookup],
|
|
||||||
['mineCard', config.bindings.mineCard],
|
|
||||||
['quitMpv', config.bindings.quitMpv],
|
|
||||||
['previousAudio', config.bindings.previousAudio],
|
|
||||||
['nextAudio', config.bindings.nextAudio],
|
|
||||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
|
||||||
['toggleMpvPause', config.bindings.toggleMpvPause],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const [actionKey, binding] of discreteActions) {
|
|
||||||
previousActions.set(actionKey, resolveDiscreteBindingPressed(activeGamepad, binding, config));
|
|
||||||
}
|
|
||||||
|
|
||||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
|
||||||
const selectionValue = resolveAxisBindingValue(
|
|
||||||
activeGamepad,
|
|
||||||
config.bindings.leftStickHorizontal,
|
|
||||||
config.triggerDeadzone,
|
|
||||||
activationThreshold,
|
|
||||||
);
|
|
||||||
syncHeldActionBlocked(selectionHold, selectionValue, now, activationThreshold);
|
|
||||||
|
|
||||||
if (options.getLookupWindowOpen()) {
|
|
||||||
syncHeldActionBlocked(
|
|
||||||
jumpHold,
|
|
||||||
resolveAxisBindingValue(
|
|
||||||
activeGamepad,
|
|
||||||
config.bindings.rightStickVertical,
|
|
||||||
config.triggerDeadzone,
|
|
||||||
activationThreshold,
|
|
||||||
),
|
|
||||||
now,
|
|
||||||
activationThreshold,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
resetHeldAction(jumpHold);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function poll(now: number): void {
|
|
||||||
const elapsedMs = lastPollAt === null ? 0 : Math.max(now - lastPollAt, 0);
|
|
||||||
lastPollAt = now;
|
|
||||||
const config = options.getConfig();
|
|
||||||
const connectedGamepads = getConnectedGamepads();
|
|
||||||
const activeGamepad = resolveActiveGamepad(connectedGamepads, config);
|
|
||||||
const previousActiveGamepadId = activeGamepadId;
|
|
||||||
publishState(connectedGamepads, activeGamepad);
|
|
||||||
|
|
||||||
if (!activeGamepad) {
|
|
||||||
previousActions = new Map();
|
|
||||||
resetHeldAction(selectionHold);
|
|
||||||
resetHeldAction(jumpHold);
|
|
||||||
lastPollAt = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeGamepad.id !== previousActiveGamepadId) {
|
|
||||||
previousActions = new Map();
|
|
||||||
resetHeldAction(selectionHold);
|
|
||||||
resetHeldAction(jumpHold);
|
|
||||||
}
|
|
||||||
|
|
||||||
let interactionAllowed =
|
|
||||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
|
||||||
if (config.enabled) {
|
|
||||||
handleActionEdge(
|
|
||||||
'toggleKeyboardOnlyMode',
|
|
||||||
config.bindings.toggleKeyboardOnlyMode,
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
options.toggleKeyboardMode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interactionAllowed =
|
|
||||||
config.enabled && options.getKeyboardModeEnabled() && !options.getInteractionBlocked();
|
|
||||||
|
|
||||||
if (!interactionAllowed) {
|
|
||||||
syncBlockedInteractionState(activeGamepad, config, now);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleActionEdge(
|
|
||||||
'toggleLookup',
|
|
||||||
config.bindings.toggleLookup,
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
options.toggleLookup,
|
|
||||||
);
|
|
||||||
handleActionEdge(
|
|
||||||
'closeLookup',
|
|
||||||
config.bindings.closeLookup,
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
options.closeLookup,
|
|
||||||
);
|
|
||||||
handleActionEdge('mineCard', config.bindings.mineCard, activeGamepad, config, options.mineCard);
|
|
||||||
handleActionEdge('quitMpv', config.bindings.quitMpv, activeGamepad, config, options.quitMpv);
|
|
||||||
|
|
||||||
const activationThreshold = Math.max(config.stickDeadzone, 0.55);
|
|
||||||
|
|
||||||
if (options.getLookupWindowOpen()) {
|
|
||||||
handleActionEdge(
|
|
||||||
'previousAudio',
|
|
||||||
config.bindings.previousAudio,
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
options.previousAudio,
|
|
||||||
);
|
|
||||||
handleActionEdge(
|
|
||||||
'nextAudio',
|
|
||||||
config.bindings.nextAudio,
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
options.nextAudio,
|
|
||||||
);
|
|
||||||
handleActionEdge(
|
|
||||||
'playCurrentAudio',
|
|
||||||
config.bindings.playCurrentAudio,
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
options.playCurrentAudio,
|
|
||||||
);
|
|
||||||
|
|
||||||
const primaryScroll = resolveAxisBindingValue(
|
|
||||||
activeGamepad,
|
|
||||||
config.bindings.leftStickVertical,
|
|
||||||
config.triggerDeadzone,
|
|
||||||
config.stickDeadzone,
|
|
||||||
);
|
|
||||||
if (elapsedMs > 0 && Math.abs(primaryScroll) >= config.stickDeadzone) {
|
|
||||||
options.scrollPopup((primaryScroll * config.scrollPixelsPerSecond * elapsedMs) / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleJumpAxis(
|
|
||||||
resolveAxisBindingValue(
|
|
||||||
activeGamepad,
|
|
||||||
config.bindings.rightStickVertical,
|
|
||||||
config.triggerDeadzone,
|
|
||||||
activationThreshold,
|
|
||||||
),
|
|
||||||
now,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
resetHeldAction(jumpHold);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleActionEdge(
|
|
||||||
'toggleMpvPause',
|
|
||||||
config.bindings.toggleMpvPause,
|
|
||||||
activeGamepad,
|
|
||||||
config,
|
|
||||||
options.toggleMpvPause,
|
|
||||||
);
|
|
||||||
|
|
||||||
handleSelectionAxis(
|
|
||||||
resolveAxisBindingValue(
|
|
||||||
activeGamepad,
|
|
||||||
config.bindings.leftStickHorizontal,
|
|
||||||
config.triggerDeadzone,
|
|
||||||
activationThreshold,
|
|
||||||
),
|
|
||||||
now,
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
poll,
|
|
||||||
getActiveGamepadId: (): string | null => activeGamepadId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import { createKeyboardHandlers } from './keyboard.js';
|
import { createKeyboardHandlers } from './keyboard.js';
|
||||||
import { createRendererState } from '../state.js';
|
import { createRendererState } from '../state.js';
|
||||||
import { YOMITAN_POPUP_COMMAND_EVENT, YOMITAN_POPUP_HIDDEN_EVENT } from '../yomitan-popup.js';
|
import { YOMITAN_POPUP_COMMAND_EVENT } from '../yomitan-popup.js';
|
||||||
|
|
||||||
type CommandEventDetail = {
|
type CommandEventDetail = {
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -11,9 +11,6 @@ type CommandEventDetail = {
|
|||||||
key?: string;
|
key?: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
repeat?: boolean;
|
repeat?: boolean;
|
||||||
direction?: number;
|
|
||||||
deltaX?: number;
|
|
||||||
deltaY?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createClassList() {
|
function createClassList() {
|
||||||
@@ -47,12 +44,9 @@ function installKeyboardTestGlobals() {
|
|||||||
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
|
||||||
|
|
||||||
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
|
||||||
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
|
|
||||||
const commandEvents: CommandEventDetail[] = [];
|
const commandEvents: CommandEventDetail[] = [];
|
||||||
const mpvCommands: Array<Array<string | number>> = [];
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
let playbackPausedResponse: boolean | null = false;
|
let playbackPausedResponse: boolean | null = false;
|
||||||
let selectionClearCount = 0;
|
|
||||||
let selectionAddCount = 0;
|
|
||||||
|
|
||||||
let popupVisible = false;
|
let popupVisible = false;
|
||||||
|
|
||||||
@@ -66,12 +60,8 @@ function installKeyboardTestGlobals() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selection = {
|
const selection = {
|
||||||
removeAllRanges: () => {
|
removeAllRanges: () => {},
|
||||||
selectionClearCount += 1;
|
addRange: () => {},
|
||||||
},
|
|
||||||
addRange: () => {
|
|
||||||
selectionAddCount += 1;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
const overlayFocusCalls: Array<{ preventScroll?: boolean }> = [];
|
||||||
@@ -106,20 +96,12 @@ function installKeyboardTestGlobals() {
|
|||||||
Object.defineProperty(globalThis, 'window', {
|
Object.defineProperty(globalThis, 'window', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
addEventListener: (type: string, listener: (event: unknown) => void) => {
|
addEventListener: () => {},
|
||||||
const listeners = windowListeners.get(type) ?? [];
|
|
||||||
listeners.push(listener);
|
|
||||||
windowListeners.set(type, listeners);
|
|
||||||
},
|
|
||||||
dispatchEvent: (event: Event) => {
|
dispatchEvent: (event: Event) => {
|
||||||
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
if (event.type === YOMITAN_POPUP_COMMAND_EVENT) {
|
||||||
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
const detail = (event as Event & { detail?: CommandEventDetail }).detail;
|
||||||
commandEvents.push(detail ?? {});
|
commandEvents.push(detail ?? {});
|
||||||
}
|
}
|
||||||
const listeners = windowListeners.get(event.type) ?? [];
|
|
||||||
for (const listener of listeners) {
|
|
||||||
listener(event);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
getComputedStyle: () => ({
|
getComputedStyle: () => ({
|
||||||
@@ -210,13 +192,6 @@ function installKeyboardTestGlobals() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchWindowEvent(type: string): void {
|
|
||||||
const listeners = windowListeners.get(type) ?? [];
|
|
||||||
for (const listener of listeners) {
|
|
||||||
listener(new Event(type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
@@ -249,7 +224,6 @@ function installKeyboardTestGlobals() {
|
|||||||
windowFocusCalls: () => windowFocusCalls,
|
windowFocusCalls: () => windowFocusCalls,
|
||||||
dispatchKeydown,
|
dispatchKeydown,
|
||||||
dispatchFocusInOnPopup,
|
dispatchFocusInOnPopup,
|
||||||
dispatchWindowEvent,
|
|
||||||
setPopupVisible: (value: boolean) => {
|
setPopupVisible: (value: boolean) => {
|
||||||
popupVisible = value;
|
popupVisible = value;
|
||||||
},
|
},
|
||||||
@@ -257,8 +231,6 @@ function installKeyboardTestGlobals() {
|
|||||||
setPlaybackPausedResponse: (value: boolean | null) => {
|
setPlaybackPausedResponse: (value: boolean | null) => {
|
||||||
playbackPausedResponse = value;
|
playbackPausedResponse = value;
|
||||||
},
|
},
|
||||||
selectionClearCount: () => selectionClearCount,
|
|
||||||
selectionAddCount: () => selectionAddCount,
|
|
||||||
restore,
|
restore,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -266,9 +238,6 @@ function installKeyboardTestGlobals() {
|
|||||||
function createKeyboardHandlerHarness() {
|
function createKeyboardHandlerHarness() {
|
||||||
const testGlobals = installKeyboardTestGlobals();
|
const testGlobals = installKeyboardTestGlobals();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
let controllerSelectOpenCount = 0;
|
|
||||||
let controllerDebugOpenCount = 0;
|
|
||||||
let controllerSelectKeydownCount = 0;
|
|
||||||
|
|
||||||
const createWordNode = (left: number) => ({
|
const createWordNode = (left: number) => ({
|
||||||
classList: createClassList(),
|
classList: createClassList(),
|
||||||
@@ -301,30 +270,16 @@ function createKeyboardHandlerHarness() {
|
|||||||
handleSubsyncKeydown: () => false,
|
handleSubsyncKeydown: () => false,
|
||||||
handleKikuKeydown: () => false,
|
handleKikuKeydown: () => false,
|
||||||
handleJimakuKeydown: () => false,
|
handleJimakuKeydown: () => false,
|
||||||
handleControllerSelectKeydown: () => {
|
|
||||||
controllerSelectKeydownCount += 1;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
handleControllerDebugKeydown: () => false,
|
|
||||||
handleSessionHelpKeydown: () => false,
|
handleSessionHelpKeydown: () => false,
|
||||||
openSessionHelpModal: () => {},
|
openSessionHelpModal: () => {},
|
||||||
appendClipboardVideoToQueue: () => {},
|
appendClipboardVideoToQueue: () => {},
|
||||||
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
getPlaybackPaused: () => testGlobals.getPlaybackPaused(),
|
||||||
openControllerSelectModal: () => {
|
|
||||||
controllerSelectOpenCount += 1;
|
|
||||||
},
|
|
||||||
openControllerDebugModal: () => {
|
|
||||||
controllerDebugOpenCount += 1;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ctx,
|
ctx,
|
||||||
handlers,
|
handlers,
|
||||||
testGlobals,
|
testGlobals,
|
||||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
|
||||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
|
||||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
|
||||||
setWordCount: (count: number) => {
|
setWordCount: (count: number) => {
|
||||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||||
},
|
},
|
||||||
@@ -463,91 +418,6 @@ test('keyboard mode: repeated popup navigation keys are forwarded while popup is
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keyboard mode: controller helpers dispatch popup audio play/cycle and scroll bridge commands', async () => {
|
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
testGlobals.setPopupVisible(true);
|
|
||||||
|
|
||||||
assert.equal(handlers.playCurrentAudioForController(), true);
|
|
||||||
assert.equal(handlers.cyclePopupAudioSourceForController(1), true);
|
|
||||||
assert.equal(handlers.scrollPopupByController(48, -24), true);
|
|
||||||
|
|
||||||
assert.deepEqual(testGlobals.commandEvents.slice(-3), [
|
|
||||||
{ type: 'playCurrentAudio' },
|
|
||||||
{ type: 'cycleAudioSource', direction: 1 },
|
|
||||||
{ type: 'scrollBy', deltaX: 48, deltaY: -24 },
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: Alt+Shift+C opens controller debug modal', async () => {
|
|
||||||
const { testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({
|
|
||||||
key: 'C',
|
|
||||||
code: 'KeyC',
|
|
||||||
altKey: true,
|
|
||||||
shiftKey: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controllerDebugOpenCount(), 1);
|
|
||||||
} finally {
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: Alt+Shift+C opens controller debug modal even while popup is visible', async () => {
|
|
||||||
const { ctx, testGlobals, handlers, controllerDebugOpenCount } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({
|
|
||||||
key: 'C',
|
|
||||||
code: 'KeyC',
|
|
||||||
altKey: true,
|
|
||||||
shiftKey: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(controllerDebugOpenCount(), 1);
|
|
||||||
} finally {
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: controller select modal handles arrow keys before yomitan popup', async () => {
|
|
||||||
const { ctx, testGlobals, handlers, controllerSelectKeydownCount } =
|
|
||||||
createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
ctx.state.controllerSelectModalOpen = true;
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
testGlobals.setPopupVisible(true);
|
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
|
||||||
|
|
||||||
assert.equal(controllerSelectKeydownCount(), 1);
|
|
||||||
assert.equal(
|
|
||||||
testGlobals.commandEvents.some(
|
|
||||||
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -620,153 +490,6 @@ test('keyboard mode: opening lookup restores overlay keyboard focus', async () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keyboard mode: turning mode off clears selected token highlight', async () => {
|
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
ctx.state.keyboardSelectedWordIndex = 1;
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
|
||||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
|
||||||
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
|
||||||
assert.equal(ctx.state.keyboardSelectedWordIndex, null);
|
|
||||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: popup hidden after mode off clears stale selected token highlight', async () => {
|
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
ctx.state.keyboardSelectedWordIndex = 1;
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
testGlobals.setPopupVisible(true);
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
|
|
||||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
|
||||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
|
||||||
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
ctx.state.yomitanPopupVisible = false;
|
|
||||||
testGlobals.setPopupVisible(false);
|
|
||||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
|
||||||
|
|
||||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
|
||||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), false);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: closing lookup keeps controller selection but clears native text selection', async () => {
|
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
ctx.state.keyboardSelectedWordIndex = 1;
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
|
|
||||||
const wordNodes = ctx.dom.subtitleRoot.querySelectorAll();
|
|
||||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
|
||||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
|
||||||
|
|
||||||
handlers.handleLookupWindowToggleRequested();
|
|
||||||
await wait(0);
|
|
||||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), true);
|
|
||||||
assert.equal(testGlobals.selectionAddCount() > 0, true);
|
|
||||||
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
testGlobals.setPopupVisible(true);
|
|
||||||
handlers.closeLookupWindow();
|
|
||||||
ctx.state.yomitanPopupVisible = false;
|
|
||||||
testGlobals.setPopupVisible(false);
|
|
||||||
testGlobals.dispatchWindowEvent(YOMITAN_POPUP_HIDDEN_EVENT);
|
|
||||||
await wait(0);
|
|
||||||
|
|
||||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, true);
|
|
||||||
assert.equal(wordNodes[1]?.classList.contains('keyboard-selected'), true);
|
|
||||||
assert.equal(ctx.dom.subtitleRoot.classList.contains('has-selection'), false);
|
|
||||||
assert.equal(testGlobals.selectionClearCount() > 0, true);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: closing lookup clears yomitan active text source so same token can reopen immediately', async () => {
|
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
ctx.state.keyboardSelectedWordIndex = 1;
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
|
|
||||||
handlers.handleLookupWindowToggleRequested();
|
|
||||||
await wait(0);
|
|
||||||
|
|
||||||
ctx.state.yomitanPopupVisible = true;
|
|
||||||
testGlobals.setPopupVisible(true);
|
|
||||||
handlers.handleLookupWindowToggleRequested();
|
|
||||||
await wait(0);
|
|
||||||
|
|
||||||
const closeCommands = testGlobals.commandEvents.filter(
|
|
||||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
|
||||||
);
|
|
||||||
assert.deepEqual(closeCommands.slice(-2), [
|
|
||||||
{ type: 'setVisible', visible: false },
|
|
||||||
{ type: 'clearActiveTextSource' },
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: lookup toggle closes popup when DOM visibility is the source of truth', async () => {
|
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
ctx.state.keyboardSelectedWordIndex = 1;
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
ctx.state.yomitanPopupVisible = false;
|
|
||||||
testGlobals.setPopupVisible(true);
|
|
||||||
|
|
||||||
handlers.handleLookupWindowToggleRequested();
|
|
||||||
await wait(0);
|
|
||||||
|
|
||||||
const closeCommands = testGlobals.commandEvents.filter(
|
|
||||||
(event) => event.type === 'setVisible' || event.type === 'clearActiveTextSource',
|
|
||||||
);
|
|
||||||
assert.deepEqual(closeCommands.slice(-2), [
|
|
||||||
{ type: 'setVisible', visible: false },
|
|
||||||
{ type: 'clearActiveTextSource' },
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
test('keyboard mode: moving right beyond end jumps next subtitle and resets selector to start', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -815,52 +538,6 @@ test('keyboard mode: moving left beyond start jumps previous subtitle and sets s
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keyboard mode: empty subtitle gap left and right still seek adjacent subtitle lines', async () => {
|
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
setWordCount(0);
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'ArrowRight', code: 'ArrowRight' });
|
|
||||||
await wait(0);
|
|
||||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'ArrowLeft', code: 'ArrowLeft' });
|
|
||||||
await wait(0);
|
|
||||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller mode: empty subtitle gap horizontal move still seeks adjacent subtitle lines', async () => {
|
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
setWordCount(0);
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
|
|
||||||
assert.equal(handlers.moveSelectionForController(1), true);
|
|
||||||
await wait(0);
|
|
||||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', 1]);
|
|
||||||
|
|
||||||
assert.equal(handlers.moveSelectionForController(-1), true);
|
|
||||||
await wait(0);
|
|
||||||
assert.deepEqual(testGlobals.mpvCommands.at(-1), ['sub-seek', -1]);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle selection', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
@@ -893,28 +570,6 @@ test('keyboard mode: popup-open edge jump refreshes lookup on the new subtitle s
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keyboard mode: natural subtitle advance resets selector to the start of the new line', async () => {
|
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
handlers.handleKeyboardModeToggleRequested();
|
|
||||||
|
|
||||||
setWordCount(3);
|
|
||||||
ctx.state.keyboardSelectedWordIndex = 2;
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
|
|
||||||
handlers.handleSubtitleContentUpdated();
|
|
||||||
setWordCount(4);
|
|
||||||
handlers.syncKeyboardTokenSelection();
|
|
||||||
|
|
||||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 0);
|
|
||||||
} finally {
|
|
||||||
ctx.state.keyboardDrivenModeEnabled = false;
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
test('keyboard mode: edge jump while paused re-applies paused state after subtitle seek', async () => {
|
||||||
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals, setWordCount } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ export function createKeyboardHandlers(
|
|||||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
|
||||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||||
openSessionHelpModal: (opening: {
|
openSessionHelpModal: (opening: {
|
||||||
bindingKey: 'KeyH' | 'KeyK';
|
bindingKey: 'KeyH' | 'KeyK';
|
||||||
@@ -25,8 +23,6 @@ export function createKeyboardHandlers(
|
|||||||
}) => void;
|
}) => void;
|
||||||
appendClipboardVideoToQueue: () => void;
|
appendClipboardVideoToQueue: () => void;
|
||||||
getPlaybackPaused: () => Promise<boolean | null>;
|
getPlaybackPaused: () => Promise<boolean | null>;
|
||||||
openControllerSelectModal: () => void;
|
|
||||||
openControllerDebugModal: () => void;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
// Timeout for the modal chord capture window (e.g. Y followed by H/K).
|
||||||
@@ -34,7 +30,6 @@ export function createKeyboardHandlers(
|
|||||||
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
const KEYBOARD_SELECTED_WORD_CLASS = 'keyboard-selected';
|
||||||
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
let pendingSelectionAnchorAfterSubtitleSeek: 'start' | 'end' | null = null;
|
||||||
let pendingLookupRefreshAfterSubtitleSeek = false;
|
let pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
let resetSelectionToStartOnNextSubtitleSync = false;
|
|
||||||
|
|
||||||
const CHORD_MAP = new Map<
|
const CHORD_MAP = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -110,39 +105,6 @@ export function createKeyboardHandlers(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchYomitanPopupCycleAudioSource(direction: -1 | 1) {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
|
||||||
detail: {
|
|
||||||
type: 'cycleAudioSource',
|
|
||||||
direction,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchYomitanPopupPlayCurrentAudio() {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
|
||||||
detail: {
|
|
||||||
type: 'playCurrentAudio',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchYomitanPopupScrollBy(deltaX: number, deltaY: number) {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
|
||||||
detail: {
|
|
||||||
type: 'scrollBy',
|
|
||||||
deltaX,
|
|
||||||
deltaY,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchYomitanFrontendScanSelectedText() {
|
function dispatchYomitanFrontendScanSelectedText() {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
||||||
@@ -153,16 +115,6 @@ export function createKeyboardHandlers(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchYomitanFrontendClearActiveTextSource() {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent(YOMITAN_POPUP_COMMAND_EVENT, {
|
|
||||||
detail: {
|
|
||||||
type: 'clearActiveTextSource',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
function isPrimaryModifierPressed(e: KeyboardEvent): boolean {
|
||||||
return e.ctrlKey || e.metaKey;
|
return e.ctrlKey || e.metaKey;
|
||||||
}
|
}
|
||||||
@@ -177,41 +129,23 @@ export function createKeyboardHandlers(
|
|||||||
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
return isPrimaryModifierPressed(e) && !e.altKey && !e.shiftKey && isYKey && !e.repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isControllerModalShortcut(e: KeyboardEvent): boolean {
|
|
||||||
return !e.ctrlKey && !e.metaKey && e.altKey && !e.repeat && e.code === 'KeyC';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSubtitleWordNodes(): HTMLElement[] {
|
function getSubtitleWordNodes(): HTMLElement[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
ctx.dom.subtitleRoot.querySelectorAll<HTMLElement>('.word[data-token-index]'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearKeyboardSelectedWordClasses(
|
function syncKeyboardTokenSelection(): void {
|
||||||
wordNodes: HTMLElement[] = getSubtitleWordNodes(),
|
const wordNodes = getSubtitleWordNodes();
|
||||||
): void {
|
|
||||||
for (const wordNode of wordNodes) {
|
for (const wordNode of wordNodes) {
|
||||||
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
wordNode.classList.remove(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function clearNativeSubtitleSelection(): void {
|
|
||||||
window.getSelection()?.removeAllRanges();
|
|
||||||
ctx.dom.subtitleRoot.classList.remove('has-selection');
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncKeyboardTokenSelection(): void {
|
|
||||||
const wordNodes = getSubtitleWordNodes();
|
|
||||||
clearKeyboardSelectedWordClasses(wordNodes);
|
|
||||||
|
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
if (!ctx.state.keyboardDrivenModeEnabled || wordNodes.length === 0) {
|
||||||
ctx.state.keyboardSelectedWordIndex = null;
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
ctx.state.keyboardSelectionVisible = false;
|
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
resetSelectionToStartOnNextSubtitleSync = false;
|
|
||||||
clearNativeSubtitleSelection();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -219,9 +153,7 @@ export function createKeyboardHandlers(
|
|||||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
||||||
ctx.state.keyboardSelectedWordIndex =
|
ctx.state.keyboardSelectedWordIndex =
|
||||||
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
pendingSelectionAnchorAfterSubtitleSeek === 'start' ? 0 : wordNodes.length - 1;
|
||||||
ctx.state.keyboardSelectionVisible = true;
|
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
resetSelectionToStartOnNextSubtitleSync = false;
|
|
||||||
const shouldRefreshLookup =
|
const shouldRefreshLookup =
|
||||||
pendingLookupRefreshAfterSubtitleSeek &&
|
pendingLookupRefreshAfterSubtitleSeek &&
|
||||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document));
|
||||||
@@ -233,32 +165,23 @@ export function createKeyboardHandlers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resetSelectionToStartOnNextSubtitleSync) {
|
|
||||||
ctx.state.keyboardSelectedWordIndex = 0;
|
|
||||||
ctx.state.keyboardSelectionVisible = true;
|
|
||||||
resetSelectionToStartOnNextSubtitleSync = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedIndex = Math.min(
|
const selectedIndex = Math.min(
|
||||||
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
Math.max(ctx.state.keyboardSelectedWordIndex ?? 0, 0),
|
||||||
wordNodes.length - 1,
|
wordNodes.length - 1,
|
||||||
);
|
);
|
||||||
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
ctx.state.keyboardSelectedWordIndex = selectedIndex;
|
||||||
const selectedWordNode = wordNodes[selectedIndex];
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
if (selectedWordNode && ctx.state.keyboardSelectionVisible) {
|
if (selectedWordNode) {
|
||||||
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
selectedWordNode.classList.add(KEYBOARD_SELECTED_WORD_CLASS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||||
ctx.state.keyboardSelectionVisible = enabled;
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
ctx.state.keyboardSelectedWordIndex = null;
|
ctx.state.keyboardSelectedWordIndex = null;
|
||||||
pendingSelectionAnchorAfterSubtitleSeek = null;
|
pendingSelectionAnchorAfterSubtitleSeek = null;
|
||||||
pendingLookupRefreshAfterSubtitleSeek = false;
|
pendingLookupRefreshAfterSubtitleSeek = false;
|
||||||
resetSelectionToStartOnNextSubtitleSync = false;
|
|
||||||
clearNativeSubtitleSelection();
|
|
||||||
}
|
}
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
}
|
}
|
||||||
@@ -290,7 +213,6 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
const nextIndex = currentIndex + delta;
|
const nextIndex = currentIndex + delta;
|
||||||
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
ctx.state.keyboardSelectedWordIndex = nextIndex;
|
||||||
ctx.state.keyboardSelectionVisible = true;
|
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
return 'moved';
|
return 'moved';
|
||||||
}
|
}
|
||||||
@@ -394,7 +316,6 @@ export function createKeyboardHandlers(
|
|||||||
const selectedWordNode = wordNodes[selectedIndex];
|
const selectedWordNode = wordNodes[selectedIndex];
|
||||||
if (!selectedWordNode) return false;
|
if (!selectedWordNode) return false;
|
||||||
|
|
||||||
ctx.state.keyboardSelectionVisible = true;
|
|
||||||
syncKeyboardTokenSelection();
|
syncKeyboardTokenSelection();
|
||||||
selectWordNodeText(selectedWordNode);
|
selectWordNodeText(selectedWordNode);
|
||||||
|
|
||||||
@@ -426,105 +347,19 @@ export function createKeyboardHandlers(
|
|||||||
toggleKeyboardDrivenMode();
|
toggleKeyboardDrivenMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubtitleContentUpdated(): void {
|
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pendingSelectionAnchorAfterSubtitleSeek) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resetSelectionToStartOnNextSubtitleSync = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLookupWindowToggleRequested(): void {
|
function handleLookupWindowToggleRequested(): void {
|
||||||
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
if (ctx.state.yomitanPopupVisible) {
|
||||||
closeLookupWindow();
|
dispatchYomitanPopupVisibility(false);
|
||||||
|
if (ctx.state.keyboardDrivenModeEnabled) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
restoreOverlayKeyboardFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLookupWindow(): boolean {
|
|
||||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchYomitanPopupVisibility(false);
|
|
||||||
dispatchYomitanFrontendClearActiveTextSource();
|
|
||||||
clearNativeSubtitleSelection();
|
|
||||||
if (ctx.state.keyboardDrivenModeEnabled) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
restoreOverlayKeyboardFocus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveSelectionForController(delta: -1 | 1): boolean {
|
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
|
||||||
const result = moveKeyboardSelection(delta);
|
|
||||||
if (result === 'no-words') {
|
|
||||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result === 'start-boundary' || result === 'end-boundary') {
|
|
||||||
seekAdjacentSubtitleAndQueueSelection(delta, popupVisible);
|
|
||||||
} else if (popupVisible && result === 'moved') {
|
|
||||||
triggerLookupForSelectedWord();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function forwardPopupKeydownForController(
|
|
||||||
key: string,
|
|
||||||
code: string,
|
|
||||||
repeat: boolean = true,
|
|
||||||
): boolean {
|
|
||||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
dispatchYomitanPopupKeydown(key, code, [], repeat);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mineSelectedFromController(): boolean {
|
|
||||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
dispatchYomitanPopupMineSelected();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cyclePopupAudioSourceForController(direction: -1 | 1): boolean {
|
|
||||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
dispatchYomitanPopupCycleAudioSource(direction);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function playCurrentAudioForController(): boolean {
|
|
||||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
dispatchYomitanPopupPlayCurrentAudio();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollPopupByController(deltaX: number, deltaY: number): boolean {
|
|
||||||
if (!ctx.state.yomitanPopupVisible && !isYomitanPopupVisible(document)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
dispatchYomitanPopupScrollBy(deltaX, deltaY);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreOverlayKeyboardFocus(): void {
|
function restoreOverlayKeyboardFocus(): void {
|
||||||
void window.electronAPI.focusMainWindow();
|
void window.electronAPI.focusMainWindow();
|
||||||
window.focus();
|
window.focus();
|
||||||
@@ -566,17 +401,17 @@ export function createKeyboardHandlers(
|
|||||||
const key = e.code;
|
const key = e.code;
|
||||||
if (key === 'ArrowLeft') {
|
if (key === 'ArrowLeft') {
|
||||||
const result = moveKeyboardSelection(-1);
|
const result = moveKeyboardSelection(-1);
|
||||||
if (result === 'start-boundary' || result === 'no-words') {
|
if (result === 'start-boundary') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
seekAdjacentSubtitleAndQueueSelection(-1, false);
|
||||||
}
|
}
|
||||||
return true;
|
return result !== 'no-words';
|
||||||
}
|
}
|
||||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
const result = moveKeyboardSelection(1);
|
const result = moveKeyboardSelection(1);
|
||||||
if (result === 'end-boundary' || result === 'no-words') {
|
if (result === 'end-boundary') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(1, false);
|
seekAdjacentSubtitleAndQueueSelection(1, false);
|
||||||
}
|
}
|
||||||
return true;
|
return result !== 'no-words';
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -593,7 +428,7 @@ export function createKeyboardHandlers(
|
|||||||
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
const popupVisible = ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document);
|
||||||
if (key === 'ArrowLeft' || key === 'KeyH') {
|
if (key === 'ArrowLeft' || key === 'KeyH') {
|
||||||
const result = moveKeyboardSelection(-1);
|
const result = moveKeyboardSelection(-1);
|
||||||
if (result === 'start-boundary' || result === 'no-words') {
|
if (result === 'start-boundary') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
seekAdjacentSubtitleAndQueueSelection(-1, popupVisible);
|
||||||
} else if (popupVisible && result === 'moved') {
|
} else if (popupVisible && result === 'moved') {
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
@@ -603,7 +438,7 @@ export function createKeyboardHandlers(
|
|||||||
|
|
||||||
if (key === 'ArrowRight' || key === 'KeyL') {
|
if (key === 'ArrowRight' || key === 'KeyL') {
|
||||||
const result = moveKeyboardSelection(1);
|
const result = moveKeyboardSelection(1);
|
||||||
if (result === 'end-boundary' || result === 'no-words') {
|
if (result === 'end-boundary') {
|
||||||
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
seekAdjacentSubtitleAndQueueSelection(1, popupVisible);
|
||||||
} else if (popupVisible && result === 'moved') {
|
} else if (popupVisible && result === 'moved') {
|
||||||
triggerLookupForSelectedWord();
|
triggerLookupForSelectedWord();
|
||||||
@@ -705,9 +540,7 @@ export function createKeyboardHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, () => {
|
||||||
clearNativeSubtitleSelection();
|
|
||||||
if (!ctx.state.keyboardDrivenModeEnabled) {
|
if (!ctx.state.keyboardDrivenModeEnabled) {
|
||||||
syncKeyboardTokenSelection();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
restoreOverlayKeyboardFocus();
|
restoreOverlayKeyboardFocus();
|
||||||
@@ -760,6 +593,13 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) {
|
||||||
|
if (handleYomitanPopupKeybind(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ctx.state.runtimeOptionsModalOpen) {
|
if (ctx.state.runtimeOptionsModalOpen) {
|
||||||
options.handleRuntimeOptionsKeydown(e);
|
options.handleRuntimeOptionsKeydown(e);
|
||||||
return;
|
return;
|
||||||
@@ -776,29 +616,11 @@ export function createKeyboardHandlers(
|
|||||||
options.handleJimakuKeydown(e);
|
options.handleJimakuKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.state.controllerSelectModalOpen) {
|
|
||||||
options.handleControllerSelectKeydown(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ctx.state.controllerDebugModalOpen) {
|
|
||||||
options.handleControllerDebugKeydown(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ctx.state.sessionHelpModalOpen) {
|
if (ctx.state.sessionHelpModalOpen) {
|
||||||
options.handleSessionHelpKeydown(e);
|
options.handleSessionHelpKeydown(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
(ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document)) &&
|
|
||||||
!isControllerModalShortcut(e)
|
|
||||||
) {
|
|
||||||
if (handleYomitanPopupKeybind(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
if (ctx.state.keyboardDrivenModeEnabled && handleKeyboardDrivenModeNavigation(e)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -849,16 +671,6 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isControllerModalShortcut(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
options.openControllerDebugModal();
|
|
||||||
} else {
|
|
||||||
options.openControllerSelectModal();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyString = keyEventToString(e);
|
const keyString = keyEventToString(e);
|
||||||
const command = ctx.state.keybindingsMap.get(keyString);
|
const command = ctx.state.keybindingsMap.get(keyString);
|
||||||
|
|
||||||
@@ -895,15 +707,7 @@ export function createKeyboardHandlers(
|
|||||||
setupMpvInputForwarding,
|
setupMpvInputForwarding,
|
||||||
updateKeybindings,
|
updateKeybindings,
|
||||||
syncKeyboardTokenSelection,
|
syncKeyboardTokenSelection,
|
||||||
handleSubtitleContentUpdated,
|
|
||||||
handleKeyboardModeToggleRequested,
|
handleKeyboardModeToggleRequested,
|
||||||
handleLookupWindowToggleRequested,
|
handleLookupWindowToggleRequested,
|
||||||
closeLookupWindow,
|
|
||||||
moveSelectionForController,
|
|
||||||
forwardPopupKeydownForController,
|
|
||||||
mineSelectedFromController,
|
|
||||||
cyclePopupAudioSourceForController,
|
|
||||||
playCurrentAudioForController,
|
|
||||||
scrollPopupByController,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,12 +30,6 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
<!-- Programmatic focus fallback target for Electron/window focus management. -->
|
||||||
<div id="overlay" tabindex="-1">
|
<div id="overlay" tabindex="-1">
|
||||||
<div
|
|
||||||
id="controllerStatusToast"
|
|
||||||
class="controller-status-toast hidden"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
></div>
|
|
||||||
<div
|
<div
|
||||||
id="overlayErrorToast"
|
id="overlayErrorToast"
|
||||||
class="overlay-error-toast hidden"
|
class="overlay-error-toast hidden"
|
||||||
@@ -198,64 +192,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="controllerSelectModal" class="modal hidden" aria-hidden="true">
|
|
||||||
<div class="modal-content runtime-modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="modal-title">Controller Configuration</div>
|
|
||||||
<button id="controllerSelectClose" class="modal-close" type="button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label class="controller-select-field">
|
|
||||||
<span>Preferred Controller</span>
|
|
||||||
<select id="controllerSelectPicker"></select>
|
|
||||||
</label>
|
|
||||||
<div id="controllerSelectSummary" class="controller-select-summary"></div>
|
|
||||||
<div id="controllerConfigList" class="controller-config-list"></div>
|
|
||||||
<div id="controllerSelectStatus" class="runtime-options-status"></div>
|
|
||||||
<div class="subsync-footer">
|
|
||||||
<button id="controllerSelectSave" class="kiku-confirm-button" type="button">
|
|
||||||
Save Controller
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="controllerDebugModal" class="modal hidden" aria-hidden="true">
|
|
||||||
<div class="modal-content runtime-modal-content controller-debug-content">
|
|
||||||
<div
|
|
||||||
id="controllerDebugToast"
|
|
||||||
class="controller-debug-toast hidden"
|
|
||||||
aria-live="polite"
|
|
||||||
></div>
|
|
||||||
<div class="modal-header">
|
|
||||||
<div class="modal-title">Controller Debug</div>
|
|
||||||
<button id="controllerDebugClose" class="modal-close" type="button">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div id="controllerDebugStatus" class="runtime-options-status"></div>
|
|
||||||
<div id="controllerDebugSummary" class="controller-debug-summary"></div>
|
|
||||||
<div class="controller-debug-grid">
|
|
||||||
<div>
|
|
||||||
<div class="jimaku-section-title">Axes</div>
|
|
||||||
<pre id="controllerDebugAxes" class="controller-debug-pre"></pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="jimaku-section-title">Buttons</div>
|
|
||||||
<pre id="controllerDebugButtons" class="controller-debug-pre"></pre>
|
|
||||||
</div>
|
|
||||||
<div class="controller-debug-span">
|
|
||||||
<div class="controller-debug-section-header">
|
|
||||||
<div class="jimaku-section-title">Config</div>
|
|
||||||
<button id="controllerDebugCopy" class="kiku-cancel-button" type="button">
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre id="controllerDebugButtonIndices" class="controller-debug-pre"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
<div id="sessionHelpModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content session-help-content">
|
<div class="modal-content session-help-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createControllerConfigForm } from './controller-config-form.js';
|
|
||||||
|
|
||||||
function createClassList(initialTokens: string[] = []) {
|
|
||||||
const tokens = new Set(initialTokens);
|
|
||||||
return {
|
|
||||||
add: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.add(entry);
|
|
||||||
},
|
|
||||||
remove: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.delete(entry);
|
|
||||||
},
|
|
||||||
toggle: (entry: string, force?: boolean) => {
|
|
||||||
if (force === undefined) {
|
|
||||||
if (tokens.has(entry)) tokens.delete(entry);
|
|
||||||
else tokens.add(entry);
|
|
||||||
return tokens.has(entry);
|
|
||||||
}
|
|
||||||
if (force) tokens.add(entry);
|
|
||||||
else tokens.delete(entry);
|
|
||||||
return force;
|
|
||||||
},
|
|
||||||
contains: (entry: string) => tokens.has(entry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFakeElement() {
|
|
||||||
const attributes = new Map<string, string>();
|
|
||||||
const el = {
|
|
||||||
className: '',
|
|
||||||
textContent: '',
|
|
||||||
_innerHTML: '',
|
|
||||||
value: '',
|
|
||||||
disabled: false,
|
|
||||||
selected: false,
|
|
||||||
type: '',
|
|
||||||
children: [] as any[],
|
|
||||||
listeners: new Map<string, Array<(e?: any) => void>>(),
|
|
||||||
classList: createClassList(),
|
|
||||||
appendChild(child: any) {
|
|
||||||
this.children.push(child);
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
addEventListener(type: string, listener: (e?: any) => void) {
|
|
||||||
const existing = this.listeners.get(type) ?? [];
|
|
||||||
existing.push(listener);
|
|
||||||
this.listeners.set(type, existing);
|
|
||||||
},
|
|
||||||
dispatch(type: string) {
|
|
||||||
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
|
|
||||||
for (const listener of this.listeners.get(type) ?? []) {
|
|
||||||
listener(fakeEvent);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setAttribute(name: string, value: string) {
|
|
||||||
attributes.set(name, value);
|
|
||||||
},
|
|
||||||
getAttribute(name: string) {
|
|
||||||
return attributes.get(name) ?? null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Object.defineProperty(el, 'innerHTML', {
|
|
||||||
get() {
|
|
||||||
return el._innerHTML;
|
|
||||||
},
|
|
||||||
set(v: string) {
|
|
||||||
el._innerHTML = v;
|
|
||||||
if (v === '') el.children.length = 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('controller config form renders rows and dispatches learn clear reset callbacks', () => {
|
|
||||||
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
createElement: () => createFakeElement(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const calls: string[] = [];
|
|
||||||
const container = createFakeElement();
|
|
||||||
const form = createControllerConfigForm({
|
|
||||||
container: container as never,
|
|
||||||
getBindings: () =>
|
|
||||||
({
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
}) as never,
|
|
||||||
getLearningActionId: () => 'toggleLookup',
|
|
||||||
getDpadLearningActionId: () => null,
|
|
||||||
onLearn: (actionId, bindingType) => calls.push(`learn:${actionId}:${bindingType}`),
|
|
||||||
onClear: (actionId) => calls.push(`clear:${actionId}`),
|
|
||||||
onReset: (actionId) => calls.push(`reset:${actionId}`),
|
|
||||||
onDpadLearn: (actionId) => calls.push(`dpadLearn:${actionId}`),
|
|
||||||
onDpadClear: (actionId) => calls.push(`dpadClear:${actionId}`),
|
|
||||||
onDpadReset: (actionId) => calls.push(`dpadReset:${actionId}`),
|
|
||||||
});
|
|
||||||
|
|
||||||
form.render();
|
|
||||||
|
|
||||||
// In the new compact list layout, children are:
|
|
||||||
// [0] group header, [1] first binding row (auto-expanded because learning), [2] edit panel, [3] next row, ...
|
|
||||||
const firstRow = container.children[1];
|
|
||||||
assert.equal(firstRow.classList.contains('expanded'), true);
|
|
||||||
|
|
||||||
// After expanding, the edit panel is inserted after the row:
|
|
||||||
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
|
|
||||||
const editPanel = container.children[2];
|
|
||||||
// editPanel > inner > actions > learnButton
|
|
||||||
const inner = editPanel.children[0];
|
|
||||||
const actions = inner.children[1];
|
|
||||||
const learnButton = actions.children[0];
|
|
||||||
learnButton.dispatch('click');
|
|
||||||
actions.children[1].dispatch('click');
|
|
||||||
actions.children[2].dispatch('click');
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
|
||||||
'learn:toggleLookup:discrete',
|
|
||||||
'clear:toggleLookup',
|
|
||||||
'reset:toggleLookup',
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (previousDocumentDescriptor) {
|
|
||||||
Object.defineProperty(globalThis, 'document', previousDocumentDescriptor);
|
|
||||||
} else {
|
|
||||||
Reflect.deleteProperty(globalThis, 'document');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
import type {
|
|
||||||
ControllerDpadFallback,
|
|
||||||
ResolvedControllerAxisBinding,
|
|
||||||
ResolvedControllerConfig,
|
|
||||||
ResolvedControllerDiscreteBinding,
|
|
||||||
} from '../../types';
|
|
||||||
|
|
||||||
type ControllerBindingActionId = keyof ResolvedControllerConfig['bindings'];
|
|
||||||
|
|
||||||
type ControllerBindingDefinition = {
|
|
||||||
id: ControllerBindingActionId;
|
|
||||||
label: string;
|
|
||||||
group: string;
|
|
||||||
bindingType: 'discrete' | 'axis';
|
|
||||||
defaultBinding: ResolvedControllerConfig['bindings'][ControllerBindingActionId];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CONTROLLER_BINDING_DEFINITIONS: ControllerBindingDefinition[] = [
|
|
||||||
{
|
|
||||||
id: 'toggleLookup',
|
|
||||||
label: 'Toggle Lookup',
|
|
||||||
group: 'Lookup',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'closeLookup',
|
|
||||||
label: 'Close Lookup',
|
|
||||||
group: 'Lookup',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 1 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mineCard',
|
|
||||||
label: 'Mine Card',
|
|
||||||
group: 'Lookup',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 2 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'toggleKeyboardOnlyMode',
|
|
||||||
label: 'Toggle Keyboard-Only Mode',
|
|
||||||
group: 'Playback',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 3 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'toggleMpvPause',
|
|
||||||
label: 'Toggle MPV Pause',
|
|
||||||
group: 'Playback',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 9 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'quitMpv',
|
|
||||||
label: 'Quit MPV',
|
|
||||||
group: 'Playback',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 6 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'previousAudio',
|
|
||||||
label: 'Previous Audio',
|
|
||||||
group: 'Popup Audio',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'none' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nextAudio',
|
|
||||||
label: 'Next Audio',
|
|
||||||
group: 'Popup Audio',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 5 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'playCurrentAudio',
|
|
||||||
label: 'Play Current Audio',
|
|
||||||
group: 'Popup Audio',
|
|
||||||
bindingType: 'discrete',
|
|
||||||
defaultBinding: { kind: 'button', buttonIndex: 4 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'leftStickHorizontal',
|
|
||||||
label: 'Token Move',
|
|
||||||
group: 'Navigation',
|
|
||||||
bindingType: 'axis',
|
|
||||||
defaultBinding: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'leftStickVertical',
|
|
||||||
label: 'Popup Scroll',
|
|
||||||
group: 'Navigation',
|
|
||||||
bindingType: 'axis',
|
|
||||||
defaultBinding: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rightStickHorizontal',
|
|
||||||
label: 'Alt Horizontal',
|
|
||||||
group: 'Navigation',
|
|
||||||
bindingType: 'axis',
|
|
||||||
defaultBinding: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rightStickVertical',
|
|
||||||
label: 'Popup Jump',
|
|
||||||
group: 'Navigation',
|
|
||||||
bindingType: 'axis',
|
|
||||||
defaultBinding: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getControllerBindingDefinition(actionId: ControllerBindingActionId) {
|
|
||||||
return CONTROLLER_BINDING_DEFINITIONS.find((definition) => definition.id === actionId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultControllerBinding(actionId: ControllerBindingActionId) {
|
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
|
||||||
if (!definition) {
|
|
||||||
return { kind: 'none' } as const;
|
|
||||||
}
|
|
||||||
return JSON.parse(JSON.stringify(definition.defaultBinding)) as ResolvedControllerConfig['bindings'][ControllerBindingActionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultDpadFallback(actionId: ControllerBindingActionId): ControllerDpadFallback {
|
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
|
||||||
if (!definition || definition.defaultBinding.kind !== 'axis') return 'none';
|
|
||||||
const binding = definition.defaultBinding;
|
|
||||||
return 'dpadFallback' in binding && binding.dpadFallback ? binding.dpadFallback : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
const STANDARD_BUTTON_NAMES: Record<number, string> = {
|
|
||||||
0: 'A / Cross',
|
|
||||||
1: 'B / Circle',
|
|
||||||
2: 'X / Square',
|
|
||||||
3: 'Y / Triangle',
|
|
||||||
4: 'LB / L1',
|
|
||||||
5: 'RB / R1',
|
|
||||||
6: 'Back / Select',
|
|
||||||
7: 'Start / Options',
|
|
||||||
8: 'L3 / LS',
|
|
||||||
9: 'R3 / RS',
|
|
||||||
10: 'Left Stick Click',
|
|
||||||
11: 'Right Stick Click',
|
|
||||||
12: 'D-pad Up',
|
|
||||||
13: 'D-pad Down',
|
|
||||||
14: 'D-pad Left',
|
|
||||||
15: 'D-pad Right',
|
|
||||||
16: 'Guide / Home',
|
|
||||||
};
|
|
||||||
|
|
||||||
const STANDARD_AXIS_NAMES: Record<number, string> = {
|
|
||||||
0: 'Left Stick X',
|
|
||||||
1: 'Left Stick Y',
|
|
||||||
2: 'Left Trigger',
|
|
||||||
3: 'Right Stick X',
|
|
||||||
4: 'Right Stick Y',
|
|
||||||
5: 'Right Trigger',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DPAD_FALLBACK_LABELS: Record<ControllerDpadFallback, string> = {
|
|
||||||
none: 'None',
|
|
||||||
horizontal: 'D-pad \u2194',
|
|
||||||
vertical: 'D-pad \u2195',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getFriendlyButtonName(buttonIndex: number): string {
|
|
||||||
return STANDARD_BUTTON_NAMES[buttonIndex] ?? `Button ${buttonIndex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFriendlyAxisName(axisIndex: number): string {
|
|
||||||
return STANDARD_AXIS_NAMES[axisIndex] ?? `Axis ${axisIndex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatControllerBindingSummary(
|
|
||||||
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
|
|
||||||
): string {
|
|
||||||
if (binding.kind === 'none') {
|
|
||||||
return 'Disabled';
|
|
||||||
}
|
|
||||||
if ('direction' in binding) {
|
|
||||||
return `Axis ${binding.axisIndex} ${binding.direction === 'positive' ? '+' : '-'}`;
|
|
||||||
}
|
|
||||||
if ('buttonIndex' in binding) {
|
|
||||||
return `Button ${binding.buttonIndex}`;
|
|
||||||
}
|
|
||||||
if (binding.dpadFallback === 'none') {
|
|
||||||
return `Axis ${binding.axisIndex}`;
|
|
||||||
}
|
|
||||||
return `Axis ${binding.axisIndex} + D-pad ${binding.dpadFallback}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFriendlyStickLabel(binding: ResolvedControllerAxisBinding): string {
|
|
||||||
if (binding.kind === 'none') return 'None';
|
|
||||||
return getFriendlyAxisName(binding.axisIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFriendlyBindingLabel(
|
|
||||||
binding: ResolvedControllerDiscreteBinding | ResolvedControllerAxisBinding,
|
|
||||||
): string {
|
|
||||||
if (binding.kind === 'none') return 'None';
|
|
||||||
if ('direction' in binding) {
|
|
||||||
const name = getFriendlyAxisName(binding.axisIndex);
|
|
||||||
return `${name} ${binding.direction === 'positive' ? '+' : '\u2212'}`;
|
|
||||||
}
|
|
||||||
if ('buttonIndex' in binding) return getFriendlyButtonName(binding.buttonIndex);
|
|
||||||
return getFriendlyAxisName(binding.axisIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unique key for expanded rows. Stick rows use the action id, dpad rows append ':dpad'. */
|
|
||||||
type ExpandedRowKey = string;
|
|
||||||
|
|
||||||
export function createControllerConfigForm(options: {
|
|
||||||
container: HTMLElement;
|
|
||||||
getBindings: () => ResolvedControllerConfig['bindings'];
|
|
||||||
getLearningActionId: () => ControllerBindingActionId | null;
|
|
||||||
getDpadLearningActionId: () => ControllerBindingActionId | null;
|
|
||||||
onLearn: (actionId: ControllerBindingActionId, bindingType: 'discrete' | 'axis') => void;
|
|
||||||
onClear: (actionId: ControllerBindingActionId) => void;
|
|
||||||
onReset: (actionId: ControllerBindingActionId) => void;
|
|
||||||
onDpadLearn: (actionId: ControllerBindingActionId) => void;
|
|
||||||
onDpadClear: (actionId: ControllerBindingActionId) => void;
|
|
||||||
onDpadReset: (actionId: ControllerBindingActionId) => void;
|
|
||||||
}) {
|
|
||||||
let expandedRowKey: ExpandedRowKey | null = null;
|
|
||||||
|
|
||||||
function render(): void {
|
|
||||||
options.container.innerHTML = '';
|
|
||||||
let lastGroup = '';
|
|
||||||
const learningActionId = options.getLearningActionId();
|
|
||||||
const dpadLearningActionId = options.getDpadLearningActionId();
|
|
||||||
|
|
||||||
// Auto-expand when learning starts
|
|
||||||
if (learningActionId) {
|
|
||||||
expandedRowKey = learningActionId;
|
|
||||||
} else if (dpadLearningActionId) {
|
|
||||||
expandedRowKey = `${dpadLearningActionId}:dpad`;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const definition of CONTROLLER_BINDING_DEFINITIONS) {
|
|
||||||
if (definition.group !== lastGroup) {
|
|
||||||
const header = document.createElement('div');
|
|
||||||
header.className = 'controller-config-group';
|
|
||||||
header.textContent = definition.group;
|
|
||||||
options.container.appendChild(header);
|
|
||||||
lastGroup = definition.group;
|
|
||||||
}
|
|
||||||
|
|
||||||
const binding = options.getBindings()[definition.id];
|
|
||||||
|
|
||||||
if (definition.bindingType === 'axis') {
|
|
||||||
renderAxisStickRow(definition, binding as ResolvedControllerAxisBinding, learningActionId);
|
|
||||||
renderAxisDpadRow(definition, binding as ResolvedControllerAxisBinding, dpadLearningActionId);
|
|
||||||
} else {
|
|
||||||
renderDiscreteRow(definition, binding, learningActionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDiscreteRow(
|
|
||||||
definition: ControllerBindingDefinition,
|
|
||||||
binding: ResolvedControllerConfig['bindings'][ControllerBindingActionId],
|
|
||||||
learningActionId: ControllerBindingActionId | null,
|
|
||||||
): void {
|
|
||||||
const rowKey = definition.id as string;
|
|
||||||
const isExpanded = expandedRowKey === rowKey;
|
|
||||||
const isLearning = learningActionId === definition.id;
|
|
||||||
|
|
||||||
const row = createRow(definition.label, formatFriendlyBindingLabel(binding), binding.kind === 'none', isExpanded);
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
options.container.appendChild(row);
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
const hint = isLearning
|
|
||||||
? 'Press a button, trigger, or move a stick\u2026'
|
|
||||||
: `Currently: ${formatControllerBindingSummary(binding)}`;
|
|
||||||
const panel = createEditPanel(hint, isLearning, {
|
|
||||||
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, definition.bindingType); },
|
|
||||||
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
|
|
||||||
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
|
|
||||||
});
|
|
||||||
options.container.appendChild(panel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAxisStickRow(
|
|
||||||
definition: ControllerBindingDefinition,
|
|
||||||
binding: ResolvedControllerAxisBinding,
|
|
||||||
learningActionId: ControllerBindingActionId | null,
|
|
||||||
): void {
|
|
||||||
const rowKey = definition.id as string;
|
|
||||||
const isExpanded = expandedRowKey === rowKey;
|
|
||||||
const isLearning = learningActionId === definition.id;
|
|
||||||
|
|
||||||
const row = createRow(`${definition.label} (Stick)`, formatFriendlyStickLabel(binding), binding.kind === 'none', isExpanded);
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
options.container.appendChild(row);
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
const summary = binding.kind === 'none' ? 'Disabled' : `Axis ${binding.axisIndex}`;
|
|
||||||
const hint = isLearning ? 'Move a stick or trigger\u2026' : `Currently: ${summary}`;
|
|
||||||
const panel = createEditPanel(hint, isLearning, {
|
|
||||||
onLearn: (e) => { e.stopPropagation(); options.onLearn(definition.id, 'axis'); },
|
|
||||||
onClear: (e) => { e.stopPropagation(); options.onClear(definition.id); },
|
|
||||||
onReset: (e) => { e.stopPropagation(); options.onReset(definition.id); },
|
|
||||||
});
|
|
||||||
options.container.appendChild(panel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAxisDpadRow(
|
|
||||||
definition: ControllerBindingDefinition,
|
|
||||||
binding: ResolvedControllerAxisBinding,
|
|
||||||
dpadLearningActionId: ControllerBindingActionId | null,
|
|
||||||
): void {
|
|
||||||
const rowKey = `${definition.id as string}:dpad`;
|
|
||||||
const isExpanded = expandedRowKey === rowKey;
|
|
||||||
const isLearning = dpadLearningActionId === definition.id;
|
|
||||||
|
|
||||||
const dpadFallback: ControllerDpadFallback = binding.kind === 'none' ? 'none' : binding.dpadFallback;
|
|
||||||
const badgeText = DPAD_FALLBACK_LABELS[dpadFallback];
|
|
||||||
const row = createRow(`${definition.label} (D-pad)`, badgeText, dpadFallback === 'none', isExpanded);
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
expandedRowKey = expandedRowKey === rowKey ? null : rowKey;
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
options.container.appendChild(row);
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
const hint = isLearning
|
|
||||||
? 'Press a D-pad direction\u2026'
|
|
||||||
: `Currently: ${DPAD_FALLBACK_LABELS[dpadFallback]}`;
|
|
||||||
const panel = createEditPanel(hint, isLearning, {
|
|
||||||
onLearn: (e) => { e.stopPropagation(); options.onDpadLearn(definition.id); },
|
|
||||||
onClear: (e) => { e.stopPropagation(); options.onDpadClear(definition.id); },
|
|
||||||
onReset: (e) => { e.stopPropagation(); options.onDpadReset(definition.id); },
|
|
||||||
});
|
|
||||||
options.container.appendChild(panel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRow(labelText: string, badgeText: string, isDisabled: boolean, isExpanded: boolean): HTMLDivElement {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'controller-config-row';
|
|
||||||
if (isExpanded) row.classList.add('expanded');
|
|
||||||
|
|
||||||
const label = document.createElement('div');
|
|
||||||
label.className = 'controller-config-label';
|
|
||||||
label.textContent = labelText;
|
|
||||||
|
|
||||||
const right = document.createElement('div');
|
|
||||||
right.className = 'controller-config-right';
|
|
||||||
|
|
||||||
const badge = document.createElement('span');
|
|
||||||
badge.className = 'controller-config-badge';
|
|
||||||
if (isDisabled) badge.classList.add('disabled');
|
|
||||||
badge.textContent = badgeText;
|
|
||||||
|
|
||||||
const editIcon = document.createElement('span');
|
|
||||||
editIcon.className = 'controller-config-edit-icon';
|
|
||||||
editIcon.textContent = '\u270E';
|
|
||||||
|
|
||||||
right.appendChild(badge);
|
|
||||||
right.appendChild(editIcon);
|
|
||||||
row.appendChild(label);
|
|
||||||
row.appendChild(right);
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEditPanel(
|
|
||||||
hintText: string,
|
|
||||||
isLearning: boolean,
|
|
||||||
callbacks: {
|
|
||||||
onLearn: (e: Event) => void;
|
|
||||||
onClear: (e: Event) => void;
|
|
||||||
onReset: (e: Event) => void;
|
|
||||||
},
|
|
||||||
): HTMLDivElement {
|
|
||||||
const panel = document.createElement('div');
|
|
||||||
panel.className = 'controller-config-edit-panel';
|
|
||||||
|
|
||||||
const inner = document.createElement('div');
|
|
||||||
inner.className = 'controller-config-edit-inner';
|
|
||||||
|
|
||||||
const hint = document.createElement('div');
|
|
||||||
hint.className = 'controller-config-edit-hint';
|
|
||||||
if (isLearning) hint.classList.add('learning');
|
|
||||||
hint.textContent = hintText;
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'controller-config-edit-actions';
|
|
||||||
|
|
||||||
const learnButton = document.createElement('button');
|
|
||||||
learnButton.type = 'button';
|
|
||||||
learnButton.className = isLearning ? 'btn-learn active' : 'btn-learn';
|
|
||||||
learnButton.textContent = isLearning ? 'Listening\u2026' : 'Learn';
|
|
||||||
learnButton.addEventListener('click', callbacks.onLearn);
|
|
||||||
|
|
||||||
const clearButton = document.createElement('button');
|
|
||||||
clearButton.type = 'button';
|
|
||||||
clearButton.className = 'btn-secondary';
|
|
||||||
clearButton.textContent = 'Clear';
|
|
||||||
clearButton.addEventListener('click', callbacks.onClear);
|
|
||||||
|
|
||||||
const resetButton = document.createElement('button');
|
|
||||||
resetButton.type = 'button';
|
|
||||||
resetButton.className = 'btn-secondary';
|
|
||||||
resetButton.textContent = 'Reset';
|
|
||||||
resetButton.addEventListener('click', callbacks.onReset);
|
|
||||||
|
|
||||||
actions.appendChild(learnButton);
|
|
||||||
actions.appendChild(clearButton);
|
|
||||||
actions.appendChild(resetButton);
|
|
||||||
|
|
||||||
inner.appendChild(hint);
|
|
||||||
inner.appendChild(actions);
|
|
||||||
panel.appendChild(inner);
|
|
||||||
|
|
||||||
return panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { render };
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createRendererState } from '../state.js';
|
|
||||||
import { createControllerDebugModal } from './controller-debug.js';
|
|
||||||
|
|
||||||
function createClassList(initialTokens: string[] = []) {
|
|
||||||
const tokens = new Set(initialTokens);
|
|
||||||
return {
|
|
||||||
add: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.add(entry);
|
|
||||||
},
|
|
||||||
remove: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.delete(entry);
|
|
||||||
},
|
|
||||||
contains: (entry: string) => tokens.has(entry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('controller debug modal renders active controller axes, buttons, and config-ready button indices', () => {
|
|
||||||
const globals = globalThis as typeof globalThis & { window?: unknown };
|
|
||||||
const previousWindow = globals.window;
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
electronAPI: {
|
|
||||||
notifyOverlayModalClosed: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const state = createRendererState();
|
|
||||||
state.connectedGamepads = [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }];
|
|
||||||
state.activeGamepadId = 'pad-1';
|
|
||||||
state.controllerRawAxes = [0.5, -0.25];
|
|
||||||
state.controllerRawButtons = [{ value: 1, pressed: true, touched: true }];
|
|
||||||
state.controllerConfig = {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: '',
|
|
||||||
preferredGamepadLabel: '',
|
|
||||||
smoothScroll: true,
|
|
||||||
scrollPixelsPerSecond: 900,
|
|
||||||
horizontalJumpPixels: 160,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
triggerInputMode: 'auto',
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
repeatDelayMs: 320,
|
|
||||||
repeatIntervalMs: 120,
|
|
||||||
buttonIndices: {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
buttonEast: 1,
|
|
||||||
buttonWest: 2,
|
|
||||||
buttonNorth: 3,
|
|
||||||
leftShoulder: 4,
|
|
||||||
rightShoulder: 5,
|
|
||||||
leftStickPress: 9,
|
|
||||||
rightStickPress: 10,
|
|
||||||
leftTrigger: 6,
|
|
||||||
rightTrigger: 7,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
dom: {
|
|
||||||
overlay: { classList: createClassList() },
|
|
||||||
controllerDebugModal: {
|
|
||||||
classList: createClassList(['hidden']),
|
|
||||||
setAttribute: () => {},
|
|
||||||
},
|
|
||||||
controllerDebugClose: { addEventListener: () => {} },
|
|
||||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
|
||||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
|
||||||
controllerDebugSummary: { textContent: '' },
|
|
||||||
controllerDebugAxes: { textContent: '' },
|
|
||||||
controllerDebugButtons: { textContent: '' },
|
|
||||||
controllerDebugButtonIndices: { textContent: '' },
|
|
||||||
},
|
|
||||||
state,
|
|
||||||
};
|
|
||||||
|
|
||||||
const modal = createControllerDebugModal(ctx as never, {
|
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.openControllerDebugModal();
|
|
||||||
|
|
||||||
assert.match(ctx.dom.controllerDebugStatus.textContent, /pad-1/);
|
|
||||||
assert.match(ctx.dom.controllerDebugSummary.textContent, /standard/);
|
|
||||||
assert.match(ctx.dom.controllerDebugAxes.textContent, /axis\[0\] = 0\.500/);
|
|
||||||
assert.match(
|
|
||||||
ctx.dom.controllerDebugButtons.textContent,
|
|
||||||
/button\[0\] value=1\.000 pressed=true/,
|
|
||||||
);
|
|
||||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"buttonIndices": \{/);
|
|
||||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"select": 6/);
|
|
||||||
assert.match(ctx.dom.controllerDebugButtonIndices.textContent, /"leftStickPress": 9/);
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller debug modal copies buttonIndices config to clipboard', async () => {
|
|
||||||
const globals = globalThis as typeof globalThis & {
|
|
||||||
window?: unknown;
|
|
||||||
navigator?: unknown;
|
|
||||||
};
|
|
||||||
const previousWindow = globals.window;
|
|
||||||
const previousNavigator = globals.navigator;
|
|
||||||
const copied: string[] = [];
|
|
||||||
const handlers: { copy: null | (() => void) } = { copy: null };
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
electronAPI: {
|
|
||||||
notifyOverlayModalClosed: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
clipboard: {
|
|
||||||
writeText: async (text: string) => {
|
|
||||||
copied.push(text);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const state = createRendererState();
|
|
||||||
state.controllerConfig = {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: '',
|
|
||||||
preferredGamepadLabel: '',
|
|
||||||
smoothScroll: true,
|
|
||||||
scrollPixelsPerSecond: 900,
|
|
||||||
horizontalJumpPixels: 160,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
triggerInputMode: 'auto',
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
repeatDelayMs: 320,
|
|
||||||
repeatIntervalMs: 120,
|
|
||||||
buttonIndices: {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
buttonEast: 1,
|
|
||||||
buttonWest: 2,
|
|
||||||
buttonNorth: 3,
|
|
||||||
leftShoulder: 4,
|
|
||||||
rightShoulder: 5,
|
|
||||||
leftStickPress: 9,
|
|
||||||
rightStickPress: 10,
|
|
||||||
leftTrigger: 6,
|
|
||||||
rightTrigger: 7,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
dom: {
|
|
||||||
overlay: { classList: createClassList() },
|
|
||||||
controllerDebugModal: {
|
|
||||||
classList: createClassList(['hidden']),
|
|
||||||
setAttribute: () => {},
|
|
||||||
},
|
|
||||||
controllerDebugClose: { addEventListener: () => {} },
|
|
||||||
controllerDebugCopy: {
|
|
||||||
addEventListener: (_event: string, handler: () => void) => {
|
|
||||||
handlers.copy = handler;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
controllerDebugToast: { textContent: '', classList: createClassList(['hidden']) },
|
|
||||||
controllerDebugStatus: { textContent: '', classList: createClassList() },
|
|
||||||
controllerDebugSummary: { textContent: '' },
|
|
||||||
controllerDebugAxes: { textContent: '' },
|
|
||||||
controllerDebugButtons: { textContent: '' },
|
|
||||||
controllerDebugButtonIndices: { textContent: '' },
|
|
||||||
},
|
|
||||||
state,
|
|
||||||
};
|
|
||||||
|
|
||||||
const modal = createControllerDebugModal(ctx as never, {
|
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.wireDomEvents();
|
|
||||||
modal.openControllerDebugModal();
|
|
||||||
if (handlers.copy) {
|
|
||||||
handlers.copy();
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
assert.deepEqual(copied, [ctx.dom.controllerDebugButtonIndices.textContent]);
|
|
||||||
assert.match(
|
|
||||||
ctx.dom.controllerDebugStatus.textContent,
|
|
||||||
/Copied controller buttonIndices config/,
|
|
||||||
);
|
|
||||||
assert.match(
|
|
||||||
ctx.dom.controllerDebugToast.textContent,
|
|
||||||
/Copied controller buttonIndices config/,
|
|
||||||
);
|
|
||||||
assert.equal(ctx.dom.controllerDebugToast.classList.contains('hidden'), false);
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
||||||
Object.defineProperty(globalThis, 'navigator', {
|
|
||||||
configurable: true,
|
|
||||||
value: previousNavigator,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
|
||||||
|
|
||||||
function formatAxes(values: number[]): string {
|
|
||||||
if (values.length === 0) return 'No controller axes available.';
|
|
||||||
return values.map((value, index) => `axis[${index}] = ${value.toFixed(3)}`).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatButtons(
|
|
||||||
values: Array<{ value: number; pressed: boolean; touched?: boolean }>,
|
|
||||||
): string {
|
|
||||||
if (values.length === 0) return 'No controller buttons available.';
|
|
||||||
return values
|
|
||||||
.map(
|
|
||||||
(button, index) =>
|
|
||||||
`button[${index}] value=${button.value.toFixed(3)} pressed=${button.pressed} touched=${button.touched ?? false}`,
|
|
||||||
)
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatButtonIndices(
|
|
||||||
value: {
|
|
||||||
select: number;
|
|
||||||
buttonSouth: number;
|
|
||||||
buttonEast: number;
|
|
||||||
buttonNorth: number;
|
|
||||||
buttonWest: number;
|
|
||||||
leftShoulder: number;
|
|
||||||
rightShoulder: number;
|
|
||||||
leftStickPress: number;
|
|
||||||
rightStickPress: number;
|
|
||||||
leftTrigger: number;
|
|
||||||
rightTrigger: number;
|
|
||||||
} | null,
|
|
||||||
): string {
|
|
||||||
if (!value) {
|
|
||||||
return 'No controller config loaded.';
|
|
||||||
}
|
|
||||||
return `"buttonIndices": ${JSON.stringify(value, null, 2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeTextToClipboard(text: string): Promise<void> {
|
|
||||||
if (!navigator.clipboard?.writeText) {
|
|
||||||
throw new Error('Clipboard API unavailable.');
|
|
||||||
}
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createControllerDebugModal(
|
|
||||||
ctx: RendererContext,
|
|
||||||
options: {
|
|
||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
|
||||||
syncSettingsModalSubtitleSuppression: () => void;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function setStatus(message: string, isError: boolean = false): void {
|
|
||||||
ctx.dom.controllerDebugStatus.textContent = message;
|
|
||||||
if (isError) {
|
|
||||||
ctx.dom.controllerDebugStatus.classList.add('error');
|
|
||||||
} else {
|
|
||||||
ctx.dom.controllerDebugStatus.classList.remove('error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearToastTimer(): void {
|
|
||||||
if (toastTimer === null) return;
|
|
||||||
clearTimeout(toastTimer);
|
|
||||||
toastTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideToast(): void {
|
|
||||||
clearToastTimer();
|
|
||||||
ctx.dom.controllerDebugToast.classList.add('hidden');
|
|
||||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(message: string, isError: boolean = false): void {
|
|
||||||
clearToastTimer();
|
|
||||||
ctx.dom.controllerDebugToast.textContent = message;
|
|
||||||
ctx.dom.controllerDebugToast.classList.remove('hidden');
|
|
||||||
if (isError) {
|
|
||||||
ctx.dom.controllerDebugToast.classList.add('error');
|
|
||||||
} else {
|
|
||||||
ctx.dom.controllerDebugToast.classList.remove('error');
|
|
||||||
}
|
|
||||||
toastTimer = setTimeout(() => {
|
|
||||||
hideToast();
|
|
||||||
}, 1800);
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(): void {
|
|
||||||
const activeDevice = ctx.state.connectedGamepads.find(
|
|
||||||
(device) => device.id === ctx.state.activeGamepadId,
|
|
||||||
);
|
|
||||||
setStatus(
|
|
||||||
activeDevice?.id ??
|
|
||||||
(ctx.state.connectedGamepads.length > 0
|
|
||||||
? 'Controller connected.'
|
|
||||||
: 'No controller detected.'),
|
|
||||||
);
|
|
||||||
ctx.dom.controllerDebugSummary.textContent =
|
|
||||||
ctx.state.connectedGamepads.length > 0
|
|
||||||
? ctx.state.connectedGamepads
|
|
||||||
.map((device) => {
|
|
||||||
const tags = [
|
|
||||||
`#${device.index}`,
|
|
||||||
device.mapping,
|
|
||||||
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
return `${device.id || `Gamepad ${device.index}`} (${tags.join(', ')})`;
|
|
||||||
})
|
|
||||||
.join('\n')
|
|
||||||
: 'Connect a controller and press any button to populate raw input values.';
|
|
||||||
ctx.dom.controllerDebugAxes.textContent = formatAxes(ctx.state.controllerRawAxes);
|
|
||||||
ctx.dom.controllerDebugButtons.textContent = formatButtons(ctx.state.controllerRawButtons);
|
|
||||||
ctx.dom.controllerDebugButtonIndices.textContent = formatButtonIndices(
|
|
||||||
ctx.state.controllerConfig?.buttonIndices ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyButtonIndicesToClipboard(): Promise<void> {
|
|
||||||
const text = ctx.dom.controllerDebugButtonIndices.textContent.trim();
|
|
||||||
if (text.length === 0 || text === 'No controller config loaded.') {
|
|
||||||
setStatus('No buttonIndices config available to copy.', true);
|
|
||||||
showToast('No buttonIndices config available to copy.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await writeTextToClipboard(text);
|
|
||||||
setStatus('Copied controller buttonIndices config.');
|
|
||||||
showToast('Copied controller buttonIndices config.');
|
|
||||||
} catch {
|
|
||||||
setStatus('Failed to copy controller buttonIndices config.', true);
|
|
||||||
showToast('Failed to copy controller buttonIndices config.', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openControllerDebugModal(): void {
|
|
||||||
ctx.state.controllerDebugModalOpen = true;
|
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
|
||||||
ctx.dom.controllerDebugModal.classList.remove('hidden');
|
|
||||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'false');
|
|
||||||
hideToast();
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeControllerDebugModal(): void {
|
|
||||||
if (!ctx.state.controllerDebugModalOpen) return;
|
|
||||||
ctx.state.controllerDebugModalOpen = false;
|
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
|
||||||
ctx.dom.controllerDebugModal.classList.add('hidden');
|
|
||||||
ctx.dom.controllerDebugModal.setAttribute('aria-hidden', 'true');
|
|
||||||
hideToast();
|
|
||||||
window.electronAPI.notifyOverlayModalClosed('controller-debug');
|
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleControllerDebugKeydown(event: KeyboardEvent): boolean {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
closeControllerDebugModal();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSnapshot(): void {
|
|
||||||
if (!ctx.state.controllerDebugModalOpen) return;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireDomEvents(): void {
|
|
||||||
ctx.dom.controllerDebugClose.addEventListener('click', () => {
|
|
||||||
closeControllerDebugModal();
|
|
||||||
});
|
|
||||||
ctx.dom.controllerDebugCopy.addEventListener('click', () => {
|
|
||||||
void copyButtonIndicesToClipboard();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
openControllerDebugModal,
|
|
||||||
closeControllerDebugModal,
|
|
||||||
handleControllerDebugKeydown,
|
|
||||||
updateSnapshot,
|
|
||||||
wireDomEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { createRendererState } from '../state.js';
|
|
||||||
import { createControllerSelectModal } from './controller-select.js';
|
|
||||||
|
|
||||||
function createClassList(initialTokens: string[] = []) {
|
|
||||||
const tokens = new Set(initialTokens);
|
|
||||||
return {
|
|
||||||
add: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.add(entry);
|
|
||||||
},
|
|
||||||
remove: (...entries: string[]) => {
|
|
||||||
for (const entry of entries) tokens.delete(entry);
|
|
||||||
},
|
|
||||||
toggle: (entry: string, force?: boolean) => {
|
|
||||||
if (force === undefined) {
|
|
||||||
if (tokens.has(entry)) tokens.delete(entry);
|
|
||||||
else tokens.add(entry);
|
|
||||||
return tokens.has(entry);
|
|
||||||
}
|
|
||||||
if (force) tokens.add(entry);
|
|
||||||
else tokens.delete(entry);
|
|
||||||
return force;
|
|
||||||
},
|
|
||||||
contains: (entry: string) => tokens.has(entry),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFakeElement() {
|
|
||||||
const attributes = new Map<string, string>();
|
|
||||||
const el = {
|
|
||||||
className: '',
|
|
||||||
textContent: '',
|
|
||||||
_innerHTML: '',
|
|
||||||
value: '',
|
|
||||||
disabled: false,
|
|
||||||
selected: false,
|
|
||||||
type: '',
|
|
||||||
children: [] as any[],
|
|
||||||
listeners: new Map<string, Array<(e?: any) => void>>(),
|
|
||||||
classList: createClassList(),
|
|
||||||
appendChild(child: any) {
|
|
||||||
this.children.push(child);
|
|
||||||
return child;
|
|
||||||
},
|
|
||||||
addEventListener(type: string, listener: (e?: any) => void) {
|
|
||||||
const existing = this.listeners.get(type) ?? [];
|
|
||||||
existing.push(listener);
|
|
||||||
this.listeners.set(type, existing);
|
|
||||||
},
|
|
||||||
dispatch(type: string) {
|
|
||||||
const fakeEvent = { stopPropagation: () => {}, preventDefault: () => {} };
|
|
||||||
for (const listener of this.listeners.get(type) ?? []) {
|
|
||||||
listener(fakeEvent);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setAttribute(name: string, value: string) {
|
|
||||||
attributes.set(name, value);
|
|
||||||
},
|
|
||||||
getAttribute(name: string) {
|
|
||||||
return attributes.get(name) ?? null;
|
|
||||||
},
|
|
||||||
querySelector(selector: string) {
|
|
||||||
const match = selector.match(/^\[data-testid="(.+)"\]$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const testId = match[1];
|
|
||||||
for (const child of el.children) {
|
|
||||||
if (typeof child.getAttribute === 'function' && child.getAttribute('data-testid') === testId) {
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
if (typeof child.querySelector === 'function') {
|
|
||||||
const nested = child.querySelector(selector);
|
|
||||||
if (nested) return nested;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
focus: () => {},
|
|
||||||
};
|
|
||||||
Object.defineProperty(el, 'innerHTML', {
|
|
||||||
get() {
|
|
||||||
return el._innerHTML;
|
|
||||||
},
|
|
||||||
set(v: string) {
|
|
||||||
el._innerHTML = v;
|
|
||||||
if (v === '') el.children.length = 0;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
function installFakeDom() {
|
|
||||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
|
||||||
const previousWindow = globals.window;
|
|
||||||
const previousDocument = globals.document;
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'document', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
createElement: () => createFakeElement(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
restore: () => {
|
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
|
||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContext() {
|
|
||||||
const state = createRendererState();
|
|
||||||
state.controllerConfig = {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: 'pad-1',
|
|
||||||
preferredGamepadLabel: 'pad-1',
|
|
||||||
smoothScroll: true,
|
|
||||||
scrollPixelsPerSecond: 900,
|
|
||||||
horizontalJumpPixels: 160,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
triggerInputMode: 'auto',
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
repeatDelayMs: 320,
|
|
||||||
repeatIntervalMs: 120,
|
|
||||||
buttonIndices: {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
buttonEast: 1,
|
|
||||||
buttonWest: 2,
|
|
||||||
buttonNorth: 3,
|
|
||||||
leftShoulder: 4,
|
|
||||||
rightShoulder: 5,
|
|
||||||
leftStickPress: 9,
|
|
||||||
rightStickPress: 10,
|
|
||||||
leftTrigger: 6,
|
|
||||||
rightTrigger: 7,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
state.connectedGamepads = [
|
|
||||||
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
|
|
||||||
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
|
|
||||||
];
|
|
||||||
state.activeGamepadId = 'pad-1';
|
|
||||||
|
|
||||||
const dom = {
|
|
||||||
overlay: { classList: createClassList(), focus: () => {} },
|
|
||||||
controllerSelectModal: { classList: createClassList(['hidden']), setAttribute: () => {} },
|
|
||||||
controllerSelectClose: createFakeElement(),
|
|
||||||
controllerSelectPicker: createFakeElement(),
|
|
||||||
controllerSelectSummary: createFakeElement(),
|
|
||||||
controllerConfigList: createFakeElement(),
|
|
||||||
controllerSelectStatus: { textContent: '', classList: createClassList() },
|
|
||||||
controllerSelectSave: createFakeElement(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return { state, dom };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('controller select modal saves preferred controller from dropdown selection', async () => {
|
|
||||||
const domHandle = installFakeDom();
|
|
||||||
const saved: unknown[] = [];
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
focus: () => {},
|
|
||||||
electronAPI: {
|
|
||||||
saveControllerConfig: async (update: unknown) => {
|
|
||||||
saved.push(update);
|
|
||||||
},
|
|
||||||
notifyOverlayModalClosed: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { state, dom } = buildContext();
|
|
||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.wireDomEvents();
|
|
||||||
modal.openControllerSelectModal();
|
|
||||||
state.controllerDeviceSelectedIndex = 1;
|
|
||||||
|
|
||||||
await modal.handleControllerSelectKeydown({
|
|
||||||
key: 'Enter',
|
|
||||||
preventDefault: () => {},
|
|
||||||
} as KeyboardEvent);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
assert.deepEqual(saved, [
|
|
||||||
{
|
|
||||||
preferredGamepadId: 'pad-2',
|
|
||||||
preferredGamepadLabel: 'pad-2',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
domHandle.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller select modal learn mode captures fresh button input and persists binding', async () => {
|
|
||||||
const domHandle = installFakeDom();
|
|
||||||
const saved: unknown[] = [];
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
focus: () => {},
|
|
||||||
electronAPI: {
|
|
||||||
saveControllerConfig: async (update: unknown) => {
|
|
||||||
saved.push(update);
|
|
||||||
},
|
|
||||||
notifyOverlayModalClosed: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { state, dom } = buildContext();
|
|
||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.wireDomEvents();
|
|
||||||
modal.openControllerSelectModal();
|
|
||||||
|
|
||||||
// In the new compact list layout, children are:
|
|
||||||
// [0] group header, [1] first binding row, [2] second binding row, ...
|
|
||||||
// Click the row to expand the inline edit panel
|
|
||||||
const firstRow = dom.controllerConfigList.children[1];
|
|
||||||
firstRow.dispatch('click');
|
|
||||||
|
|
||||||
// After expanding, the edit panel is inserted after the row:
|
|
||||||
// [0] group header, [1] row, [2] edit panel, [3] next row, ...
|
|
||||||
const editPanel = dom.controllerConfigList.children[2];
|
|
||||||
// editPanel > inner > actions > learnButton
|
|
||||||
const inner = editPanel.children[0];
|
|
||||||
const actions = inner.children[1];
|
|
||||||
const learnButton = actions.children[0];
|
|
||||||
learnButton.dispatch('click');
|
|
||||||
|
|
||||||
state.controllerRawButtons = Array.from({ length: 12 }, () => ({
|
|
||||||
value: 0,
|
|
||||||
pressed: false,
|
|
||||||
touched: false,
|
|
||||||
}));
|
|
||||||
state.controllerRawButtons[11] = { value: 1, pressed: true, touched: true };
|
|
||||||
modal.updateDevices();
|
|
||||||
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
assert.deepEqual(saved.at(-1), {
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.deepEqual(state.controllerConfig?.bindings.toggleLookup, {
|
|
||||||
kind: 'button',
|
|
||||||
buttonIndex: 11,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
domHandle.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('controller select modal uses unique picker values for duplicate controller ids', async () => {
|
|
||||||
const domHandle = installFakeDom();
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
focus: () => {},
|
|
||||||
electronAPI: {
|
|
||||||
saveControllerConfig: async () => {},
|
|
||||||
notifyOverlayModalClosed: () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { state, dom } = buildContext();
|
|
||||||
state.connectedGamepads = [
|
|
||||||
{ id: 'same-pad', index: 0, mapping: 'standard', connected: true },
|
|
||||||
{ id: 'same-pad', index: 1, mapping: 'standard', connected: true },
|
|
||||||
];
|
|
||||||
state.activeGamepadId = 'same-pad';
|
|
||||||
|
|
||||||
const modal = createControllerSelectModal({ state, dom } as never, {
|
|
||||||
modalStateReader: { isAnyModalOpen: () => false },
|
|
||||||
syncSettingsModalSubtitleSuppression: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.wireDomEvents();
|
|
||||||
modal.openControllerSelectModal();
|
|
||||||
|
|
||||||
const [firstOption, secondOption] = dom.controllerSelectPicker.children;
|
|
||||||
assert.notEqual(firstOption.value, secondOption.value);
|
|
||||||
|
|
||||||
dom.controllerSelectPicker.value = secondOption.value;
|
|
||||||
dom.controllerSelectPicker.dispatch('change');
|
|
||||||
|
|
||||||
assert.equal(state.controllerDeviceSelectedIndex, 1);
|
|
||||||
} finally {
|
|
||||||
domHandle.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
|
||||||
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
|
||||||
import {
|
|
||||||
createControllerConfigForm,
|
|
||||||
getControllerBindingDefinition,
|
|
||||||
getDefaultControllerBinding,
|
|
||||||
getDefaultDpadFallback,
|
|
||||||
} from './controller-config-form.js';
|
|
||||||
|
|
||||||
function clampSelectedIndex(ctx: RendererContext): void {
|
|
||||||
if (ctx.state.connectedGamepads.length === 0) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
|
||||||
Math.max(ctx.state.controllerDeviceSelectedIndex, 0),
|
|
||||||
ctx.state.connectedGamepads.length - 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createControllerSelectModal(
|
|
||||||
ctx: RendererContext,
|
|
||||||
options: {
|
|
||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
|
||||||
syncSettingsModalSubtitleSuppression: () => void;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
let selectedControllerKey: string | null = null;
|
|
||||||
let lastRenderedDevicesKey = '';
|
|
||||||
let lastRenderedActiveGamepadId: string | null = null;
|
|
||||||
let lastRenderedPreferredId = '';
|
|
||||||
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
|
|
||||||
type ControllerBindingValue =
|
|
||||||
NonNullable<NonNullable<typeof ctx.state.controllerConfig>['bindings']>[ControllerBindingKey];
|
|
||||||
let learningActionId: ControllerBindingKey | null = null;
|
|
||||||
let dpadLearningActionId: ControllerBindingKey | null = null;
|
|
||||||
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
|
||||||
|
|
||||||
const controllerConfigForm = createControllerConfigForm({
|
|
||||||
container: ctx.dom.controllerConfigList,
|
|
||||||
getBindings: () =>
|
|
||||||
ctx.state.controllerConfig?.bindings ?? {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
getLearningActionId: () => learningActionId,
|
|
||||||
getDpadLearningActionId: () => dpadLearningActionId,
|
|
||||||
onLearn: (actionId, bindingType) => {
|
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
|
||||||
if (!definition) return;
|
|
||||||
dpadLearningActionId = null;
|
|
||||||
const config = ctx.state.controllerConfig;
|
|
||||||
bindingCapture = createControllerBindingCapture({
|
|
||||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
|
||||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
|
||||||
});
|
|
||||||
const currentBinding = config?.bindings[actionId];
|
|
||||||
const currentDpadFallback =
|
|
||||||
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
|
||||||
? currentBinding.dpadFallback
|
|
||||||
: 'none';
|
|
||||||
bindingCapture.arm(
|
|
||||||
bindingType === 'axis'
|
|
||||||
? {
|
|
||||||
actionId,
|
|
||||||
bindingType: 'axis',
|
|
||||||
dpadFallback: currentDpadFallback,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
actionId,
|
|
||||||
bindingType: 'discrete',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
axes: ctx.state.controllerRawAxes,
|
|
||||||
buttons: ctx.state.controllerRawButtons,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
learningActionId = actionId;
|
|
||||||
controllerConfigForm.render();
|
|
||||||
setStatus(`Waiting for input for ${definition.label}.`);
|
|
||||||
},
|
|
||||||
onClear: (actionId) => {
|
|
||||||
void saveBinding(actionId, { kind: 'none' });
|
|
||||||
},
|
|
||||||
onReset: (actionId) => {
|
|
||||||
void saveBinding(actionId, getDefaultControllerBinding(actionId));
|
|
||||||
},
|
|
||||||
onDpadLearn: (actionId) => {
|
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
|
||||||
if (!definition) return;
|
|
||||||
learningActionId = null;
|
|
||||||
const config = ctx.state.controllerConfig;
|
|
||||||
bindingCapture = createControllerBindingCapture({
|
|
||||||
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
|
||||||
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
|
||||||
});
|
|
||||||
bindingCapture.arm(
|
|
||||||
{ actionId, bindingType: 'dpad' },
|
|
||||||
{
|
|
||||||
axes: ctx.state.controllerRawAxes,
|
|
||||||
buttons: ctx.state.controllerRawButtons,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
dpadLearningActionId = actionId;
|
|
||||||
controllerConfigForm.render();
|
|
||||||
setStatus(`Press a D-pad direction for ${definition.label}.`);
|
|
||||||
},
|
|
||||||
onDpadClear: (actionId) => {
|
|
||||||
void saveDpadFallback(actionId, 'none');
|
|
||||||
},
|
|
||||||
onDpadReset: (actionId) => {
|
|
||||||
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function getDevicesKey(): string {
|
|
||||||
return ctx.state.connectedGamepads
|
|
||||||
.map((device) => `${device.id}|${device.index}|${device.mapping}|${device.connected}`)
|
|
||||||
.join('||');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeviceSelectionKey(device: { id: string; index: number }): string {
|
|
||||||
return `${device.id}:${device.index}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSelectedControllerId(): void {
|
|
||||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
|
||||||
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSelectedIndexToCurrentController(): void {
|
|
||||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
|
||||||
const activeIndex = ctx.state.connectedGamepads.findIndex(
|
|
||||||
(device) => device.id === ctx.state.activeGamepadId,
|
|
||||||
);
|
|
||||||
if (activeIndex >= 0) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = activeIndex;
|
|
||||||
syncSelectedControllerId();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const preferredIndex = ctx.state.connectedGamepads.findIndex(
|
|
||||||
(device) => device.id === preferredId,
|
|
||||||
);
|
|
||||||
if (preferredIndex >= 0) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
|
|
||||||
syncSelectedControllerId();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clampSelectedIndex(ctx);
|
|
||||||
syncSelectedControllerId();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStatus(message: string, isError = false): void {
|
|
||||||
ctx.dom.controllerSelectStatus.textContent = message;
|
|
||||||
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPicker(): void {
|
|
||||||
ctx.dom.controllerSelectPicker.innerHTML = '';
|
|
||||||
clampSelectedIndex(ctx);
|
|
||||||
|
|
||||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
|
||||||
ctx.state.connectedGamepads.forEach((device, index) => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = getDeviceSelectionKey(device);
|
|
||||||
option.selected = index === ctx.state.controllerDeviceSelectedIndex;
|
|
||||||
option.textContent = `${device.id || `Gamepad ${device.index}`} (${[
|
|
||||||
`#${device.index}`,
|
|
||||||
device.mapping || 'unknown',
|
|
||||||
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
|
||||||
device.id === preferredId ? 'saved' : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(', ')})`;
|
|
||||||
ctx.dom.controllerSelectPicker.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
|
|
||||||
ctx.dom.controllerSelectSummary.textContent =
|
|
||||||
ctx.state.connectedGamepads.length === 0
|
|
||||||
? 'No controller detected.'
|
|
||||||
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
|
|
||||||
|
|
||||||
lastRenderedDevicesKey = getDevicesKey();
|
|
||||||
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
|
|
||||||
lastRenderedPreferredId = preferredId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveControllerConfig(update: Parameters<typeof window.electronAPI.saveControllerConfig>[0]) {
|
|
||||||
await window.electronAPI.saveControllerConfig(update);
|
|
||||||
if (!ctx.state.controllerConfig) return;
|
|
||||||
if (update.preferredGamepadId !== undefined) {
|
|
||||||
ctx.state.controllerConfig.preferredGamepadId = update.preferredGamepadId;
|
|
||||||
}
|
|
||||||
if (update.preferredGamepadLabel !== undefined) {
|
|
||||||
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
|
|
||||||
}
|
|
||||||
if (update.bindings) {
|
|
||||||
ctx.state.controllerConfig.bindings = {
|
|
||||||
...ctx.state.controllerConfig.bindings,
|
|
||||||
...update.bindings,
|
|
||||||
} as typeof ctx.state.controllerConfig.bindings;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveBinding(
|
|
||||||
actionId: ControllerBindingKey,
|
|
||||||
binding: ControllerBindingValue,
|
|
||||||
): Promise<void> {
|
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
|
||||||
try {
|
|
||||||
await saveControllerConfig({
|
|
||||||
bindings: {
|
|
||||||
[actionId]: binding,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
learningActionId = null;
|
|
||||||
dpadLearningActionId = null;
|
|
||||||
bindingCapture = null;
|
|
||||||
controllerConfigForm.render();
|
|
||||||
setStatus(`${definition?.label ?? actionId} updated.`);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
setStatus(`Failed to save binding: ${message}`, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveDpadFallback(
|
|
||||||
actionId: ControllerBindingKey,
|
|
||||||
dpadFallback: import('../../types').ControllerDpadFallback,
|
|
||||||
): Promise<void> {
|
|
||||||
const definition = getControllerBindingDefinition(actionId);
|
|
||||||
const currentBinding = ctx.state.controllerConfig?.bindings[actionId];
|
|
||||||
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
|
||||||
const updated = { ...currentBinding, dpadFallback };
|
|
||||||
try {
|
|
||||||
await saveControllerConfig({ bindings: { [actionId]: updated } });
|
|
||||||
dpadLearningActionId = null;
|
|
||||||
bindingCapture = null;
|
|
||||||
controllerConfigForm.render();
|
|
||||||
setStatus(`${definition?.label ?? actionId} D-pad updated.`);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
setStatus(`Failed to save D-pad binding: ${message}`, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSelectedController(): Promise<void> {
|
|
||||||
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
|
||||||
if (!selected) {
|
|
||||||
setStatus('No controller selected.', true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await saveControllerConfig({
|
|
||||||
preferredGamepadId: selected.id,
|
|
||||||
preferredGamepadLabel: selected.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
setStatus(`Failed to save preferred controller: ${message}`, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncSelectedControllerId();
|
|
||||||
renderPicker();
|
|
||||||
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDevices(): void {
|
|
||||||
if (!ctx.state.controllerSelectModalOpen) return;
|
|
||||||
if (selectedControllerKey) {
|
|
||||||
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
|
||||||
(device) => getDeviceSelectionKey(device) === selectedControllerKey,
|
|
||||||
);
|
|
||||||
if (preservedIndex >= 0) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
|
||||||
} else {
|
|
||||||
syncSelectedIndexToCurrentController();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
syncSelectedIndexToCurrentController();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bindingCapture && (learningActionId || dpadLearningActionId)) {
|
|
||||||
const result = bindingCapture.poll({
|
|
||||||
axes: ctx.state.controllerRawAxes,
|
|
||||||
buttons: ctx.state.controllerRawButtons,
|
|
||||||
});
|
|
||||||
if (result) {
|
|
||||||
if (result.bindingType === 'dpad') {
|
|
||||||
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
|
|
||||||
} else {
|
|
||||||
void saveBinding(result.actionId as ControllerBindingKey, result.binding as ControllerBindingValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
|
||||||
const shouldRender =
|
|
||||||
getDevicesKey() !== lastRenderedDevicesKey ||
|
|
||||||
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
|
|
||||||
preferredId !== lastRenderedPreferredId;
|
|
||||||
if (shouldRender) {
|
|
||||||
renderPicker();
|
|
||||||
controllerConfigForm.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.state.connectedGamepads.length === 0 && !learningActionId && !dpadLearningActionId) {
|
|
||||||
setStatus('No controllers detected.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openControllerSelectModal(): void {
|
|
||||||
ctx.state.controllerSelectModalOpen = true;
|
|
||||||
syncSelectedIndexToCurrentController();
|
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
|
||||||
ctx.dom.controllerSelectModal.classList.remove('hidden');
|
|
||||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
|
|
||||||
window.focus();
|
|
||||||
ctx.dom.overlay.focus({ preventScroll: true });
|
|
||||||
renderPicker();
|
|
||||||
controllerConfigForm.render();
|
|
||||||
if (ctx.state.connectedGamepads.length === 0) {
|
|
||||||
setStatus('No controllers detected.');
|
|
||||||
} else {
|
|
||||||
setStatus('Choose a controller or click Learn to remap an action.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeControllerSelectModal(): void {
|
|
||||||
if (!ctx.state.controllerSelectModalOpen) return;
|
|
||||||
learningActionId = null;
|
|
||||||
dpadLearningActionId = null;
|
|
||||||
bindingCapture = null;
|
|
||||||
ctx.state.controllerSelectModalOpen = false;
|
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
|
||||||
ctx.dom.controllerSelectModal.classList.add('hidden');
|
|
||||||
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
|
|
||||||
window.electronAPI.notifyOverlayModalClosed('controller-select');
|
|
||||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
if (learningActionId || dpadLearningActionId) {
|
|
||||||
learningActionId = null;
|
|
||||||
dpadLearningActionId = null;
|
|
||||||
bindingCapture = null;
|
|
||||||
controllerConfigForm.render();
|
|
||||||
setStatus('Controller learn mode cancelled.');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
closeControllerSelectModal();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
|
|
||||||
event.preventDefault();
|
|
||||||
if (ctx.state.connectedGamepads.length > 0) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
|
||||||
ctx.state.connectedGamepads.length - 1,
|
|
||||||
ctx.state.controllerDeviceSelectedIndex + 1,
|
|
||||||
);
|
|
||||||
syncSelectedControllerId();
|
|
||||||
renderPicker();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
|
|
||||||
event.preventDefault();
|
|
||||||
if (ctx.state.connectedGamepads.length > 0) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = Math.max(
|
|
||||||
0,
|
|
||||||
ctx.state.controllerDeviceSelectedIndex - 1,
|
|
||||||
);
|
|
||||||
syncSelectedControllerId();
|
|
||||||
renderPicker();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId) {
|
|
||||||
event.preventDefault();
|
|
||||||
void saveSelectedController();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireDomEvents(): void {
|
|
||||||
ctx.dom.controllerSelectClose.addEventListener('click', () => {
|
|
||||||
closeControllerSelectModal();
|
|
||||||
});
|
|
||||||
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
|
||||||
void saveSelectedController();
|
|
||||||
});
|
|
||||||
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
|
|
||||||
const selectedKey = ctx.dom.controllerSelectPicker.value;
|
|
||||||
const selectedIndex = ctx.state.connectedGamepads.findIndex(
|
|
||||||
(device) => getDeviceSelectionKey(device) === selectedKey,
|
|
||||||
);
|
|
||||||
if (selectedIndex >= 0) {
|
|
||||||
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
|
||||||
syncSelectedControllerId();
|
|
||||||
renderPicker();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
openControllerSelectModal,
|
|
||||||
closeControllerSelectModal,
|
|
||||||
handleControllerSelectKeydown,
|
|
||||||
updateDevices,
|
|
||||||
wireDomEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -26,11 +26,7 @@ import type {
|
|||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
|
||||||
import { createMouseHandlers } from './handlers/mouse.js';
|
import { createMouseHandlers } from './handlers/mouse.js';
|
||||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
|
||||||
import { createControllerDebugModal } from './modals/controller-debug.js';
|
|
||||||
import { createControllerSelectModal } from './modals/controller-select.js';
|
|
||||||
import { createJimakuModal } from './modals/jimaku.js';
|
import { createJimakuModal } from './modals/jimaku.js';
|
||||||
import { createKikuModal } from './modals/kiku.js';
|
import { createKikuModal } from './modals/kiku.js';
|
||||||
import { createSessionHelpModal } from './modals/session-help.js';
|
import { createSessionHelpModal } from './modals/session-help.js';
|
||||||
@@ -40,7 +36,6 @@ import { createPositioningController } from './positioning.js';
|
|||||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
import { createRendererState } from './state.js';
|
import { createRendererState } from './state.js';
|
||||||
import { createSubtitleRenderer } from './subtitle-render.js';
|
import { createSubtitleRenderer } from './subtitle-render.js';
|
||||||
import { isYomitanPopupVisible } from './yomitan-popup.js';
|
|
||||||
import {
|
import {
|
||||||
createRendererRecoveryController,
|
createRendererRecoveryController,
|
||||||
registerRendererGlobalErrorHandlers,
|
registerRendererGlobalErrorHandlers,
|
||||||
@@ -60,8 +55,6 @@ const ctx = {
|
|||||||
|
|
||||||
function isAnySettingsModalOpen(): boolean {
|
function isAnySettingsModalOpen(): boolean {
|
||||||
return (
|
return (
|
||||||
ctx.state.controllerSelectModalOpen ||
|
|
||||||
ctx.state.controllerDebugModalOpen ||
|
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
ctx.state.subsyncModalOpen ||
|
ctx.state.subsyncModalOpen ||
|
||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
@@ -72,8 +65,6 @@ function isAnySettingsModalOpen(): boolean {
|
|||||||
|
|
||||||
function isAnyModalOpen(): boolean {
|
function isAnyModalOpen(): boolean {
|
||||||
return (
|
return (
|
||||||
ctx.state.controllerSelectModalOpen ||
|
|
||||||
ctx.state.controllerDebugModalOpen ||
|
|
||||||
ctx.state.jimakuModalOpen ||
|
ctx.state.jimakuModalOpen ||
|
||||||
ctx.state.kikuModalOpen ||
|
ctx.state.kikuModalOpen ||
|
||||||
ctx.state.runtimeOptionsModalOpen ||
|
ctx.state.runtimeOptionsModalOpen ||
|
||||||
@@ -101,15 +92,6 @@ const subsyncModal = createSubsyncModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
const controllerSelectModal = createControllerSelectModal(ctx, {
|
|
||||||
modalStateReader: { isAnyModalOpen },
|
|
||||||
syncSettingsModalSubtitleSuppression,
|
|
||||||
});
|
|
||||||
const controllerDebugModal = createControllerDebugModal(ctx, {
|
|
||||||
modalStateReader: { isAnyModalOpen },
|
|
||||||
syncSettingsModalSubtitleSuppression,
|
|
||||||
});
|
|
||||||
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
|
|
||||||
const sessionHelpModal = createSessionHelpModal(ctx, {
|
const sessionHelpModal = createSessionHelpModal(ctx, {
|
||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
@@ -127,22 +109,12 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
|
||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
|
||||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||||
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
openSessionHelpModal: sessionHelpModal.openSessionHelpModal,
|
||||||
appendClipboardVideoToQueue: () => {
|
appendClipboardVideoToQueue: () => {
|
||||||
void window.electronAPI.appendClipboardVideoToQueue();
|
void window.electronAPI.appendClipboardVideoToQueue();
|
||||||
},
|
},
|
||||||
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
getPlaybackPaused: () => window.electronAPI.getPlaybackPaused(),
|
||||||
openControllerSelectModal: () => {
|
|
||||||
controllerSelectModal.openControllerSelectModal();
|
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-select');
|
|
||||||
},
|
|
||||||
openControllerDebugModal: () => {
|
|
||||||
controllerDebugModal.openControllerDebugModal();
|
|
||||||
window.electronAPI.notifyOverlayModalOpened('controller-debug');
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const mouseHandlers = createMouseHandlers(ctx, {
|
const mouseHandlers = createMouseHandlers(ctx, {
|
||||||
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
|
||||||
@@ -160,7 +132,6 @@ const mouseHandlers = createMouseHandlers(ctx, {
|
|||||||
let lastSubtitlePreview = '';
|
let lastSubtitlePreview = '';
|
||||||
let lastSecondarySubtitlePreview = '';
|
let lastSecondarySubtitlePreview = '';
|
||||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let controllerAnimationFrameId: number | null = null;
|
|
||||||
|
|
||||||
function truncateForErrorLog(text: string): string {
|
function truncateForErrorLog(text: string): string {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
@@ -181,8 +152,6 @@ function getSubtitleTextForPreview(data: SubtitleData | string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveModal(): string | null {
|
function getActiveModal(): string | null {
|
||||||
if (ctx.state.controllerSelectModalOpen) return 'controller-select';
|
|
||||||
if (ctx.state.controllerDebugModalOpen) return 'controller-debug';
|
|
||||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||||
@@ -192,12 +161,6 @@ function getActiveModal(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dismissActiveUiAfterError(): void {
|
function dismissActiveUiAfterError(): void {
|
||||||
if (ctx.state.controllerSelectModalOpen) {
|
|
||||||
controllerSelectModal.closeControllerSelectModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.controllerDebugModalOpen) {
|
|
||||||
controllerDebugModal.closeControllerDebugModal();
|
|
||||||
}
|
|
||||||
if (ctx.state.jimakuModalOpen) {
|
if (ctx.state.jimakuModalOpen) {
|
||||||
jimakuModal.closeJimakuModal();
|
jimakuModal.closeJimakuModal();
|
||||||
}
|
}
|
||||||
@@ -217,132 +180,6 @@ function dismissActiveUiAfterError(): void {
|
|||||||
syncSettingsModalSubtitleSuppression();
|
syncSettingsModalSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyControllerSnapshot(snapshot: {
|
|
||||||
connectedGamepads: Array<{ id: string; index: number; mapping: string; connected: boolean }>;
|
|
||||||
activeGamepadId: string | null;
|
|
||||||
rawAxes: number[];
|
|
||||||
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
|
|
||||||
}): void {
|
|
||||||
controllerStatusIndicator.update({
|
|
||||||
connectedGamepads: snapshot.connectedGamepads,
|
|
||||||
activeGamepadId: snapshot.activeGamepadId,
|
|
||||||
});
|
|
||||||
ctx.state.connectedGamepads = snapshot.connectedGamepads;
|
|
||||||
ctx.state.activeGamepadId = snapshot.activeGamepadId;
|
|
||||||
ctx.state.controllerRawAxes = snapshot.rawAxes;
|
|
||||||
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
|
||||||
controllerSelectModal.updateDevices();
|
|
||||||
controllerDebugModal.updateSnapshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitControllerPopupScroll(deltaPixels: number): void {
|
|
||||||
if (deltaPixels === 0) return;
|
|
||||||
keyboardHandlers.scrollPopupByController(0, deltaPixels);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitControllerPopupJump(deltaPixels: number): void {
|
|
||||||
if (deltaPixels === 0) return;
|
|
||||||
keyboardHandlers.scrollPopupByController(0, deltaPixels * 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startControllerPolling(): void {
|
|
||||||
if (controllerAnimationFrameId !== null) {
|
|
||||||
cancelAnimationFrame(controllerAnimationFrameId);
|
|
||||||
controllerAnimationFrameId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const gamepadController = createGamepadController({
|
|
||||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
|
||||||
getConfig: () =>
|
|
||||||
ctx.state.controllerConfig ?? {
|
|
||||||
enabled: true,
|
|
||||||
preferredGamepadId: '',
|
|
||||||
preferredGamepadLabel: '',
|
|
||||||
smoothScroll: true,
|
|
||||||
scrollPixelsPerSecond: 900,
|
|
||||||
horizontalJumpPixels: 160,
|
|
||||||
stickDeadzone: 0.2,
|
|
||||||
triggerInputMode: 'auto',
|
|
||||||
triggerDeadzone: 0.5,
|
|
||||||
repeatDelayMs: 320,
|
|
||||||
repeatIntervalMs: 120,
|
|
||||||
buttonIndices: {
|
|
||||||
select: 6,
|
|
||||||
buttonSouth: 0,
|
|
||||||
buttonEast: 1,
|
|
||||||
buttonWest: 2,
|
|
||||||
buttonNorth: 3,
|
|
||||||
leftShoulder: 4,
|
|
||||||
rightShoulder: 5,
|
|
||||||
leftStickPress: 9,
|
|
||||||
rightStickPress: 10,
|
|
||||||
leftTrigger: 6,
|
|
||||||
rightTrigger: 7,
|
|
||||||
},
|
|
||||||
bindings: {
|
|
||||||
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
||||||
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
||||||
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
||||||
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
||||||
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
||||||
previousAudio: { kind: 'none' },
|
|
||||||
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
||||||
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
||||||
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
||||||
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
||||||
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
||||||
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
||||||
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getKeyboardModeEnabled: () => ctx.state.keyboardDrivenModeEnabled,
|
|
||||||
getLookupWindowOpen: () => ctx.state.yomitanPopupVisible || isYomitanPopupVisible(document),
|
|
||||||
getInteractionBlocked: () => isAnyModalOpen(),
|
|
||||||
toggleKeyboardMode: () => keyboardHandlers.handleKeyboardModeToggleRequested(),
|
|
||||||
toggleLookup: () => keyboardHandlers.handleLookupWindowToggleRequested(),
|
|
||||||
closeLookup: () => {
|
|
||||||
keyboardHandlers.closeLookupWindow();
|
|
||||||
},
|
|
||||||
moveSelection: (delta) => {
|
|
||||||
keyboardHandlers.moveSelectionForController(delta);
|
|
||||||
},
|
|
||||||
mineCard: () => {
|
|
||||||
keyboardHandlers.mineSelectedFromController();
|
|
||||||
},
|
|
||||||
quitMpv: () => {
|
|
||||||
window.electronAPI.sendMpvCommand(['quit']);
|
|
||||||
},
|
|
||||||
previousAudio: () => {
|
|
||||||
keyboardHandlers.cyclePopupAudioSourceForController(-1);
|
|
||||||
},
|
|
||||||
nextAudio: () => {
|
|
||||||
keyboardHandlers.cyclePopupAudioSourceForController(1);
|
|
||||||
},
|
|
||||||
playCurrentAudio: () => {
|
|
||||||
keyboardHandlers.playCurrentAudioForController();
|
|
||||||
},
|
|
||||||
toggleMpvPause: () => {
|
|
||||||
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
|
|
||||||
},
|
|
||||||
scrollPopup: (deltaPixels) => {
|
|
||||||
emitControllerPopupScroll(deltaPixels);
|
|
||||||
},
|
|
||||||
jumpPopup: (deltaPixels) => {
|
|
||||||
emitControllerPopupJump(deltaPixels);
|
|
||||||
},
|
|
||||||
onState: (snapshot) => {
|
|
||||||
applyControllerSnapshot(snapshot);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const poll = (now: number): void => {
|
|
||||||
gamepadController.poll(now);
|
|
||||||
controllerAnimationFrameId = requestAnimationFrame(poll);
|
|
||||||
};
|
|
||||||
|
|
||||||
controllerAnimationFrameId = requestAnimationFrame(poll);
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreOverlayInteractionAfterError(): void {
|
function restoreOverlayInteractionAfterError(): void {
|
||||||
ctx.state.isOverSubtitle = false;
|
ctx.state.isOverSubtitle = false;
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
ctx.dom.overlay.classList.remove('interactive');
|
||||||
@@ -461,7 +298,6 @@ async function init(): Promise<void> {
|
|||||||
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
window.electronAPI.onSubtitle((data: SubtitleData) => {
|
||||||
runGuarded('subtitle:update', () => {
|
runGuarded('subtitle:update', () => {
|
||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
|
||||||
keyboardHandlers.handleSubtitleContentUpdated();
|
|
||||||
subtitleRenderer.renderSubtitle(data);
|
subtitleRenderer.renderSubtitle(data);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
@@ -481,7 +317,6 @@ async function init(): Promise<void> {
|
|||||||
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw();
|
||||||
}
|
}
|
||||||
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
|
||||||
keyboardHandlers.handleSubtitleContentUpdated();
|
|
||||||
subtitleRenderer.renderSubtitle(initialSubtitle);
|
subtitleRenderer.renderSubtitle(initialSubtitle);
|
||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
|
|
||||||
@@ -520,8 +355,6 @@ async function init(): Promise<void> {
|
|||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
subsyncModal.wireDomEvents();
|
subsyncModal.wireDomEvents();
|
||||||
controllerSelectModal.wireDomEvents();
|
|
||||||
controllerDebugModal.wireDomEvents();
|
|
||||||
sessionHelpModal.wireDomEvents();
|
sessionHelpModal.wireDomEvents();
|
||||||
|
|
||||||
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => {
|
||||||
@@ -540,13 +373,6 @@ async function init(): Promise<void> {
|
|||||||
mouseHandlers.setupDragging();
|
mouseHandlers.setupDragging();
|
||||||
|
|
||||||
await keyboardHandlers.setupMpvInputForwarding();
|
await keyboardHandlers.setupMpvInputForwarding();
|
||||||
try {
|
|
||||||
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load controller config.', error);
|
|
||||||
ctx.state.controllerConfig = null;
|
|
||||||
}
|
|
||||||
startControllerPolling();
|
|
||||||
|
|
||||||
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
|
||||||
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
ControllerButtonSnapshot,
|
|
||||||
ControllerDeviceInfo,
|
|
||||||
ResolvedControllerConfig,
|
|
||||||
JimakuEntry,
|
JimakuEntry,
|
||||||
JimakuFileEntry,
|
JimakuFileEntry,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
@@ -56,15 +53,6 @@ export type RendererState = {
|
|||||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
subsyncSourceTracks: SubsyncSourceTrack[];
|
||||||
subsyncSubmitting: boolean;
|
subsyncSubmitting: boolean;
|
||||||
|
|
||||||
controllerSelectModalOpen: boolean;
|
|
||||||
controllerDebugModalOpen: boolean;
|
|
||||||
controllerDeviceSelectedIndex: number;
|
|
||||||
controllerConfig: ResolvedControllerConfig | null;
|
|
||||||
connectedGamepads: ControllerDeviceInfo[];
|
|
||||||
activeGamepadId: string | null;
|
|
||||||
controllerRawAxes: number[];
|
|
||||||
controllerRawButtons: ControllerButtonSnapshot[];
|
|
||||||
|
|
||||||
sessionHelpModalOpen: boolean;
|
sessionHelpModalOpen: boolean;
|
||||||
sessionHelpSelectedIndex: number;
|
sessionHelpSelectedIndex: number;
|
||||||
|
|
||||||
@@ -94,7 +82,6 @@ export type RendererState = {
|
|||||||
chordPending: boolean;
|
chordPending: boolean;
|
||||||
chordTimeout: ReturnType<typeof setTimeout> | null;
|
chordTimeout: ReturnType<typeof setTimeout> | null;
|
||||||
keyboardDrivenModeEnabled: boolean;
|
keyboardDrivenModeEnabled: boolean;
|
||||||
keyboardSelectionVisible: boolean;
|
|
||||||
keyboardSelectedWordIndex: number | null;
|
keyboardSelectedWordIndex: number | null;
|
||||||
yomitanPopupVisible: boolean;
|
yomitanPopupVisible: boolean;
|
||||||
};
|
};
|
||||||
@@ -135,15 +122,6 @@ export function createRendererState(): RendererState {
|
|||||||
subsyncSourceTracks: [],
|
subsyncSourceTracks: [],
|
||||||
subsyncSubmitting: false,
|
subsyncSubmitting: false,
|
||||||
|
|
||||||
controllerSelectModalOpen: false,
|
|
||||||
controllerDebugModalOpen: false,
|
|
||||||
controllerDeviceSelectedIndex: 0,
|
|
||||||
controllerConfig: null,
|
|
||||||
connectedGamepads: [],
|
|
||||||
activeGamepadId: null,
|
|
||||||
controllerRawAxes: [],
|
|
||||||
controllerRawButtons: [],
|
|
||||||
|
|
||||||
sessionHelpModalOpen: false,
|
sessionHelpModalOpen: false,
|
||||||
sessionHelpSelectedIndex: 0,
|
sessionHelpSelectedIndex: 0,
|
||||||
|
|
||||||
@@ -173,7 +151,6 @@ export function createRendererState(): RendererState {
|
|||||||
chordPending: false,
|
chordPending: false,
|
||||||
chordTimeout: null,
|
chordTimeout: null,
|
||||||
keyboardDrivenModeEnabled: false,
|
keyboardDrivenModeEnabled: false,
|
||||||
keyboardSelectionVisible: false,
|
|
||||||
keyboardSelectedWordIndex: null,
|
keyboardSelectedWordIndex: null,
|
||||||
yomitanPopupVisible: false,
|
yomitanPopupVisible: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,33 +55,6 @@ body {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controller-status-toast {
|
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 16px;
|
|
||||||
max-width: min(360px, calc(100vw - 32px));
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(138, 213, 202, 0.45);
|
|
||||||
background: linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
|
|
||||||
color: rgba(228, 255, 251, 0.98);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-6px);
|
|
||||||
transition:
|
|
||||||
opacity 160ms ease,
|
|
||||||
transform 160ms ease;
|
|
||||||
z-index: 1300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-status-toast:not(.hidden) {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay-error-toast {
|
.overlay-error-toast {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
@@ -348,12 +321,6 @@ body.settings-modal-open #subtitleContainer {
|
|||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.settings-modal-open iframe.yomitan-popup,
|
|
||||||
body.settings-modal-open iframe[id^='yomitan-popup'] {
|
|
||||||
display: none !important;
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#subtitleRoot .c {
|
#subtitleRoot .c {
|
||||||
display: inline;
|
display: inline;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1046,10 +1013,6 @@ iframe[id^='yomitan-popup'] {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-options-list-entry {
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime-options-item {
|
.runtime-options-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1059,15 +1022,7 @@ iframe[id^='yomitan-popup'] {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-options-item-button {
|
.runtime-options-item:last-child {
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
text-align: left;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime-options-list-entry:last-child .runtime-options-item {
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,11 +1030,6 @@ iframe[id^='yomitan-popup'] {
|
|||||||
background: rgba(100, 180, 255, 0.15);
|
background: rgba(100, 180, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-options-item-button:focus-visible {
|
|
||||||
outline: 2px solid rgba(100, 180, 255, 0.85);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime-options-label {
|
.runtime-options-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -1105,275 +1055,12 @@ iframe[id^='yomitan-popup'] {
|
|||||||
color: #ff8f8f;
|
color: #ff8f8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controller-select-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.88);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-select-field select {
|
|
||||||
min-height: 38px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(10, 14, 20, 0.9);
|
|
||||||
color: rgba(255, 255, 255, 0.94);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-select-summary {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-group {
|
|
||||||
margin-top: 14px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(120, 190, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-group:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-row:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-row.expanded {
|
|
||||||
background: rgba(100, 180, 255, 0.06);
|
|
||||||
border-color: rgba(100, 180, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-label {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
background: rgba(100, 180, 255, 0.12);
|
|
||||||
color: rgba(100, 180, 255, 0.95);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-badge.disabled {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
color: rgba(255, 255, 255, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-edit-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
color: rgba(255, 255, 255, 0.3);
|
|
||||||
transition: color 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-row:hover .controller-config-edit-icon {
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-edit-panel {
|
|
||||||
overflow: hidden;
|
|
||||||
animation: configEditSlideIn 180ms ease-out;
|
|
||||||
border-bottom: 1px solid rgba(100, 180, 255, 0.12);
|
|
||||||
background: rgba(100, 180, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes configEditSlideIn {
|
|
||||||
from { max-height: 0; opacity: 0; }
|
|
||||||
to { max-height: 120px; opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-edit-inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-edit-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-edit-hint.learning {
|
|
||||||
color: rgba(100, 180, 255, 0.95);
|
|
||||||
animation: configLearnPulse 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes configLearnPulse {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.6; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-config-edit-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-learn {
|
|
||||||
padding: 5px 14px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid rgba(100, 180, 255, 0.4);
|
|
||||||
background: rgba(100, 180, 255, 0.15);
|
|
||||||
color: rgba(100, 180, 255, 0.95);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-learn:hover {
|
|
||||||
background: rgba(100, 180, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-learn.active {
|
|
||||||
border-color: rgba(100, 180, 255, 0.7);
|
|
||||||
background: rgba(100, 180, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 5px 12px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(255, 255, 255, 0.55);
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease, color 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-content {
|
|
||||||
position: relative;
|
|
||||||
width: min(760px, 94%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-toast {
|
|
||||||
position: absolute;
|
|
||||||
top: 18px;
|
|
||||||
right: 56px;
|
|
||||||
z-index: 2;
|
|
||||||
max-width: min(320px, calc(100% - 88px));
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(120, 214, 168, 0.34);
|
|
||||||
background: rgba(20, 38, 30, 0.96);
|
|
||||||
color: rgba(220, 255, 232, 0.98);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-toast.error {
|
|
||||||
border-color: rgba(255, 143, 143, 0.34);
|
|
||||||
background: rgba(52, 22, 24, 0.96);
|
|
||||||
color: rgba(255, 225, 225, 0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-summary {
|
|
||||||
min-height: 18px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255, 255, 255, 0.86);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-span {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controller-debug-pre {
|
|
||||||
min-height: 220px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
background: rgba(0, 0, 0, 0.38);
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-help-content {
|
.session-help-content {
|
||||||
width: min(760px, 92%);
|
width: min(760px, 92%);
|
||||||
max-height: 84%;
|
max-height: 84%;
|
||||||
color: rgba(255, 255, 255, 0.95);
|
color: rgba(255, 255, 255, 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.controller-debug-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-help-shortcut,
|
.session-help-shortcut,
|
||||||
.session-help-warning,
|
.session-help-warning,
|
||||||
.session-help-status {
|
.session-help-status {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export type RendererDom = {
|
|||||||
subtitleRoot: HTMLElement;
|
subtitleRoot: HTMLElement;
|
||||||
subtitleContainer: HTMLElement;
|
subtitleContainer: HTMLElement;
|
||||||
overlay: HTMLElement;
|
overlay: HTMLElement;
|
||||||
controllerStatusToast: HTMLDivElement;
|
|
||||||
overlayErrorToast: HTMLDivElement;
|
overlayErrorToast: HTMLDivElement;
|
||||||
secondarySubContainer: HTMLElement;
|
secondarySubContainer: HTMLElement;
|
||||||
secondarySubRoot: HTMLElement;
|
secondarySubRoot: HTMLElement;
|
||||||
@@ -57,24 +56,6 @@ export type RendererDom = {
|
|||||||
subsyncRunButton: HTMLButtonElement;
|
subsyncRunButton: HTMLButtonElement;
|
||||||
subsyncStatus: HTMLDivElement;
|
subsyncStatus: HTMLDivElement;
|
||||||
|
|
||||||
controllerSelectModal: HTMLDivElement;
|
|
||||||
controllerSelectClose: HTMLButtonElement;
|
|
||||||
controllerSelectPicker: HTMLSelectElement;
|
|
||||||
controllerSelectSummary: HTMLDivElement;
|
|
||||||
controllerSelectStatus: HTMLDivElement;
|
|
||||||
controllerConfigList: HTMLDivElement;
|
|
||||||
controllerSelectSave: HTMLButtonElement;
|
|
||||||
|
|
||||||
controllerDebugModal: HTMLDivElement;
|
|
||||||
controllerDebugClose: HTMLButtonElement;
|
|
||||||
controllerDebugCopy: HTMLButtonElement;
|
|
||||||
controllerDebugToast: HTMLDivElement;
|
|
||||||
controllerDebugStatus: HTMLDivElement;
|
|
||||||
controllerDebugSummary: HTMLDivElement;
|
|
||||||
controllerDebugAxes: HTMLPreElement;
|
|
||||||
controllerDebugButtons: HTMLPreElement;
|
|
||||||
controllerDebugButtonIndices: HTMLPreElement;
|
|
||||||
|
|
||||||
sessionHelpModal: HTMLDivElement;
|
sessionHelpModal: HTMLDivElement;
|
||||||
sessionHelpClose: HTMLButtonElement;
|
sessionHelpClose: HTMLButtonElement;
|
||||||
sessionHelpShortcut: HTMLDivElement;
|
sessionHelpShortcut: HTMLDivElement;
|
||||||
@@ -97,7 +78,6 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
subtitleRoot: getRequiredElement<HTMLElement>('subtitleRoot'),
|
||||||
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
|
||||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
|
||||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||||
@@ -152,26 +132,6 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
||||||
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
||||||
|
|
||||||
controllerSelectModal: getRequiredElement<HTMLDivElement>('controllerSelectModal'),
|
|
||||||
controllerSelectClose: getRequiredElement<HTMLButtonElement>('controllerSelectClose'),
|
|
||||||
controllerSelectPicker: getRequiredElement<HTMLSelectElement>('controllerSelectPicker'),
|
|
||||||
controllerSelectSummary: getRequiredElement<HTMLDivElement>('controllerSelectSummary'),
|
|
||||||
controllerSelectStatus: getRequiredElement<HTMLDivElement>('controllerSelectStatus'),
|
|
||||||
controllerConfigList: getRequiredElement<HTMLDivElement>('controllerConfigList'),
|
|
||||||
controllerSelectSave: getRequiredElement<HTMLButtonElement>('controllerSelectSave'),
|
|
||||||
|
|
||||||
controllerDebugModal: getRequiredElement<HTMLDivElement>('controllerDebugModal'),
|
|
||||||
controllerDebugClose: getRequiredElement<HTMLButtonElement>('controllerDebugClose'),
|
|
||||||
controllerDebugCopy: getRequiredElement<HTMLButtonElement>('controllerDebugCopy'),
|
|
||||||
controllerDebugToast: getRequiredElement<HTMLDivElement>('controllerDebugToast'),
|
|
||||||
controllerDebugStatus: getRequiredElement<HTMLDivElement>('controllerDebugStatus'),
|
|
||||||
controllerDebugSummary: getRequiredElement<HTMLDivElement>('controllerDebugSummary'),
|
|
||||||
controllerDebugAxes: getRequiredElement<HTMLPreElement>('controllerDebugAxes'),
|
|
||||||
controllerDebugButtons: getRequiredElement<HTMLPreElement>('controllerDebugButtons'),
|
|
||||||
controllerDebugButtonIndices: getRequiredElement<HTMLPreElement>(
|
|
||||||
'controllerDebugButtonIndices',
|
|
||||||
),
|
|
||||||
|
|
||||||
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
sessionHelpModal: getRequiredElement<HTMLDivElement>('sessionHelpModal'),
|
||||||
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
sessionHelpClose: getRequiredElement<HTMLButtonElement>('sessionHelpClose'),
|
||||||
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
sessionHelpShortcut: getRequiredElement<HTMLDivElement>('sessionHelpShortcut'),
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
// @ts-expect-error Vendor Yomitan modules are JS-only in this repo.
|
|
||||||
import { Display } from '../../vendor/subminer-yomitan/ext/js/display/display.js';
|
|
||||||
|
|
||||||
test('yomitan display scroll bridge uses popup scroll container instead of window scroll', () => {
|
|
||||||
let scrolledTo: { x: number; y: number } | null = null;
|
|
||||||
const result = Display.prototype._onMessageScrollBy.call(
|
|
||||||
{
|
|
||||||
_windowScroll: {
|
|
||||||
x: 24,
|
|
||||||
y: 80,
|
|
||||||
to(x: number, y: number) {
|
|
||||||
scrolledTo = { x, y };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ deltaX: 12, deltaY: -20 },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result, true);
|
|
||||||
assert.deepEqual(scrolledTo, { x: 36, y: 60 });
|
|
||||||
});
|
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
import type { OverlayContentMeasurement, RuntimeOptionId, RuntimeOptionValue } from '../../types';
|
||||||
|
|
||||||
export const OVERLAY_HOSTED_MODALS = [
|
export const OVERLAY_HOSTED_MODALS = ['runtime-options', 'subsync', 'jimaku', 'kiku'] as const;
|
||||||
'runtime-options',
|
|
||||||
'subsync',
|
|
||||||
'jimaku',
|
|
||||||
'kiku',
|
|
||||||
'controller-select',
|
|
||||||
'controller-debug',
|
|
||||||
] as const;
|
|
||||||
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
export type OverlayHostedModal = (typeof OVERLAY_HOSTED_MODALS)[number];
|
||||||
|
|
||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
@@ -19,8 +12,6 @@ export const IPC_CHANNELS = {
|
|||||||
toggleDevTools: 'toggle-dev-tools',
|
toggleDevTools: 'toggle-dev-tools',
|
||||||
toggleOverlay: 'toggle-overlay',
|
toggleOverlay: 'toggle-overlay',
|
||||||
saveSubtitlePosition: 'save-subtitle-position',
|
saveSubtitlePosition: 'save-subtitle-position',
|
||||||
saveControllerConfig: 'save-controller-config',
|
|
||||||
saveControllerPreference: 'save-controller-preference',
|
|
||||||
setMecabEnabled: 'set-mecab-enabled',
|
setMecabEnabled: 'set-mecab-enabled',
|
||||||
mpvCommand: 'mpv-command',
|
mpvCommand: 'mpv-command',
|
||||||
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
setAnkiConnectEnabled: 'set-anki-connect-enabled',
|
||||||
@@ -41,7 +32,6 @@ export const IPC_CHANNELS = {
|
|||||||
getMecabStatus: 'get-mecab-status',
|
getMecabStatus: 'get-mecab-status',
|
||||||
getKeybindings: 'get-keybindings',
|
getKeybindings: 'get-keybindings',
|
||||||
getConfigShortcuts: 'get-config-shortcuts',
|
getConfigShortcuts: 'get-config-shortcuts',
|
||||||
getControllerConfig: 'get-controller-config',
|
|
||||||
getSecondarySubMode: 'get-secondary-sub-mode',
|
getSecondarySubMode: 'get-secondary-sub-mode',
|
||||||
getCurrentSecondarySub: 'get-current-secondary-sub',
|
getCurrentSecondarySub: 'get-current-secondary-sub',
|
||||||
focusMainWindow: 'focus-main-window',
|
focusMainWindow: 'focus-main-window',
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
ControllerConfigUpdate,
|
|
||||||
ControllerPreferenceUpdate,
|
|
||||||
JimakuDownloadQuery,
|
JimakuDownloadQuery,
|
||||||
JimakuFilesQuery,
|
JimakuFilesQuery,
|
||||||
JimakuSearchQuery,
|
JimakuSearchQuery,
|
||||||
@@ -50,109 +48,6 @@ export function parseSubtitlePosition(value: unknown): SubtitlePosition | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseControllerPreferenceUpdate(value: unknown): ControllerPreferenceUpdate | null {
|
|
||||||
if (!isObject(value)) return null;
|
|
||||||
if (typeof value.preferredGamepadId !== 'string') return null;
|
|
||||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
|
||||||
return {
|
|
||||||
preferredGamepadId: value.preferredGamepadId,
|
|
||||||
preferredGamepadLabel: value.preferredGamepadLabel,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDiscreteBinding(value: unknown) {
|
|
||||||
if (!isObject(value) || typeof value.kind !== 'string') return null;
|
|
||||||
if (value.kind === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
if (value.kind === 'button') {
|
|
||||||
if (!isInteger(value.buttonIndex) || value.buttonIndex < 0) return null;
|
|
||||||
return { kind: 'button', buttonIndex: value.buttonIndex };
|
|
||||||
}
|
|
||||||
if (value.kind === 'axis') {
|
|
||||||
if (!isInteger(value.axisIndex) || value.axisIndex < 0) return null;
|
|
||||||
if (value.direction !== 'negative' && value.direction !== 'positive') return null;
|
|
||||||
return { kind: 'axis', axisIndex: value.axisIndex, direction: value.direction };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAxisBinding(value: unknown) {
|
|
||||||
if (isObject(value) && value.kind === 'none') {
|
|
||||||
return { kind: 'none' };
|
|
||||||
}
|
|
||||||
if (!isObject(value) || value.kind !== 'axis') return null;
|
|
||||||
if (!isInteger(value.axisIndex) || value.axisIndex < 0) return null;
|
|
||||||
if (
|
|
||||||
value.dpadFallback !== undefined &&
|
|
||||||
value.dpadFallback !== 'none' &&
|
|
||||||
value.dpadFallback !== 'horizontal' &&
|
|
||||||
value.dpadFallback !== 'vertical'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: 'axis',
|
|
||||||
axisIndex: value.axisIndex,
|
|
||||||
...(value.dpadFallback === undefined ? {} : { dpadFallback: value.dpadFallback }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpdate | null {
|
|
||||||
if (!isObject(value)) return null;
|
|
||||||
const update: ControllerConfigUpdate = {};
|
|
||||||
|
|
||||||
if (value.enabled !== undefined) {
|
|
||||||
if (typeof value.enabled !== 'boolean') return null;
|
|
||||||
update.enabled = value.enabled;
|
|
||||||
}
|
|
||||||
if (value.preferredGamepadId !== undefined) {
|
|
||||||
if (typeof value.preferredGamepadId !== 'string') return null;
|
|
||||||
update.preferredGamepadId = value.preferredGamepadId;
|
|
||||||
}
|
|
||||||
if (value.preferredGamepadLabel !== undefined) {
|
|
||||||
if (typeof value.preferredGamepadLabel !== 'string') return null;
|
|
||||||
update.preferredGamepadLabel = value.preferredGamepadLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value.bindings !== undefined) {
|
|
||||||
if (!isObject(value.bindings)) return null;
|
|
||||||
const bindings: NonNullable<ControllerConfigUpdate['bindings']> = {};
|
|
||||||
const discreteKeys = [
|
|
||||||
'toggleLookup',
|
|
||||||
'closeLookup',
|
|
||||||
'toggleKeyboardOnlyMode',
|
|
||||||
'mineCard',
|
|
||||||
'quitMpv',
|
|
||||||
'previousAudio',
|
|
||||||
'nextAudio',
|
|
||||||
'playCurrentAudio',
|
|
||||||
'toggleMpvPause',
|
|
||||||
] as const;
|
|
||||||
for (const key of discreteKeys) {
|
|
||||||
if (value.bindings[key] === undefined) continue;
|
|
||||||
const parsed = parseDiscreteBinding(value.bindings[key]);
|
|
||||||
if (!parsed) return null;
|
|
||||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
|
||||||
}
|
|
||||||
const axisKeys = [
|
|
||||||
'leftStickHorizontal',
|
|
||||||
'leftStickVertical',
|
|
||||||
'rightStickHorizontal',
|
|
||||||
'rightStickVertical',
|
|
||||||
] as const;
|
|
||||||
for (const key of axisKeys) {
|
|
||||||
if (value.bindings[key] === undefined) continue;
|
|
||||||
const parsed = parseAxisBinding(value.bindings[key]);
|
|
||||||
if (!parsed) return null;
|
|
||||||
bindings[key] = parsed as NonNullable<ControllerConfigUpdate['bindings']>[typeof key];
|
|
||||||
}
|
|
||||||
update.bindings = bindings;
|
|
||||||
}
|
|
||||||
|
|
||||||
return update;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||||
if (!isObject(value)) return null;
|
if (!isObject(value)) return null;
|
||||||
const { engine, sourceTrackId } = value;
|
const { engine, sourceTrackId } = value;
|
||||||
|
|||||||
@@ -223,10 +223,7 @@ export function ensureDefaultConfigBootstrap(options: {
|
|||||||
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
const mkdirSync = options.mkdirSync ?? fs.mkdirSync;
|
||||||
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
|
const writeFileSync = options.writeFileSync ?? fs.writeFileSync;
|
||||||
|
|
||||||
if (
|
if (existsSync(options.configFilePaths.jsoncPath) || existsSync(options.configFilePaths.jsonPath)) {
|
||||||
existsSync(options.configFilePaths.jsoncPath) ||
|
|
||||||
existsSync(options.configFilePaths.jsonPath)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
194
src/types.ts
194
src/types.ts
@@ -375,159 +375,6 @@ export interface ShortcutsConfig {
|
|||||||
openJimaku?: string | null;
|
openJimaku?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ControllerButtonBinding =
|
|
||||||
| 'none'
|
|
||||||
| 'select'
|
|
||||||
| 'buttonSouth'
|
|
||||||
| 'buttonEast'
|
|
||||||
| 'buttonNorth'
|
|
||||||
| 'buttonWest'
|
|
||||||
| 'leftShoulder'
|
|
||||||
| 'rightShoulder'
|
|
||||||
| 'leftStickPress'
|
|
||||||
| 'rightStickPress'
|
|
||||||
| 'leftTrigger'
|
|
||||||
| 'rightTrigger';
|
|
||||||
|
|
||||||
export type ControllerAxisBinding = 'leftStickX' | 'leftStickY' | 'rightStickX' | 'rightStickY';
|
|
||||||
export type ControllerTriggerInputMode = 'auto' | 'digital' | 'analog';
|
|
||||||
export type ControllerAxisDirection = 'negative' | 'positive';
|
|
||||||
export type ControllerDpadFallback = 'none' | 'horizontal' | 'vertical';
|
|
||||||
|
|
||||||
export interface ControllerNoneBinding {
|
|
||||||
kind: 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerButtonInputBinding {
|
|
||||||
kind: 'button';
|
|
||||||
buttonIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerAxisDirectionInputBinding {
|
|
||||||
kind: 'axis';
|
|
||||||
axisIndex: number;
|
|
||||||
direction: ControllerAxisDirection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerAxisInputBinding {
|
|
||||||
kind: 'axis';
|
|
||||||
axisIndex: number;
|
|
||||||
dpadFallback?: ControllerDpadFallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ControllerDiscreteBindingConfig =
|
|
||||||
| ControllerButtonBinding
|
|
||||||
| ControllerNoneBinding
|
|
||||||
| ControllerButtonInputBinding
|
|
||||||
| ControllerAxisDirectionInputBinding;
|
|
||||||
|
|
||||||
export type ResolvedControllerDiscreteBinding =
|
|
||||||
| ControllerNoneBinding
|
|
||||||
| ControllerButtonInputBinding
|
|
||||||
| ControllerAxisDirectionInputBinding;
|
|
||||||
|
|
||||||
export type ControllerAxisBindingConfig =
|
|
||||||
| ControllerAxisBinding
|
|
||||||
| ControllerNoneBinding
|
|
||||||
| ControllerAxisInputBinding;
|
|
||||||
|
|
||||||
export type ResolvedControllerAxisBinding =
|
|
||||||
| ControllerNoneBinding
|
|
||||||
| {
|
|
||||||
kind: 'axis';
|
|
||||||
axisIndex: number;
|
|
||||||
dpadFallback: ControllerDpadFallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ControllerBindingsConfig {
|
|
||||||
toggleLookup?: ControllerDiscreteBindingConfig;
|
|
||||||
closeLookup?: ControllerDiscreteBindingConfig;
|
|
||||||
toggleKeyboardOnlyMode?: ControllerDiscreteBindingConfig;
|
|
||||||
mineCard?: ControllerDiscreteBindingConfig;
|
|
||||||
quitMpv?: ControllerDiscreteBindingConfig;
|
|
||||||
previousAudio?: ControllerDiscreteBindingConfig;
|
|
||||||
nextAudio?: ControllerDiscreteBindingConfig;
|
|
||||||
playCurrentAudio?: ControllerDiscreteBindingConfig;
|
|
||||||
toggleMpvPause?: ControllerDiscreteBindingConfig;
|
|
||||||
leftStickHorizontal?: ControllerAxisBindingConfig;
|
|
||||||
leftStickVertical?: ControllerAxisBindingConfig;
|
|
||||||
rightStickHorizontal?: ControllerAxisBindingConfig;
|
|
||||||
rightStickVertical?: ControllerAxisBindingConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolvedControllerBindingsConfig {
|
|
||||||
toggleLookup?: ResolvedControllerDiscreteBinding;
|
|
||||||
closeLookup?: ResolvedControllerDiscreteBinding;
|
|
||||||
toggleKeyboardOnlyMode?: ResolvedControllerDiscreteBinding;
|
|
||||||
mineCard?: ResolvedControllerDiscreteBinding;
|
|
||||||
quitMpv?: ResolvedControllerDiscreteBinding;
|
|
||||||
previousAudio?: ResolvedControllerDiscreteBinding;
|
|
||||||
nextAudio?: ResolvedControllerDiscreteBinding;
|
|
||||||
playCurrentAudio?: ResolvedControllerDiscreteBinding;
|
|
||||||
toggleMpvPause?: ResolvedControllerDiscreteBinding;
|
|
||||||
leftStickHorizontal?: ResolvedControllerAxisBinding;
|
|
||||||
leftStickVertical?: ResolvedControllerAxisBinding;
|
|
||||||
rightStickHorizontal?: ResolvedControllerAxisBinding;
|
|
||||||
rightStickVertical?: ResolvedControllerAxisBinding;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerButtonIndicesConfig {
|
|
||||||
select?: number;
|
|
||||||
buttonSouth?: number;
|
|
||||||
buttonEast?: number;
|
|
||||||
buttonNorth?: number;
|
|
||||||
buttonWest?: number;
|
|
||||||
leftShoulder?: number;
|
|
||||||
rightShoulder?: number;
|
|
||||||
leftStickPress?: number;
|
|
||||||
rightStickPress?: number;
|
|
||||||
leftTrigger?: number;
|
|
||||||
rightTrigger?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerConfig {
|
|
||||||
enabled?: boolean;
|
|
||||||
preferredGamepadId?: string;
|
|
||||||
preferredGamepadLabel?: string;
|
|
||||||
smoothScroll?: boolean;
|
|
||||||
scrollPixelsPerSecond?: number;
|
|
||||||
horizontalJumpPixels?: number;
|
|
||||||
stickDeadzone?: number;
|
|
||||||
triggerInputMode?: ControllerTriggerInputMode;
|
|
||||||
triggerDeadzone?: number;
|
|
||||||
repeatDelayMs?: number;
|
|
||||||
repeatIntervalMs?: number;
|
|
||||||
buttonIndices?: ControllerButtonIndicesConfig;
|
|
||||||
bindings?: ControllerBindingsConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerPreferenceUpdate {
|
|
||||||
preferredGamepadId: string;
|
|
||||||
preferredGamepadLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ControllerConfigUpdate = ControllerConfig;
|
|
||||||
|
|
||||||
export interface ControllerDeviceInfo {
|
|
||||||
id: string;
|
|
||||||
index: number;
|
|
||||||
mapping: string;
|
|
||||||
connected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerButtonSnapshot {
|
|
||||||
value: number;
|
|
||||||
pressed: boolean;
|
|
||||||
touched?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ControllerRuntimeSnapshot {
|
|
||||||
connectedGamepads: ControllerDeviceInfo[];
|
|
||||||
activeGamepadId: string | null;
|
|
||||||
rawAxes: number[];
|
|
||||||
rawButtons: ControllerButtonSnapshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
|
||||||
export interface JimakuConfig {
|
export interface JimakuConfig {
|
||||||
@@ -644,7 +491,6 @@ export interface Config {
|
|||||||
websocket?: WebSocketConfig;
|
websocket?: WebSocketConfig;
|
||||||
annotationWebsocket?: AnnotationWebSocketConfig;
|
annotationWebsocket?: AnnotationWebSocketConfig;
|
||||||
texthooker?: TexthookerConfig;
|
texthooker?: TexthookerConfig;
|
||||||
controller?: ControllerConfig;
|
|
||||||
ankiConnect?: AnkiConnectConfig;
|
ankiConnect?: AnkiConnectConfig;
|
||||||
shortcuts?: ShortcutsConfig;
|
shortcuts?: ShortcutsConfig;
|
||||||
secondarySub?: SecondarySubConfig;
|
secondarySub?: SecondarySubConfig;
|
||||||
@@ -673,21 +519,6 @@ export interface ResolvedConfig {
|
|||||||
websocket: Required<WebSocketConfig>;
|
websocket: Required<WebSocketConfig>;
|
||||||
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
annotationWebsocket: Required<AnnotationWebSocketConfig>;
|
||||||
texthooker: Required<TexthookerConfig>;
|
texthooker: Required<TexthookerConfig>;
|
||||||
controller: {
|
|
||||||
enabled: boolean;
|
|
||||||
preferredGamepadId: string;
|
|
||||||
preferredGamepadLabel: string;
|
|
||||||
smoothScroll: boolean;
|
|
||||||
scrollPixelsPerSecond: number;
|
|
||||||
horizontalJumpPixels: number;
|
|
||||||
stickDeadzone: number;
|
|
||||||
triggerInputMode: ControllerTriggerInputMode;
|
|
||||||
triggerDeadzone: number;
|
|
||||||
repeatDelayMs: number;
|
|
||||||
repeatIntervalMs: number;
|
|
||||||
buttonIndices: Required<ControllerButtonIndicesConfig>;
|
|
||||||
bindings: Required<ResolvedControllerBindingsConfig>;
|
|
||||||
};
|
|
||||||
ankiConnect: AnkiConnectConfig & {
|
ankiConnect: AnkiConnectConfig & {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -1015,8 +846,6 @@ export interface ConfigHotReloadPayload {
|
|||||||
secondarySubMode: SecondarySubMode;
|
secondarySubMode: SecondarySubMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ResolvedControllerConfig = ResolvedConfig['controller'];
|
|
||||||
|
|
||||||
export interface SubtitleHoverTokenPayload {
|
export interface SubtitleHoverTokenPayload {
|
||||||
tokenIndex: number | null;
|
tokenIndex: number | null;
|
||||||
}
|
}
|
||||||
@@ -1041,9 +870,6 @@ export interface ElectronAPI {
|
|||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
getKeybindings: () => Promise<Keybinding[]>;
|
getKeybindings: () => Promise<Keybinding[]>;
|
||||||
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
||||||
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
|
||||||
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
|
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
|
|
||||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||||
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||||
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||||
@@ -1077,24 +903,8 @@ export interface ElectronAPI {
|
|||||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||||
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
|
||||||
notifyOverlayModalClosed: (
|
notifyOverlayModalClosed: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||||
modal:
|
notifyOverlayModalOpened: (modal: 'runtime-options' | 'subsync' | 'jimaku' | 'kiku') => void;
|
||||||
| 'runtime-options'
|
|
||||||
| 'subsync'
|
|
||||||
| 'jimaku'
|
|
||||||
| 'kiku'
|
|
||||||
| 'controller-select'
|
|
||||||
| 'controller-debug',
|
|
||||||
) => void;
|
|
||||||
notifyOverlayModalOpened: (
|
|
||||||
modal:
|
|
||||||
| 'runtime-options'
|
|
||||||
| 'subsync'
|
|
||||||
| 'jimaku'
|
|
||||||
| 'kiku'
|
|
||||||
| 'controller-select'
|
|
||||||
| 'controller-debug',
|
|
||||||
) => void;
|
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
2
vendor/subminer-yomitan
vendored
2
vendor/subminer-yomitan
vendored
Submodule vendor/subminer-yomitan updated: 979a162904...66cb7a06f1
Reference in New Issue
Block a user