Skip to content

Commit f6019d3

Browse files
authored
Add frontend callback (PR #46)
Also: - add proper logging using structlog for the servers (#37) - refactor configuration handling and keyboard detection for the servers.
2 parents 78444f7 + 57235d4 commit f6019d3

10 files changed

Lines changed: 556 additions & 143 deletions

File tree

gambaterm/console.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from pathlib import Path
55
import tempfile
66
from enum import IntEnum
7-
from typing import Callable
7+
from typing import TYPE_CHECKING
8+
9+
if TYPE_CHECKING:
10+
from .main import AppConfig
811

912
import numpy as np
1013
import numpy.typing as npt
@@ -53,11 +56,8 @@ def add_console_arguments(cls, parser: argparse.ArgumentParser) -> None:
5356
pass
5457

5558
@classmethod
56-
def pop_console_arguments(
57-
cls, namespace: argparse.Namespace
58-
) -> Callable[[], Console]:
59-
romfile: Path = namespace.romfile
60-
return lambda: cls(romfile)
59+
def from_app_config(cls, app_config: AppConfig) -> Console:
60+
return cls(app_config.romfile)
6161

6262
def __init__(self, romfile: Path):
6363
self.romfile = str(romfile.resolve())
@@ -117,17 +117,17 @@ def add_console_arguments(cls, parser: argparse.ArgumentParser) -> None:
117117
)
118118

119119
@classmethod
120-
def pop_console_arguments(
121-
cls, namespace: argparse.Namespace
122-
) -> Callable[[], Console]:
123-
romfile: Path = namespace.romfile
124-
input_file: Path | None = namespace.input_file
125-
save_directory: Path | None = namespace.save_directory
126-
force_gameboy: bool = namespace.__dict__.pop("force_gameboy")
120+
def from_app_config(cls, app_config: AppConfig) -> GameboyColor:
121+
romfile: Path = app_config.romfile
122+
input_file: Path | None = app_config.input_file
123+
save_directory: Path | None = app_config.save_directory
124+
force_gameboy: bool = getattr(
125+
app_config.console_namespace, "force_gameboy", False
126+
)
127127
# Save directory defaults to the rom file directory (unless we read the input from a file)
128128
if input_file is None and save_directory is None:
129129
save_directory = romfile.parent
130-
return lambda: cls(romfile, save_directory, force_gameboy)
130+
return cls(romfile, save_directory, force_gameboy)
131131

132132
def __init__(
133133
self,

gambaterm/main.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
import time
55
import argparse
66
from pathlib import Path
7-
from typing import ContextManager
8-
from dataclasses import dataclass
7+
from typing import ContextManager, TYPE_CHECKING
8+
import dataclasses
9+
from dataclasses import dataclass, field
910

1011
from blessed import Terminal
1112

@@ -18,6 +19,10 @@
1819
from .controller_input import combine_console_input_from_controller_context
1920
from .file_input import console_input_from_file_context, write_input_context
2021

22+
# `typing.Self` is not available in python 3.10
23+
if TYPE_CHECKING:
24+
from typing import Self
25+
2126

2227
@dataclass
2328
class AppConfig:
@@ -29,13 +34,26 @@ class AppConfig:
2934
speed: float
3035
skip_inputs: int
3136
cpr_sync: bool
32-
save_directory: Path | None
37+
save_directory: Path | None = None
38+
console_namespace: argparse.Namespace = field(default_factory=argparse.Namespace)
39+
40+
@classmethod
41+
def from_namespace(cls, namespace: argparse.Namespace) -> Self:
42+
allowed_keys = {
43+
f.name for f in dataclasses.fields(cls) if f.name != "console_namespace"
44+
}
45+
kwargs = {k: v for k, v in vars(namespace).items() if k in allowed_keys}
46+
console_keys = {
47+
k: v for k, v in vars(namespace).items() if k not in allowed_keys
48+
}
49+
kwargs["console_namespace"] = argparse.Namespace(**console_keys)
50+
return cls(**kwargs)
3351

3452

3553
@dataclass
3654
class LocalAppConfig(AppConfig):
37-
enable_controller: bool
38-
write_input: Path | None
55+
enable_controller: bool = False
56+
write_input: Path | None = None
3957

4058

4159
def add_base_arguments(parser: argparse.ArgumentParser) -> None:
@@ -138,16 +156,15 @@ def main(
138156

139157
# Parse arguments
140158
namespace = parser.parse_args(parser_args)
141-
disable_audio: bool = namespace.__dict__.pop("disable_audio")
142-
console_callback = console_cls.pop_console_arguments(namespace)
143-
args = LocalAppConfig(**vars(namespace))
159+
disable_audio = getattr(namespace, "disable_audio", False)
160+
args = LocalAppConfig.from_namespace(namespace)
144161

145162
# Check that the ROM file exists
146163
if not args.romfile.exists():
147164
raise SystemExit(f"ROM file `{args.romfile}` does not exist")
148165

149166
# Instantiate the console and terminal
150-
console = console_callback()
167+
console = console_cls.from_app_config(args)
151168
terminal = Terminal()
152169

153170
# Prepare input context

gambaterm/remote_terminal.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
"""
2-
Provide a blessed Terminal subclass for remote (SSH/telnet) streams.
2+
Provide common resources for both Telnet and SSH terminals.
33
"""
44

55
from __future__ import annotations
66

77
import codecs
8+
from concurrent.futures import ThreadPoolExecutor, CancelledError
9+
from enum import Enum
810
import hashlib
911
import contextlib
10-
from typing import IO, Generator
12+
from typing import IO, Callable, Generator, TypeAlias, TYPE_CHECKING
1113

1214
from blessed import Terminal as BlessedTerminal
1315
from blessed.terminal import WINSZ
1416

17+
if TYPE_CHECKING:
18+
from .main import AppConfig
19+
1520

1621
class RemoteTerminal(BlessedTerminal):
1722
"""A blessed Terminal subclass for remote streams (SSH, telnet).
@@ -87,6 +92,55 @@ def update_size(self, rows: int, columns: int) -> None:
8792
self._columns = columns
8893

8994

95+
class KeyboardSupport(Enum):
96+
BASIC = "basic"
97+
KEYBOARD_PROTOCOL = "keyboard_protocol"
98+
X11 = "x11"
99+
100+
101+
class KeyboardSupportDetection:
102+
def __init__(
103+
self,
104+
terminal: RemoteTerminal,
105+
display: str | None = None,
106+
executor: ThreadPoolExecutor | None = None,
107+
) -> None:
108+
self.terminal = terminal
109+
self.display = display
110+
self.executor = executor
111+
self._cache: KeyboardSupport | None = None
112+
113+
def get(self, timeout: float = 3.0) -> KeyboardSupport:
114+
if self._cache is not None:
115+
return self._cache
116+
self._cache = self._detect(timeout)
117+
return self._cache
118+
119+
def _detect(self, timeout: float = 3.0) -> KeyboardSupport:
120+
from .keyboard_input import is_kitty_keyboard_protocol_supported
121+
122+
if is_kitty_keyboard_protocol_supported(self.terminal, timeout=timeout):
123+
return KeyboardSupport.KEYBOARD_PROTOCOL
124+
125+
elif self.display and self.executor:
126+
from .x11_keyboard_input import is_x11_display_functional
127+
128+
try:
129+
if self.executor.submit(is_x11_display_functional, self.display).result(
130+
timeout=timeout
131+
):
132+
return KeyboardSupport.X11
133+
except CancelledError:
134+
pass
135+
136+
return KeyboardSupport.BASIC
137+
138+
139+
FrontendCallback: TypeAlias = Callable[
140+
[RemoteTerminal, "AppConfig", KeyboardSupportDetection], "AppConfig"
141+
]
142+
143+
90144
def user_directory_name(username: str | None) -> str:
91145
"""Hash the username into a safe directory name.
92146

0 commit comments

Comments
 (0)