ibm5250 0.1.0.dev8__tar.gz → 0.1.0.dev9__tar.gz
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.
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/PKG-INFO +1 -1
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/pyproject.toml +1 -1
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/__init__.py +6 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/connection.py +25 -21
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/data-stream-logic.md +29 -8
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/datastream.py +241 -17
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/screen.py +83 -21
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/terminal.py +44 -45
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/uv.lock +1 -1
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/.github/copilot-instructions.md +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/.github/workflows/README.md +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/.github/workflows/publish.yml +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/.gitignore +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/LICENSE +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/README.md +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/scripts/compute_version.py +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/constants.py +0 -0
- {ibm5250-0.1.0.dev8 → ibm5250-0.1.0.dev9}/src/ibm5250/exceptions.py +0 -0
|
@@ -35,6 +35,9 @@ from .datastream import (
|
|
|
35
35
|
DSEventType,
|
|
36
36
|
FieldResponse,
|
|
37
37
|
build_aid_response,
|
|
38
|
+
build_save_screen_response,
|
|
39
|
+
decode_save_screen_fields,
|
|
40
|
+
decode_save_screen_image,
|
|
38
41
|
ebcdic_decode,
|
|
39
42
|
ebcdic_decode_stripped,
|
|
40
43
|
ebcdic_encode,
|
|
@@ -81,6 +84,9 @@ __all__ = [
|
|
|
81
84
|
"DSEventType",
|
|
82
85
|
"FieldResponse",
|
|
83
86
|
"build_aid_response",
|
|
87
|
+
"build_save_screen_response",
|
|
88
|
+
"decode_save_screen_image",
|
|
89
|
+
"decode_save_screen_fields",
|
|
84
90
|
# EBCDIC helpers
|
|
85
91
|
"ebcdic_encode",
|
|
86
92
|
"ebcdic_decode",
|
|
@@ -26,7 +26,6 @@ from dataclasses import dataclass
|
|
|
26
26
|
|
|
27
27
|
from .constants import (
|
|
28
28
|
AID,
|
|
29
|
-
Command,
|
|
30
29
|
TelnetCmd,
|
|
31
30
|
TelnetOpt,
|
|
32
31
|
TN5250EDataType,
|
|
@@ -62,6 +61,9 @@ _TN5250E_ATTN_FLAG = 0x40
|
|
|
62
61
|
# host will run its Attention program (e.g. the System Request / Assist menu).
|
|
63
62
|
_TN5250E_OP_CANCEL_INVITE = 0x0A
|
|
64
63
|
|
|
64
|
+
# Byte 9 operation code for the Save Screen response record (RFC 2877 §4.3).
|
|
65
|
+
_TN5250E_OP_SAVE_SCREEN_RESPONSE = 0x04
|
|
66
|
+
|
|
65
67
|
_MAX_NEG_PASSES = 40 # Maximum Telnet option exchange iterations during negotiation
|
|
66
68
|
|
|
67
69
|
|
|
@@ -71,19 +73,14 @@ _MAX_NEG_PASSES = 40 # Maximum Telnet option exchange iterations during negotia
|
|
|
71
73
|
|
|
72
74
|
_TN5250E_HEADER_LEN = 10
|
|
73
75
|
|
|
74
|
-
# The
|
|
75
|
-
#
|
|
76
|
-
# begins at ``6 + header[6]`` (normally 6 + 4 = 10).
|
|
76
|
+
# The 5250 payload starts after the fixed ten-byte TN5250E header. Byte 9 is
|
|
77
|
+
# the per-direction sequence number, not a variable-header length.
|
|
77
78
|
_TN5250E_FIXED_HEADER_LEN = 6
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def _payload_offset(frame: bytes) -> int:
|
|
81
82
|
"""Return the offset of the 5250 payload within a TN5250E *frame*."""
|
|
82
|
-
|
|
83
|
-
if not _TN5250E_HEADER_LEN <= offset <= len(frame):
|
|
84
|
-
# Malformed variable-header length; fall back to the standard 10 bytes.
|
|
85
|
-
return _TN5250E_HEADER_LEN
|
|
86
|
-
return offset
|
|
83
|
+
return _TN5250E_HEADER_LEN
|
|
87
84
|
|
|
88
85
|
|
|
89
86
|
@dataclass
|
|
@@ -113,7 +110,7 @@ class TN5250EHeader:
|
|
|
113
110
|
self.data_type, # 6: data type
|
|
114
111
|
0x00, # 7: request/response
|
|
115
112
|
self.err_flag, # 8: error recovery
|
|
116
|
-
var_len, # 9:
|
|
113
|
+
var_len, # 9: operation code (var-header length; 3 = Put/Get)
|
|
117
114
|
]
|
|
118
115
|
)
|
|
119
116
|
+ var_hdr
|
|
@@ -337,21 +334,28 @@ class TN5250Connection:
|
|
|
337
334
|
frame = _build_tn5250e_frame(req_resp=0x00, opcode=_TN5250E_OP_CANCEL_INVITE)
|
|
338
335
|
self._send_frame(frame, description="Sending Cancel-Invite response")
|
|
339
336
|
|
|
340
|
-
def send_save_screen_response(self,
|
|
337
|
+
def send_save_screen_response(self, screen_data: bytes) -> None:
|
|
341
338
|
"""Send the TN5250E-level Save Screen acknowledgment.
|
|
342
339
|
|
|
343
340
|
When the server issues an ESC+SAVE_SCREEN (0x04 0x02) command the
|
|
344
|
-
terminal
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
341
|
+
terminal replies with a complete, serialized presentation space. The
|
|
342
|
+
host stores this opaque regeneration buffer and returns it in a later
|
|
343
|
+
Restore Screen command.
|
|
344
|
+
|
|
345
|
+
Parameters
|
|
346
|
+
----------
|
|
347
|
+
screen_data:
|
|
348
|
+
A complete length-prefixed Restore Screen response produced by the
|
|
349
|
+
datastream encoder.
|
|
349
350
|
"""
|
|
350
351
|
if not self._tn5250e_active:
|
|
351
352
|
return # save/restore handshake only occurs in TN5250E mode
|
|
352
353
|
|
|
353
|
-
|
|
354
|
-
|
|
354
|
+
frame = _build_tn5250e_frame(
|
|
355
|
+
req_resp=0x00,
|
|
356
|
+
opcode=_TN5250E_OP_SAVE_SCREEN_RESPONSE,
|
|
357
|
+
var_hdr=screen_data,
|
|
358
|
+
)
|
|
355
359
|
self._send_frame(frame, description="Sending Save Screen response")
|
|
356
360
|
|
|
357
361
|
def recv_record(self) -> bytes:
|
|
@@ -772,7 +776,7 @@ def _iac_escape(data: bytes) -> bytes:
|
|
|
772
776
|
|
|
773
777
|
|
|
774
778
|
def _build_tn5250e_frame(*, req_resp: int, opcode: int, var_hdr: bytes = b"") -> bytes:
|
|
775
|
-
"""Build an IAC-EOR-terminated TN5250E frame with
|
|
779
|
+
"""Build an IAC-EOR-terminated TN5250E frame with an optional 5250 payload.
|
|
776
780
|
|
|
777
781
|
Produces the fixed 10-byte header (RFC 2877 §3.3) followed by an optional
|
|
778
782
|
variable header, IAC-escapes it, and appends the IAC-EOR record terminator.
|
|
@@ -784,7 +788,7 @@ def _build_tn5250e_frame(*, req_resp: int, opcode: int, var_hdr: bytes = b"") ->
|
|
|
784
788
|
req_resp:
|
|
785
789
|
Byte 7 (request/response flags) — e.g. the Attn flag.
|
|
786
790
|
opcode:
|
|
787
|
-
Byte 9
|
|
791
|
+
Byte 9 operation code (RFC 2877 §4.3) — how the host identifies the record.
|
|
788
792
|
var_hdr:
|
|
789
793
|
Optional variable-header bytes appended after the fixed header.
|
|
790
794
|
"""
|
|
@@ -800,7 +804,7 @@ def _build_tn5250e_frame(*, req_resp: int, opcode: int, var_hdr: bytes = b"") ->
|
|
|
800
804
|
TN5250EDataType.INPUT, # 6: data type (workstation → host)
|
|
801
805
|
req_resp, # 7: request/response flags
|
|
802
806
|
0x00, # 8: error recovery
|
|
803
|
-
opcode, # 9:
|
|
807
|
+
opcode, # 9: operation code
|
|
804
808
|
]
|
|
805
809
|
)
|
|
806
810
|
return _iac_escape(header + var_hdr) + bytes(
|
|
@@ -38,11 +38,13 @@ The **TN5250Connection** manages the TCP socket to the IBM i (AS/400) host.
|
|
|
38
38
|
### Save Screen Response (`send_save_screen_response`)
|
|
39
39
|
|
|
40
40
|
When the server issues a SAVE_SCREEN command, the terminal must reply with a
|
|
41
|
-
TN5250E-framed
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
TN5250E-framed record carrying `ESC + RESTORE_SCREEN + a two-byte length + the
|
|
42
|
+
current presentation space`. The image is freshly serialized from the current
|
|
43
|
+
screen model: geometry, cursor/home positions, the complete character/attribute
|
|
44
|
+
plane, and the field table. Host-to-terminal WTD, Clear Unit, and Read commands
|
|
45
|
+
are never replayed. The host stores this opaque regeneration buffer and returns
|
|
46
|
+
it in a later Restore Screen command. See
|
|
47
|
+
[Save/Restore Screen Flow](#saverestore-screen-flow).
|
|
46
48
|
|
|
47
49
|
---
|
|
48
50
|
|
|
@@ -130,12 +132,19 @@ terminator for the variable-length part of an SF order.
|
|
|
130
132
|
after SF is then the attribute.
|
|
131
133
|
- The **FFW** (Field Format Word) defines field behaviour — bypass/input,
|
|
132
134
|
keyboard shift, mandatory entry, and so on. Byte 0 always has bit `0x40` set,
|
|
133
|
-
which is how it is told apart from an attribute byte.
|
|
135
|
+
which is how it is told apart from an attribute byte. Byte 1 bit `0x80` is
|
|
136
|
+
**Auto-Enter**: the host expects an Enter AID to be sent automatically the
|
|
137
|
+
moment the field is completely filled (`Field.auto_enter`). On the wire this
|
|
138
|
+
looks like e.g. `1D 40 A0 26 00 01` — FFW `40 A0`, attribute `26`, length `1`
|
|
139
|
+
— which is a plain auto-enter field, _not_ an FCW pair.
|
|
134
140
|
- Zero or more **FCW** (Field Control Word) pairs may follow; the chain ends at
|
|
135
141
|
the first attribute byte, _not_ at a continuation bit.
|
|
136
142
|
- The attribute byte is written at the field's start position, and the field's
|
|
137
143
|
length is the **two bytes after it** — it is not derived from an FCW and it is
|
|
138
144
|
not inferred from where the next field begins.
|
|
145
|
+
- A **Start of Header (SOH)** begins a new format table. Applying it discards
|
|
146
|
+
existing field definitions without clearing display characters; subsequent
|
|
147
|
+
SF orders rebuild the table for the current write.
|
|
139
148
|
|
|
140
149
|
### Write Structured Field (WSF / Query)
|
|
141
150
|
|
|
@@ -170,6 +179,8 @@ The **Screen** holds the actual state that represents what you'd see on a physic
|
|
|
170
179
|
- **Field list** — all SF/MF-defined fields with their position, length, format word, and modified-data-tag.
|
|
171
180
|
- **Cursor position** — where the blinking cursor sits.
|
|
172
181
|
- **Keyboard locked flag** — when True, the terminal won't send AID keys.
|
|
182
|
+
- **Last AID** — the most recent AID successfully sent to the host; included
|
|
183
|
+
in Save Screen device state.
|
|
173
184
|
|
|
174
185
|
### Applying Events
|
|
175
186
|
|
|
@@ -210,8 +221,8 @@ This is the central method — it:
|
|
|
210
221
|
2. Feeds it through `parser.parse()` to get a list of `DSEvent` objects.
|
|
211
222
|
3. Iterates through events looking for special commands:
|
|
212
223
|
- **QUERY** → sends back a Query Reply with terminal capabilities
|
|
213
|
-
- **SAVE_SCREEN** → deep-copies the current `Screen
|
|
214
|
-
- **RESTORE_SCREEN** →
|
|
224
|
+
- **SAVE_SCREEN** → deep-copies the current `Screen` onto a stack, serializes one complete regeneration buffer from that model, and returns it in the save-screen response
|
|
225
|
+
- **RESTORE_SCREEN** → pops the most recently saved `Screen`; the host-returned regeneration buffer is opaque because the local copy is authoritative, then any remaining events from the same record are applied on top
|
|
215
226
|
- **RESIZE_SCREEN** → re-creates the parser and screen at the new dimensions
|
|
216
227
|
4. If no RESTORE occurred, applies all events to the screen normally.
|
|
217
228
|
5. **Post-RESTORE keyboard drain**: After restoring a screen, the keyboard is locked (because it was locked when saved). The server sends a follow-up record to unlock it. The terminal tries one more `_recv_and_apply()` to consume that unlock record. If nothing comes (RecvTimeout), it moves on.
|
|
@@ -224,6 +235,16 @@ This is the central method — it:
|
|
|
224
235
|
4. Sets `keyboard_locked = True` (the server will unlock it when it replies).
|
|
225
236
|
5. Calls `_recv_and_apply()` to wait for and process the server's response.
|
|
226
237
|
|
|
238
|
+
### Auto-Enter fields
|
|
239
|
+
|
|
240
|
+
When `type_into()` fills a field whose FFW has the Auto-Enter bit set, it
|
|
241
|
+
submits `AID.ENTER` itself (via the normal `send_key` path) rather than waiting
|
|
242
|
+
for the caller — mirroring a physical 5250. The cursor is placed on the
|
|
243
|
+
auto-enter field first, because `set_field_text` otherwise advances it to the
|
|
244
|
+
next field once the current one is full. Only the modified-data tags are cleared
|
|
245
|
+
locally; the resulting screen (often a Save/Restore popup handshake) is driven
|
|
246
|
+
entirely by the host and handled by `_recv_and_apply()`.
|
|
247
|
+
|
|
227
248
|
### Waiting: `wait_for_text(text)`
|
|
228
249
|
|
|
229
250
|
1. Checks if the text already appears on screen (from events already applied).
|
|
@@ -34,6 +34,10 @@ import struct
|
|
|
34
34
|
from collections.abc import Iterator
|
|
35
35
|
from dataclasses import dataclass, field
|
|
36
36
|
from enum import Enum, auto
|
|
37
|
+
from typing import TYPE_CHECKING
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from .screen import Screen
|
|
37
41
|
|
|
38
42
|
from .constants import (
|
|
39
43
|
AID,
|
|
@@ -41,7 +45,6 @@ from .constants import (
|
|
|
41
45
|
EBCDIC_SPACE,
|
|
42
46
|
FFW,
|
|
43
47
|
KNOWN_ORDERS,
|
|
44
|
-
SAVE_SCREEN_ID_LEN,
|
|
45
48
|
SCREEN_COLS,
|
|
46
49
|
SCREEN_COLS_132,
|
|
47
50
|
SCREEN_ROWS,
|
|
@@ -82,6 +85,7 @@ class DSEventType(Enum):
|
|
|
82
85
|
SET_ATTRIBUTE = auto() # Set a display/character attribute (SA)
|
|
83
86
|
COMMAND = auto() # Raw command code (WTD, read, etc.)
|
|
84
87
|
QUERY = auto() # Server requests terminal capability query reply
|
|
88
|
+
START_OF_HEADER = auto() # SOH order carrying the current format header
|
|
85
89
|
|
|
86
90
|
|
|
87
91
|
@dataclass
|
|
@@ -219,25 +223,20 @@ class DataStreamParser:
|
|
|
219
223
|
yield from self._parse_wsf(data_view, byte_offset, total_bytes)
|
|
220
224
|
return
|
|
221
225
|
elif sub_command in (Command.SAVE_SCREEN, Command.RESTORE_SCREEN):
|
|
222
|
-
# The server is issuing a save/restore command with an ESC
|
|
223
|
-
# prefix. A Restore Screen carries the two-byte screen
|
|
224
|
-
# identifier that the workstation returned when the host
|
|
225
|
-
# asked it to save that screen.
|
|
226
226
|
byte_offset += 1
|
|
227
|
-
|
|
228
|
-
if
|
|
229
|
-
|
|
230
|
-
and total_bytes - byte_offset == SAVE_SCREEN_ID_LEN
|
|
231
|
-
):
|
|
232
|
-
save_id = bytes(data_view[byte_offset:total_bytes])
|
|
227
|
+
restore_data = b""
|
|
228
|
+
if sub_command == Command.RESTORE_SCREEN:
|
|
229
|
+
restore_data = bytes(data_view[byte_offset:total_bytes])
|
|
233
230
|
byte_offset = total_bytes
|
|
234
231
|
log.debug(
|
|
235
|
-
"ESC + command 0x%02X (
|
|
232
|
+
"ESC + command 0x%02X (%d restore bytes) — emitting COMMAND event",
|
|
236
233
|
sub_command,
|
|
237
|
-
|
|
234
|
+
len(restore_data),
|
|
238
235
|
)
|
|
239
236
|
yield DSEvent(
|
|
240
|
-
type=DSEventType.COMMAND,
|
|
237
|
+
type=DSEventType.COMMAND,
|
|
238
|
+
command=sub_command,
|
|
239
|
+
data=restore_data,
|
|
241
240
|
)
|
|
242
241
|
return
|
|
243
242
|
else:
|
|
@@ -301,10 +300,13 @@ class DataStreamParser:
|
|
|
301
300
|
current_buffer_position + len(data_chunk)
|
|
302
301
|
) % self._size
|
|
303
302
|
else:
|
|
303
|
+
soh_start = byte_offset # at the length byte
|
|
304
304
|
byte_offset += 1
|
|
305
|
-
byte_offset += max(
|
|
306
|
-
|
|
307
|
-
|
|
305
|
+
byte_offset += max(0, start_of_header_length)
|
|
306
|
+
yield DSEvent(
|
|
307
|
+
type=DSEventType.START_OF_HEADER,
|
|
308
|
+
data=bytes(data_view[soh_start:byte_offset]),
|
|
309
|
+
)
|
|
308
310
|
|
|
309
311
|
case Order.TRANSPARENT_DATA:
|
|
310
312
|
byte_offset += 1
|
|
@@ -618,6 +620,228 @@ class DataStreamParser:
|
|
|
618
620
|
# ---------------------------------------------------------------------------
|
|
619
621
|
|
|
620
622
|
|
|
623
|
+
# Fixed segments of the Save/Restore Screen (04 12) structured field, taken
|
|
624
|
+
# from a known-good "nikkie 6.1" reply. Unlike a Write To Display (04 11)
|
|
625
|
+
# packet, this structured field is a self-describing snapshot: order bytes in
|
|
626
|
+
# the buffer are shift-escaped with 0x10, and the buffer is preceded by a
|
|
627
|
+
# device identity/capability header and a device/keyboard state block.
|
|
628
|
+
_SAVE_SCREEN_SHIFT = 0x10
|
|
629
|
+
_SAVE_SCREEN_DEVICE_ID = b"\x10\x01\x0bnikkie 6.1\x00"
|
|
630
|
+
_SAVE_SCREEN_CAPS = bytes.fromhex("08 20 08 1f 1a 07 7f") # 24x80 device capabilities
|
|
631
|
+
_SAVE_SCREEN_MARKER = b"\x18\x50" # begin 80-column display snapshot
|
|
632
|
+
_SAVE_SCREEN_BUFFER_INTRO = 0x04 # 10 04 <len> <dims> <character plane>
|
|
633
|
+
_SAVE_SCREEN_DEVICE_STATE = b"\x01\x01"
|
|
634
|
+
_SAVE_SCREEN_PLANE_STATE = b"\x01\x00\x00"
|
|
635
|
+
_SAVE_SCREEN_CURSOR_TAG = 0x02
|
|
636
|
+
# Active-field pointer: 00 00 <field id> 00. The field id is screen-dependent
|
|
637
|
+
# (the format-table index the cursor sits in); pinned until derivation is
|
|
638
|
+
# verified against more capture frames. Never read back on our restore path.
|
|
639
|
+
_SAVE_SCREEN_ACTIVE_FIELD_POINTER = b"\x00\x00\x01\x00"
|
|
640
|
+
# Constant keyboard/shift/insert mode flags that follow the field pointer.
|
|
641
|
+
_SAVE_SCREEN_INPUT_MODE_FLAGS = b"\x00\x01\x01\x00\x01\x00"
|
|
642
|
+
# The AID byte (screen.last_aid) is emitted here, between the mode flags and
|
|
643
|
+
# the trailer — it is dynamic, not part of either constant.
|
|
644
|
+
_SAVE_SCREEN_INPUT_STATE_TRAILER = bytes.fromhex("00 0b 00 00 00 03 00 00")
|
|
645
|
+
# Two full presentation-space window definitions are derived from geometry.
|
|
646
|
+
# Neutral format header used when no host SOH has been received.
|
|
647
|
+
_SAVE_SCREEN_DEFAULT_SOH = b"\x07" + bytes(7)
|
|
648
|
+
# Per-field descriptor flag: 0xC5 for input-attribute (0x24) fields, 0xC7 otherwise.
|
|
649
|
+
_SAVE_SCREEN_INPUT_ATTR = 0x24
|
|
650
|
+
_SAVE_SCREEN_FIELD_FLAG_INPUT = 0xC5
|
|
651
|
+
_SAVE_SCREEN_FIELD_FLAG_OTHER = 0xC7
|
|
652
|
+
_SAVE_SCREEN_FIELD_ENTRY_LEN = 9 # flag(3) + data_pos(2) + len(1) + ffw(2) + attr(1)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def build_save_screen_response(screen: Screen) -> bytes:
|
|
656
|
+
"""Serialize the current screen as a Save/Restore Screen (04 12) reply.
|
|
657
|
+
|
|
658
|
+
The reply mirrors a "nikkie 6.1" save: device identity, two window
|
|
659
|
+
descriptors, a device-state block, the run-length-compressed image
|
|
660
|
+
(``10 04``), and the trailing window (``10 05`` / ``10 06``) and
|
|
661
|
+
field-format (``10 08``) sections. The two-byte length after
|
|
662
|
+
``ESC RESTORE_SCREEN`` includes itself.
|
|
663
|
+
"""
|
|
664
|
+
size = screen.rows * screen.cols
|
|
665
|
+
image = _compress_save_screen_plane(bytes(screen._chars[:size]))
|
|
666
|
+
|
|
667
|
+
body = bytearray()
|
|
668
|
+
body += _SAVE_SCREEN_DEVICE_ID
|
|
669
|
+
body += b"\x10\x02" + _SAVE_SCREEN_CAPS
|
|
670
|
+
body += _SAVE_SCREEN_MARKER
|
|
671
|
+
body += _SAVE_SCREEN_CAPS # second window descriptor (bare, no 10 02)
|
|
672
|
+
body += _SAVE_SCREEN_MARKER
|
|
673
|
+
body += _save_screen_state_block(screen)
|
|
674
|
+
body += bytes([_SAVE_SCREEN_SHIFT, _SAVE_SCREEN_BUFFER_INTRO])
|
|
675
|
+
body += struct.pack(">H", 4 + len(image)) # length counts LL LL + SS SS + image
|
|
676
|
+
body += struct.pack(">H", size)
|
|
677
|
+
body += image
|
|
678
|
+
body += _save_screen_windows(size)
|
|
679
|
+
body += _save_screen_format_table(screen)
|
|
680
|
+
|
|
681
|
+
return (
|
|
682
|
+
bytes([Command.ESC, Command.RESTORE_SCREEN])
|
|
683
|
+
+ struct.pack(">H", 2 + len(body))
|
|
684
|
+
+ bytes(body)
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _dims_for_size(size: int) -> tuple[int, int]:
|
|
689
|
+
"""Map a plane size back to (rows, cols) for the two supported geometries."""
|
|
690
|
+
if size == SCREEN_ROWS_27 * SCREEN_COLS_132:
|
|
691
|
+
return SCREEN_ROWS_27, SCREEN_COLS_132
|
|
692
|
+
return SCREEN_ROWS, SCREEN_COLS
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def decode_save_screen_image(buffer: bytes) -> tuple[int, int, bytes] | None:
|
|
696
|
+
"""Extract ``(rows, cols, character_plane)`` from a Save/Restore buffer.
|
|
697
|
+
|
|
698
|
+
The inverse of the ``10 04`` image built by
|
|
699
|
+
:func:`build_save_screen_response`: locate the section, read its declared
|
|
700
|
+
length and dimensions, and run-length-decode the plane. Returns ``None``
|
|
701
|
+
when no image section is present.
|
|
702
|
+
"""
|
|
703
|
+
marker = buffer.find(bytes([_SAVE_SCREEN_SHIFT, _SAVE_SCREEN_BUFFER_INTRO]))
|
|
704
|
+
if marker == -1 or marker + 6 > len(buffer):
|
|
705
|
+
return None
|
|
706
|
+
declared_len = int.from_bytes(buffer[marker + 2 : marker + 4], "big")
|
|
707
|
+
size = int.from_bytes(buffer[marker + 4 : marker + 6], "big")
|
|
708
|
+
image = buffer[marker + 6 : marker + 6 + declared_len - 4]
|
|
709
|
+
plane = bytearray()
|
|
710
|
+
index = 0
|
|
711
|
+
total = len(image)
|
|
712
|
+
while index < total and len(plane) < size:
|
|
713
|
+
if (
|
|
714
|
+
image[index] == _SAVE_SCREEN_SHIFT
|
|
715
|
+
and index + 3 < total
|
|
716
|
+
and image[index + 1] == Order.SET_BUFFER_ADDRESS
|
|
717
|
+
):
|
|
718
|
+
count = image[index + 2]
|
|
719
|
+
plane += bytes([image[index + 3]]) * count
|
|
720
|
+
index += 4
|
|
721
|
+
else:
|
|
722
|
+
plane.append(image[index])
|
|
723
|
+
index += 1
|
|
724
|
+
if len(plane) < size:
|
|
725
|
+
plane += bytes([EBCDIC_SPACE]) * (size - len(plane))
|
|
726
|
+
rows, cols = _dims_for_size(size)
|
|
727
|
+
return rows, cols, bytes(plane[:size])
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _save_screen_state_block(screen: Screen) -> bytes:
|
|
731
|
+
"""Return the device-state block with live cursor and screen geometry."""
|
|
732
|
+
cursor_pos = screen.cursor_pos
|
|
733
|
+
rows = screen.rows
|
|
734
|
+
cols = screen.cols
|
|
735
|
+
size = rows * cols
|
|
736
|
+
last_row_start = size - cols
|
|
737
|
+
last_cell = size - 1
|
|
738
|
+
pos_hi, pos_lo = (cursor_pos >> 8) & 0xFF, cursor_pos & 0xFF
|
|
739
|
+
row, col = encode_addr(cursor_pos, cols)
|
|
740
|
+
|
|
741
|
+
block = bytearray(9) # no format-area pointers
|
|
742
|
+
block += struct.pack(">HBB", cursor_pos, row, col)
|
|
743
|
+
block += _SAVE_SCREEN_DEVICE_STATE
|
|
744
|
+
block += bytes(7)
|
|
745
|
+
block += struct.pack(">BHHB", rows, last_row_start, last_cell, 0)
|
|
746
|
+
block += struct.pack(">BBHH", rows, rows, last_row_start, last_cell)
|
|
747
|
+
block += bytes(5)
|
|
748
|
+
block += struct.pack(
|
|
749
|
+
">BHHHH", rows, last_row_start, last_cell, last_row_start + 1, last_cell - 1
|
|
750
|
+
)
|
|
751
|
+
block += struct.pack(">BHH", rows + 1, size, size + cols - 1)
|
|
752
|
+
block += struct.pack(">BHH", rows + 2, size + cols, size + 2 * cols - 1)
|
|
753
|
+
block += _SAVE_SCREEN_PLANE_STATE
|
|
754
|
+
block += bytes([_SAVE_SCREEN_CURSOR_TAG, pos_hi, pos_lo])
|
|
755
|
+
block += _SAVE_SCREEN_ACTIVE_FIELD_POINTER
|
|
756
|
+
block += _SAVE_SCREEN_INPUT_MODE_FLAGS
|
|
757
|
+
block += bytes([screen.last_aid]) # dynamic: the last AID key sent
|
|
758
|
+
block += _SAVE_SCREEN_INPUT_STATE_TRAILER
|
|
759
|
+
return bytes(block)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _save_screen_windows(size: int) -> bytes:
|
|
763
|
+
"""Build the 10 05 / 10 06 window definitions sized to the screen."""
|
|
764
|
+
geometry = struct.pack(">H", size)
|
|
765
|
+
section = b"\x00\x09" + geometry + b"\x10\x12" + geometry + b"\xc0"
|
|
766
|
+
return b"\x10\x05" + section + b"\x10\x06" + section
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _save_screen_format_table(screen: Screen) -> bytes:
|
|
770
|
+
"""Build the 10 08 field-format table from the host SOH and input fields.
|
|
771
|
+
|
|
772
|
+
Each field is a fixed 9-byte descriptor: a 3-byte flag prefix, a 2-byte
|
|
773
|
+
data position, a length byte, the 2-byte FFW, and the attribute byte.
|
|
774
|
+
"""
|
|
775
|
+
header = screen.last_start_of_header or _SAVE_SCREEN_DEFAULT_SOH
|
|
776
|
+
entries = bytearray()
|
|
777
|
+
for input_field in screen.input_fields:
|
|
778
|
+
flag = (
|
|
779
|
+
_SAVE_SCREEN_FIELD_FLAG_INPUT
|
|
780
|
+
if input_field.attribute == _SAVE_SCREEN_INPUT_ATTR
|
|
781
|
+
else _SAVE_SCREEN_FIELD_FLAG_OTHER
|
|
782
|
+
)
|
|
783
|
+
entries += bytes([flag, 0x00, 0x00])
|
|
784
|
+
entries += struct.pack(">H", input_field.data_pos)
|
|
785
|
+
entries += bytes([input_field.length & 0xFF])
|
|
786
|
+
entries += input_field.field_format_word[:2].ljust(2, b"\x00")
|
|
787
|
+
entries += bytes([input_field.attribute])
|
|
788
|
+
payload = header + bytes([len(screen.input_fields) & 0xFF]) + bytes(entries)
|
|
789
|
+
return b"\x10\x08" + struct.pack(">H", 2 + len(payload)) + bytes(payload)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def decode_save_screen_fields(buffer: bytes) -> list[tuple[int, int, bytes, int]]:
|
|
793
|
+
"""Extract field specs from a Save/Restore 10 08 table.
|
|
794
|
+
|
|
795
|
+
The inverse of :func:`_save_screen_format_table`: each field is a fixed
|
|
796
|
+
9-byte descriptor. Returns ``(start_pos, length, ffw, attribute)`` tuples,
|
|
797
|
+
or an empty list when no format table is present.
|
|
798
|
+
"""
|
|
799
|
+
marker = buffer.find(b"\x10\x08")
|
|
800
|
+
if marker == -1 or marker + 4 > len(buffer):
|
|
801
|
+
return []
|
|
802
|
+
declared_len = int.from_bytes(buffer[marker + 2 : marker + 4], "big")
|
|
803
|
+
payload = buffer[marker + 4 : marker + 4 + declared_len - 2]
|
|
804
|
+
if not payload:
|
|
805
|
+
return []
|
|
806
|
+
soh_len = payload[0]
|
|
807
|
+
index = 1 + soh_len
|
|
808
|
+
if index >= len(payload):
|
|
809
|
+
return []
|
|
810
|
+
count = payload[index]
|
|
811
|
+
index += 1
|
|
812
|
+
specs: list[tuple[int, int, bytes, int]] = []
|
|
813
|
+
for _ in range(count):
|
|
814
|
+
entry = payload[index : index + _SAVE_SCREEN_FIELD_ENTRY_LEN]
|
|
815
|
+
if len(entry) < _SAVE_SCREEN_FIELD_ENTRY_LEN:
|
|
816
|
+
break
|
|
817
|
+
data_pos = int.from_bytes(entry[3:5], "big")
|
|
818
|
+
specs.append((max(0, data_pos - 1), entry[5], bytes(entry[6:8]), entry[8]))
|
|
819
|
+
index += _SAVE_SCREEN_FIELD_ENTRY_LEN
|
|
820
|
+
return specs
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _compress_save_screen_plane(plane: bytes) -> bytes:
|
|
824
|
+
"""Run-length compress a linear cell plane for a Save Screen image.
|
|
825
|
+
|
|
826
|
+
Runs of four or more identical bytes become ``10 11 <count> <byte>``;
|
|
827
|
+
shorter runs are emitted literally. Counts are capped at 255 per order.
|
|
828
|
+
"""
|
|
829
|
+
out = bytearray()
|
|
830
|
+
index = 0
|
|
831
|
+
length = len(plane)
|
|
832
|
+
while index < length:
|
|
833
|
+
byte = plane[index]
|
|
834
|
+
run = 1
|
|
835
|
+
while index + run < length and plane[index + run] == byte and run < 0xFF:
|
|
836
|
+
run += 1
|
|
837
|
+
if run >= 4:
|
|
838
|
+
out += bytes([_SAVE_SCREEN_SHIFT, Order.SET_BUFFER_ADDRESS, run, byte])
|
|
839
|
+
else:
|
|
840
|
+
out += bytes([byte]) * run
|
|
841
|
+
index += run
|
|
842
|
+
return bytes(out)
|
|
843
|
+
|
|
844
|
+
|
|
621
845
|
@dataclass
|
|
622
846
|
class FieldResponse:
|
|
623
847
|
"""One field's contribution to an AID response."""
|
|
@@ -19,6 +19,7 @@ from collections.abc import Sequence
|
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
|
|
21
21
|
from .constants import (
|
|
22
|
+
AID,
|
|
22
23
|
CC2,
|
|
23
24
|
EBCDIC_NULL,
|
|
24
25
|
EBCDIC_SPACE,
|
|
@@ -26,10 +27,18 @@ from .constants import (
|
|
|
26
27
|
SCREEN_COLS,
|
|
27
28
|
SCREEN_ROWS,
|
|
28
29
|
Attr,
|
|
30
|
+
Command,
|
|
29
31
|
pos_to_rowcol,
|
|
30
32
|
rowcol_to_pos,
|
|
31
33
|
)
|
|
32
|
-
from .datastream import
|
|
34
|
+
from .datastream import (
|
|
35
|
+
DSEvent,
|
|
36
|
+
DSEventType,
|
|
37
|
+
decode_save_screen_fields,
|
|
38
|
+
decode_save_screen_image,
|
|
39
|
+
ebcdic_decode,
|
|
40
|
+
ebcdic_encode,
|
|
41
|
+
)
|
|
33
42
|
from .exceptions import FieldNotFound
|
|
34
43
|
|
|
35
44
|
log = logging.getLogger(__name__)
|
|
@@ -108,6 +117,28 @@ class Field:
|
|
|
108
117
|
"""True when the field's display attribute is 'non-display' (password)."""
|
|
109
118
|
return self.attribute & Attr.NON_DISPLAY_MASK == Attr.NON_DISPLAY_MASK
|
|
110
119
|
|
|
120
|
+
@property
|
|
121
|
+
def auto_enter(self) -> bool:
|
|
122
|
+
"""True when the FFW Auto-Enter bit is set.
|
|
123
|
+
|
|
124
|
+
The host submits an Enter AID automatically as soon as such a field is
|
|
125
|
+
completely filled.
|
|
126
|
+
"""
|
|
127
|
+
return len(self.field_format_word) >= 2 and bool(
|
|
128
|
+
self.field_format_word[1] & FFW.AUTO_ENTER
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def writable_length(self) -> int:
|
|
133
|
+
"""Number of positions writable on the field's first row.
|
|
134
|
+
|
|
135
|
+
A field's formal area can span to the next field, but data is only
|
|
136
|
+
written up to the end of the row holding the first data byte (see
|
|
137
|
+
:meth:`Screen.set_field_text`), so auto-enter fills at this boundary.
|
|
138
|
+
"""
|
|
139
|
+
row_remaining = self._screen_cols - (self.data_pos % self._screen_cols)
|
|
140
|
+
return max(0, min(self.length, row_remaining))
|
|
141
|
+
|
|
111
142
|
@property
|
|
112
143
|
def mdt(self) -> bool:
|
|
113
144
|
"""True if the Modified Data Tag is set (field was touched)."""
|
|
@@ -164,22 +195,39 @@ class Screen:
|
|
|
164
195
|
self._keyboard_locked: bool = True
|
|
165
196
|
self._write_lengths: dict[int, int] = {} # pos → length of last write
|
|
166
197
|
self._pending_scroll_clear: bool = False
|
|
198
|
+
self._last_start_of_header: bytes = b"" # SOH header from the last WTD
|
|
199
|
+
self._last_aid: int = AID.NO_AID
|
|
167
200
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_save_buffer(cls, buffer: bytes) -> Screen | None:
|
|
203
|
+
"""Reconstruct a screen from a host Save/Restore Screen buffer.
|
|
204
|
+
|
|
205
|
+
The 04 12 buffer is self-contained: the character plane comes from its
|
|
206
|
+
10 04 image and every field from its 10 08 format table. Returns None
|
|
207
|
+
when the buffer has no image section.
|
|
208
|
+
"""
|
|
209
|
+
decoded = decode_save_screen_image(buffer)
|
|
210
|
+
if decoded is None:
|
|
211
|
+
return None
|
|
212
|
+
rows, cols, plane = decoded
|
|
213
|
+
screen = cls(rows=rows, cols=cols)
|
|
214
|
+
screen._chars[: len(plane)] = plane
|
|
215
|
+
for start_pos, length, ffw, attribute in decode_save_screen_fields(buffer):
|
|
216
|
+
if not 0 <= start_pos < screen._size:
|
|
217
|
+
continue
|
|
218
|
+
screen._fields.append(
|
|
219
|
+
Field(
|
|
220
|
+
start_pos=start_pos,
|
|
221
|
+
length=length,
|
|
222
|
+
field_format_word=ffw,
|
|
223
|
+
attribute=attribute or Attr.NORMAL,
|
|
224
|
+
_explicit_length=length,
|
|
225
|
+
_screen_cols=cols,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
screen._attrs[start_pos] = attribute
|
|
229
|
+
screen._fields.sort(key=lambda f: f.start_pos)
|
|
230
|
+
return screen
|
|
183
231
|
|
|
184
232
|
# ------------------------------------------------------------------
|
|
185
233
|
# Event application
|
|
@@ -204,6 +252,9 @@ class Screen:
|
|
|
204
252
|
log.debug("Screen resized to %d rows × %d cols", self.rows, self.cols)
|
|
205
253
|
|
|
206
254
|
elif event_type == DSEventType.COMMAND:
|
|
255
|
+
if event.command == Command.CLEAR_FORMAT_TABLE:
|
|
256
|
+
self._fields.clear()
|
|
257
|
+
self._write_lengths.clear()
|
|
207
258
|
cc2 = event.control_char_2
|
|
208
259
|
if cc2 & CC2.RESET_MDT:
|
|
209
260
|
for screen_field in self._fields:
|
|
@@ -231,6 +282,11 @@ class Screen:
|
|
|
231
282
|
elif event_type == DSEventType.SET_ATTRIBUTE and 0 <= event.position < self._size:
|
|
232
283
|
self._attrs[event.position] = event.attribute_value
|
|
233
284
|
|
|
285
|
+
elif event_type == DSEventType.START_OF_HEADER:
|
|
286
|
+
self._fields.clear()
|
|
287
|
+
self._write_lengths.clear()
|
|
288
|
+
self._last_start_of_header = event.data
|
|
289
|
+
|
|
234
290
|
def apply_all(self, events: Sequence[DSEvent]) -> None:
|
|
235
291
|
"""Apply a sequence of events, then recalculate field lengths."""
|
|
236
292
|
deferred_unlock = False
|
|
@@ -284,6 +340,10 @@ class Screen:
|
|
|
284
340
|
def cursor_pos(self) -> int:
|
|
285
341
|
return self._cursor
|
|
286
342
|
|
|
343
|
+
@property
|
|
344
|
+
def last_aid(self) -> int:
|
|
345
|
+
return self._last_aid
|
|
346
|
+
|
|
287
347
|
@property
|
|
288
348
|
def cursor_row(self) -> int:
|
|
289
349
|
return pos_to_rowcol(self._cursor, self.cols)[0]
|
|
@@ -296,6 +356,11 @@ class Screen:
|
|
|
296
356
|
def keyboard_locked(self) -> bool:
|
|
297
357
|
return self._keyboard_locked
|
|
298
358
|
|
|
359
|
+
@property
|
|
360
|
+
def last_start_of_header(self) -> bytes:
|
|
361
|
+
"""SOH header bytes (length + data) from the most recent WTD."""
|
|
362
|
+
return self._last_start_of_header
|
|
363
|
+
|
|
299
364
|
@property
|
|
300
365
|
def fields(self) -> list[Field]:
|
|
301
366
|
"""All fields (protected and input) on the current screen."""
|
|
@@ -305,7 +370,6 @@ class Screen:
|
|
|
305
370
|
def input_fields(self) -> list[Field]:
|
|
306
371
|
"""Only unprotected (input) fields."""
|
|
307
372
|
return [f for f in self._fields if not f.is_protected]
|
|
308
|
-
|
|
309
373
|
# ---- text extraction ----
|
|
310
374
|
|
|
311
375
|
def get_text(self, row: int, col: int, length: int) -> str:
|
|
@@ -568,11 +632,9 @@ class Screen:
|
|
|
568
632
|
return [f for f in self._fields if f.mdt and not f.is_protected]
|
|
569
633
|
|
|
570
634
|
def clear_modified_fields(self) -> None:
|
|
571
|
-
"""
|
|
635
|
+
"""Clear field modification bookkeeping without changing screen data."""
|
|
572
636
|
for f in self._fields:
|
|
573
|
-
if f.mdt
|
|
574
|
-
pos = f.data_pos
|
|
575
|
-
self._chars[pos : pos + len(f._saved_data)] = f._saved_data
|
|
637
|
+
if f.mdt:
|
|
576
638
|
f._saved_data = b""
|
|
577
639
|
f.mdt = False
|
|
578
640
|
|
|
@@ -40,6 +40,7 @@ from .datastream import (
|
|
|
40
40
|
FieldResponse,
|
|
41
41
|
build_aid_response,
|
|
42
42
|
build_query_reply,
|
|
43
|
+
build_save_screen_response,
|
|
43
44
|
)
|
|
44
45
|
from .exceptions import (
|
|
45
46
|
ConnectionError,
|
|
@@ -78,8 +79,12 @@ class FieldProxy:
|
|
|
78
79
|
|
|
79
80
|
@value.setter
|
|
80
81
|
def value(self, text: str) -> None:
|
|
81
|
-
"""Write *text* into the field (marks MDT).
|
|
82
|
-
|
|
82
|
+
"""Write *text* into the field (marks MDT).
|
|
83
|
+
|
|
84
|
+
Routed through :meth:`Terminal.type_into`, so filling a field whose FFW
|
|
85
|
+
has the Auto-Enter bit set submits the host's Enter AID automatically.
|
|
86
|
+
"""
|
|
87
|
+
self._term.type_into(self._field, text)
|
|
83
88
|
|
|
84
89
|
@property
|
|
85
90
|
def field(self) -> Field:
|
|
@@ -154,8 +159,6 @@ class Terminal:
|
|
|
154
159
|
self._poll_interval = poll_interval
|
|
155
160
|
self._rows = rows
|
|
156
161
|
self._cols = cols
|
|
157
|
-
self._saved_screens: list[tuple[bytes, Screen]] = []
|
|
158
|
-
self._next_save_id: int = 1
|
|
159
162
|
|
|
160
163
|
# ------------------------------------------------------------------
|
|
161
164
|
# Connection management
|
|
@@ -250,13 +253,27 @@ class Terminal:
|
|
|
250
253
|
The text to enter.
|
|
251
254
|
clear_first:
|
|
252
255
|
If *True*, clear any existing content first.
|
|
256
|
+
|
|
257
|
+
Notes
|
|
258
|
+
-----
|
|
259
|
+
If *field* has the FFW Auto-Enter bit set and this call fills it, the
|
|
260
|
+
host's Enter AID is sent automatically (blocking for the response), just
|
|
261
|
+
as a physical 5250 terminal would. Do not follow such a call with an
|
|
262
|
+
explicit ``send_key(AID.ENTER)``.
|
|
253
263
|
"""
|
|
254
264
|
f = field.field if isinstance(field, FieldProxy) else field
|
|
255
265
|
if clear_first:
|
|
256
|
-
|
|
266
|
+
content = text
|
|
257
267
|
else:
|
|
258
|
-
|
|
259
|
-
|
|
268
|
+
content = self._screen.get_field_text(f).rstrip() + text
|
|
269
|
+
self._screen.set_field_text(f, content)
|
|
270
|
+
# An auto-enter field makes the host submit Enter the instant it fills.
|
|
271
|
+
# set_field_text advances the cursor to the next field once a field is
|
|
272
|
+
# full, so restore it to this field before submitting to match the AID
|
|
273
|
+
# a real 5250 emulator sends.
|
|
274
|
+
if f.auto_enter and f.writable_length and len(content) >= f.writable_length:
|
|
275
|
+
self._screen._cursor = f.data_pos
|
|
276
|
+
self.send_key(AID.ENTER)
|
|
260
277
|
|
|
261
278
|
def move_cursor(self, row: int, col: int) -> None:
|
|
262
279
|
"""Move the cursor to 1-based *(row, col)* without sending a key."""
|
|
@@ -536,6 +553,7 @@ class Terminal:
|
|
|
536
553
|
cursor_pos=self._screen.cursor_pos,
|
|
537
554
|
cols=self._screen.cols,
|
|
538
555
|
)
|
|
556
|
+
self._screen._last_aid = aid
|
|
539
557
|
self._screen.clear_modified_fields()
|
|
540
558
|
self._screen._keyboard_locked = True
|
|
541
559
|
if aid in (AID.PAGE_DOWN, AID.PAGE_UP):
|
|
@@ -585,6 +603,7 @@ class Terminal:
|
|
|
585
603
|
else:
|
|
586
604
|
self._screen.apply_all(parsed_events)
|
|
587
605
|
|
|
606
|
+
|
|
588
607
|
def _resize(self, rows: int, cols: int) -> None:
|
|
589
608
|
"""Track a screen-size change and re-create the parser to match."""
|
|
590
609
|
self._rows = rows
|
|
@@ -593,49 +612,29 @@ class Terminal:
|
|
|
593
612
|
log.debug("Parser resized to %d rows × %d cols", rows, cols)
|
|
594
613
|
|
|
595
614
|
def _save_screen(self) -> None:
|
|
596
|
-
"""
|
|
615
|
+
"""Acknowledge the host's Save Screen with a serialized presentation space.
|
|
597
616
|
|
|
598
|
-
The
|
|
599
|
-
|
|
600
|
-
of the saved screens it wants displayed again.
|
|
601
|
-
"""
|
|
602
|
-
save_id = bytes([self._next_save_id, self._next_save_id + 1])
|
|
603
|
-
# Identifiers are a byte wide, so wrap before either byte overflows.
|
|
604
|
-
self._next_save_id = self._next_save_id % 254 + 1
|
|
605
|
-
self._saved_screens.append((save_id, self._screen.copy()))
|
|
606
|
-
log.debug("Saved screen as id %s", save_id.hex())
|
|
607
|
-
self._conn.send_save_screen_response(save_id)
|
|
608
|
-
|
|
609
|
-
def _restore_screen(self, save_id: bytes) -> bool:
|
|
610
|
-
"""Restore the screen the host identifies by *save_id*.
|
|
611
|
-
|
|
612
|
-
Returns ``True`` if a saved screen was restored. Screens saved after
|
|
613
|
-
the restored one are discarded — the host has unwound past them.
|
|
617
|
+
The response is a self-contained snapshot; the host stores it and echoes
|
|
618
|
+
it back verbatim on Restore, so no local copy is retained.
|
|
614
619
|
"""
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
620
|
+
response = build_save_screen_response(self._screen)
|
|
621
|
+
log.debug("Saved screen (%d response bytes)", len(response))
|
|
622
|
+
self._conn.send_save_screen_response(response)
|
|
618
623
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
i
|
|
622
|
-
for i in range(len(self._saved_screens) - 1, -1, -1)
|
|
623
|
-
if self._saved_screens[i][0] == save_id
|
|
624
|
-
),
|
|
625
|
-
None,
|
|
626
|
-
)
|
|
627
|
-
if index is None:
|
|
628
|
-
# Unknown identifier: fall back to the most recently saved screen.
|
|
629
|
-
log.debug(
|
|
630
|
-
"Restore Screen id %s not found; using most recent save",
|
|
631
|
-
save_id.hex() or "-",
|
|
632
|
-
)
|
|
633
|
-
index = len(self._saved_screens) - 1
|
|
624
|
+
def _restore_screen(self, restore_data: bytes) -> bool:
|
|
625
|
+
"""Restore the screen entirely from the host-returned buffer.
|
|
634
626
|
|
|
635
|
-
self
|
|
636
|
-
|
|
627
|
+
The 04 12 buffer is a self-contained snapshot: its 10 04 image is the
|
|
628
|
+
character plane and its 10 08 table defines every field. No local
|
|
629
|
+
presentation state is consulted.
|
|
630
|
+
"""
|
|
631
|
+
restored = Screen.from_save_buffer(restore_data)
|
|
632
|
+
if restored is None:
|
|
633
|
+
log.debug("Restore Screen buffer had no image — ignoring")
|
|
634
|
+
return False
|
|
635
|
+
self._screen = restored
|
|
637
636
|
self._screen.clear_modified_fields()
|
|
638
|
-
# The
|
|
637
|
+
# The restored screen carries its own dimensions; the parser must follow it.
|
|
639
638
|
self._resize(self._screen.rows, self._screen.cols)
|
|
640
639
|
return True
|
|
641
640
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|