motioninput-tui 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- motioninput_tui/__init__.py +9 -0
- motioninput_tui/__main__.py +179 -0
- motioninput_tui/config.py +246 -0
- motioninput_tui/constants.py +41 -0
- motioninput_tui/controls/__init__.py +1 -0
- motioninput_tui/controls/buttons.py +131 -0
- motioninput_tui/controls/gamepad.py +256 -0
- motioninput_tui/controls/layouts.py +549 -0
- motioninput_tui/controls/source.py +125 -0
- motioninput_tui/engine/__init__.py +1 -0
- motioninput_tui/engine/buffer.py +114 -0
- motioninput_tui/engine/motions.py +801 -0
- motioninput_tui/engine/notation.py +207 -0
- motioninput_tui/engine/recognizer.py +377 -0
- motioninput_tui/engine/ruleset.py +159 -0
- motioninput_tui/engine/session.py +295 -0
- motioninput_tui/gamepad_probe.py +216 -0
- motioninput_tui/games/__init__.py +1 -0
- motioninput_tui/games/data/hsf2.json +1903 -0
- motioninput_tui/games/data/kof2001.json +9076 -0
- motioninput_tui/games/data/kof98.json +7700 -0
- motioninput_tui/games/data/lb2.json +2972 -0
- motioninput_tui/games/data/sfa3.json +6920 -0
- motioninput_tui/games/data/sfiii3.json +5456 -0
- motioninput_tui/games/data/ssii.json +2366 -0
- motioninput_tui/games/data/ssvsp.json +4306 -0
- motioninput_tui/games/data/usfiv.json +10064 -0
- motioninput_tui/games/loader.py +86 -0
- motioninput_tui/games/models.py +137 -0
- motioninput_tui/games/rulesets.py +406 -0
- motioninput_tui/notation_styles.py +379 -0
- motioninput_tui/py.typed +0 -0
- motioninput_tui/settings.py +73 -0
- motioninput_tui/terminal/__init__.py +6 -0
- motioninput_tui/terminal/detect.py +143 -0
- motioninput_tui/terminal/kitty.py +143 -0
- motioninput_tui/tui/__init__.py +5 -0
- motioninput_tui/tui/app.py +251 -0
- motioninput_tui/tui/keyboard_driver.py +105 -0
- motioninput_tui/tui/screens/__init__.py +6 -0
- motioninput_tui/tui/screens/gamepad_bind.py +155 -0
- motioninput_tui/tui/screens/input_display.py +199 -0
- motioninput_tui/tui/screens/input_picker.py +243 -0
- motioninput_tui/tui/screens/keyboard_bind.py +154 -0
- motioninput_tui/tui/screens/notation.py +152 -0
- motioninput_tui/tui/screens/settings.py +87 -0
- motioninput_tui/tui/screens/setup.py +228 -0
- motioninput_tui/tui/screens/training.py +222 -0
- motioninput_tui/tui/widgets/__init__.py +7 -0
- motioninput_tui/tui/widgets/input_strip.py +57 -0
- motioninput_tui/tui/widgets/move_feed.py +75 -0
- motioninput_tui/tui/widgets/movelist.py +92 -0
- motioninput_tui/tui/widgets/panel.py +110 -0
- motioninput_tui/tui/widgets/settings_list.py +99 -0
- motioninput_tui/utils/__init__.py +1 -0
- motioninput_tui/utils/logger.py +78 -0
- motioninput_tui-0.1.0.dist-info/METADATA +106 -0
- motioninput_tui-0.1.0.dist-info/RECORD +61 -0
- motioninput_tui-0.1.0.dist-info/WHEEL +4 -0
- motioninput_tui-0.1.0.dist-info/entry_points.txt +4 -0
- motioninput_tui-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Main entrypoint."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich import traceback
|
|
8
|
+
|
|
9
|
+
from .config import Config, config_path
|
|
10
|
+
from .constants import PROGRAM_NAME, PROGRAM_NAME_WITH_FULL_VERSION, PROGRAM_NAME_WITH_VERSION
|
|
11
|
+
from .controls.layouts import DEFAULT_LAYOUT, available_layouts
|
|
12
|
+
from .engine.recognizer import BufferPolicy
|
|
13
|
+
from .games.loader import GameDataMissingError, available_games, load_game
|
|
14
|
+
from .games.rulesets import GAME_SPECS
|
|
15
|
+
from .terminal import detect, query_support
|
|
16
|
+
from .utils.logger import get_logger, setup_logger_cli
|
|
17
|
+
|
|
18
|
+
traceback.install(extra_lines=2)
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_args() -> argparse.Namespace:
|
|
23
|
+
parser = argparse.ArgumentParser(prog=PROGRAM_NAME, description="Fighting game motion input trainer.")
|
|
24
|
+
parser.add_argument("--game", choices=sorted(GAME_SPECS), help="Skip the game picker.")
|
|
25
|
+
parser.add_argument("--character", help="Skip the character picker. Needs --game.")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--layout",
|
|
28
|
+
choices=sorted(layout.key for layout in available_layouts()),
|
|
29
|
+
default=None,
|
|
30
|
+
help=f"Control layout (default: last used, or {DEFAULT_LAYOUT}).",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--no-key-release",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Do not ask the terminal for key release reporting; infer holds from auto-repeat instead.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--loose-buffer",
|
|
39
|
+
action=argparse.BooleanOptionalAction,
|
|
40
|
+
default=None,
|
|
41
|
+
help="Do not spend inputs when a move comes out, so one motion can feed several moves. "
|
|
42
|
+
"Not how the games behave; it is also in the trainer's settings, ctrl+b. Default: last used.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--config",
|
|
46
|
+
type=Path,
|
|
47
|
+
default=None,
|
|
48
|
+
help=f"Config file holding the last used selection (default: {config_path()}).",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument("--list", action="store_true", help="List games and characters, then exit.")
|
|
51
|
+
parser.add_argument("--check-terminal", action="store_true", help="Report terminal suitability, then exit.")
|
|
52
|
+
parser.add_argument("--version", action="version", version=PROGRAM_NAME_WITH_FULL_VERSION)
|
|
53
|
+
parser.add_argument("-v", action="count", default=0, help="Increase verbosity (can be used multiple times).")
|
|
54
|
+
return parser.parse_args()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _print_terminal() -> int:
|
|
58
|
+
info = detect()
|
|
59
|
+
logger.info("Terminal: %s (%s, %s)", info.name, info.speed, info.detail)
|
|
60
|
+
if info.warning():
|
|
61
|
+
logger.warning("%s", info.warning())
|
|
62
|
+
else:
|
|
63
|
+
logger.info("Should be fast enough for accurate input timing.")
|
|
64
|
+
|
|
65
|
+
releases = query_support()
|
|
66
|
+
if releases is None:
|
|
67
|
+
logger.info("Key releases: could not ask, no terminal attached.")
|
|
68
|
+
elif releases:
|
|
69
|
+
logger.info("Key releases: supported. Holds will be tracked exactly.")
|
|
70
|
+
else:
|
|
71
|
+
logger.warning(
|
|
72
|
+
"Key releases: not supported. Holds will be inferred from auto-repeat, "
|
|
73
|
+
"so charge moves depend on your keyboard repeat delay. "
|
|
74
|
+
"Terminals that do support this: Ghostty, Alacritty, WezTerm, kitty, foot, Contour, Rio."
|
|
75
|
+
)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _print_roster() -> int:
|
|
80
|
+
for game in available_games():
|
|
81
|
+
logger.info("%s - %s", game.key, game.name)
|
|
82
|
+
for character in game.characters:
|
|
83
|
+
# The input display's "characters" are button sets, with no moves
|
|
84
|
+
# to count, so they say what they are instead.
|
|
85
|
+
if character.moves:
|
|
86
|
+
logger.info(" %-22s %d trainable moves", character.key, len(character.trainable_moves))
|
|
87
|
+
else:
|
|
88
|
+
logger.info(" %-22s %s", character.key, character.name)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> int:
|
|
93
|
+
"""Main entrypoint."""
|
|
94
|
+
args = _get_args()
|
|
95
|
+
setup_logger_cli(args.v)
|
|
96
|
+
logger.debug("%s", PROGRAM_NAME_WITH_VERSION)
|
|
97
|
+
|
|
98
|
+
if args.check_terminal:
|
|
99
|
+
return _print_terminal()
|
|
100
|
+
if args.list:
|
|
101
|
+
return _print_roster()
|
|
102
|
+
|
|
103
|
+
if args.character and not args.game:
|
|
104
|
+
logger.error("--character needs --game as well")
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
config = Config.load(args.config)
|
|
108
|
+
_apply_overrides(config, args)
|
|
109
|
+
|
|
110
|
+
if not _resolve_selection(config, from_cli=bool(args.character)):
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
info = detect()
|
|
114
|
+
if info.should_warn:
|
|
115
|
+
logger.warning("%s", info.warning())
|
|
116
|
+
|
|
117
|
+
# Ask before the interface takes over the terminal, so holds are tracked
|
|
118
|
+
# exactly from the very first keystroke rather than from the first release.
|
|
119
|
+
key_release = False if args.no_key_release else bool(query_support())
|
|
120
|
+
if key_release:
|
|
121
|
+
logger.debug("Terminal reports key releases; holds will be tracked exactly")
|
|
122
|
+
else:
|
|
123
|
+
logger.info("Terminal does not report key releases; holds will be inferred from auto-repeat")
|
|
124
|
+
|
|
125
|
+
from .tui import MotionInputApp # ruff: ignore[import-outside-top-level] - importing textual is slow, only do it when running the app
|
|
126
|
+
|
|
127
|
+
MotionInputApp(
|
|
128
|
+
config,
|
|
129
|
+
key_release=key_release,
|
|
130
|
+
skip_setup=bool(args.game and args.character),
|
|
131
|
+
).run()
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_selection(config: Config, *, from_cli: bool) -> bool:
|
|
136
|
+
"""Check the selection against the rosters. False means do not start.
|
|
137
|
+
|
|
138
|
+
A remembered character can simply be gone: rosters are regenerated, and a
|
|
139
|
+
name override in ``motioninput_tui_datagen/names.py`` renames the key with the character.
|
|
140
|
+
That is no reason to refuse to start, so the selection is dropped and the
|
|
141
|
+
picker opens on it instead. A character named on the command line is
|
|
142
|
+
different, and still gets an error.
|
|
143
|
+
"""
|
|
144
|
+
if not config.game:
|
|
145
|
+
return True
|
|
146
|
+
try:
|
|
147
|
+
game = load_game(config.game)
|
|
148
|
+
except GameDataMissingError as exc:
|
|
149
|
+
logger.error("%s", exc) # ruff: ignore[error-instead-of-exception] - a traceback helps nobody here
|
|
150
|
+
return False
|
|
151
|
+
if not config.character:
|
|
152
|
+
return True
|
|
153
|
+
try:
|
|
154
|
+
config.character = game.character(config.character).key
|
|
155
|
+
except KeyError as exc:
|
|
156
|
+
if from_cli:
|
|
157
|
+
logger.error("%s", exc) # ruff: ignore[error-instead-of-exception] - a traceback helps nobody here
|
|
158
|
+
return False
|
|
159
|
+
logger.info("Forgetting the saved character, it is not in the roster any more: %s", exc)
|
|
160
|
+
config.character = None
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _apply_overrides(config: Config, args: argparse.Namespace) -> None:
|
|
165
|
+
"""Let command line arguments win over what was remembered."""
|
|
166
|
+
if args.game:
|
|
167
|
+
config.game = args.game
|
|
168
|
+
# Not the character from whatever game was being played last: whoever
|
|
169
|
+
# was last trained on this one. --character always wins, and cannot be
|
|
170
|
+
# given without --game.
|
|
171
|
+
config.character = args.character or config.characters.get(args.game)
|
|
172
|
+
if args.layout:
|
|
173
|
+
config.layout = args.layout
|
|
174
|
+
if args.loose_buffer is not None:
|
|
175
|
+
config.buffer_policy = BufferPolicy.LOOSE if args.loose_buffer else BufferPolicy.CONSUME
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
sys.exit(main()) # pragma: no cover
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Remembering the last used selection between runs.
|
|
2
|
+
|
|
3
|
+
Stored as JSON under the XDG config directory, which is
|
|
4
|
+
``~/.config/motioninput-tui/config.json`` unless ``XDG_CONFIG_HOME`` says
|
|
5
|
+
otherwise. Nothing here is essential: a missing, unreadable or corrupt file
|
|
6
|
+
just means the defaults are used, and a config that cannot be written is
|
|
7
|
+
logged and ignored rather than interrupting a training session.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .controls.layouts import DEFAULT_LAYOUT, KEYBOARD_DEFAULT_BINDINGS, LAYOUTS
|
|
18
|
+
from .engine.recognizer import BufferPolicy
|
|
19
|
+
from .notation_styles import STYLES
|
|
20
|
+
from .utils.logger import get_logger
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
APP_DIR_NAME = "motioninput-tui"
|
|
25
|
+
CONFIG_FILENAME = "config.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def config_dir() -> Path:
|
|
29
|
+
"""The directory holding the config file."""
|
|
30
|
+
override = os.environ.get("XDG_CONFIG_HOME")
|
|
31
|
+
base = Path(override).expanduser() if override else Path.home() / ".config"
|
|
32
|
+
return base / APP_DIR_NAME
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_path() -> Path:
|
|
36
|
+
"""The config file itself."""
|
|
37
|
+
return config_dir() / CONFIG_FILENAME
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class Config:
|
|
42
|
+
"""What the trainer remembers between runs.
|
|
43
|
+
|
|
44
|
+
``key_release`` is deliberately not stored. It is probed per terminal on
|
|
45
|
+
every launch, so remembering it would disable exact tracking after moving
|
|
46
|
+
to a different terminal.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
game: str | None = None
|
|
50
|
+
character: str | None = None
|
|
51
|
+
characters: dict[str, str] = field(default_factory=dict)
|
|
52
|
+
"""Who was last trained on each game, ``{game key: character key}``.
|
|
53
|
+
:meth:`save` folds the current selection in, so coming back to a game comes
|
|
54
|
+
back to the character as well."""
|
|
55
|
+
layout: str = DEFAULT_LAYOUT
|
|
56
|
+
buffer_policy: BufferPolicy = BufferPolicy.CONSUME
|
|
57
|
+
lenient_half_circles: bool = True
|
|
58
|
+
"""Whether a half circle may skip straight down. See
|
|
59
|
+
:mod:`motioninput_tui.settings`."""
|
|
60
|
+
neo_geo_slant: bool = False
|
|
61
|
+
"""Whether the Neo Geo's four buttons are arranged as the arcade slants
|
|
62
|
+
them. See :mod:`motioninput_tui.controls.buttons`."""
|
|
63
|
+
notation: dict[str, str] = field(default_factory=dict)
|
|
64
|
+
"""How each family of motions is written, ``{family: style}``. Empty means
|
|
65
|
+
the plain default. See :mod:`motioninput_tui.notation_styles`."""
|
|
66
|
+
gamepad_bindings: dict[str, str] = field(default_factory=dict)
|
|
67
|
+
"""The player's gamepad attack rebinds, ``{button name: pad code}``. Empty
|
|
68
|
+
means the built-in default. :func:`~.controls.layouts.gamepad_layout` has
|
|
69
|
+
the final say on which entries are usable."""
|
|
70
|
+
keyboard_bindings: dict[str, str] = field(default_factory=dict)
|
|
71
|
+
"""The custom keyboard layout's rebinds, ``{slot: key name}`` over the four
|
|
72
|
+
movement axes and the six attacks. Empty means the built-in default;
|
|
73
|
+
:func:`~.controls.layouts.keyboard_layout` has the final say."""
|
|
74
|
+
path: Path | None = None
|
|
75
|
+
"""Where this was loaded from, and where :meth:`save` writes back to."""
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def loose_buffer(self) -> bool:
|
|
79
|
+
"""The buffer policy as a plain on/off, which is how it is presented.
|
|
80
|
+
|
|
81
|
+
The settings pane treats every setting as a boolean attribute, and
|
|
82
|
+
``buffer_policy`` is the one that is really an enum, so it is bridged
|
|
83
|
+
here rather than special cased there.
|
|
84
|
+
"""
|
|
85
|
+
return self.buffer_policy is BufferPolicy.LOOSE
|
|
86
|
+
|
|
87
|
+
@loose_buffer.setter
|
|
88
|
+
def loose_buffer(self, on: bool) -> None:
|
|
89
|
+
self.buffer_policy = BufferPolicy.LOOSE if on else BufferPolicy.CONSUME
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def load(cls, path: Path | None = None) -> Config:
|
|
93
|
+
"""Read the saved config, falling back to defaults on any problem."""
|
|
94
|
+
target = path or config_path()
|
|
95
|
+
try:
|
|
96
|
+
with target.open(encoding="utf-8") as handle:
|
|
97
|
+
raw = json.load(handle)
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
return cls(path=target)
|
|
100
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
101
|
+
logger.warning("Ignoring unreadable config at %s: %s", target, exc)
|
|
102
|
+
return cls(path=target)
|
|
103
|
+
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
logger.warning("Ignoring config at %s: expected an object", target)
|
|
106
|
+
return cls(path=target)
|
|
107
|
+
return cls(
|
|
108
|
+
game=_optional_str(raw.get("game")),
|
|
109
|
+
character=_optional_str(raw.get("character")),
|
|
110
|
+
characters=_valid_characters(raw.get("characters")),
|
|
111
|
+
layout=_valid_layout(raw.get("layout")),
|
|
112
|
+
buffer_policy=_valid_policy(raw.get("buffer_policy")),
|
|
113
|
+
lenient_half_circles=_valid_flag(raw.get("lenient_half_circles"), default=True),
|
|
114
|
+
neo_geo_slant=_valid_flag(raw.get("neo_geo_slant"), default=False),
|
|
115
|
+
notation=_valid_notation(raw.get("notation")),
|
|
116
|
+
gamepad_bindings=_valid_gamepad_bindings(raw.get("gamepad_bindings")),
|
|
117
|
+
keyboard_bindings=_valid_keyboard_bindings(raw.get("keyboard_bindings")),
|
|
118
|
+
path=target,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def save(self, path: Path | None = None) -> bool:
|
|
122
|
+
"""Write the config. Returns False if it could not be saved."""
|
|
123
|
+
target = path or self.path or config_path()
|
|
124
|
+
if self.game and self.character:
|
|
125
|
+
self.characters[self.game] = self.character
|
|
126
|
+
payload = {
|
|
127
|
+
"game": self.game,
|
|
128
|
+
"character": self.character,
|
|
129
|
+
"characters": self.characters,
|
|
130
|
+
"layout": self.layout,
|
|
131
|
+
"buffer_policy": str(self.buffer_policy),
|
|
132
|
+
"lenient_half_circles": self.lenient_half_circles,
|
|
133
|
+
"neo_geo_slant": self.neo_geo_slant,
|
|
134
|
+
"notation": self.notation,
|
|
135
|
+
"gamepad_bindings": self.gamepad_bindings,
|
|
136
|
+
"keyboard_bindings": self.keyboard_bindings,
|
|
137
|
+
}
|
|
138
|
+
try:
|
|
139
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
_write_atomic(target, json.dumps(payload, indent=2) + "\n")
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
logger.warning("Could not save config to %s: %s", target, exc)
|
|
143
|
+
return False
|
|
144
|
+
logger.debug("Saved config to %s", target)
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _write_atomic(target: Path, text: str) -> None:
|
|
149
|
+
"""Write via a temporary file so an interrupted run cannot truncate it."""
|
|
150
|
+
handle = tempfile.NamedTemporaryFile( # ruff: ignore[open-file-with-context-handler] - closed before the rename
|
|
151
|
+
"w",
|
|
152
|
+
encoding="utf-8",
|
|
153
|
+
dir=target.parent,
|
|
154
|
+
prefix=f".{target.name}.",
|
|
155
|
+
delete=False,
|
|
156
|
+
)
|
|
157
|
+
temporary = Path(handle.name)
|
|
158
|
+
try:
|
|
159
|
+
with handle:
|
|
160
|
+
handle.write(text)
|
|
161
|
+
temporary.replace(target)
|
|
162
|
+
except OSError:
|
|
163
|
+
temporary.unlink(missing_ok=True)
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _optional_str(value: object) -> str | None:
|
|
168
|
+
return value if isinstance(value, str) and value else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
_LAYOUT_ALIASES = {"hitbox": "keyboard-left", "southpaw": "keyboard-right"}
|
|
172
|
+
"""The keyboard layouts that were replaced, mapped to their nearest successor so
|
|
173
|
+
a config from before the change still opens somewhere sensible."""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _valid_layout(value: object) -> str:
|
|
177
|
+
# An unavailable layout (gamepad without the extra installed) falls back,
|
|
178
|
+
# so a stale config cannot drop the trainer into a dead input mode.
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
value = _LAYOUT_ALIASES.get(value, value)
|
|
181
|
+
if isinstance(value, str) and value in LAYOUTS and LAYOUTS[value].available:
|
|
182
|
+
return value
|
|
183
|
+
return DEFAULT_LAYOUT
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _valid_keyboard_bindings(value: object) -> dict[str, str]:
|
|
187
|
+
# Best-effort like the rest of the loader; keyboard_layout is authoritative.
|
|
188
|
+
if not isinstance(value, dict):
|
|
189
|
+
return {}
|
|
190
|
+
return {
|
|
191
|
+
slot: key
|
|
192
|
+
for slot, key in value.items()
|
|
193
|
+
if isinstance(slot, str) and slot in KEYBOARD_DEFAULT_BINDINGS and isinstance(key, str) and key
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _valid_characters(value: object) -> dict[str, str]:
|
|
198
|
+
# Best-effort like the rest of the loader; a character who has since left
|
|
199
|
+
# the roster is dealt with by the picker falling back to the first one.
|
|
200
|
+
if not isinstance(value, dict):
|
|
201
|
+
return {}
|
|
202
|
+
return {
|
|
203
|
+
game: character
|
|
204
|
+
for game, character in value.items()
|
|
205
|
+
if isinstance(game, str) and game and isinstance(character, str) and character
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _valid_flag(value: object, *, default: bool) -> bool:
|
|
210
|
+
"""A saved on/off, ignoring anything that is not one."""
|
|
211
|
+
return value if isinstance(value, bool) else default
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _valid_policy(value: object) -> BufferPolicy:
|
|
215
|
+
try:
|
|
216
|
+
return BufferPolicy(value)
|
|
217
|
+
except ValueError:
|
|
218
|
+
return BufferPolicy.CONSUME
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _valid_notation(value: object) -> dict[str, str]:
|
|
222
|
+
# Styles come and go as the notation menu grows, so an entry naming one
|
|
223
|
+
# that is not there any more is dropped rather than left to draw nothing.
|
|
224
|
+
if not isinstance(value, dict):
|
|
225
|
+
return {}
|
|
226
|
+
known = {family.value: {style.key for style in styles} for family, styles in STYLES.items()}
|
|
227
|
+
return {
|
|
228
|
+
family: style
|
|
229
|
+
for family, style in value.items()
|
|
230
|
+
if isinstance(family, str) and isinstance(style, str) and style in known.get(family, ())
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_PAD_CODE = re.compile(r"^pad:\d+$")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _valid_gamepad_bindings(value: object) -> dict[str, str]:
|
|
238
|
+
# Best-effort, like the rest of the loader: keep the entries that look sane
|
|
239
|
+
# and drop the rest. gamepad_layout does the authoritative check.
|
|
240
|
+
if not isinstance(value, dict):
|
|
241
|
+
return {}
|
|
242
|
+
return {
|
|
243
|
+
key: code
|
|
244
|
+
for key, code in value.items()
|
|
245
|
+
if isinstance(key, str) and isinstance(code, str) and _PAD_CODE.match(code)
|
|
246
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Version tracking within the package."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
PROGRAM_NAME = Path(__file__).parent.name.replace("_", "-").lower() # Calculate this
|
|
7
|
+
PROGRAM_REPO_URL = "https://github.com/kism/motioninput-tui"
|
|
8
|
+
try:
|
|
9
|
+
PROGRAM_VERSION = version(PROGRAM_NAME)
|
|
10
|
+
except PackageNotFoundError: # pragma: no cover
|
|
11
|
+
PROGRAM_VERSION = "<unknown, please run uv sync>"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_version_str() -> str:
|
|
15
|
+
"""Get a string representation of the version, including branch and commit hash."""
|
|
16
|
+
repo_root = Path(__file__).parent.parent
|
|
17
|
+
git_head_log = repo_root / ".git" / "logs" / "HEAD"
|
|
18
|
+
git_head = repo_root / ".git" / "HEAD"
|
|
19
|
+
last_commit = ""
|
|
20
|
+
current_branch = ""
|
|
21
|
+
|
|
22
|
+
if git_head_log.is_file():
|
|
23
|
+
with git_head_log.open("r") as f:
|
|
24
|
+
lines = f.readlines()
|
|
25
|
+
if lines:
|
|
26
|
+
last_commit = lines[-1].strip().split(" ")[1][:7] # Get the new commit hash, first 7 characters
|
|
27
|
+
|
|
28
|
+
if git_head.is_file():
|
|
29
|
+
with git_head.open("r") as f:
|
|
30
|
+
current_branch = f.read().strip().split("/")[-1]
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
f"{PROGRAM_NAME} "
|
|
34
|
+
f"v{PROGRAM_VERSION}"
|
|
35
|
+
f"{('-' + current_branch) if current_branch and (last_commit not in current_branch) else ''}"
|
|
36
|
+
f"{('/' + last_commit + '') if last_commit else ''}"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PROGRAM_NAME_WITH_VERSION = f"{PROGRAM_NAME} v{PROGRAM_VERSION}"
|
|
41
|
+
PROGRAM_NAME_WITH_FULL_VERSION = _get_version_str()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Physical control layouts and the input sources that read them."""
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Button sets: how many attack buttons a game has, and what they are called.
|
|
2
|
+
|
|
3
|
+
A layout says where your fingers go; a set says what those positions mean. They
|
|
4
|
+
are kept apart so that adding a game with a different panel is a table entry
|
|
5
|
+
rather than a new layout for every keyboard arrangement.
|
|
6
|
+
|
|
7
|
+
A set is rows of buttons, laid onto the layout's rows of attack keys in order.
|
|
8
|
+
On the southpaw layout, whose attack keys are ``asdf`` over ``zxcv``, that gives:
|
|
9
|
+
|
|
10
|
+
Street Fighter asd zxc LP MP HP over LK MK HK
|
|
11
|
+
Mortal Kombat asd zx HP HK BL over LP LK, the modern pad mapping
|
|
12
|
+
Neo Geo asdf zxcv A B C D, the same four on both rows
|
|
13
|
+
Neo Geo slant zx as A B on the bottom row, C D above them
|
|
14
|
+
Tekken as zx □ △ over ✕ ○
|
|
15
|
+
Eight button asdf zxcv 1-8
|
|
16
|
+
|
|
17
|
+
The Neo Geo has two arrangements in circulation, so which one is used is the
|
|
18
|
+
player's choice: :func:`arrangement` applies it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
from motioninput_tui.engine.notation import Button
|
|
24
|
+
|
|
25
|
+
_B = Button
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ButtonSet:
|
|
30
|
+
"""The attack buttons of one game's panel, in rows.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
key: What is saved in the config, and the pseudo-character key the
|
|
34
|
+
input display is picked with.
|
|
35
|
+
name: What the pickers call it.
|
|
36
|
+
rows: Buttons in panel order, top row first. A row is laid onto the
|
|
37
|
+
matching row of the layout's attack keys, so a row longer than the
|
|
38
|
+
layout has keys for simply runs out. The same button may appear
|
|
39
|
+
twice, which is how the Neo Geo gets its second row.
|
|
40
|
+
note: One line on where the arrangement comes from.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
key: str
|
|
44
|
+
name: str
|
|
45
|
+
rows: tuple[tuple[Button, ...], ...]
|
|
46
|
+
note: str = ""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def buttons(self) -> tuple[Button, ...]:
|
|
50
|
+
"""Every button once, in panel order."""
|
|
51
|
+
seen: dict[Button, None] = {}
|
|
52
|
+
for row in self.rows:
|
|
53
|
+
for button in row:
|
|
54
|
+
seen.setdefault(button, None)
|
|
55
|
+
return tuple(seen)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
STREET_FIGHTER = ButtonSet(
|
|
59
|
+
key="street-fighter",
|
|
60
|
+
name="Street Fighter, 6 button",
|
|
61
|
+
rows=((_B.LP, _B.MP, _B.HP), (_B.LK, _B.MK, _B.HK)),
|
|
62
|
+
note="Three punches over three kicks.",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
MORTAL_KOMBAT = ButtonSet(
|
|
66
|
+
key="mortal-kombat",
|
|
67
|
+
name="Mortal Kombat, 5 button",
|
|
68
|
+
# Not the arcade panel: this is how modern Mortal Kombat maps onto a six
|
|
69
|
+
# button controller, the two heavy attacks and block on top with the light
|
|
70
|
+
# pair beneath, which is why the bottom row is the short one.
|
|
71
|
+
rows=((_B.HP, _B.HK, _B.BL), (_B.LP, _B.LK)),
|
|
72
|
+
note="Heavy punch, heavy kick, block, with the light pair beneath.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
NEO_GEO = ButtonSet(
|
|
76
|
+
key="neo-geo",
|
|
77
|
+
name="Neo Geo, 4 button",
|
|
78
|
+
rows=((_B.A, _B.B, _B.C, _B.D), (_B.A, _B.B, _B.C, _B.D)),
|
|
79
|
+
note="A B C D straight across, and again on the row below.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
NEO_GEO_SLANT = ButtonSet(
|
|
83
|
+
key="neo-geo-slant",
|
|
84
|
+
name="Neo Geo, arcade slant",
|
|
85
|
+
rows=((_B.C, _B.D), (_B.A, _B.B)),
|
|
86
|
+
note="A B on the bottom row with C D above, as the arcade panel slants them.",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
TEKKEN = ButtonSet(
|
|
90
|
+
key="tekken",
|
|
91
|
+
name="Tekken, 4 button",
|
|
92
|
+
rows=((_B.SQUARE, _B.TRIANGLE), (_B.CROSS, _B.CIRCLE)),
|
|
93
|
+
note="Left and right punch over left and right kick.",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
EIGHT_BUTTON = ButtonSet(
|
|
97
|
+
key="eight",
|
|
98
|
+
name="Eight button",
|
|
99
|
+
rows=((_B.B1, _B.B2, _B.B3, _B.B4), (_B.B5, _B.B6, _B.B7, _B.B8)),
|
|
100
|
+
note="Every attack key a layout has, numbered.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
BUTTON_SETS: tuple[ButtonSet, ...] = (
|
|
104
|
+
STREET_FIGHTER,
|
|
105
|
+
MORTAL_KOMBAT,
|
|
106
|
+
NEO_GEO,
|
|
107
|
+
TEKKEN,
|
|
108
|
+
EIGHT_BUTTON,
|
|
109
|
+
)
|
|
110
|
+
"""The sets offered in the pickers. The Neo Geo slant is not among them: it is
|
|
111
|
+
the same set differently arranged, and :func:`arrangement` chooses it."""
|
|
112
|
+
|
|
113
|
+
DEFAULT_SET = STREET_FIGHTER
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_set(key: str) -> ButtonSet:
|
|
117
|
+
"""Look up a button set by key, falling back to the Street Fighter six."""
|
|
118
|
+
for button_set in (*BUTTON_SETS, NEO_GEO_SLANT):
|
|
119
|
+
if button_set.key == key:
|
|
120
|
+
return button_set
|
|
121
|
+
return DEFAULT_SET
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def arrangement(button_set: ButtonSet, *, slanted_neo_geo: bool) -> ButtonSet:
|
|
125
|
+
"""The set as the player wants it arranged.
|
|
126
|
+
|
|
127
|
+
Only the Neo Geo has a second arrangement, so this is a no-op for the rest.
|
|
128
|
+
"""
|
|
129
|
+
if slanted_neo_geo and button_set is NEO_GEO:
|
|
130
|
+
return NEO_GEO_SLANT
|
|
131
|
+
return button_set
|