ibm5250 0.1.0.dev4__tar.gz → 0.1.0.dev5__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.dev4
3
+ Version: 0.1.0.dev5
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.dev4"
3
+ version = "0.1.0.dev5"
4
4
  description = "IBM 5250 terminal automation library"
5
5
  requires-python = ">=3.11"
6
6
 
@@ -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: _VAR_HDR_OP_HI _VAR_HDR_OP_LO <AID>
88
- var_hdr = bytes([_VAR_HDR_OP_HI, _VAR_HDR_OP_LO, self.aid])
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: AID byte is the first byte of the payload
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 = 0x01
125
- RESET_MDT = 0x02 # Clear all MDT bits
126
- RESET_MDT_UNLOCK = 0x03
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
  # ---------------------------------------------------------------------------
@@ -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
- | `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 |
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
- - `0x01`: Unlock the keyboard (let the user type)
69
- - `0x02`: Reset all Modified Data Tags (MDT) on fields
70
- - `0x03`: Both unlock + reset MDT
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
- if (
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
- byte_offset < total_bytes
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 byte_offset + 2 > total_bytes:
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
- 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)
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
- # 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
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 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.
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
- 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
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 current_byte == Command.ESC and next_byte == Command.WRITE_TO_DISPLAY:
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
- # Real inline WSF attribute marker (4 bytes: marker + type + value + trailing)
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 = bytearray(
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,7 @@ 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
67
69
 
68
70
  # ---- derived properties ----
69
71
 
@@ -90,7 +92,16 @@ class Field:
90
92
  @property
91
93
  def is_non_display(self) -> bool:
92
94
  """True when the field is non-display (password entry)."""
93
- return (self.field_format_word[1] & 0xE0) == FFW.NON_DISPLAY
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
+ )
94
105
 
95
106
  @property
96
107
  def mdt(self) -> bool:
@@ -189,7 +200,7 @@ class Screen:
189
200
 
190
201
  elif event_type == DSEventType.COMMAND:
191
202
  cc2 = event.control_char_2
192
- if cc2 & 0x02: # reset MDT bits on all fields
203
+ if cc2 & CC2.RESET_MDT:
193
204
  for screen_field in self._fields:
194
205
  screen_field.mdt = False
195
206
 
@@ -201,17 +212,6 @@ class Screen:
201
212
 
202
213
  elif event_type == DSEventType.WRITE_CHARS:
203
214
  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,7 +236,10 @@ 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 ev.type == DSEventType.COMMAND and ev.control_char_2 & 0x01:
239
+ if (
240
+ ev.type == DSEventType.COMMAND
241
+ and ev.control_char_2 & CC2.UNLOCK_KEYBOARD
242
+ ):
240
243
  deferred_unlock = True
241
244
  if ev.type == DSEventType.CLEAR_SCREEN:
242
245
  has_clear = True
@@ -308,23 +311,14 @@ class Screen:
308
311
  zero_based_row = row - 1
309
312
  zero_based_col = col - 1
310
313
  buffer_pos = rowcol_to_pos(zero_based_row, zero_based_col, self.cols)
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)
314
+ positions = [(buffer_pos + offset) % self._size for offset in range(length)]
315
+ return ebcdic_decode(self._display_bytes(positions))
318
316
 
319
317
  def get_row(self, row: int) -> str:
320
318
  """Return the full text of *row* (0-based) as a Python string."""
321
319
  row_start_pos = row * self.cols
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)
320
+ positions = range(row_start_pos, row_start_pos + self.cols)
321
+ return ebcdic_decode(self._display_bytes(positions))
328
322
 
329
323
  def get_all_rows(self) -> list[str]:
330
324
  """Return all rows as a list of strings."""
@@ -365,7 +359,15 @@ class Screen:
365
359
  f._saved_data = bytes(self._chars[pos : pos + write_len])
366
360
  self._chars[pos : pos + len(encoded)] = encoded
367
361
  f.mdt = True
368
- self._cursor = pos # move cursor into the field
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
369
371
 
370
372
  # ---- field lookups ----
371
373
 
@@ -582,6 +584,38 @@ class Screen:
582
584
  self._write_lengths.clear()
583
585
  self._keyboard_locked = True
584
586
 
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
+
585
619
  def _write(self, start: int, data: bytes) -> None:
586
620
  end = min(start + len(data), self._size)
587
621
  count = end - start
@@ -600,8 +634,10 @@ class Screen:
600
634
  """Add or update a field from an SF/MF event."""
601
635
  new_field = Field(
602
636
  start_pos=event.position,
637
+ length=event.field_length,
603
638
  field_format_word=event.field_format_word,
604
639
  field_control_words=event.field_control_words,
640
+ _explicit_length=event.field_length,
605
641
  )
606
642
  # Replace any existing field at this position (dedup, preserving order)
607
643
  for i, existing in enumerate(self._fields):
@@ -621,6 +657,9 @@ class Screen:
621
657
  if not fields:
622
658
  return
623
659
  for field_index, current_field in enumerate(fields):
660
+ if current_field._explicit_length:
661
+ current_field.length = current_field._explicit_length
662
+ continue
624
663
  if field_index + 1 < len(fields):
625
664
  next_field_start_pos = fields[field_index + 1].start_pos
626
665
  else:
@@ -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()
@@ -520,9 +523,18 @@ class Terminal:
520
523
  for modified_field in modified_fields
521
524
  ]
522
525
  aid_payload = build_aid_response(
523
- self._screen.cursor_pos, field_responses, cols=self._screen.cols
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,
524
537
  )
525
- self._conn.send_record(aid_payload, data_type=TN5250EDataType.INPUT, aid=aid)
526
538
  self._screen.clear_modified_fields()
527
539
  self._screen._keyboard_locked = True
528
540
  if aid in (AID.PAGE_DOWN, AID.PAGE_UP):
@@ -553,7 +565,9 @@ class Terminal:
553
565
  event.type == DSEventType.COMMAND
554
566
  and event.command == Command.SAVE_SCREEN
555
567
  ):
556
- self._saved_screens.append(self._screen.copy())
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
557
571
  self._save_pending = True
558
572
  self._conn.send_save_screen_response()
559
573
  elif (
@@ -561,10 +575,12 @@ class Terminal:
561
575
  and event.command == Command.RESTORE_SCREEN
562
576
  ):
563
577
  if self._saved_screens:
564
- # Back-to-back SAVE+RESTORE: discard the redundant save
565
578
  if self._save_pending and len(self._saved_screens) >= 2:
566
- self._saved_screens.pop()
567
- self._screen = self._saved_screens.pop()
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()
568
584
  self._screen.clear_modified_fields()
569
585
  # If a CLEAR_UNIT_ALT resized the parser before restore,
570
586
  # the restored screen dimensions won't match — resize preserving content
@@ -606,7 +622,6 @@ class Terminal:
606
622
  if restore_index is not None:
607
623
  post_events = parsed_events[restore_index + 1:]
608
624
  self._screen.apply_all(post_events)
609
- print(f"RESTORE: screen text after apply_all:\n{self._screen.get_screen_text()}")
610
625
  # After RESTORE, the saved screen has keyboard locked. The server
611
626
  # sends a follow-up record to unlock it — consume it now to
612
627
  # prevent out-of-turn AID sends.
@@ -616,6 +631,18 @@ class Terminal:
616
631
  except RecvTimeout:
617
632
  pass
618
633
  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()
619
646
  self._screen.apply_all(parsed_events)
620
647
 
621
648
  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.dev4"
16
+ version = "0.1.0.dev5"
17
17
  source = { editable = "." }
18
18
 
19
19
  [package.dev-dependencies]
File without changes
File without changes
File without changes