ibm5250 0.1.0.dev8__tar.gz → 0.1.0.dev9__tar.gz

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