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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ibm5250
3
- Version: 0.1.0.dev6
3
+ Version: 0.1.0.dev7
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.dev6"
3
+ version = "0.1.0.dev7"
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
  # ---------------------------------------------------------------------------
@@ -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), both 0-based."""
545
- return divmod(pos, cols)
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 (both 0-based)."""
550
- return row * cols + col
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
- | `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,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 (self.field_format_word[1] & 0xE0) == FFW.NON_DISPLAY
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
- """0-based screen row of the first data byte."""
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
- """0-based screen column of the first data byte."""
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 & 0x02: # reset MDT bits on all fields
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 ev.type == DSEventType.COMMAND and ev.control_char_2 & 0x01:
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 // self.cols
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 % self.cols
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
- zero_based_row = row - 1
309
- zero_based_col = col - 1
310
- 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)
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* (0-based) as a Python string."""
321
- 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)
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(r) for r in range(self.rows)]
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 # move cursor into the field
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 whose data area contains *(row, col)*, or ``None``."""
374
- pos = rowcol_to_pos(row, col, self.cols)
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)* (0-based) of the first occurrence of *text*, or ``None``."""
550
+ """Return 1-based *(row, col)* for the first occurrence, or ``None``."""
550
551
  rows = self.get_all_rows()
551
- for r, row_text in enumerate(rows):
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 r, idx
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 r in range(self.rows):
651
- lines.append("|" + self.get_row(r) + "|")
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
- row_idx = display_row - 1
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)* (0-based) without sending a key."""
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)* (0-based)."""
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 (unprotected) field at *(row, col)* (0-based).
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, field_responses, cols=self._screen.cols
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._saved_screens.append(self._screen.copy())
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
- self._screen = self._saved_screens.pop()
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:
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "ibm5250"
16
- version = "0.1.0.dev6"
16
+ version = "0.1.0.dev7"
17
17
  source = { editable = "." }
18
18
 
19
19
  [package.dev-dependencies]
File without changes
File without changes
File without changes