Skip to content

Commit eb6a18c

Browse files
committed
Merge remote-tracking branch 'upstream/main' into jq/add-telnet-server
2 parents 75a15a6 + 6fdb9d1 commit eb6a18c

15 files changed

Lines changed: 326 additions & 224 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ The key bindings are not configurable at the moment:
219219
| Buttons | Keyboard with arrows | Keyboard with WASD | Controller |
220220
|------------|----------------------|--------------------|-----------------------|
221221
| Directions | Arrows | W A S D | Left hat / Left stick |
222-
| A | Z | K | Button 0 / Button 3 |
223-
| B | X | J | Button 1 / Button 2 |
222+
| A | X | K | Button 0 / Button 3 |
223+
| B | Z | J | Button 1 / Button 2 |
224224
| Start | Enter | Enter | Button 7 |
225225
| Select | Right Shift | Right Shift | Button 6 |
226226

gambaterm/audio.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,20 @@ def audio_player(
7777
console: Console, speed_factor: float = 1.0
7878
) -> Iterator[AudioOut | None]:
7979
# Perform late imports
80-
# Those can fail if a linux machine doesn't have portaudio or libsamplerate
81-
# installed
8280
import samplerate
83-
import sounddevice
81+
82+
# Especially for sounddevice, as it doesn't package the portaudio library in its manylinux wheels.
83+
try:
84+
import sounddevice
85+
except OSError:
86+
raise SystemExit(
87+
"""\
88+
Audio output is not available because the PortAudio library could not be found.
89+
Please make sure you have portaudio installed.
90+
For example, on Debian-based distributions, you can run:
91+
$ sudo apt install libportaudio2
92+
Otherswise, you can use the --no-audio option to run without audio support."""
93+
)
8494

8595
input_rate = console.FPS * console.TICKS_IN_FRAME
8696
resampler = samplerate.Resampler("linear", channels=2)

gambaterm/blessed_keyboard_input.py

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from __future__ import annotations
22

3-
import re
43
from contextlib import contextmanager
5-
from dataclasses import dataclass, field
64
from typing import Callable, Iterator
75

86
from blessed import Terminal
@@ -11,17 +9,6 @@
119
from .dom_codes import DomCode
1210
from .keys import ASCII_PRINTABLE_TO_DOM_CODE
1311

14-
_CPR_RE = re.compile(r"\x1b\[\d+;\d+R")
15-
16-
17-
@dataclass
18-
class KeyboardState:
19-
"""Snapshot returned by a keyboard polling function."""
20-
21-
pressed: set[DomCode] = field(default_factory=set)
22-
cpr_received: bool = False
23-
keystrokes: "list[Keystroke]" = field(default_factory=list)
24-
2512

2613
# Blessed synthesizes key_name as "KEY_{char}" for A-Z and 0-9 on release
2714
# and repeat events, so we need mappings for both functional keys and
@@ -61,38 +48,42 @@ def keystroke_to_dom_code(keystroke: Keystroke) -> DomCode | None:
6148
@contextmanager
6249
def blessed_key_pressed_context(
6350
term: Terminal,
64-
) -> Iterator[Callable[[], KeyboardState]]:
51+
) -> Iterator[tuple[Callable[[], set[DomCode]], Callable[[], list[Keystroke]]]]:
6552
"""Context manager providing a get_pressed() callable using blessed's kitty protocol."""
66-
state = KeyboardState()
53+
pressed: set[DomCode] = set()
54+
keystrokes: list[Keystroke] = []
6755

6856
with term.enable_kitty_keyboard(
6957
report_events=True,
7058
report_alternates=True,
7159
report_all_keys=True,
7260
):
7361

74-
def get_pressed() -> KeyboardState:
75-
state.cpr_received = False
76-
state.keystrokes.clear()
62+
def _update() -> None:
7763
while True:
7864
key = term.inkey(timeout=0)
7965
if not key:
8066
break
81-
state.keystrokes.append(key)
82-
# Cursor position response
83-
if _CPR_RE.match(str(key)):
84-
state.cpr_received = True
85-
continue
67+
keystrokes.append(key)
8668
dom_code = keystroke_to_dom_code(key)
8769
if dom_code is None:
8870
continue
8971
if key.released:
90-
state.pressed.discard(dom_code)
72+
pressed.discard(dom_code)
9173
else:
92-
state.pressed.add(dom_code)
93-
return state
74+
pressed.add(dom_code)
75+
76+
def get_pressed() -> set[DomCode]:
77+
_update()
78+
return pressed.copy()
79+
80+
def pop_keystrokes() -> list[Keystroke]:
81+
nonlocal keystrokes
82+
_update()
83+
result, keystrokes = keystrokes, []
84+
return result
9485

9586
try:
96-
yield get_pressed
87+
yield get_pressed, pop_keystrokes
9788
finally:
98-
state.pressed.clear()
89+
pressed.clear()

gambaterm/colors.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,13 @@
11
from __future__ import annotations
22

3-
import os
43
import sys
54
from enum import IntEnum
65

76
from blessed import Terminal
87

9-
# Terminals that support at least 16 colors but may not be detected
10-
# correctly by curses/terminfo.
11-
BASIC_TERMINALS = [
12-
"screen",
13-
"vt100",
14-
"vt220",
15-
"rxvt",
16-
"color",
17-
"ansi",
18-
"cygwin",
19-
"linux",
20-
]
21-
228

239
class ColorMode(IntEnum):
24-
NO_COLOR = 0
10+
COULD_NOT_DETECT = 0
2511
HAS_2_BIT_COLOR = 1
2612
HAS_4_BIT_COLOR = 2
2713
HAS_8_BIT_COLOR = 3
@@ -54,11 +40,7 @@ def detect_local_color_mode(term: Terminal) -> ColorMode:
5440
return ColorMode.HAS_4_BIT_COLOR
5541
if n >= 4:
5642
return ColorMode.HAS_2_BIT_COLOR
57-
# Fallback for terminals that curses/terminfo under-reports
58-
term_env = os.environ.get("TERM", "").lower()
59-
if any(x in term_env for x in BASIC_TERMINALS):
60-
return ColorMode.HAS_4_BIT_COLOR
61-
return ColorMode.NO_COLOR
43+
return ColorMode.COULD_NOT_DETECT
6244

6345

6446
def main() -> None:

gambaterm/console.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from pathlib import Path
55
import tempfile
66
from enum import IntEnum
7-
from typing import Callable, Set
7+
from typing import Callable
88

99
import numpy as np
1010
import numpy.typing as npt
@@ -96,10 +96,6 @@ def handle_event(self, event: Event) -> None:
9696
assert False
9797

9898

99-
# Type Alias
100-
InputGetter = Callable[[], Set[Console.Input]]
101-
102-
10399
class GameboyColor(Console):
104100
WIDTH: int = 160
105101
HEIGHT: int = 144

gambaterm/controller_input.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from typing import Callable, ContextManager, Iterator
55
from contextlib import contextmanager
66

7-
from .console import Console, InputGetter
7+
from .console import Console
8+
from .input_getter import BaseInputGetter, StackedInputGetter
89

910

1011
def get_controller_input_mapping(console: Console) -> dict[str, Console.Input]:
@@ -97,7 +98,9 @@ def get_pressed() -> set[str]:
9798

9899

99100
@contextmanager
100-
def console_input_from_controller_context(console: Console) -> Iterator[InputGetter]:
101+
def console_input_from_controller_context(
102+
console: Console,
103+
) -> Iterator[Callable[[], set[Console.Input]]]:
101104
input_mapping = get_controller_input_mapping(console)
102105
event_mapping = get_controller_event_mapping(console)
103106
current_pressed: set[str] = set()
@@ -119,10 +122,23 @@ def get_gb_input() -> set[Console.Input]:
119122
yield get_gb_input
120123

121124

125+
class ControllerInputGetter(StackedInputGetter):
126+
def __init__(
127+
self,
128+
base_getter: BaseInputGetter,
129+
extra_get_pressed: Callable[[], set[Console.Input]],
130+
) -> None:
131+
super().__init__(base_getter)
132+
self._extra_get_pressed = extra_get_pressed
133+
134+
def get_pressed(self) -> set[Console.Input]:
135+
return super().get_pressed() | self._extra_get_pressed()
136+
137+
122138
@contextmanager
123139
def combine_console_input_from_controller_context(
124-
console: Console, context: ContextManager[InputGetter]
125-
) -> Iterator[InputGetter]:
126-
with context as getter1:
127-
with console_input_from_controller_context(console) as getter2:
128-
yield lambda: getter1() | getter2()
140+
context: ContextManager[BaseInputGetter],
141+
) -> Iterator[ControllerInputGetter]:
142+
with context as base_getter:
143+
with console_input_from_controller_context(base_getter.console) as getter2:
144+
yield ControllerInputGetter(base_getter, getter2)

gambaterm/file_input.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,24 @@
66
from zipfile import ZipFile, BadZipFile
77
from typing import ContextManager, Iterator
88

9-
from .console import Console, InputGetter
9+
from blessed import Terminal
10+
11+
from .console import Console
12+
from .input_getter import BaseInputGetter, StackedInputGetter
13+
14+
15+
class FileInputGetter(BaseInputGetter):
16+
def __init__(
17+
self,
18+
console: Console,
19+
terminal: Terminal,
20+
input_generator: Iterator[set[Console.Input]],
21+
) -> None:
22+
super().__init__(console, terminal)
23+
self._generator = input_generator
24+
25+
def get_pressed(self) -> set[Console.Input]:
26+
return next(self._generator)
1027

1128

1229
def get_inputs_ref(console: Console) -> list[Console.Input]:
@@ -45,8 +62,8 @@ def open_input_log_file(path: Path) -> Iterator[TextIOWrapper]:
4562

4663
@contextmanager
4764
def console_input_from_file_context(
48-
console: Console, path: Path, skip_first_frames: int = 188
49-
) -> Iterator[InputGetter]:
65+
console: Console, terminal: Terminal, path: Path, skip_first_frames: int = 188
66+
) -> Iterator[FileInputGetter]:
5067
inputs_ref = get_inputs_ref(console)
5168
with open_input_log_file(path) as f:
5269

@@ -62,19 +79,24 @@ def gen() -> Iterator[set[Console.Input]]:
6279
yield set()
6380

6481
input_generator = gen()
65-
yield lambda: next(input_generator)
82+
yield FileInputGetter(console, terminal, input_generator)
83+
84+
85+
class WriteInputGetter(StackedInputGetter):
86+
def __init__(self, base_getter: BaseInputGetter, file: TextIOWrapper) -> None:
87+
super().__init__(base_getter)
88+
self._file = file
89+
90+
def get_pressed(self) -> set[Console.Input]:
91+
value = super().get_pressed()
92+
print(value_to_line(self.console, value), file=self._file)
93+
return value
6694

6795

6896
@contextmanager
6997
def write_input_context(
70-
console: Console, context: ContextManager[InputGetter], path: Path
71-
) -> Iterator[InputGetter]:
98+
context: ContextManager[BaseInputGetter], path: Path
99+
) -> Iterator[WriteInputGetter]:
72100
with open(path, "w") as f:
73-
with context as getter:
74-
75-
def new_getter() -> set[Console.Input]:
76-
value = getter()
77-
print(value_to_line(console, value), file=f)
78-
return value
79-
80-
yield new_getter
101+
with context as base_getter:
102+
yield WriteInputGetter(base_getter, f)

gambaterm/input_getter.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from blessed.keyboard import Keystroke
2+
from blessed.terminal import Terminal
3+
4+
from .console import Console
5+
6+
7+
def pop_keystrokes_from_terminal(terminal: Terminal) -> list[Keystroke]:
8+
return list(iter(lambda: terminal.inkey(timeout=0), ""))
9+
10+
11+
class BaseInputGetter:
12+
"""Base class for input getters.
13+
14+
This class reports both:
15+
- the currently pressed console buttons
16+
- the keystrokes that occurred since the last call
17+
18+
The reason why those apparently separated responsibilities are combined in the same class
19+
is that some input sources (e.g. keyboard with kitty protocol) provide both information through the same API.
20+
21+
It provides default implementation for pop_keystrokes() that reads from the terminal,
22+
which is useful for input sources that do not mess with the terminal input.
23+
24+
More practically:
25+
- KittyInputGetter: reports pressed console buttons and keystrokes from the terminal using the kitty keyboard protocol
26+
- X11InputGetter: reports pressed console buttons from X11 events, and reports keystrokes from the terminal
27+
- PynputInputGetter: reports pressed console buttons from OS key hooks, and reports keystrokes from the terminal
28+
- FileInputGetter: reports pressed console buttons from a file, and reports keystrokes from the terminal
29+
"""
30+
31+
def __init__(self, console: Console, terminal: Terminal) -> None:
32+
self._console = console
33+
self._terminal = terminal
34+
35+
@property
36+
def console(self) -> Console:
37+
return self._console
38+
39+
@property
40+
def terminal(self) -> Terminal:
41+
return self._terminal
42+
43+
def get_pressed(self) -> set[Console.Input]:
44+
"""Get the currently pressed inputs."""
45+
raise NotImplementedError
46+
47+
def pop_keystrokes(self) -> list[Keystroke]:
48+
"""Get the keystrokes that occurred since the last call."""
49+
return pop_keystrokes_from_terminal(self.terminal)
50+
51+
52+
class StackedInputGetter(BaseInputGetter):
53+
"""
54+
BaseInputGetter that combines the pressed inputs of a base getter with extra pressed inputs from another callable.
55+
56+
This is useful to combine multiple input sources, e.g. keyboard and controller.
57+
58+
More pratically:
59+
- ControllerInputGetter: add console inputs from a controller on top of another input getter
60+
- WriteInputGetter: write the console inputs from another input getter to a file, without modifying them
61+
"""
62+
63+
def __init__(self, base_getter: BaseInputGetter) -> None:
64+
self.base_getter = base_getter
65+
66+
@property
67+
def console(self) -> Console:
68+
return self.base_getter.console
69+
70+
@property
71+
def terminal(self) -> Terminal:
72+
return self.base_getter.terminal
73+
74+
def get_pressed(self) -> set[Console.Input]:
75+
return self.base_getter.get_pressed()
76+
77+
def pop_keystrokes(self) -> list[Keystroke]:
78+
return self.base_getter.pop_keystrokes()

0 commit comments

Comments
 (0)