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.
Files changed (61) hide show
  1. motioninput_tui/__init__.py +9 -0
  2. motioninput_tui/__main__.py +179 -0
  3. motioninput_tui/config.py +246 -0
  4. motioninput_tui/constants.py +41 -0
  5. motioninput_tui/controls/__init__.py +1 -0
  6. motioninput_tui/controls/buttons.py +131 -0
  7. motioninput_tui/controls/gamepad.py +256 -0
  8. motioninput_tui/controls/layouts.py +549 -0
  9. motioninput_tui/controls/source.py +125 -0
  10. motioninput_tui/engine/__init__.py +1 -0
  11. motioninput_tui/engine/buffer.py +114 -0
  12. motioninput_tui/engine/motions.py +801 -0
  13. motioninput_tui/engine/notation.py +207 -0
  14. motioninput_tui/engine/recognizer.py +377 -0
  15. motioninput_tui/engine/ruleset.py +159 -0
  16. motioninput_tui/engine/session.py +295 -0
  17. motioninput_tui/gamepad_probe.py +216 -0
  18. motioninput_tui/games/__init__.py +1 -0
  19. motioninput_tui/games/data/hsf2.json +1903 -0
  20. motioninput_tui/games/data/kof2001.json +9076 -0
  21. motioninput_tui/games/data/kof98.json +7700 -0
  22. motioninput_tui/games/data/lb2.json +2972 -0
  23. motioninput_tui/games/data/sfa3.json +6920 -0
  24. motioninput_tui/games/data/sfiii3.json +5456 -0
  25. motioninput_tui/games/data/ssii.json +2366 -0
  26. motioninput_tui/games/data/ssvsp.json +4306 -0
  27. motioninput_tui/games/data/usfiv.json +10064 -0
  28. motioninput_tui/games/loader.py +86 -0
  29. motioninput_tui/games/models.py +137 -0
  30. motioninput_tui/games/rulesets.py +406 -0
  31. motioninput_tui/notation_styles.py +379 -0
  32. motioninput_tui/py.typed +0 -0
  33. motioninput_tui/settings.py +73 -0
  34. motioninput_tui/terminal/__init__.py +6 -0
  35. motioninput_tui/terminal/detect.py +143 -0
  36. motioninput_tui/terminal/kitty.py +143 -0
  37. motioninput_tui/tui/__init__.py +5 -0
  38. motioninput_tui/tui/app.py +251 -0
  39. motioninput_tui/tui/keyboard_driver.py +105 -0
  40. motioninput_tui/tui/screens/__init__.py +6 -0
  41. motioninput_tui/tui/screens/gamepad_bind.py +155 -0
  42. motioninput_tui/tui/screens/input_display.py +199 -0
  43. motioninput_tui/tui/screens/input_picker.py +243 -0
  44. motioninput_tui/tui/screens/keyboard_bind.py +154 -0
  45. motioninput_tui/tui/screens/notation.py +152 -0
  46. motioninput_tui/tui/screens/settings.py +87 -0
  47. motioninput_tui/tui/screens/setup.py +228 -0
  48. motioninput_tui/tui/screens/training.py +222 -0
  49. motioninput_tui/tui/widgets/__init__.py +7 -0
  50. motioninput_tui/tui/widgets/input_strip.py +57 -0
  51. motioninput_tui/tui/widgets/move_feed.py +75 -0
  52. motioninput_tui/tui/widgets/movelist.py +92 -0
  53. motioninput_tui/tui/widgets/panel.py +110 -0
  54. motioninput_tui/tui/widgets/settings_list.py +99 -0
  55. motioninput_tui/utils/__init__.py +1 -0
  56. motioninput_tui/utils/logger.py +78 -0
  57. motioninput_tui-0.1.0.dist-info/METADATA +106 -0
  58. motioninput_tui-0.1.0.dist-info/RECORD +61 -0
  59. motioninput_tui-0.1.0.dist-info/WHEEL +4 -0
  60. motioninput_tui-0.1.0.dist-info/entry_points.txt +4 -0
  61. motioninput_tui-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,9 @@
1
+ """Python App motioninput-tui."""
2
+
3
+ from .constants import PROGRAM_NAME, PROGRAM_REPO_URL, PROGRAM_VERSION
4
+
5
+ __all__ = [
6
+ "PROGRAM_NAME",
7
+ "PROGRAM_REPO_URL",
8
+ "PROGRAM_VERSION",
9
+ ]
@@ -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