Skip to content

Commit ee6c27a

Browse files
committed
refactor away getattr()'s
1 parent a18a3bb commit ee6c27a

6 files changed

Lines changed: 88 additions & 74 deletions

File tree

gambaterm/blessed_keyboard_input.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import re
44
from contextlib import contextmanager
5+
from dataclasses import dataclass, field
56
from typing import Callable, Iterator
67

78
from blessed import Terminal
@@ -12,6 +13,15 @@
1213

1314
_CPR_RE = re.compile(r"\x1b\[\d+;\d+R")
1415

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+
24+
1525
# Blessed synthesizes key_name as "KEY_{char}" for A-Z and 0-9 on release
1626
# and repeat events, so we need mappings for both functional keys and
1727
# printable keys that are used in game input/event mappings.
@@ -50,18 +60,18 @@ def keystroke_to_dom_code(keystroke: Keystroke) -> DomCode | None:
5060
@contextmanager
5161
def blessed_key_pressed_context(
5262
term: Terminal,
53-
) -> Iterator[Callable[[], set[DomCode]]]:
63+
) -> Iterator[Callable[[], KeyboardState]]:
5464
"""Context manager providing a get_pressed() callable using blessed's kitty protocol."""
55-
pressed: set[DomCode] = set()
65+
state = KeyboardState()
5666

5767
with term.enable_kitty_keyboard(
5868
report_events=True,
5969
report_alternates=True,
6070
report_all_keys=True,
6171
):
6272

63-
def get_pressed() -> set[DomCode]:
64-
get_pressed.cpr_received = False
73+
def get_pressed() -> KeyboardState:
74+
state.cpr_received = False
6575
while True:
6676
key = term.inkey(timeout=0)
6777
if not key:
@@ -79,20 +89,18 @@ def get_pressed() -> set[DomCode]:
7989
raise OSError
8090
# Cursor position response
8191
if _CPR_RE.match(str(key)):
82-
get_pressed.cpr_received = True
92+
state.cpr_received = True
8393
continue
8494
dom_code = keystroke_to_dom_code(key)
8595
if dom_code is None:
8696
continue
8797
if key.released:
88-
pressed.discard(dom_code)
98+
state.pressed.discard(dom_code)
8999
else:
90-
pressed.add(dom_code)
91-
return pressed
92-
93-
get_pressed.cpr_received = False
100+
state.pressed.add(dom_code)
101+
return state
94102

95103
try:
96104
yield get_pressed
97105
finally:
98-
pressed.clear()
106+
state.pressed.clear()

gambaterm/keyboard_input.py

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from .dom_codes import DomCode
1212
from .console import Console, InputGetter
13-
from .blessed_keyboard_input import blessed_key_pressed_context
13+
from .blessed_keyboard_input import KeyboardState, blessed_key_pressed_context
1414
from .pynput_keyboard_input import pynput_key_pressed_context
1515
from .x11_keyboard_input import x11_key_pressed_context
1616

@@ -43,6 +43,10 @@ def get_input_mapping(console: Console) -> dict[DomCode, Console.Input]:
4343
DomCode.ARROW_DOWN: console.Input.DOWN,
4444
DomCode.ARROW_LEFT: console.Input.LEFT,
4545
DomCode.ARROW_RIGHT: console.Input.RIGHT,
46+
DomCode.NUMPAD8: console.Input.UP,
47+
DomCode.NUMPAD2: console.Input.DOWN,
48+
DomCode.NUMPAD4: console.Input.LEFT,
49+
DomCode.NUMPAD6: console.Input.RIGHT,
4650
DomCode.US_Z: console.Input.A,
4751
DomCode.US_X: console.Input.B,
4852
# WASD controls
@@ -72,31 +76,46 @@ def get_event_mapping(console: Console) -> dict[DomCode, Console.Event]:
7276
}
7377

7478

75-
def make_get_input(
76-
console: Console,
77-
get_pressed: Callable[[], set[DomCode]],
78-
) -> InputGetter:
79-
current_pressed: set[DomCode] = set()
80-
input_mapping = get_input_mapping(console)
81-
event_mapping = get_event_mapping(console)
82-
83-
def get_input() -> set[Console.Input]:
84-
nonlocal current_pressed
85-
old_pressed, current_pressed = current_pressed, set(get_pressed())
86-
# Propagate CPR flag from keyboard handler to run loop
87-
get_input.cpr_received = getattr(get_pressed, "cpr_received", False)
88-
for event in map(event_mapping.get, current_pressed - old_pressed):
79+
class GameInputGetter:
80+
"""Callable that translates raw key state into console inputs."""
81+
82+
def __init__(
83+
self,
84+
console: Console,
85+
get_pressed: Callable[[], set[DomCode] | KeyboardState],
86+
) -> None:
87+
self._get_pressed = get_pressed
88+
self._current_pressed: set[DomCode] = set()
89+
self._input_mapping = get_input_mapping(console)
90+
self._event_mapping = get_event_mapping(console)
91+
self._console = console
92+
self.cpr_state = KeyboardState()
93+
94+
def __call__(self) -> set[Console.Input]:
95+
result = self._get_pressed()
96+
if isinstance(result, KeyboardState):
97+
self.cpr_state.cpr_received = result.cpr_received
98+
new_pressed = set(result.pressed)
99+
else:
100+
self.cpr_state.cpr_received = False
101+
new_pressed = set(result)
102+
old_pressed, self._current_pressed = self._current_pressed, new_pressed
103+
for event in map(self._event_mapping.get, new_pressed - old_pressed):
89104
if event is None:
90105
continue
91-
console.handle_event(event)
106+
self._console.handle_event(event)
92107
return {
93-
input_mapping[keysym]
94-
for keysym in current_pressed
95-
if keysym in input_mapping
108+
self._input_mapping[keysym]
109+
for keysym in self._current_pressed
110+
if keysym in self._input_mapping
96111
}
97112

98-
get_input.cpr_received = False
99-
return get_input
113+
114+
def make_get_input(
115+
console: Console,
116+
get_pressed: Callable[[], set[DomCode] | KeyboardState],
117+
) -> GameInputGetter:
118+
return GameInputGetter(console, get_pressed)
100119

101120

102121
def _kitty_supported(term: Terminal) -> bool:
@@ -153,7 +172,7 @@ def console_input_from_keyboard_context(
153172
if xdg_session_type is None:
154173
xdg_session_type = os.environ.get("XDG_SESSION_TYPE", "")
155174
if xdg_session_type != "x11":
156-
raise RuntimeError(MESSAGE_FOR_WAYLAND_USERS)
175+
raise RuntimeError(MESSAGE_SUGGESTING_KITTY_SUPPORT)
157176
with console_input_from_x11_keyboard_context(console, display) as get_input:
158177
yield get_input
159178
else:
@@ -166,7 +185,7 @@ def key_pressed_context(
166185
term: Terminal,
167186
display: str | None = None,
168187
xdg_session_type: str | None = None,
169-
) -> Iterator[Callable[[], set[DomCode]]]:
188+
) -> Iterator[Callable[[], set[DomCode] | KeyboardState]]:
170189
if _kitty_supported(term):
171190
with blessed_key_pressed_context(term) as get_pressed:
172191
yield get_pressed
@@ -193,7 +212,11 @@ def main() -> None:
193212
with key_pressed_context(term) as get_pressed:
194213
while True:
195214
# Get codes
196-
codes = (x.value for x in get_pressed())
215+
result = get_pressed()
216+
pressed = result.pressed if isinstance(
217+
result, KeyboardState
218+
) else result
219+
codes = (x.value for x in pressed)
197220
# Print pressed key codes
198221
print("\r", *codes, flush=True, end=term.clear_eol)
199222
# Tick

gambaterm/main.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,6 @@ def main(
140140
f"Invalid color mode `{args.color_mode}`: the value must be between 1 and 4"
141141
)
142142

143-
# Query terminal background color BEFORE entering kitty keyboard mode,
144-
# as the OSC 11 response would leak into the keyboard input stream.
145-
bg_color = term.get_bgcolor(bits=8)
146-
147143
# Enter terminal raw mode
148144
with term.raw():
149145
try:
@@ -178,7 +174,6 @@ def main(
178174
break_after=args.break_after,
179175
speed_factor=args.speed_factor,
180176
use_cpr_sync=args.cpr_sync,
181-
bg_color=bg_color,
182177
)
183178

184179
# Deal with ctrl+c and ctrl+d exceptions

gambaterm/pynput_keyboard_input.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ def get_value_from_pynput_key_code(key: pynput.keyboard.KeyCode) -> DomCode | No
3434
assert False
3535

3636

37-
38-
3937
@contextmanager
4038
def pynput_key_pressed_context() -> Iterator[Callable[[], set[DomCode]]]:
4139
import pynput.keyboard

gambaterm/run.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .termblit import blit
1515
from .audio import AudioOut
1616
from .console import Console, InputGetter
17+
from .keyboard_input import GameInputGetter
1718
from .colors import ColorMode
1819

1920

@@ -53,19 +54,11 @@ def run(
5354
break_after: int | None = None,
5455
speed_factor: float = 1.0,
5556
use_cpr_sync: bool = False,
56-
bg_color: tuple[int, int, int] = (-1, -1, -1),
5757
) -> None:
5858
assert color_mode > 0
5959

6060
# Prepare buffers with invalid data
61-
# Use terminal background color so the blitter can skip pixels that
62-
# already match it on the first frame
63-
r, g, b = bg_color
64-
if r == -1:
65-
bg_val = np.uint32(0) # assume black if detection fails
66-
else:
67-
bg_val = np.uint32((r << 16) | (g << 8) | b)
68-
video = np.full((console.HEIGHT, console.WIDTH), bg_val, np.uint32)
61+
video = np.full((console.HEIGHT, console.WIDTH), 0, np.uint32)
6962
audio = np.full((2 * console.TICKS_IN_FRAME, 2), -0x7FFF, np.int16)
7063
last_frame = video.copy()
7164

@@ -92,6 +85,13 @@ def run(
9285
if audio_out:
9386
start -= 0.1
9487

88+
# Resolve CPR state once (only GameInputGetter has it)
89+
cpr_state = (
90+
get_input.cpr_state
91+
if isinstance(get_input, GameInputGetter)
92+
else None
93+
)
94+
9595
# Prepare state
9696
new_frame = False
9797
screen_ready = True
@@ -121,7 +121,7 @@ def run(
121121
audio_out.send(audio[:samples, :])
122122

123123
# Check for CPR response (set by keyboard handler during get_input)
124-
if use_cpr_sync and getattr(get_input, "cpr_received", False):
124+
if use_cpr_sync and cpr_state is not None and cpr_state.cpr_received:
125125
screen_ready = True
126126

127127
# Render video
@@ -133,20 +133,22 @@ def run(
133133
# Check terminal size
134134
new_height = term.height or 24
135135
new_width = term.width or 80
136-
maybe_clear_prefix, maybe_clear_suffix = b"", b""
136+
maybe_clear_seq = b""
137137
if (new_height, new_width) != (height, width):
138-
# Write video frame with clear sequence inside synchronized output mode (DEC
139-
# 2026) to prevent flicker, and, set last_frame as inverse of the current frame
140-
# to ensure a full redraw by the blitter
141-
maybe_clear_prefix = b"\033[?2026h\033[H\033[2J"
142-
maybe_clear_suffix = b"\033[?2026l"
138+
maybe_clear_seq = b"\033[H\033[2J"
143139
height, width = new_height, new_width
144140
refx, refy = get_ref(width, height, console)
145141
last_frame = ~video
146-
# Render frame
147-
video_data = maybe_clear_prefix + blit(
148-
video, last_frame, refx, refy, width - 1, height, color_mode
149-
) + maybe_clear_suffix
142+
# Render frame with synchronized output mode (DEC 2026) to prevent flickering
143+
# when the screen is cleared, or an artificial CRT-like "rolling band" side-effects
144+
# from fast "sprite blinking" meant to cause "transparency" effect on original HW,
145+
# https://zladx.github.io/posts/links-awakening-partial-translucency
146+
video_data = (
147+
b"\033[?2026h"
148+
+ maybe_clear_seq
149+
+ blit(video, last_frame, refx, refy, width - 1, height, color_mode)
150+
+ b"\033[?2026l"
151+
)
150152
last_frame = video.copy()
151153
# Update reporting
152154
data_length.append(len(video_data))

gambaterm/ssh.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ async def ssh_process_handler(process: SSHServerProcess[str]) -> int:
111111
executor: ThreadPoolExecutor = process.get_extra_info("executor")
112112
display = process.channel.get_x11_display()
113113
command = process.channel.get_command()
114-
environment = dict(process.channel.get_environment())
115114
terminal_type = process.get_terminal_type()
116115
connection = process.get_extra_info("connection")
117116
username = process.get_extra_info("username")
@@ -182,17 +181,6 @@ async def ssh_process_handler(process: SSHServerProcess[str]) -> int:
182181
else:
183182
color_mode = ColorMode.HAS_24_BIT_COLOR
184183

185-
if color_mode == ColorMode.NO_COLOR:
186-
print(
187-
f"Your terminal `{terminal_type}` doesn't seem to support colors.",
188-
f"Try to force a color mode by appending `-t -- --color-mode 3`"
189-
f" to the ssh command",
190-
sep="\r\n",
191-
file=process.stdout,
192-
)
193-
print(f"< User `{username}` terminal `{terminal_type}` does not support colors")
194-
return 1
195-
196184
# Now is a good time to instanciate the console
197185
# (it might fail if the ROM does not exist for instance)
198186
console = console_callback()

0 commit comments

Comments
 (0)