ibm5250 0.1.0.dev5__tar.gz → 0.1.0.dev6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ibm5250
3
- Version: 0.1.0.dev5
3
+ Version: 0.1.0.dev6
4
4
  Summary: IBM 5250 terminal automation library
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ibm5250"
3
- version = "0.1.0.dev5"
3
+ version = "0.1.0.dev6"
4
4
  description = "IBM 5250 terminal automation library"
5
5
  requires-python = ">=3.11"
6
6
 
@@ -33,7 +33,6 @@ from .constants import (
33
33
  TN5250EDataType,
34
34
  TN5250ERecordType,
35
35
  TN5250ESubNeg,
36
- encode_addr,
37
36
  )
38
37
  from .exceptions import ConnectionError, ProtocolError, RecvTimeout
39
38
 
@@ -49,6 +48,8 @@ _TELNET_OPT_CMD_LEN = 3 # bytes: IAC + command + option (WILL/WONT/DO/DONT)
49
48
  # SNA / TN5250E wire-format framing constants
50
49
  _GDS_RECORD_ID_HI = 0x12 # SNA GDS record-type identifier, high byte
51
50
  _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
52
53
  _SAVE_SCREEN_RESP_CC1 = 0x01 # CC1 value in the Save Screen response variable header
53
54
 
54
55
  # TN5250E header byte 7 (request/response flags). Real emulators set bit 0x40
@@ -81,12 +82,10 @@ class TN5250EHeader:
81
82
  err_flag: int = 0x00
82
83
 
83
84
  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
86
85
 
87
86
  def to_bytes(self, payload_len: int) -> bytes:
88
- # 3-byte variable header: cursor row, cursor column, AID.
89
- var_hdr = bytes([self.cursor_row, self.cursor_col, self.aid])
87
+ # 3-byte variable header: _VAR_HDR_OP_HI _VAR_HDR_OP_LO <AID>
88
+ var_hdr = bytes([_VAR_HDR_OP_HI, _VAR_HDR_OP_LO, self.aid])
90
89
  var_len = len(var_hdr)
91
90
  total = payload_len + _TN5250E_HEADER_LEN + var_len
92
91
  return (
@@ -239,8 +238,6 @@ class TN5250Connection:
239
238
  *,
240
239
  data_type: int = TN5250EDataType.INPUT,
241
240
  aid: int = AID.NO_AID,
242
- cursor_pos: int | None = None,
243
- cols: int = 80,
244
241
  ) -> None:
245
242
  """Send a 5250 data record to the host.
246
243
 
@@ -251,21 +248,16 @@ class TN5250Connection:
251
248
  raise ConnectionError("Not connected")
252
249
 
253
250
  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
- )
257
251
 
258
252
  if self._tn5250e_active:
259
253
  hdr = TN5250EHeader(
260
254
  data_type=data_type,
261
255
  aid=aid,
262
- cursor_row=cursor_row,
263
- cursor_col=cursor_col,
264
256
  ).to_bytes(len(escaped))
265
257
  frame = hdr + escaped
266
258
  else:
267
- # Basic TN5250 carries AID and cursor address in the data stream.
268
- frame = bytes([aid, cursor_row, cursor_col]) + escaped
259
+ # Basic TN5250: AID byte is the first byte of the payload
260
+ frame = bytes([aid]) + escaped
269
261
 
270
262
  frame += bytes([TelnetCmd.INTERPRET_AS_COMMAND, TelnetCmd.END_OF_RECORD])
271
263
 
@@ -87,7 +87,6 @@ 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
91
90
  WRITE_TO_DISPLAY = (
92
91
  0x11 # main screen update command; followed by CC1/CC2 and orders
93
92
  )
@@ -122,10 +121,11 @@ class CC2:
122
121
  """Control Character 2 — second byte after WTD command."""
123
122
 
124
123
  RESET = 0x00
125
- UNLOCK_KEYBOARD = 0x08
126
- RESET_MDT = 0x10 # Clear all MDT bits
127
- RESET_MDT_UNLOCK = 0x18
124
+ UNLOCK_KEYBOARD = 0x01
125
+ RESET_MDT = 0x02 # Clear all MDT bits
126
+ RESET_MDT_UNLOCK = 0x03
128
127
  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
  # ---------------------------------------------------------------------------
@@ -24,8 +24,7 @@ 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. For input records, the three-byte TN5250E
28
- variable header contains the cursor row, cursor column, and AID.
27
+ 1. Takes raw 5250 payload bytes.
29
28
  2. IAC-escapes them (any 0xFF in the data becomes 0xFF 0xFF).
30
29
  3. Prepends the TN5250E header (with data-type and AID byte) if in enhanced mode.
31
30
  4. Appends IAC-EOR terminator.
@@ -45,17 +44,16 @@ The **DataStreamParser** takes a raw 5250 payload (one record from the connectio
45
44
 
46
45
  Every 5250 record starts with a **command byte**:
47
46
 
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 |
47
+ | Command | Meaning |
48
+ | ------------------------------- | ----------------------------------------------------------------------- |
49
+ | `0x11` (Write to Display) | Main screen update — followed by CC1, CC2, then orders/data |
50
+ | `0x02` (Save Screen) | Server wants to save the current screen (same format as WTD) |
51
+ | `0x12` (Restore Screen) | Server wants to restore a previously saved screen |
52
+ | `0x04` (ESC) | Escape prefix — next byte is the real command (WTD, WSF, Save, Restore) |
53
+ | `0xF3` (Write Structured Field) | Carries structured data like Query commands |
54
+ | `0x42/0x52/0x72/0x62` | Read commands — server asks client to send back screen/field data |
55
+ | `0x21` (Write Error Code) | Display error on status line |
56
+ | `0x50` (Clear Format Table) | Discard field definitions |
59
57
 
60
58
  ### Control Characters (CC1 / CC2)
61
59
 
@@ -67,9 +65,9 @@ After a WTD or Save Screen command, two bytes control screen-level behavior:
67
65
  - `0x00`: Don't clear — overlay on existing content
68
66
 
69
67
  - **CC2** — Keyboard and field flags:
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
68
+ - `0x01`: Unlock the keyboard (let the user type)
69
+ - `0x02`: Reset all Modified Data Tags (MDT) on fields
70
+ - `0x03`: Both unlock + reset MDT
73
71
 
74
72
  ### Orders (the body of a WTD)
75
73
 
@@ -148,11 +146,6 @@ The **Screen** holds the actual state that represents what you'd see on a physic
148
146
  - `find_field_by_label(label)` — finds the first input field immediately after the given label text
149
147
  - `get_field_text(field)` — reads the EBCDIC content of a specific field and decodes it
150
148
 
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
-
156
149
  ---
157
150
 
158
151
  ## 4. Terminal Layer (`terminal.py`)
@@ -36,6 +36,7 @@ from dataclasses import dataclass, field
36
36
  from enum import Enum, auto
37
37
 
38
38
  from .constants import (
39
+ _KNOWN_ORDERS,
39
40
  AID,
40
41
  CC1,
41
42
  EBCDIC_NULL,
@@ -86,7 +87,6 @@ class DSEvent:
86
87
  data: bytes = b"" # EBCDIC bytes (WRITE_CHARS, REPEAT_TO char, …)
87
88
  field_format_word: bytes = b"" # Field Format Word 2-byte tuple (START_FIELD)
88
89
  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,20 +208,8 @@ class DataStreamParser:
208
208
  byte_offset,
209
209
  data_view[byte_offset] if byte_offset < total_bytes else 0xFF,
210
210
  )
211
- clear_unit = (
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
211
  if (
223
- not clear_unit
224
- and byte_offset < total_bytes
212
+ byte_offset < total_bytes
225
213
  and data_view[byte_offset] == Command.WRITE_STRUCTURED_FIELD
226
214
  ):
227
215
  # ESC 0xF3 — Write Structured Field
@@ -234,7 +222,7 @@ class DataStreamParser:
234
222
  if byte_offset < total_bytes and data_view[byte_offset] in (
235
223
  Command.SAVE_SCREEN,
236
224
  Command.RESTORE_SCREEN,
237
- ) and not clear_unit:
225
+ ):
238
226
  sub_command = data_view[byte_offset]
239
227
  byte_offset += 1
240
228
  log.debug(
@@ -244,17 +232,14 @@ class DataStreamParser:
244
232
  return
245
233
  # ESC + WTD (0x11): explicit Write to Display
246
234
  if (
247
- not clear_unit
248
- and byte_offset < total_bytes
235
+ byte_offset < total_bytes
249
236
  and data_view[byte_offset] == Command.WRITE_TO_DISPLAY
250
237
  ):
251
238
  byte_offset += 1 # consume the 0x11 WTD byte
252
239
  # fall through to read CC1/CC2 below
253
240
  # ESC used as an implicit WTD command: next two bytes are CC1/CC2.
254
241
  # IBM i sometimes sends 0x04 CC1 CC2 orders instead of 0x11 CC1 CC2.
255
- if clear_unit:
256
- pass
257
- elif byte_offset + 2 > total_bytes:
242
+ if byte_offset + 2 > total_bytes:
258
243
  # Only one byte remains — treat it as a standalone CC1 (clear command)
259
244
  if byte_offset + 1 <= total_bytes:
260
245
  cc1 = data_view[byte_offset]
@@ -273,33 +258,32 @@ class DataStreamParser:
273
258
  else:
274
259
  log.debug("ESC with insufficient bytes for CC1/CC2 — skipping")
275
260
  return
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)
261
+ cc1 = data_view[byte_offset]
262
+ cc2 = data_view[byte_offset + 1]
263
+ byte_offset += 2
264
+ log.debug(
265
+ "ESC treated as implicit WTD: CC1=0x%02X CC2=0x%02X", cc1, cc2
266
+ )
267
+ yield DSEvent(
268
+ type=DSEventType.COMMAND,
269
+ command=command_byte,
270
+ control_char_1=cc1,
271
+ control_char_2=cc2,
272
+ )
273
+ cc1_clear_flags = cc1 & (CC1.CLEAR_UNIT | CC1.CLEAR_UNIT_ALT)
274
+ if cc1_clear_flags in (
275
+ CC1.CLEAR_UNIT,
276
+ CC1.CLEAR_UNIT_ALT,
277
+ CC1.CLEAR_FORMAT_TABLE,
278
+ ): # Clear Unit, Clear Unit Alternate, or Clear Format Table
279
+ yield DSEvent(type=DSEventType.CLEAR_SCREEN)
280
+ if cc1_clear_flags == CC1.CLEAR_UNIT_ALT:
281
+ alt_rows = SCREEN_ROWS if self.rows == SCREEN_ROWS_27 else SCREEN_ROWS_27
282
+ alt_cols = SCREEN_COLS if self.cols == SCREEN_COLS_132 else SCREEN_COLS_132
283
+ self.rows = alt_rows
284
+ self.cols = alt_cols
285
+ self._size = alt_rows * alt_cols
286
+ yield DSEvent(type=DSEventType.RESIZE_SCREEN, rows=alt_rows, cols=alt_cols)
303
287
 
304
288
  case _:
305
289
  # Unrecognised command — treat remainder as orders/data
@@ -473,7 +457,6 @@ class DataStreamParser:
473
457
  # Collect optional Field Control Word (FCW) pairs (2 bytes each).
474
458
  # FCW pairs follow until we encounter a known order byte or run out of data.
475
459
  field_control_words: list[bytes] = []
476
- field_length = 0
477
460
  while byte_offset + 2 <= total_bytes:
478
461
  next_byte = data_view[byte_offset]
479
462
  # Orders that can immediately follow an SF/MF — stop collecting FCWs
@@ -498,10 +481,17 @@ class DataStreamParser:
498
481
  )
499
482
  byte_offset += 2
500
483
  field_control_words.append(field_control_word)
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
484
+ # FCW type 0x22 carries an additional field-length byte
485
+ if (field_control_word[0] & 0x7F) == 0x22 and byte_offset < total_bytes:
486
+ byte_offset += 1 # skip field length byte
487
+ # FCW type 0x26 (resequencing) carries an additional byte;
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
505
495
  # If bit 15 of the FCW is 0, this is the last FCW in the chain
506
496
  if not (field_control_word[0] & FCW.CONTINUATION_BIT):
507
497
  break
@@ -510,26 +500,34 @@ class DataStreamParser:
510
500
  position=current_buffer_position,
511
501
  field_format_word=field_format_word,
512
502
  field_control_words=field_control_words,
513
- field_length=field_length,
514
503
  )
515
504
  yield ev
516
505
  current_buffer_position = (
517
506
  current_buffer_position + 1
518
507
  ) % self._size # attribute byte consumes one position
519
508
 
520
- # --- Inline attribute marker (0x02) / ESC (0x04) ---
509
+ # --- Inline Write Structured Field (0x02) / ESC (0x04) ---
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.
521
512
  case Command.INLINE_WRITE_STRUCTURED_FIELD | Command.ESC:
522
513
  next_byte = data_view[byte_offset + 1] if byte_offset + 1 < total_bytes else 0xFF
523
- if current_byte == Command.INLINE_WRITE_STRUCTURED_FIELD:
524
- byte_offset += min(4, total_bytes - byte_offset)
514
+ third_byte = data_view[byte_offset + 2] if byte_offset + 2 < total_bytes else 0xFF
515
+ # Disambiguate: 0x04 0x11 could be ESC+WTD or data+SBA.
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
525
524
  # ESC + WTD (0x04 0x11): embedded Write to Display sub-command
526
- elif next_byte == Command.WRITE_TO_DISPLAY:
525
+ elif current_byte == Command.ESC and next_byte == Command.WRITE_TO_DISPLAY:
527
526
  if byte_offset + 4 > total_bytes:
528
527
  break
529
528
  cc1 = data_view[byte_offset + 2]
530
529
  cc2 = data_view[byte_offset + 3]
531
530
  byte_offset += 4 # consume ESC + WTD + CC1 + CC2
532
- current_buffer_position = 0
533
531
  log.debug(
534
532
  "Inline ESC+WTD: CC1=0x%02X CC2=0x%02X", cc1, cc2
535
533
  )
@@ -549,8 +547,18 @@ class DataStreamParser:
549
547
  self.cols = alt_cols
550
548
  self._size = alt_rows * alt_cols
551
549
  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)
552
560
  else:
553
- # Other inline ESC markers are four bytes.
561
+ # Real inline WSF attribute marker (4 bytes: marker + type + value + trailing)
554
562
  byte_offset += 4 if byte_offset + 4 <= total_bytes else 3
555
563
 
556
564
  # --- SET_ATTRIBUTE ---
@@ -687,8 +695,6 @@ def build_aid_response(
687
695
  cursor_pos: int,
688
696
  fields: list[FieldResponse],
689
697
  cols: int = SCREEN_COLS,
690
- *,
691
- include_cursor: bool = True,
692
698
  ) -> bytes:
693
699
  """Build the workstation→host input response record payload.
694
700
 
@@ -711,16 +717,10 @@ def build_aid_response(
711
717
  bytes
712
718
  Raw 5250 payload ready for :meth:`TN5250Connection.send_record`.
713
719
  """
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
-
721
720
  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 += bytes(
723
+ response_payload = bytearray(
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,6 +734,13 @@ 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
+
737
744
  return bytes(response_payload)
738
745
 
739
746
 
@@ -19,7 +19,6 @@ from collections.abc import Sequence
19
19
  from dataclasses import dataclass, field
20
20
 
21
21
  from .constants import (
22
- CC2,
23
22
  EBCDIC_NULL,
24
23
  EBCDIC_SPACE,
25
24
  FFW,
@@ -65,7 +64,6 @@ class Field:
65
64
  False # Modified Data Tag — set when user edits the field
66
65
  )
67
66
  _saved_data: bytes = b"" # original content before user modification
68
- _explicit_length: int = 0
69
67
 
70
68
  # ---- derived properties ----
71
69
 
@@ -92,16 +90,7 @@ class Field:
92
90
  @property
93
91
  def is_non_display(self) -> bool:
94
92
  """True when the field is non-display (password entry)."""
95
- return (
96
- (self.field_format_word[1] & FFW.DISPLAY_ATTR_MASK) == FFW.NON_DISPLAY
97
- or (
98
- self.field_format_word[1] == 0x00
99
- and any(
100
- (fcw[0] & 0x7F) == 0x27
101
- for fcw in self.field_control_words
102
- )
103
- )
104
- )
93
+ return (self.field_format_word[1] & 0xE0) == FFW.NON_DISPLAY
105
94
 
106
95
  @property
107
96
  def mdt(self) -> bool:
@@ -200,7 +189,7 @@ class Screen:
200
189
 
201
190
  elif event_type == DSEventType.COMMAND:
202
191
  cc2 = event.control_char_2
203
- if cc2 & CC2.RESET_MDT:
192
+ if cc2 & 0x02: # reset MDT bits on all fields
204
193
  for screen_field in self._fields:
205
194
  screen_field.mdt = False
206
195
 
@@ -212,6 +201,17 @@ class Screen:
212
201
 
213
202
  elif event_type == DSEventType.WRITE_CHARS:
214
203
  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
215
 
216
216
  elif event_type == DSEventType.ERASE_TO:
217
217
  self._fill_range(event.position, event.attribute_value, EBCDIC_NULL)
@@ -236,10 +236,7 @@ class Screen:
236
236
  pre_existing_field_rows: set[int] = {f.start_pos // self.cols for f in self._fields}
237
237
 
238
238
  for ev in events:
239
- if (
240
- ev.type == DSEventType.COMMAND
241
- and ev.control_char_2 & CC2.UNLOCK_KEYBOARD
242
- ):
239
+ if ev.type == DSEventType.COMMAND and ev.control_char_2 & 0x01:
243
240
  deferred_unlock = True
244
241
  if ev.type == DSEventType.CLEAR_SCREEN:
245
242
  has_clear = True
@@ -311,14 +308,23 @@ class Screen:
311
308
  zero_based_row = row - 1
312
309
  zero_based_col = col - 1
313
310
  buffer_pos = rowcol_to_pos(zero_based_row, zero_based_col, self.cols)
314
- positions = [(buffer_pos + offset) % self._size for offset in range(length)]
315
- return ebcdic_decode(self._display_bytes(positions))
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)
316
318
 
317
319
  def get_row(self, row: int) -> str:
318
320
  """Return the full text of *row* (0-based) as a Python string."""
319
321
  row_start_pos = row * self.cols
320
- positions = range(row_start_pos, row_start_pos + self.cols)
321
- return ebcdic_decode(self._display_bytes(positions))
322
+ row_bytes = self._chars[row_start_pos : row_start_pos + self.cols]
323
+ # Bytes 0x00–0x3F are IBM 5250 field/character attribute cells, not
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)
322
328
 
323
329
  def get_all_rows(self) -> list[str]:
324
330
  """Return all rows as a list of strings."""
@@ -359,15 +365,7 @@ class Screen:
359
365
  f._saved_data = bytes(self._chars[pos : pos + write_len])
360
366
  self._chars[pos : pos + len(encoded)] = encoded
361
367
  f.mdt = True
362
- self._cursor = min(pos + len(encoded), self._size - 1)
363
- if len(encoded) == write_len:
364
- next_fields = [
365
- candidate
366
- for candidate in self.input_fields
367
- if candidate.data_pos > f.data_pos
368
- ]
369
- if next_fields:
370
- self._cursor = min(next_fields, key=lambda candidate: candidate.data_pos).data_pos
368
+ self._cursor = pos # move cursor into the field
371
369
 
372
370
  # ---- field lookups ----
373
371
 
@@ -584,38 +582,6 @@ class Screen:
584
582
  self._write_lengths.clear()
585
583
  self._keyboard_locked = True
586
584
 
587
- def _display_bytes(self, positions: Sequence[int]) -> bytes:
588
- """Return displayable bytes, honoring non-display attributes and fields."""
589
- position_list = list(positions)
590
- if not position_list:
591
- return b""
592
-
593
- hidden = False
594
- for byte in self._chars[: position_list[0]]:
595
- if 0x20 <= byte <= 0x3F:
596
- hidden = (byte & 0x07) == 0x07
597
-
598
- non_display_positions = {
599
- pos
600
- for screen_field in self._fields
601
- if screen_field.is_non_display
602
- for pos in range(
603
- screen_field.data_pos,
604
- min(screen_field.data_pos + screen_field.length, self._size),
605
- )
606
- }
607
- display = bytearray()
608
- for pos in position_list:
609
- byte = self._chars[pos]
610
- if 0x20 <= byte <= 0x3F:
611
- hidden = (byte & 0x07) == 0x07
612
- display.append(EBCDIC_SPACE)
613
- elif byte < 0x40 or hidden or pos in non_display_positions:
614
- display.append(EBCDIC_SPACE)
615
- else:
616
- display.append(byte)
617
- return bytes(display)
618
-
619
585
  def _write(self, start: int, data: bytes) -> None:
620
586
  end = min(start + len(data), self._size)
621
587
  count = end - start
@@ -634,10 +600,8 @@ class Screen:
634
600
  """Add or update a field from an SF/MF event."""
635
601
  new_field = Field(
636
602
  start_pos=event.position,
637
- length=event.field_length,
638
603
  field_format_word=event.field_format_word,
639
604
  field_control_words=event.field_control_words,
640
- _explicit_length=event.field_length,
641
605
  )
642
606
  # Replace any existing field at this position (dedup, preserving order)
643
607
  for i, existing in enumerate(self._fields):
@@ -657,9 +621,6 @@ class Screen:
657
621
  if not fields:
658
622
  return
659
623
  for field_index, current_field in enumerate(fields):
660
- if current_field._explicit_length:
661
- current_field.length = current_field._explicit_length
662
- continue
663
624
  if field_index + 1 < len(fields):
664
625
  next_field_start_pos = fields[field_index + 1].start_pos
665
626
  else:
@@ -156,7 +156,6 @@ 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
160
159
 
161
160
  # ------------------------------------------------------------------
162
161
  # Connection management
@@ -210,8 +209,6 @@ class Terminal:
210
209
  wait:
211
210
  Block until a screen update is received.
212
211
  """
213
- if self._screen.keyboard_locked:
214
- self.wait_for_keyboard_unlock()
215
212
  self._send_aid(aid)
216
213
  if wait:
217
214
  self._recv_and_apply()
@@ -523,18 +520,9 @@ class Terminal:
523
520
  for modified_field in modified_fields
524
521
  ]
525
522
  aid_payload = build_aid_response(
526
- self._screen.cursor_pos,
527
- field_responses,
528
- cols=self._screen.cols,
529
- include_cursor=False,
530
- )
531
- self._conn.send_record(
532
- aid_payload,
533
- data_type=TN5250EDataType.INPUT,
534
- aid=aid,
535
- cursor_pos=self._screen.cursor_pos,
536
- cols=self._screen.cols,
523
+ self._screen.cursor_pos, field_responses, cols=self._screen.cols
537
524
  )
525
+ self._conn.send_record(aid_payload, data_type=TN5250EDataType.INPUT, aid=aid)
538
526
  self._screen.clear_modified_fields()
539
527
  self._screen._keyboard_locked = True
540
528
  if aid in (AID.PAGE_DOWN, AID.PAGE_UP):
@@ -565,9 +553,7 @@ class Terminal:
565
553
  event.type == DSEventType.COMMAND
566
554
  and event.command == Command.SAVE_SCREEN
567
555
  ):
568
- screen_to_save = self._screen_before_partial_update or self._screen
569
- self._saved_screens.append(screen_to_save.copy())
570
- self._screen_before_partial_update = None
556
+ self._saved_screens.append(self._screen.copy())
571
557
  self._save_pending = True
572
558
  self._conn.send_save_screen_response()
573
559
  elif (
@@ -575,12 +561,10 @@ class Terminal:
575
561
  and event.command == Command.RESTORE_SCREEN
576
562
  ):
577
563
  if self._saved_screens:
564
+ # Back-to-back SAVE+RESTORE: discard the redundant save
578
565
  if self._save_pending and len(self._saved_screens) >= 2:
579
- saved_current = self._saved_screens.pop()
580
- self._screen = self._saved_screens.pop()
581
- self._saved_screens.append(saved_current)
582
- else:
583
- self._screen = self._saved_screens.pop()
566
+ self._saved_screens.pop()
567
+ self._screen = self._saved_screens.pop()
584
568
  self._screen.clear_modified_fields()
585
569
  # If a CLEAR_UNIT_ALT resized the parser before restore,
586
570
  # the restored screen dimensions won't match — resize preserving content
@@ -622,6 +606,7 @@ class Terminal:
622
606
  if restore_index is not None:
623
607
  post_events = parsed_events[restore_index + 1:]
624
608
  self._screen.apply_all(post_events)
609
+ print(f"RESTORE: screen text after apply_all:\n{self._screen.get_screen_text()}")
625
610
  # After RESTORE, the saved screen has keyboard locked. The server
626
611
  # sends a follow-up record to unlock it — consume it now to
627
612
  # prevent out-of-turn AID sends.
@@ -631,18 +616,6 @@ class Terminal:
631
616
  except RecvTimeout:
632
617
  pass
633
618
  else:
634
- has_screen_content = any(
635
- event.type not in (DSEventType.COMMAND, DSEventType.QUERY)
636
- for event in parsed_events
637
- )
638
- if has_screen_content:
639
- if any(
640
- event.type == DSEventType.CLEAR_SCREEN
641
- for event in parsed_events
642
- ):
643
- self._screen_before_partial_update = None
644
- else:
645
- self._screen_before_partial_update = self._screen.copy()
646
619
  self._screen.apply_all(parsed_events)
647
620
 
648
621
  def _send_query_reply(self) -> None:
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "ibm5250"
16
- version = "0.1.0.dev5"
16
+ version = "0.1.0.dev6"
17
17
  source = { editable = "." }
18
18
 
19
19
  [package.dev-dependencies]
File without changes
File without changes
File without changes