ibm5250 0.1.0.dev6__tar.gz → 0.1.0.dev7__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.dev6 → ibm5250-0.1.0.dev7}/PKG-INFO +1 -1
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/pyproject.toml +1 -1
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/connection.py +14 -6
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/constants.py +15 -8
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/data-stream-logic.md +21 -14
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/datastream.py +69 -76
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/screen.py +92 -49
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/terminal.py +39 -10
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/uv.lock +1 -1
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/.github/copilot-instructions.md +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/.github/workflows/README.md +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/.github/workflows/publish.yml +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/.gitignore +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/LICENSE +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/README.md +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/scripts/compute_version.py +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/__init__.py +0 -0
- {ibm5250-0.1.0.dev6 → ibm5250-0.1.0.dev7}/src/ibm5250/exceptions.py +0 -0
|
@@ -33,6 +33,7 @@ from .constants import (
|
|
|
33
33
|
TN5250EDataType,
|
|
34
34
|
TN5250ERecordType,
|
|
35
35
|
TN5250ESubNeg,
|
|
36
|
+
encode_addr,
|
|
36
37
|
)
|
|
37
38
|
from .exceptions import ConnectionError, ProtocolError, RecvTimeout
|
|
38
39
|
|
|
@@ -48,8 +49,6 @@ _TELNET_OPT_CMD_LEN = 3 # bytes: IAC + command + option (WILL/WONT/DO/DONT)
|
|
|
48
49
|
# SNA / TN5250E wire-format framing constants
|
|
49
50
|
_GDS_RECORD_ID_HI = 0x12 # SNA GDS record-type identifier, high byte
|
|
50
51
|
_GDS_RECORD_ID_LO = 0xA0 # SNA GDS record-type identifier, low byte
|
|
51
|
-
_VAR_HDR_OP_HI = 0x08 # TN5250E variable-header opcode byte 0
|
|
52
|
-
_VAR_HDR_OP_LO = 0x2C # TN5250E variable-header opcode byte 1
|
|
53
52
|
_SAVE_SCREEN_RESP_CC1 = 0x01 # CC1 value in the Save Screen response variable header
|
|
54
53
|
|
|
55
54
|
# TN5250E header byte 7 (request/response flags). Real emulators set bit 0x40
|
|
@@ -82,10 +81,12 @@ class TN5250EHeader:
|
|
|
82
81
|
err_flag: int = 0x00
|
|
83
82
|
|
|
84
83
|
aid: int = AID.NO_AID # AID byte placed in the variable header (default: no-AID)
|
|
84
|
+
cursor_row: int = 0x00
|
|
85
|
+
cursor_col: int = 0x00
|
|
85
86
|
|
|
86
87
|
def to_bytes(self, payload_len: int) -> bytes:
|
|
87
|
-
# 3-byte variable header:
|
|
88
|
-
var_hdr = bytes([
|
|
88
|
+
# 3-byte variable header: cursor row, cursor column, AID.
|
|
89
|
+
var_hdr = bytes([self.cursor_row, self.cursor_col, self.aid])
|
|
89
90
|
var_len = len(var_hdr)
|
|
90
91
|
total = payload_len + _TN5250E_HEADER_LEN + var_len
|
|
91
92
|
return (
|
|
@@ -238,6 +239,8 @@ class TN5250Connection:
|
|
|
238
239
|
*,
|
|
239
240
|
data_type: int = TN5250EDataType.INPUT,
|
|
240
241
|
aid: int = AID.NO_AID,
|
|
242
|
+
cursor_pos: int | None = None,
|
|
243
|
+
cols: int = 80,
|
|
241
244
|
) -> None:
|
|
242
245
|
"""Send a 5250 data record to the host.
|
|
243
246
|
|
|
@@ -248,16 +251,21 @@ class TN5250Connection:
|
|
|
248
251
|
raise ConnectionError("Not connected")
|
|
249
252
|
|
|
250
253
|
escaped = _iac_escape(data)
|
|
254
|
+
cursor_row, cursor_col = (
|
|
255
|
+
encode_addr(cursor_pos, cols) if cursor_pos is not None else (0, 0)
|
|
256
|
+
)
|
|
251
257
|
|
|
252
258
|
if self._tn5250e_active:
|
|
253
259
|
hdr = TN5250EHeader(
|
|
254
260
|
data_type=data_type,
|
|
255
261
|
aid=aid,
|
|
262
|
+
cursor_row=cursor_row,
|
|
263
|
+
cursor_col=cursor_col,
|
|
256
264
|
).to_bytes(len(escaped))
|
|
257
265
|
frame = hdr + escaped
|
|
258
266
|
else:
|
|
259
|
-
# Basic TN5250
|
|
260
|
-
frame = bytes([aid]) + escaped
|
|
267
|
+
# Basic TN5250 carries AID and cursor address in the data stream.
|
|
268
|
+
frame = bytes([aid, cursor_row, cursor_col]) + escaped
|
|
261
269
|
|
|
262
270
|
frame += bytes([TelnetCmd.INTERPRET_AS_COMMAND, TelnetCmd.END_OF_RECORD])
|
|
263
271
|
|
|
@@ -87,6 +87,7 @@ class Command:
|
|
|
87
87
|
0x02 # Introduces an inline WSF record within a WTD stream
|
|
88
88
|
)
|
|
89
89
|
ESC = 0x04 # precedes Write Structured Field inline
|
|
90
|
+
CLEAR_UNIT = 0x40 # erase the display and format table
|
|
90
91
|
WRITE_TO_DISPLAY = (
|
|
91
92
|
0x11 # main screen update command; followed by CC1/CC2 and orders
|
|
92
93
|
)
|
|
@@ -121,11 +122,10 @@ class CC2:
|
|
|
121
122
|
"""Control Character 2 — second byte after WTD command."""
|
|
122
123
|
|
|
123
124
|
RESET = 0x00
|
|
124
|
-
UNLOCK_KEYBOARD =
|
|
125
|
-
RESET_MDT =
|
|
126
|
-
RESET_MDT_UNLOCK =
|
|
125
|
+
UNLOCK_KEYBOARD = 0x08
|
|
126
|
+
RESET_MDT = 0x10 # Clear all MDT bits
|
|
127
|
+
RESET_MDT_UNLOCK = 0x18
|
|
127
128
|
SET_MDT = 0x04 # Set MDT on all input fields
|
|
128
|
-
MOVE_CURSOR = 0x08 # IC order sets cursor, not default position
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
# ---------------------------------------------------------------------------
|
|
@@ -541,10 +541,17 @@ def decode_addr(hi: int, lo: int, cols: int = SCREEN_COLS) -> int:
|
|
|
541
541
|
|
|
542
542
|
|
|
543
543
|
def pos_to_rowcol(pos: int, cols: int = SCREEN_COLS) -> tuple[int, int]:
|
|
544
|
-
"""Convert a linear position to (row, col)
|
|
545
|
-
|
|
544
|
+
"""Convert a zero-based linear position to a 1-based ``(row, col)``."""
|
|
545
|
+
if pos < 0:
|
|
546
|
+
raise ValueError("Buffer position must be non-negative")
|
|
547
|
+
row, col = divmod(pos, cols)
|
|
548
|
+
return row + 1, col + 1
|
|
546
549
|
|
|
547
550
|
|
|
548
551
|
def rowcol_to_pos(row: int, col: int, cols: int = SCREEN_COLS) -> int:
|
|
549
|
-
"""Convert (row, col) to a linear position
|
|
550
|
-
|
|
552
|
+
"""Convert a 1-based ``(row, col)`` to a zero-based linear position."""
|
|
553
|
+
if row < 1 or col < 1:
|
|
554
|
+
raise ValueError("Screen coordinates start at (1, 1)")
|
|
555
|
+
if col > cols:
|
|
556
|
+
raise ValueError(f"Column must be between 1 and {cols}")
|
|
557
|
+
return (row - 1) * cols + col - 1
|
|
@@ -24,7 +24,8 @@ The **TN5250Connection** manages the TCP socket to the IBM i (AS/400) host.
|
|
|
24
24
|
|
|
25
25
|
### Sending (`send_record`)
|
|
26
26
|
|
|
27
|
-
1. Takes raw 5250 payload bytes.
|
|
27
|
+
1. Takes raw 5250 payload bytes. For input records, the three-byte TN5250E
|
|
28
|
+
variable header contains the cursor row, cursor column, and AID.
|
|
28
29
|
2. IAC-escapes them (any 0xFF in the data becomes 0xFF 0xFF).
|
|
29
30
|
3. Prepends the TN5250E header (with data-type and AID byte) if in enhanced mode.
|
|
30
31
|
4. Appends IAC-EOR terminator.
|
|
@@ -44,16 +45,17 @@ The **DataStreamParser** takes a raw 5250 payload (one record from the connectio
|
|
|
44
45
|
|
|
45
46
|
Every 5250 record starts with a **command byte**:
|
|
46
47
|
|
|
47
|
-
| Command | Meaning
|
|
48
|
-
| ------------------------------- |
|
|
49
|
-
| `0x11` (Write to Display) | Main screen update — followed by CC1, CC2, then orders/data
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
48
|
+
| Command | Meaning |
|
|
49
|
+
| ------------------------------- | ----------------------------------------------------------------------------------- |
|
|
50
|
+
| `0x11` (Write to Display) | Main screen update — followed by CC1, CC2, then orders/data |
|
|
51
|
+
| `0x40` (Clear Unit) | Clears the display and field definitions |
|
|
52
|
+
| `0x02` (Save Screen) | Server wants to save the current screen (same format as WTD) |
|
|
53
|
+
| `0x12` (Restore Screen) | Server wants to restore a previously saved screen |
|
|
54
|
+
| `0x04` (ESC) | Escape prefix — next byte is the real command (Clear Unit, WTD, WSF, Save, Restore) |
|
|
55
|
+
| `0xF3` (Write Structured Field) | Carries structured data like Query commands |
|
|
56
|
+
| `0x42/0x52/0x72/0x62` | Read commands — server asks client to send back screen/field data |
|
|
57
|
+
| `0x21` (Write Error Code) | Display error on status line |
|
|
58
|
+
| `0x50` (Clear Format Table) | Discard field definitions |
|
|
57
59
|
|
|
58
60
|
### Control Characters (CC1 / CC2)
|
|
59
61
|
|
|
@@ -65,9 +67,9 @@ After a WTD or Save Screen command, two bytes control screen-level behavior:
|
|
|
65
67
|
- `0x00`: Don't clear — overlay on existing content
|
|
66
68
|
|
|
67
69
|
- **CC2** — Keyboard and field flags:
|
|
68
|
-
- `
|
|
69
|
-
- `
|
|
70
|
-
- `
|
|
70
|
+
- `0x08`: Unlock the keyboard (let the user type)
|
|
71
|
+
- `0x10`: Reset all Modified Data Tags (MDT) on fields
|
|
72
|
+
- `0x18`: Both unlock + reset MDT
|
|
71
73
|
|
|
72
74
|
### Orders (the body of a WTD)
|
|
73
75
|
|
|
@@ -146,6 +148,11 @@ The **Screen** holds the actual state that represents what you'd see on a physic
|
|
|
146
148
|
- `find_field_by_label(label)` — finds the first input field immediately after the given label text
|
|
147
149
|
- `get_field_text(field)` — reads the EBCDIC content of a specific field and decodes it
|
|
148
150
|
|
|
151
|
+
Display extraction honors 5250 non-display attributes. Text following a
|
|
152
|
+
non-display attribute byte is blanked until the next display attribute, and
|
|
153
|
+
fields carrying a non-display FCW (such as password fields) remain readable
|
|
154
|
+
through field APIs but are omitted from rendered screen text.
|
|
155
|
+
|
|
149
156
|
---
|
|
150
157
|
|
|
151
158
|
## 4. Terminal Layer (`terminal.py`)
|
|
@@ -36,7 +36,6 @@ from dataclasses import dataclass, field
|
|
|
36
36
|
from enum import Enum, auto
|
|
37
37
|
|
|
38
38
|
from .constants import (
|
|
39
|
-
_KNOWN_ORDERS,
|
|
40
39
|
AID,
|
|
41
40
|
CC1,
|
|
42
41
|
EBCDIC_NULL,
|
|
@@ -87,6 +86,7 @@ class DSEvent:
|
|
|
87
86
|
data: bytes = b"" # EBCDIC bytes (WRITE_CHARS, REPEAT_TO char, …)
|
|
88
87
|
field_format_word: bytes = b"" # Field Format Word 2-byte tuple (START_FIELD)
|
|
89
88
|
field_control_words: list[bytes] = field(default_factory=list) # optional FCW pairs
|
|
89
|
+
field_length: int = 0 # explicit field length supplied by an FCW
|
|
90
90
|
attribute_type: int = 0 # SA attribute type
|
|
91
91
|
attribute_value: int = 0 # SA attribute value
|
|
92
92
|
command: int = 0 # raw command byte (COMMAND event)
|
|
@@ -208,8 +208,20 @@ class DataStreamParser:
|
|
|
208
208
|
byte_offset,
|
|
209
209
|
data_view[byte_offset] if byte_offset < total_bytes else 0xFF,
|
|
210
210
|
)
|
|
211
|
-
|
|
211
|
+
clear_unit = (
|
|
212
212
|
byte_offset < total_bytes
|
|
213
|
+
and data_view[byte_offset] == Command.CLEAR_UNIT
|
|
214
|
+
)
|
|
215
|
+
if clear_unit:
|
|
216
|
+
byte_offset += 1
|
|
217
|
+
yield DSEvent(
|
|
218
|
+
type=DSEventType.COMMAND,
|
|
219
|
+
command=Command.CLEAR_UNIT,
|
|
220
|
+
)
|
|
221
|
+
yield DSEvent(type=DSEventType.CLEAR_SCREEN)
|
|
222
|
+
if (
|
|
223
|
+
not clear_unit
|
|
224
|
+
and byte_offset < total_bytes
|
|
213
225
|
and data_view[byte_offset] == Command.WRITE_STRUCTURED_FIELD
|
|
214
226
|
):
|
|
215
227
|
# ESC 0xF3 — Write Structured Field
|
|
@@ -222,7 +234,7 @@ class DataStreamParser:
|
|
|
222
234
|
if byte_offset < total_bytes and data_view[byte_offset] in (
|
|
223
235
|
Command.SAVE_SCREEN,
|
|
224
236
|
Command.RESTORE_SCREEN,
|
|
225
|
-
):
|
|
237
|
+
) and not clear_unit:
|
|
226
238
|
sub_command = data_view[byte_offset]
|
|
227
239
|
byte_offset += 1
|
|
228
240
|
log.debug(
|
|
@@ -232,14 +244,17 @@ class DataStreamParser:
|
|
|
232
244
|
return
|
|
233
245
|
# ESC + WTD (0x11): explicit Write to Display
|
|
234
246
|
if (
|
|
235
|
-
|
|
247
|
+
not clear_unit
|
|
248
|
+
and byte_offset < total_bytes
|
|
236
249
|
and data_view[byte_offset] == Command.WRITE_TO_DISPLAY
|
|
237
250
|
):
|
|
238
251
|
byte_offset += 1 # consume the 0x11 WTD byte
|
|
239
252
|
# fall through to read CC1/CC2 below
|
|
240
253
|
# ESC used as an implicit WTD command: next two bytes are CC1/CC2.
|
|
241
254
|
# IBM i sometimes sends 0x04 CC1 CC2 orders instead of 0x11 CC1 CC2.
|
|
242
|
-
if
|
|
255
|
+
if clear_unit:
|
|
256
|
+
pass
|
|
257
|
+
elif byte_offset + 2 > total_bytes:
|
|
243
258
|
# Only one byte remains — treat it as a standalone CC1 (clear command)
|
|
244
259
|
if byte_offset + 1 <= total_bytes:
|
|
245
260
|
cc1 = data_view[byte_offset]
|
|
@@ -258,32 +273,33 @@ class DataStreamParser:
|
|
|
258
273
|
else:
|
|
259
274
|
log.debug("ESC with insufficient bytes for CC1/CC2 — skipping")
|
|
260
275
|
return
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
276
|
+
else:
|
|
277
|
+
cc1 = data_view[byte_offset]
|
|
278
|
+
cc2 = data_view[byte_offset + 1]
|
|
279
|
+
byte_offset += 2
|
|
280
|
+
log.debug(
|
|
281
|
+
"ESC treated as implicit WTD: CC1=0x%02X CC2=0x%02X", cc1, cc2
|
|
282
|
+
)
|
|
283
|
+
yield DSEvent(
|
|
284
|
+
type=DSEventType.COMMAND,
|
|
285
|
+
command=command_byte,
|
|
286
|
+
control_char_1=cc1,
|
|
287
|
+
control_char_2=cc2,
|
|
288
|
+
)
|
|
289
|
+
cc1_clear_flags = cc1 & (CC1.CLEAR_UNIT | CC1.CLEAR_UNIT_ALT)
|
|
290
|
+
if cc1_clear_flags in (
|
|
291
|
+
CC1.CLEAR_UNIT,
|
|
292
|
+
CC1.CLEAR_UNIT_ALT,
|
|
293
|
+
CC1.CLEAR_FORMAT_TABLE,
|
|
294
|
+
): # Clear Unit, Clear Unit Alternate, or Clear Format Table
|
|
295
|
+
yield DSEvent(type=DSEventType.CLEAR_SCREEN)
|
|
296
|
+
if cc1_clear_flags == CC1.CLEAR_UNIT_ALT:
|
|
297
|
+
alt_rows = SCREEN_ROWS if self.rows == SCREEN_ROWS_27 else SCREEN_ROWS_27
|
|
298
|
+
alt_cols = SCREEN_COLS if self.cols == SCREEN_COLS_132 else SCREEN_COLS_132
|
|
299
|
+
self.rows = alt_rows
|
|
300
|
+
self.cols = alt_cols
|
|
301
|
+
self._size = alt_rows * alt_cols
|
|
302
|
+
yield DSEvent(type=DSEventType.RESIZE_SCREEN, rows=alt_rows, cols=alt_cols)
|
|
287
303
|
|
|
288
304
|
case _:
|
|
289
305
|
# Unrecognised command — treat remainder as orders/data
|
|
@@ -457,6 +473,7 @@ class DataStreamParser:
|
|
|
457
473
|
# Collect optional Field Control Word (FCW) pairs (2 bytes each).
|
|
458
474
|
# FCW pairs follow until we encounter a known order byte or run out of data.
|
|
459
475
|
field_control_words: list[bytes] = []
|
|
476
|
+
field_length = 0
|
|
460
477
|
while byte_offset + 2 <= total_bytes:
|
|
461
478
|
next_byte = data_view[byte_offset]
|
|
462
479
|
# Orders that can immediately follow an SF/MF — stop collecting FCWs
|
|
@@ -481,17 +498,10 @@ class DataStreamParser:
|
|
|
481
498
|
)
|
|
482
499
|
byte_offset += 2
|
|
483
500
|
field_control_words.append(field_control_word)
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
# only skip it if the next byte is an inline WSF/ESC marker
|
|
489
|
-
elif (field_control_word[0] & 0x7F) == 0x26 and byte_offset < total_bytes:
|
|
490
|
-
next_byte_val = data_view[byte_offset]
|
|
491
|
-
if next_byte_val not in (
|
|
492
|
-
Command.INLINE_WRITE_STRUCTURED_FIELD, Command.ESC
|
|
493
|
-
):
|
|
494
|
-
byte_offset += 1 # skip resequencing extra byte
|
|
501
|
+
fcw_type = field_control_word[0] & 0x7F
|
|
502
|
+
if fcw_type in (0x22, 0x26) and byte_offset < total_bytes:
|
|
503
|
+
field_length = data_view[byte_offset]
|
|
504
|
+
byte_offset += 1
|
|
495
505
|
# If bit 15 of the FCW is 0, this is the last FCW in the chain
|
|
496
506
|
if not (field_control_word[0] & FCW.CONTINUATION_BIT):
|
|
497
507
|
break
|
|
@@ -500,34 +510,26 @@ class DataStreamParser:
|
|
|
500
510
|
position=current_buffer_position,
|
|
501
511
|
field_format_word=field_format_word,
|
|
502
512
|
field_control_words=field_control_words,
|
|
513
|
+
field_length=field_length,
|
|
503
514
|
)
|
|
504
515
|
yield ev
|
|
505
516
|
current_buffer_position = (
|
|
506
517
|
current_buffer_position + 1
|
|
507
518
|
) % self._size # attribute byte consumes one position
|
|
508
519
|
|
|
509
|
-
# --- Inline
|
|
510
|
-
# IBM i embeds 0x02 XX YY sequences in text to carry colour/attribute overrides. Each marker is exactly 3 bytes.
|
|
511
|
-
# 0x04 (ESC) in the order stream also introduces structured fields.
|
|
520
|
+
# --- Inline attribute marker (0x02) / ESC (0x04) ---
|
|
512
521
|
case Command.INLINE_WRITE_STRUCTURED_FIELD | Command.ESC:
|
|
513
522
|
next_byte = data_view[byte_offset + 1] if byte_offset + 1 < total_bytes else 0xFF
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
# If the third byte is a plausible 1-based SBA row address (0x01–0x1B),
|
|
517
|
-
# this is a data byte followed by an SBA order — not ESC+WTD.
|
|
518
|
-
# Row 0x00 is invalid for SBA (1-based encoding), so that case
|
|
519
|
-
# falls through to the ESC+WTD handler.
|
|
520
|
-
if next_byte == Order.SET_BUFFER_ADDRESS and 0x01 <= third_byte <= 0x1B:
|
|
521
|
-
# byte[+1] is a real order and byte[+2] is a plausible operand
|
|
522
|
-
# → 0x02/0x04 is just a data byte, not a WSF marker
|
|
523
|
-
byte_offset += 1
|
|
523
|
+
if current_byte == Command.INLINE_WRITE_STRUCTURED_FIELD:
|
|
524
|
+
byte_offset += min(4, total_bytes - byte_offset)
|
|
524
525
|
# ESC + WTD (0x04 0x11): embedded Write to Display sub-command
|
|
525
|
-
elif
|
|
526
|
+
elif next_byte == Command.WRITE_TO_DISPLAY:
|
|
526
527
|
if byte_offset + 4 > total_bytes:
|
|
527
528
|
break
|
|
528
529
|
cc1 = data_view[byte_offset + 2]
|
|
529
530
|
cc2 = data_view[byte_offset + 3]
|
|
530
531
|
byte_offset += 4 # consume ESC + WTD + CC1 + CC2
|
|
532
|
+
current_buffer_position = 0
|
|
531
533
|
log.debug(
|
|
532
534
|
"Inline ESC+WTD: CC1=0x%02X CC2=0x%02X", cc1, cc2
|
|
533
535
|
)
|
|
@@ -547,18 +549,8 @@ class DataStreamParser:
|
|
|
547
549
|
self.cols = alt_cols
|
|
548
550
|
self._size = alt_rows * alt_cols
|
|
549
551
|
yield DSEvent(type=DSEventType.RESIZE_SCREEN, rows=alt_rows, cols=alt_cols)
|
|
550
|
-
elif current_byte == Command.INLINE_WRITE_STRUCTURED_FIELD and next_byte == 0x00:
|
|
551
|
-
# 0x02 0x00 0x00 XX — consume 3 if the 4th byte is a structural order
|
|
552
|
-
# (preserving the order), otherwise consume 4 (the 4th byte is trailing padding)
|
|
553
|
-
fourth_byte = data_view[byte_offset + 3] if byte_offset + 3 < total_bytes else 0xFF
|
|
554
|
-
if fourth_byte in _KNOWN_ORDERS or fourth_byte in (
|
|
555
|
-
Command.INLINE_WRITE_STRUCTURED_FIELD, Command.ESC
|
|
556
|
-
):
|
|
557
|
-
byte_offset += 3
|
|
558
|
-
else:
|
|
559
|
-
byte_offset += 4 if byte_offset + 4 <= total_bytes else (total_bytes - byte_offset)
|
|
560
552
|
else:
|
|
561
|
-
#
|
|
553
|
+
# Other inline ESC markers are four bytes.
|
|
562
554
|
byte_offset += 4 if byte_offset + 4 <= total_bytes else 3
|
|
563
555
|
|
|
564
556
|
# --- SET_ATTRIBUTE ---
|
|
@@ -695,6 +687,8 @@ def build_aid_response(
|
|
|
695
687
|
cursor_pos: int,
|
|
696
688
|
fields: list[FieldResponse],
|
|
697
689
|
cols: int = SCREEN_COLS,
|
|
690
|
+
*,
|
|
691
|
+
include_cursor: bool = True,
|
|
698
692
|
) -> bytes:
|
|
699
693
|
"""Build the workstation→host input response record payload.
|
|
700
694
|
|
|
@@ -717,10 +711,16 @@ def build_aid_response(
|
|
|
717
711
|
bytes
|
|
718
712
|
Raw 5250 payload ready for :meth:`TN5250Connection.send_record`.
|
|
719
713
|
"""
|
|
714
|
+
response_payload = bytearray()
|
|
715
|
+
if include_cursor and not fields:
|
|
716
|
+
address_high, address_low = encode_addr(cursor_pos, cols)
|
|
717
|
+
response_payload += bytes(
|
|
718
|
+
[Order.SET_BUFFER_ADDRESS, address_high, address_low]
|
|
719
|
+
)
|
|
720
|
+
|
|
720
721
|
if fields:
|
|
721
|
-
# The cursor SBA doubles as the first field's SBA — no separate entry needed.
|
|
722
722
|
address_high, address_low = encode_addr(fields[0].buffer_pos, cols)
|
|
723
|
-
response_payload
|
|
723
|
+
response_payload += bytes(
|
|
724
724
|
[Order.SET_BUFFER_ADDRESS, address_high, address_low]
|
|
725
725
|
)
|
|
726
726
|
response_payload += fields[0].data.rstrip(bytes([EBCDIC_SPACE, EBCDIC_NULL]))
|
|
@@ -734,13 +734,6 @@ def build_aid_response(
|
|
|
734
734
|
response_payload += field_response.data.rstrip(
|
|
735
735
|
bytes([EBCDIC_SPACE, EBCDIC_NULL])
|
|
736
736
|
)
|
|
737
|
-
else:
|
|
738
|
-
# No modified fields — send cursor position only.
|
|
739
|
-
address_high, address_low = encode_addr(cursor_pos, cols)
|
|
740
|
-
response_payload = bytearray(
|
|
741
|
-
[Order.SET_BUFFER_ADDRESS, address_high, address_low]
|
|
742
|
-
)
|
|
743
|
-
|
|
744
737
|
return bytes(response_payload)
|
|
745
738
|
|
|
746
739
|
|
|
@@ -19,6 +19,7 @@ from collections.abc import Sequence
|
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
|
|
21
21
|
from .constants import (
|
|
22
|
+
CC2,
|
|
22
23
|
EBCDIC_NULL,
|
|
23
24
|
EBCDIC_SPACE,
|
|
24
25
|
FFW,
|
|
@@ -64,6 +65,8 @@ class Field:
|
|
|
64
65
|
False # Modified Data Tag — set when user edits the field
|
|
65
66
|
)
|
|
66
67
|
_saved_data: bytes = b"" # original content before user modification
|
|
68
|
+
_explicit_length: int = 0
|
|
69
|
+
_screen_cols: int = SCREEN_COLS
|
|
67
70
|
|
|
68
71
|
# ---- derived properties ----
|
|
69
72
|
|
|
@@ -90,7 +93,16 @@ class Field:
|
|
|
90
93
|
@property
|
|
91
94
|
def is_non_display(self) -> bool:
|
|
92
95
|
"""True when the field is non-display (password entry)."""
|
|
93
|
-
return (
|
|
96
|
+
return (
|
|
97
|
+
(self.field_format_word[1] & FFW.DISPLAY_ATTR_MASK) == FFW.NON_DISPLAY
|
|
98
|
+
or (
|
|
99
|
+
self.field_format_word[1] == 0x00
|
|
100
|
+
and any(
|
|
101
|
+
(fcw[0] & 0x7F) == 0x27
|
|
102
|
+
for fcw in self.field_control_words
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
)
|
|
94
106
|
|
|
95
107
|
@property
|
|
96
108
|
def mdt(self) -> bool:
|
|
@@ -103,13 +115,13 @@ class Field:
|
|
|
103
115
|
|
|
104
116
|
@property
|
|
105
117
|
def row(self) -> int:
|
|
106
|
-
"""
|
|
107
|
-
return pos_to_rowcol(self.data_pos)[0]
|
|
118
|
+
"""1-based screen row of the first data byte."""
|
|
119
|
+
return pos_to_rowcol(self.data_pos, self._screen_cols)[0]
|
|
108
120
|
|
|
109
121
|
@property
|
|
110
122
|
def col(self) -> int:
|
|
111
|
-
"""
|
|
112
|
-
return pos_to_rowcol(self.data_pos)[1]
|
|
123
|
+
"""1-based screen column of the first data byte."""
|
|
124
|
+
return pos_to_rowcol(self.data_pos, self._screen_cols)[1]
|
|
113
125
|
|
|
114
126
|
def __repr__(self) -> str:
|
|
115
127
|
protection_indicator = "P" if self.is_protected else "I"
|
|
@@ -189,7 +201,7 @@ class Screen:
|
|
|
189
201
|
|
|
190
202
|
elif event_type == DSEventType.COMMAND:
|
|
191
203
|
cc2 = event.control_char_2
|
|
192
|
-
if cc2 &
|
|
204
|
+
if cc2 & CC2.RESET_MDT:
|
|
193
205
|
for screen_field in self._fields:
|
|
194
206
|
screen_field.mdt = False
|
|
195
207
|
|
|
@@ -201,17 +213,6 @@ class Screen:
|
|
|
201
213
|
|
|
202
214
|
elif event_type == DSEventType.WRITE_CHARS:
|
|
203
215
|
self._write(event.position, event.data)
|
|
204
|
-
# Clear ghost tail when a shorter value overwrites a longer one
|
|
205
|
-
pos = event.position
|
|
206
|
-
new_len = len(event.data)
|
|
207
|
-
prev_len = self._write_lengths.get(pos, 0)
|
|
208
|
-
if prev_len > new_len:
|
|
209
|
-
tail_start = pos + new_len
|
|
210
|
-
tail_end = min(pos + prev_len, self._size)
|
|
211
|
-
self._chars[tail_start:tail_end] = (
|
|
212
|
-
bytes([EBCDIC_SPACE]) * (tail_end - tail_start)
|
|
213
|
-
)
|
|
214
|
-
self._write_lengths[pos] = new_len
|
|
215
216
|
|
|
216
217
|
elif event_type == DSEventType.ERASE_TO:
|
|
217
218
|
self._fill_range(event.position, event.attribute_value, EBCDIC_NULL)
|
|
@@ -236,7 +237,10 @@ class Screen:
|
|
|
236
237
|
pre_existing_field_rows: set[int] = {f.start_pos // self.cols for f in self._fields}
|
|
237
238
|
|
|
238
239
|
for ev in events:
|
|
239
|
-
if
|
|
240
|
+
if (
|
|
241
|
+
ev.type == DSEventType.COMMAND
|
|
242
|
+
and ev.control_char_2 & CC2.UNLOCK_KEYBOARD
|
|
243
|
+
):
|
|
240
244
|
deferred_unlock = True
|
|
241
245
|
if ev.type == DSEventType.CLEAR_SCREEN:
|
|
242
246
|
has_clear = True
|
|
@@ -278,11 +282,11 @@ class Screen:
|
|
|
278
282
|
|
|
279
283
|
@property
|
|
280
284
|
def cursor_row(self) -> int:
|
|
281
|
-
return self._cursor
|
|
285
|
+
return pos_to_rowcol(self._cursor, self.cols)[0]
|
|
282
286
|
|
|
283
287
|
@property
|
|
284
288
|
def cursor_col(self) -> int:
|
|
285
|
-
return self._cursor
|
|
289
|
+
return pos_to_rowcol(self._cursor, self.cols)[1]
|
|
286
290
|
|
|
287
291
|
@property
|
|
288
292
|
def keyboard_locked(self) -> bool:
|
|
@@ -305,30 +309,19 @@ class Screen:
|
|
|
305
309
|
|
|
306
310
|
Wraps across row boundaries if needed.
|
|
307
311
|
"""
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
raw_bytes = bytes(self._chars[buffer_pos : buffer_pos + length])
|
|
312
|
-
if len(raw_bytes) < length:
|
|
313
|
-
# Wrap around to the beginning of the buffer
|
|
314
|
-
raw_bytes += bytes(self._chars[: length - len(raw_bytes)])
|
|
315
|
-
# Replace attribute bytes (< 0x40) with EBCDIC space so they display as blank
|
|
316
|
-
display_bytes = bytes(b if b >= 0x40 else EBCDIC_SPACE for b in raw_bytes)
|
|
317
|
-
return ebcdic_decode(display_bytes)
|
|
312
|
+
buffer_pos = self._coordinate_to_pos(row, col)
|
|
313
|
+
positions = [(buffer_pos + offset) % self._size for offset in range(length)]
|
|
314
|
+
return ebcdic_decode(self._display_bytes(positions))
|
|
318
315
|
|
|
319
316
|
def get_row(self, row: int) -> str:
|
|
320
|
-
"""Return the full text of *row* (
|
|
321
|
-
row_start_pos = row
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
# displayable characters. Replace them with EBCDIC space (0x40) so
|
|
325
|
-
# they render as blanks rather than as garbage CP037 glyphs.
|
|
326
|
-
display_bytes = bytes(b if b >= 0x40 else EBCDIC_SPACE for b in row_bytes)
|
|
327
|
-
return ebcdic_decode(display_bytes)
|
|
317
|
+
"""Return the full text of *row* (1-based) as a Python string."""
|
|
318
|
+
row_start_pos = self._coordinate_to_pos(row, 1)
|
|
319
|
+
positions = range(row_start_pos, row_start_pos + self.cols)
|
|
320
|
+
return ebcdic_decode(self._display_bytes(positions))
|
|
328
321
|
|
|
329
322
|
def get_all_rows(self) -> list[str]:
|
|
330
323
|
"""Return all rows as a list of strings."""
|
|
331
|
-
return [self.get_row(
|
|
324
|
+
return [self.get_row(row) for row in range(1, self.rows + 1)]
|
|
332
325
|
|
|
333
326
|
def get_screen_text(self) -> str:
|
|
334
327
|
"""Return the entire screen as a single newline-separated string."""
|
|
@@ -365,13 +358,21 @@ class Screen:
|
|
|
365
358
|
f._saved_data = bytes(self._chars[pos : pos + write_len])
|
|
366
359
|
self._chars[pos : pos + len(encoded)] = encoded
|
|
367
360
|
f.mdt = True
|
|
368
|
-
self._cursor = pos
|
|
361
|
+
self._cursor = min(pos + len(encoded), self._size - 1)
|
|
362
|
+
if len(encoded) == write_len:
|
|
363
|
+
next_fields = [
|
|
364
|
+
candidate
|
|
365
|
+
for candidate in self.input_fields
|
|
366
|
+
if candidate.data_pos > f.data_pos
|
|
367
|
+
]
|
|
368
|
+
if next_fields:
|
|
369
|
+
self._cursor = min(next_fields, key=lambda candidate: candidate.data_pos).data_pos
|
|
369
370
|
|
|
370
371
|
# ---- field lookups ----
|
|
371
372
|
|
|
372
373
|
def find_field_at(self, row: int, col: int) -> Field | None:
|
|
373
|
-
"""Return the field
|
|
374
|
-
pos =
|
|
374
|
+
"""Return the field at 1-based *(row, col)*, or ``None``."""
|
|
375
|
+
pos = self._coordinate_to_pos(row, col)
|
|
375
376
|
for f in self._fields:
|
|
376
377
|
if f.data_pos <= pos < f.data_pos + f.length:
|
|
377
378
|
return f
|
|
@@ -546,14 +547,14 @@ class Screen:
|
|
|
546
547
|
def find_text(
|
|
547
548
|
self, text: str, *, case_sensitive: bool = False
|
|
548
549
|
) -> tuple[int, int] | None:
|
|
549
|
-
"""Return *(row, col)*
|
|
550
|
+
"""Return 1-based *(row, col)* for the first occurrence, or ``None``."""
|
|
550
551
|
rows = self.get_all_rows()
|
|
551
|
-
for
|
|
552
|
+
for row, row_text in enumerate(rows, start=1):
|
|
552
553
|
s = row_text if case_sensitive else row_text.lower()
|
|
553
554
|
t = text if case_sensitive else text.lower()
|
|
554
555
|
idx = s.find(t)
|
|
555
556
|
if idx >= 0:
|
|
556
|
-
return
|
|
557
|
+
return row, idx + 1
|
|
557
558
|
return None
|
|
558
559
|
|
|
559
560
|
# ---- modified fields (for AID response) ----
|
|
@@ -575,6 +576,11 @@ class Screen:
|
|
|
575
576
|
# Internal helpers
|
|
576
577
|
# ------------------------------------------------------------------
|
|
577
578
|
|
|
579
|
+
def _coordinate_to_pos(self, row: int, col: int) -> int:
|
|
580
|
+
if row > self.rows:
|
|
581
|
+
raise ValueError(f"Row must be between 1 and {self.rows}")
|
|
582
|
+
return rowcol_to_pos(row, col, self.cols)
|
|
583
|
+
|
|
578
584
|
def _clear(self) -> None:
|
|
579
585
|
self._chars[:] = bytearray([EBCDIC_SPACE] * self._size)
|
|
580
586
|
self._attrs[:] = bytearray(self._size)
|
|
@@ -582,6 +588,38 @@ class Screen:
|
|
|
582
588
|
self._write_lengths.clear()
|
|
583
589
|
self._keyboard_locked = True
|
|
584
590
|
|
|
591
|
+
def _display_bytes(self, positions: Sequence[int]) -> bytes:
|
|
592
|
+
"""Return displayable bytes, honoring non-display attributes and fields."""
|
|
593
|
+
position_list = list(positions)
|
|
594
|
+
if not position_list:
|
|
595
|
+
return b""
|
|
596
|
+
|
|
597
|
+
hidden = False
|
|
598
|
+
for byte in self._chars[: position_list[0]]:
|
|
599
|
+
if 0x20 <= byte <= 0x3F:
|
|
600
|
+
hidden = (byte & 0x07) == 0x07
|
|
601
|
+
|
|
602
|
+
non_display_positions = {
|
|
603
|
+
pos
|
|
604
|
+
for screen_field in self._fields
|
|
605
|
+
if screen_field.is_non_display
|
|
606
|
+
for pos in range(
|
|
607
|
+
screen_field.data_pos,
|
|
608
|
+
min(screen_field.data_pos + screen_field.length, self._size),
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
display = bytearray()
|
|
612
|
+
for pos in position_list:
|
|
613
|
+
byte = self._chars[pos]
|
|
614
|
+
if 0x20 <= byte <= 0x3F:
|
|
615
|
+
hidden = (byte & 0x07) == 0x07
|
|
616
|
+
display.append(EBCDIC_SPACE)
|
|
617
|
+
elif byte < 0x40 or hidden or pos in non_display_positions:
|
|
618
|
+
display.append(EBCDIC_SPACE)
|
|
619
|
+
else:
|
|
620
|
+
display.append(byte)
|
|
621
|
+
return bytes(display)
|
|
622
|
+
|
|
585
623
|
def _write(self, start: int, data: bytes) -> None:
|
|
586
624
|
end = min(start + len(data), self._size)
|
|
587
625
|
count = end - start
|
|
@@ -600,8 +638,11 @@ class Screen:
|
|
|
600
638
|
"""Add or update a field from an SF/MF event."""
|
|
601
639
|
new_field = Field(
|
|
602
640
|
start_pos=event.position,
|
|
641
|
+
length=event.field_length,
|
|
603
642
|
field_format_word=event.field_format_word,
|
|
604
643
|
field_control_words=event.field_control_words,
|
|
644
|
+
_explicit_length=event.field_length,
|
|
645
|
+
_screen_cols=self.cols,
|
|
605
646
|
)
|
|
606
647
|
# Replace any existing field at this position (dedup, preserving order)
|
|
607
648
|
for i, existing in enumerate(self._fields):
|
|
@@ -621,6 +662,9 @@ class Screen:
|
|
|
621
662
|
if not fields:
|
|
622
663
|
return
|
|
623
664
|
for field_index, current_field in enumerate(fields):
|
|
665
|
+
if current_field._explicit_length:
|
|
666
|
+
current_field.length = current_field._explicit_length
|
|
667
|
+
continue
|
|
624
668
|
if field_index + 1 < len(fields):
|
|
625
669
|
next_field_start_pos = fields[field_index + 1].start_pos
|
|
626
670
|
else:
|
|
@@ -647,8 +691,8 @@ class Screen:
|
|
|
647
691
|
"""Pretty-print the screen with a border (useful for debugging)."""
|
|
648
692
|
divider = "+" + "-" * self.cols + "+"
|
|
649
693
|
lines = [divider]
|
|
650
|
-
for
|
|
651
|
-
lines.append("|" + self.get_row(
|
|
694
|
+
for row in range(1, self.rows + 1):
|
|
695
|
+
lines.append("|" + self.get_row(row) + "|")
|
|
652
696
|
lines.append(divider)
|
|
653
697
|
return "\n".join(lines)
|
|
654
698
|
|
|
@@ -731,8 +775,7 @@ class Screen:
|
|
|
731
775
|
|
|
732
776
|
rows = []
|
|
733
777
|
for display_row in range(display_start_row, display_end_row + 1):
|
|
734
|
-
|
|
735
|
-
row_text = self.get_row(row_idx)
|
|
778
|
+
row_text = self.get_row(display_row)
|
|
736
779
|
columns = [row_text[start:end].strip() for start, end in resolved]
|
|
737
780
|
rows.append([] if all(c == "" for c in columns) else columns)
|
|
738
781
|
return rows
|
|
@@ -156,6 +156,7 @@ class Terminal:
|
|
|
156
156
|
self._cols = cols
|
|
157
157
|
self._saved_screens: list[Screen] = []
|
|
158
158
|
self._save_pending: bool = False
|
|
159
|
+
self._screen_before_partial_update: Screen | None = None
|
|
159
160
|
|
|
160
161
|
# ------------------------------------------------------------------
|
|
161
162
|
# Connection management
|
|
@@ -209,6 +210,8 @@ class Terminal:
|
|
|
209
210
|
wait:
|
|
210
211
|
Block until a screen update is received.
|
|
211
212
|
"""
|
|
213
|
+
if self._screen.keyboard_locked:
|
|
214
|
+
self.wait_for_keyboard_unlock()
|
|
212
215
|
self._send_aid(aid)
|
|
213
216
|
if wait:
|
|
214
217
|
self._recv_and_apply()
|
|
@@ -257,7 +260,9 @@ class Terminal:
|
|
|
257
260
|
self._screen.set_field_text(f, existing + text)
|
|
258
261
|
|
|
259
262
|
def move_cursor(self, row: int, col: int) -> None:
|
|
260
|
-
"""Move the cursor to *(row, col)*
|
|
263
|
+
"""Move the cursor to 1-based *(row, col)* without sending a key."""
|
|
264
|
+
if row > self._rows:
|
|
265
|
+
raise ValueError(f"Row must be between 1 and {self._rows}")
|
|
261
266
|
pos = rowcol_to_pos(row, col, self._cols)
|
|
262
267
|
self._screen._cursor = pos
|
|
263
268
|
|
|
@@ -328,7 +333,7 @@ class Terminal:
|
|
|
328
333
|
*,
|
|
329
334
|
timeout: float | None = None,
|
|
330
335
|
) -> None:
|
|
331
|
-
"""Block until the cursor lands on *(row, col)
|
|
336
|
+
"""Block until the cursor lands on 1-based *(row, col)*."""
|
|
332
337
|
self._wait_for(
|
|
333
338
|
lambda: self._screen.cursor_row == row and self._screen.cursor_col == col,
|
|
334
339
|
desc=f"cursor at ({row},{col})",
|
|
@@ -382,7 +387,7 @@ class Terminal:
|
|
|
382
387
|
return FieldProxy(self, f)
|
|
383
388
|
|
|
384
389
|
def find_field_at(self, row: int, col: int) -> FieldProxy:
|
|
385
|
-
"""Return the
|
|
390
|
+
"""Return the unprotected field at 1-based *(row, col)*.
|
|
386
391
|
|
|
387
392
|
Raises :class:`~ibm5250.exceptions.FieldNotFound` if none exists there.
|
|
388
393
|
"""
|
|
@@ -520,9 +525,18 @@ class Terminal:
|
|
|
520
525
|
for modified_field in modified_fields
|
|
521
526
|
]
|
|
522
527
|
aid_payload = build_aid_response(
|
|
523
|
-
self._screen.cursor_pos,
|
|
528
|
+
self._screen.cursor_pos,
|
|
529
|
+
field_responses,
|
|
530
|
+
cols=self._screen.cols,
|
|
531
|
+
include_cursor=False,
|
|
532
|
+
)
|
|
533
|
+
self._conn.send_record(
|
|
534
|
+
aid_payload,
|
|
535
|
+
data_type=TN5250EDataType.INPUT,
|
|
536
|
+
aid=aid,
|
|
537
|
+
cursor_pos=self._screen.cursor_pos,
|
|
538
|
+
cols=self._screen.cols,
|
|
524
539
|
)
|
|
525
|
-
self._conn.send_record(aid_payload, data_type=TN5250EDataType.INPUT, aid=aid)
|
|
526
540
|
self._screen.clear_modified_fields()
|
|
527
541
|
self._screen._keyboard_locked = True
|
|
528
542
|
if aid in (AID.PAGE_DOWN, AID.PAGE_UP):
|
|
@@ -553,7 +567,9 @@ class Terminal:
|
|
|
553
567
|
event.type == DSEventType.COMMAND
|
|
554
568
|
and event.command == Command.SAVE_SCREEN
|
|
555
569
|
):
|
|
556
|
-
self.
|
|
570
|
+
screen_to_save = self._screen_before_partial_update or self._screen
|
|
571
|
+
self._saved_screens.append(screen_to_save.copy())
|
|
572
|
+
self._screen_before_partial_update = None
|
|
557
573
|
self._save_pending = True
|
|
558
574
|
self._conn.send_save_screen_response()
|
|
559
575
|
elif (
|
|
@@ -561,10 +577,12 @@ class Terminal:
|
|
|
561
577
|
and event.command == Command.RESTORE_SCREEN
|
|
562
578
|
):
|
|
563
579
|
if self._saved_screens:
|
|
564
|
-
# Back-to-back SAVE+RESTORE: discard the redundant save
|
|
565
580
|
if self._save_pending and len(self._saved_screens) >= 2:
|
|
566
|
-
self._saved_screens.pop()
|
|
567
|
-
|
|
581
|
+
saved_current = self._saved_screens.pop()
|
|
582
|
+
self._screen = self._saved_screens.pop()
|
|
583
|
+
self._saved_screens.append(saved_current)
|
|
584
|
+
else:
|
|
585
|
+
self._screen = self._saved_screens.pop()
|
|
568
586
|
self._screen.clear_modified_fields()
|
|
569
587
|
# If a CLEAR_UNIT_ALT resized the parser before restore,
|
|
570
588
|
# the restored screen dimensions won't match — resize preserving content
|
|
@@ -606,7 +624,6 @@ class Terminal:
|
|
|
606
624
|
if restore_index is not None:
|
|
607
625
|
post_events = parsed_events[restore_index + 1:]
|
|
608
626
|
self._screen.apply_all(post_events)
|
|
609
|
-
print(f"RESTORE: screen text after apply_all:\n{self._screen.get_screen_text()}")
|
|
610
627
|
# After RESTORE, the saved screen has keyboard locked. The server
|
|
611
628
|
# sends a follow-up record to unlock it — consume it now to
|
|
612
629
|
# prevent out-of-turn AID sends.
|
|
@@ -616,6 +633,18 @@ class Terminal:
|
|
|
616
633
|
except RecvTimeout:
|
|
617
634
|
pass
|
|
618
635
|
else:
|
|
636
|
+
has_screen_content = any(
|
|
637
|
+
event.type not in (DSEventType.COMMAND, DSEventType.QUERY)
|
|
638
|
+
for event in parsed_events
|
|
639
|
+
)
|
|
640
|
+
if has_screen_content:
|
|
641
|
+
if any(
|
|
642
|
+
event.type == DSEventType.CLEAR_SCREEN
|
|
643
|
+
for event in parsed_events
|
|
644
|
+
):
|
|
645
|
+
self._screen_before_partial_update = None
|
|
646
|
+
else:
|
|
647
|
+
self._screen_before_partial_update = self._screen.copy()
|
|
619
648
|
self._screen.apply_all(parsed_events)
|
|
620
649
|
|
|
621
650
|
def _send_query_reply(self) -> None:
|
|
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
|