-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdtl.py
More file actions
5090 lines (4787 loc) · 266 KB
/
Copy pathdtl.py
File metadata and controls
5090 lines (4787 loc) · 266 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""A pragmatic subset of IBM's Dialog Tag Language (DTL) for defining screens.
DTL is IBM's real, ISO-SGML-based markup for ISPF panels — on z/OS you write
tagged source and run it through the ``ISPDTLC`` converter to produce panels,
messages, command tables, and keylists. This module is a small, self-contained
take on the same idea: ``load_dtl(source)`` parses DTL markup into a
:class:`screen.Screen`, which then renders to a 3270 data stream.
Relationship to authentic DTL
-----------------------------
We keep DTL's tag *names* and spirit. A ``<panel>`` is a **flow box**: every
element flows down from the top (and a ``<dtafld>``'s entry field follows its
prompt), the way real DTL relies on ``ISPDTLC`` to auto-lay-out. There is no
explicit ``row``/``col`` positioning — the bundled panels are all auto-flow.
This is a pragmatic take on ``ISPDTLC``'s auto-layout (the genuinely hard part),
exercised by both the bundled panels and the conformance corpus
(``tests/dtl_examples/``).
Like real DTL the source is SGML: files may begin with a ``<!DOCTYPE DM SYSTEM>``
prolog (tolerated and ignored), tag and attribute names are case-insensitive
(``<PANEL>`` == ``<panel>``), and boolean attributes may be minimized
(``<dtafld numeric>`` means ``numeric="yes"``).
Supported tags
--------------
``<panel name help>Title`` root container. The panel's content text is its
``width depth titline`` title (``panel-title-text`` → ``Screen.title``,
centered on row 0 when that row is free;
``titline=no`` keeps it metadata-only, no line);
``help`` names a help panel; ``width``/``depth``
give the presentation-space size (default 80x24)
and bound element positions at load time.
``<area row col>`` a flow box: contained elements that omit ``row``
``<region row col width dir>`` flow down from this origin (one line each), and
those that omit ``col`` use it. A field's entry
flows one column after its
prompt. ``dir=horiz`` lays the box's children side
by side instead of stacking them, and the enclosing
flow resumes below the tallest column; ``width=n``
fixes a column's width.
``<info row col>`` protected text (label / instruction). A whole-line
``<hp>`` is CUA emphasis (high intensity + white);
a horizontal rule is a ``<divider>``.
``<topinst row col>`` top / panel / bottom instruction text. Render like
``<pnlinst row col>`` ``<info>`` (protected text); semantic DTL tags. A
``<botinst row col>`` flowed ``<botinst>`` anchors at the panel foot.
``<p>`` ``<lines>`` ``<dt>`` ``<dd>`` flowed text: paragraphs and items each render
``<pt>`` ``<pd>`` as protected lines, word-wrapped to the panel
width with a hanging indent. DTL omits end tags,
so a block element is closed by the next block tag.
``<ul>`` ``<ol>`` ``<li>`` a list: each ``<li>`` flows as a bullet/number plus
its (wrapped) text, indented one level per nesting.
``<ul>`` → ``o``/``-``/``--`` by depth; ``<ol>`` →
``1.`` then nested ``a.`` then ``i.`` (CUA style).
``<help name width depth>`` a top-level help panel — same flow root as
``<panel>`` (title text, width/depth, flow box).
Panel title: the text after ``<panel ...>``/``<help ...>`` (before its first child)
renders centered on row 0, with the body flowing beneath it.
``<dtafld row col datavar a prompt plus an input field that follows it. The
entwidth usage prompt is the text of a nested ``<dtafldd>`` child
pmtloc ...>`` (authentic DTL) or the element's own text.
``usage=out`` makes it a protected display field
(the variable's value); ``pmtloc=above`` puts the
prompt on the line above. See attrs below.
``<dtafldd>text</dtafldd>`` data-field description: a trailing description
(after the entry, sized by ``deswidth``) when the
field has its own prompt text, else it stands in
as the prompt.
``<cmdarea row col the command area (ISPF "Option/Command ===>"
entwidth ...>`` line). Renders like ``<dtafld>``; ``datavar``
defaults to ``ZCMD`` and the field is recorded
as ``Screen.command_field``.
``<selfld row col type>`` a list of menu choices; each ``<choice>`` is laid
out on its own row, auto-incrementing.
``<choice selchar name match one menu row: number, name, description. The
checkvar unavail>desc`` selection value (``match``, default the
auto-number or ``selchar``) is recorded in
``Screen.selections`` so the dialog can
validate a typed option; ``checkvar`` lands the
cursor on the current choice; ``unavail`` greys a
choice out and makes it unselectable.
``<keyl name>`` a keylist: a set of function-key bindings for
the panel (rendered as nothing; pure metadata).
``<keyi key cmd>desc`` one key binding: function key ``key`` (e.g.
``PF3``) invokes command ``cmd`` (e.g. ``EXIT``).
``<cmdtbl applid>`` an application command table (metadata).
``<cmd name altdescr>ex<t>tra`` a command; a ``<t>`` truncation point within the
external name marks the min chars to type;
``altdescr`` is the command's description (metadata).
``<cmdact action>`` the command's action (e.g. ``alias exit``,
``passthru``). Recorded in ``Screen.commands``.
``<ab row col gap>`` an action bar; its ``<abc>`` choice labels are
``<abc>label</abc>`` laid out across ``row``. Each ``<abc>`` holds
``<pdc>label<action run>`` ``<pdc>`` pull-down choices (kept in
``<pdsep>`` ``Screen.action_bar`` for future interaction); a
``<pdsep>`` is a divider row between them.
``<varclass name type msg>`` a variable class: ``type="char N"`` caps input
length, ``type="numeric N"`` makes fields numeric
(capping digits). May contain a ``<checkl>``.
``<checkl msg>`` a validity-check list; ``msg`` names the message
shown when a check fails (falls back to the
``<varclass>``'s ``msg``).
``<checki type>min max`` a check item: ``type="range"`` (``min max`` text),
``<checki type>v1 v2 ...`` ``type="values"`` (allowed values, as text or via
``<checki type parm1 parm2>`` ``parm1=EQ|NE parm2='v1 v2'``), ``type="alpha"``
(letters), or ``type="name"`` (a valid symbol). A
field's input is validated against its class's
checks. Other types (``picture`` …) stay lenient.
``<xlatl msg format>`` a translate list on a ``<varclass>``: its
``<xlati value>external`` ``<xlati>`` items name the values a field may be
typed as (``format=upper`` matches case-insensitively);
an input that is not one fails with the ``msg``.
``<lit>`` wraps an external with literal spacing.
``<varlist>`` container for ``<vardcl>`` declarations.
``<vardcl name varclass>`` declares variable ``name`` to be of class
``varclass``; a field's ``numeric`` is inherited
from it when the field omits ``numeric``.
``<msgmbr name>`` a message member: container for ``<msg>`` entries
(parsed by :func:`load_messages`, not a panel).
``<msg msgid>text`` a message; ``&NAME`` references (or a nested
``<varsub var>`` ``<varsub var=NAME>`` tag) in ``text`` are
substituted at display time. See `MessageCatalog`.
Inline in body text: ``<hp>``/``<rp>`` emphasise a phrase within a text element
(``<rp>`` — a reference phrase / help-panel link — renders underlined by default).
``<dtafld>`` attributes: ``datavar`` (field name sent back), ``entwidth`` (field
length), ``display`` (``display=no`` is non-display, e.g. password), ``numeric``,
``init`` (initial value),
``required`` (``required=yes`` must be non-empty on submit; ``msg`` names the error),
``deswidth`` (width of a trailing ``<dtafldd>`` description),
``cursor`` (place the cursor here), ``mdt`` (default yes), ``intens`` (prompt).
Variable substitution: dialog-variable references are written ISPF-style with a
leading ``&`` (e.g. ``&ZUSER``, ``&ZTIME``) and resolved from the keyword
arguments to :func:`load_dtl` before parsing. A reference is a name of 1–8
characters; an optional trailing ``.`` terminates it (and is consumed), so
``&ZUSER.X`` substitutes ``ZUSER`` followed by a literal ``X``. ``&&`` is a
literal ampersand. Names are matched case-insensitively (ISPF convention is
uppercase). An undefined reference is left untouched rather than blanked.
"""
import re
from html.parser import HTMLParser
from screen import (Screen, Text, Field, DisplayIntensity, Color, Highlight,
Outline, GraphicText, Line)
# An ISPF dialog-variable reference in panel source: ``&&`` (escaped literal
# ampersand) or ``&NAME`` with an optional terminating ``.``. A name is 1–8
# characters: a letter or one of @ # $ followed by up to 7 alphanumerics/@#$.
_DIALOG_VAR_RE = re.compile(r"&&|&([A-Za-z@#$][A-Za-z0-9@#$]{0,7})\.?")
def _substitute(source: str, variables: dict) -> str:
"""Resolve ``&NAME`` dialog-variable references against ``variables``.
``&&`` collapses to a single ``&``; a known ``&NAME`` (case-insensitive) is
replaced by its value and any trailing ``.`` consumed; an unknown reference
is left verbatim (including its terminator).
"""
upper = {k.upper(): "" if v is None else str(v) for k, v in variables.items()}
def repl(match):
if match.group(0) == "&&":
return "&"
name = match.group(1).upper()
return upper.get(name, match.group(0))
return _DIALOG_VAR_RE.sub(repl, source)
# An internal SGML general-entity declaration with a literal value, e.g.
# ``<!ENTITY guar "money-back guarantee">`` — and the matching ``&guar;``
# reference. Parameter entities (``<!ENTITY % …>``) and external/SYSTEM
# entities reference files we don't have, so they're left unresolved.
_ENTITY_DECL_RE = re.compile(
r"""<!\s*ENTITY\s+([A-Za-z][\w.-]*)\s+(?:"([^"]*)"|'([^']*)')\s*>""",
re.IGNORECASE,
)
_ENTITY_REF_RE = re.compile(r"&([A-Za-z][\w.-]*);")
def _resolve_entities(source: str) -> str:
"""Resolve internal SGML general entities: capture ``<!ENTITY name "text">``
declarations (wherever they appear, including a ``<!doctype … [ … ]>``
internal subset), drop the declarations, and replace ``&name;`` references
with their text. References to entities we didn't capture (external/SYSTEM,
parameter, or undeclared) are left verbatim."""
entities = {}
for m in _ENTITY_DECL_RE.finditer(source):
entities[m.group(1).lower()] = m.group(2) if m.group(2) is not None else m.group(3)
if not entities:
return source
source = _ENTITY_DECL_RE.sub("", source)
return _ENTITY_REF_RE.sub(
lambda m: entities.get(m.group(1).lower(), m.group(0)), source
)
_INTENSITY = {
"normal": DisplayIntensity.NORMAL,
"high": DisplayIntensity.HIGH,
"highlighted": DisplayIntensity.HIGHLIGHTED,
}
# Shown when a REQUIRED=YES field is left blank and neither the field nor its
# variable class names a MSG — a stand-in for ISPF's own system message.
_REQUIRED_DEFAULT_MSG = "Enter required field"
# ISPF option routing in a )PROC: `&ZSEL = TRANS( TRUNC(&ZCMD,'.') 0,'PANEL(x)' …)`.
# _ZSEL_TRANS_OPEN_RE finds the `TRANS(`; _balanced_parens then takes exactly its
# body (so anything after the TRANS — a second statement/assignment — can't leak in).
# _ZSEL_PAIR_RE picks each `option,'selection-string'` pair; the option is a digit run
# or a single word-boundaried letter, which skips the source expression
# (TRUNC(&ZCMD,'.')) and the `*,'?'` default without matching them.
_ZSEL_TRANS_OPEN_RE = re.compile(r"ZSEL\s*=\s*TRANS\s*\(", re.IGNORECASE)
_ZSEL_PAIR_RE = re.compile(r"\b(\d+|[A-Z])\s*,\s*'([^']*)'")
def _balanced_parens(s, open_idx):
"""The text inside the balanced parentheses whose opening ``(`` is at
``s[open_idx]`` (the opener excluded). Falls back to the rest of the string if
the parentheses never close."""
depth = 0
for i in range(open_idx, len(s)):
if s[i] == "(":
depth += 1
elif s[i] == ")":
depth -= 1
if depth == 0:
return s[open_idx + 1:i]
return s[open_idx + 1:]
# DTL COLOR / HILITE attribute values → the screen model's enums. These are real
# DTL attributes (COLOR=WHITE|RED|BLUE|GREEN|PINK|YELLOW|TURQ|%var, HILITE=USCORE|
# BLINK|REVERSE) carried by the CUA element tags that accept them (<dtafld>,
# <selfld>, <lstcol>, <hp>, <note>/<notel>/<nt>, <attr>). The canonical keywords
# are the DTL ones; a few friendly aliases are tolerated. Colour is emitted only
# to colour-capable terminals (Screen.render(color=True)); a mono terminal
# ignores it, so panels stay byte-identical there.
_COLORS = {
"white": Color.WHITE,
"red": Color.RED,
"blue": Color.BLUE,
"green": Color.GREEN,
"pink": Color.PINK,
"yellow": Color.YELLOW,
"turq": Color.TURQUOISE,
# tolerated aliases
"turquoise": Color.TURQUOISE,
"cyan": Color.TURQUOISE,
"magenta": Color.PINK,
}
_HIGHLIGHTS = {
"uscore": Highlight.UNDERSCORE,
"blink": Highlight.BLINK,
"reverse": Highlight.REVERSE,
# tolerated aliases
"underscore": Highlight.UNDERSCORE,
"rvideo": Highlight.REVERSE,
}
# A text tag's TYPE= (on <hp>/<note>/<notel>/<nt>) names a CUA *attribute type*,
# each of which ISPF renders in a fixed colour. These are the authoritative
# defaults from the z/OS ISPF Dialog Developer's Guide, Table 11 "CUA TYPE default
# keyword values" (COLOR column) — the same colours ISPF paints CUA-typed text.
# TYPE=TEXT is deliberately absent: it is the non-CUA escape hatch that instead
# enables the explicit COLOR/INTENS/HILITE attributes (which we already honour).
#
# COLOUR ONLY. Table 11 also fixes each type's HIGH/LOW intensity (ET/CH/CT/WT are
# HIGH, the rest LOW), but we map *only* the colour: colour rides an SA order that
# a mono terminal ignores, so panels stay byte-identical; an intensity lives in the
# basic field-attribute byte, so honouring it would change mono output. (#218)
_CUA_TYPE_COLORS = {
"et": Color.TURQUOISE, # emphasized text
"ch": Color.BLUE, # column heading
"ct": Color.YELLOW, # caution text
"fp": Color.GREEN, # field prompt
"lef": Color.TURQUOISE, # leading (entry) field
"li": Color.WHITE, # list item
"nt": Color.GREEN, # normal text
"pt": Color.BLUE, # panel title
"sac": Color.WHITE, # select-available choice
"wasl": Color.BLUE, # work-area separator line
"wt": Color.RED, # warning text
}
# DTL OUTLINE=NONE | L | R | O | U | BOX → the 3270 field-outlining lines.
_OUTLINES = {
"none": Outline.NONE, "l": Outline.LEFT, "r": Outline.RIGHT,
"o": Outline.OVER, "u": Outline.UNDER, "box": Outline.BOX,
}
# Each DTL element is tagged with a CUA "role" (see screen._CUA_COLORS), so a
# colour terminal renders it in the standard z/OS colour for that kind of element
# unless it carries an explicit COLOR.
# Admonition tags: a note/callout that flows as a labelled block within body text
# (help panels). ATTENTION/WARNING/NOTE prefix the text inline; CAUTION puts its
# uppercase heading on its own line with the emphasised body beneath (see
# _emit_info). <notel> (below) is the list form.
_ADMONITIONS = {
"note": "Note:", "nt": "Note:", # note / inline note
"attention": "Attention:", "caution": "CAUTION:", "warning": "Warning:",
}
# Block tags whose text flows as protected lines (like <info>): paragraphs,
# list items (<li>/<dt>/<dd>/<pt>/<pd>/<lp>), preformatted <lines>/<xmp>, and the
# admonitions above. Their list containers (<ul>/<ol>/<sl>/<dl>/<parml>/<notel>)
# are transparent — ignored (a plain container), except <notel>'s "Notes:"
# heading. A <sl> (simple list) marks its <li>s without a bullet (see below).
_FLOW_TEXT_TAGS = ("p", "li", "dt", "dd", "pt", "pd", "lp", "lines", "xmp") + tuple(_ADMONITIONS)
# Instruction tags render as protected text like <info>: <topinst> (top),
# <pnlinst> (panel), and <botinst> (bottom) instructions.
_INSTRUCTION_TAGS = ("topinst", "pnlinst", "botinst")
# Help-panel heading tags: <h1> (major) through <h4> (minor). They render as a
# high-intensity heading line in the text flow, sub-headings indented by level.
_HEADING_TAGS = ("h1", "h2", "h3", "h4")
_TEXT_TAGS = ("info",) + _INSTRUCTION_TAGS + _FLOW_TEXT_TAGS + _HEADING_TAGS
# ISPDTLC inserts a blank line BEFORE a flowed paragraph (<p>), a labelled
# paragraph (<lp>), or a panel instruction; COMPACT/NOSKIP suppress it (#210). A
# TOPINST instead gets a blank line AFTER it. See the P/LP/TOPINST tag references.
# (The other block elements — <lines>/<xmp>, <ul>/<ol>/<sl>/<notel>, <note>/<nt>,
# <dl>/<parml>, <fig> — take the same leading skip at their own emit sites.)
_BLANK_BEFORE_TAGS = ("p", "lp", "pnlinst")
_CONTENT_TAGS = _TEXT_TAGS + ("dtafld", "cmdarea", "choice", "chdiv", "figcap",
"grphdr", "dthd", "ddhd", "dldiv", "pldiv",
"textseg", "dtseg", "ptseg")
_FIELD_TAGS = ("dtafld", "cmdarea")
def _truthy(value, default=False):
if value is None:
return default
return str(value).strip().lower() in ("yes", "true", "1", "on")
def _bool_attr(attrs, key, default=False):
"""Read a boolean DTL attribute, honouring SGML attribute minimization.
``<dtafld numeric>`` (the attribute present with no value, ``html.parser``
reports ``None``) and ``numeric="numeric"`` both mean true, as does any of
yes/true/1/on. An absent attribute yields ``default``.
"""
if key not in attrs:
return default
value = attrs[key]
if value is None:
return True
return _truthy(value) or str(value).strip().lower() == key
def _intensity(attrs, key="intens", default=DisplayIntensity.NORMAL):
# The standard DTL attribute is INTENS (valid on field/selection elements);
# the non-standard ``intensity`` is no longer read (emphasis is <hp>).
return _INTENSITY.get(str(attrs.get(key, "")).lower(), default)
def _resolve_color(value, subs):
"""Map a DTL COLOR value to a :class:`Color`, or None if absent/unknown.
A ``%name`` value is a dialog-variable reference: its colour comes from the
substitution ``subs`` (the same dict that resolves ``&NAME`` references),
mirroring DTL's ``COLOR=%varname``.
"""
v = str(value or "").strip()
if v.startswith("%"):
v = str((subs or {}).get(v[1:].upper(), ""))
return _COLORS.get(v.strip().lower())
class DTLError(ValueError):
"""Raised when DTL markup is malformed (missing required attribute, etc.)."""
class _DTLParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.screen = Screen()
self._tag = None # current content-bearing tag, or None
self._attrs = None
self._chars = []
self._runs = None # inline <hp> mixed-content runs, or None
self._hp = None # the open <hp>'s (color, highlight, intensity), or None
self._selfld = None # active <selfld> layout state, or None
self._in_dtafldd = False # capturing a <dtafldd> prompt child?
self._dtafldd = None # captured <dtafldd> prompt text, or None
self._pending_ps = None # open <ps>'s (var, value) awaiting its row, or None
self._pending_chofld = None # open <chofld>'s attrs (a choice's entry field)
self._chofld_choicetext = None # the choice text captured before a <chofld>
self._pending_scrfld = None # a <scrfld> awaiting its <dtafld>/<lstcol>
self._assignl = None # open <assignl> {"destvar", "pairs"} collecting <assigni>s
self._pending_assignl = None # a finished <assignl> awaiting its <dtafld>
self._keylist = None # active <keyl> bindings dict, or None
self._keylist_name = None # <keyl name=...> (the list's name), or None
self._keylist_applid = None # <keyl applid=...> (its application id), or None
self._keylist_help = None # <keyl help=...> (keylist help panel), or None
self._keylist_action = None # <keyl action=UPDATE|DELETE> (codegen), or None
self._keyi = None # open <keyi> awaiting its FKA-text content, or None
self._varclasses = {} # <varclass> name (upper) → {"numeric", "checks", "msg"}
self._vardcls = {} # <vardcl> name (upper) → {"varclass": name}
self._cur_varclass = None # name of the <varclass> currently being defined
self._checkl = None # active <checkl> {"msg", "checks"} or None
self._in_varlist = False # inside a <varlist>?
self._in_msgmbr = False # inside a <msgmbr>?
self._msgmbr_name = "" # current <msgmbr name=...> (for <msg suffix>)
self._msgmbr_width = None # <msgmbr width=...>, or None
self._msgmbr_ccsid = None # <msgmbr ccsid=...>, or None
self.messages = {} # <msg> msgid (upper) → message text
self._msg_attrs = {} # <msg> msgid (upper) → {alarm, msgtype, smsg, help}
self._areas = [] # stack of <area>/<region> flow contexts
self._in_cmdtbl = False # inside a <cmdtbl>?
self._cur_cmd = None # current <cmd> dict awaiting its <cmdact>
self._cmd_chars = None # current <cmd>'s captured external-name text, or None
self._cmd_tpos = None # offset of a <t> truncation point within it, or None
self._ab = None # active <ab> action bar being built, or None
self._cur_abc = None # current <abc> action-bar choice, or None
self._cur_pdc = None # current <pdc> pull-down choice, or None
self._panel_title = None # capturing the panel's title text, or None
self._textline = None # <textline> segments [(text, expand)], or None
self._pandefs = {} # <pandef id> → default attrs for <panel pandef=id>
self._helpdefs = {} # <helpdef id> → default attrs for <help helpdef=id>
self._skip = None # inside a non-rendering block [tag, chars, attrs]
# — <comment>/<copyr>/<compopt>/<source>
self._title_item = None # the centered title Text (retracted on collision)
self._title_rule = None # the action-bar separator rule (retracted on collision)
self._titline = True # <panel titline=no> suppresses the on-screen title line
self._tmargin = 0 # <panel/help TMARGIN=n> top margin (rows before content)
self._bmargin = 0 # <panel/help BMARGIN=n> bottom margin (rows reserved)
self._panel_cursor = None # <panel cursor=field-name> places the cursor at that field
self._grpbox_pending = None # a <region GRPBOX> whose title text is being captured
self._lists = [] # stack of open <ul>/<ol> ({"type", "n"})
self._note_hang = None # hanging-indent col of an open <nt>, so its
# nested blocks flow under the note body (#219)
self._info_indent = 0 # <info indent=n>: extra columns its content is
# shifted right (cleared at </info> / box close)
self._lstfld = None # active <lstfld> table {"cols", "groups", …}
self._lstgrp = None # innermost open <lstgrp> column group, or None
self._lstgrp_stack = [] # open <lstgrp> groups, outermost first (nesting)
self._scroll = None # <lstfld scrollvar=> config for the command line
self._xlatl = None # active <xlatl> {"msg", "upper", "items"} or None
self._rows = None # data rows for the list field (datavar→value)
self._subs = {} # &NAME/%NAME substitution values (for COLOR=%var)
self._da = None # active <da> data area {row, col, attrs, body}
# Presentation-size overrides (rows/cols): when set, they win over the
# panel's declared/default size, so a panel can be laid out on a larger
# alternate screen (e.g. a member list showing more rows on a model 3/4).
self._override_rows = None
self._override_cols = None
# Paged-window position of ``_rows`` within the full table (see load_dtl):
# the offset of the first supplied row and the full row count, driving the
# ROW x TO y OF z status and the BOTTOM OF DATA marker. Defaults describe an
# unpaged table (offset 0, total == len(rows)).
self._row_offset = 0
self._row_total = None
# ── colour / highlight attributes ────────────────────────────────────────
def _color(self, a):
"""The Color for a tag's COLOR= attribute (honouring %var), or None."""
return _resolve_color(a.get("color"), self._subs)
def _text_colour(self, a):
"""The heading/phrase colour for a CUA text tag (<hp>/<note>/<notel>/<nt>):
its explicit COLOR= if any, else the standard CUA colour named by TYPE=
(ET/CH/…; see _CUA_TYPE_COLORS). TYPE=TEXT and unknown TYPEs contribute
nothing (COLOR alone, or None). Applied only where a tag legitimately reads
TYPE as a CUA attribute type — not folded into _color, since other tags use
TYPE for unrelated meanings (e.g. <divider type=dash>). #218"""
return self._color(a) or _CUA_TYPE_COLORS.get(
str(a.get("type", "")).strip().lower())
def _hilite(self, a):
"""The Highlight for a tag's HILITE= attribute, or None."""
return _HIGHLIGHTS.get(str(a.get("hilite", "")).strip().lower())
def _outline(self, a):
"""The Outline for a tag's OUTLINE= attribute (NONE|L|R|O|U|BOX), or None.
Field outlining draws the box line(s) around a field on an extended
terminal; a mono terminal is unaffected."""
return _OUTLINES.get(str(a.get("outline", "")).strip().lower())
# ── inline <hp> (highlighted phrase) mixed content ───────────────────────
@staticmethod
def _message_attrs(a) -> dict:
"""Presentation attributes of a <msg>. ALARM defaults from MSGTYPE:
WARNING/ACTION/CRITICAL messages sound the alarm, INFO does not (an
explicit ALARM=YES/NO overrides). SMSG is the short-message text; HELP
names the help panel the user reaches (PF1) while the message shows."""
msgtype = str(a.get("msgtype", "")).strip().lower()
if "alarm" in a:
alarm = _truthy(a.get("alarm"))
else:
alarm = msgtype in ("warning", "action", "critical")
return {"alarm": alarm, "msgtype": msgtype or None,
"smsg": a.get("smsg"), "help": a.get("help"),
# FORMAT=ASIS keeps the message's authored line breaks; FLOW (the
# default) word-wraps to the member WIDTH (see MessageCatalog.lines).
"format": str(a.get("format", "")).strip().lower() or None,
# LOCATION (AREA/MODAL/MODELESS) is where the dialog shows the
# message — a message area or a pop-up window. Recorded so the
# server can place it; not a rendering effect here. #127.
"location": str(a.get("location", "")).strip().lower() or None}
def _hp_hilite(self, a):
"""The Highlight for an <hp> phrase: its HILITE= or the DTL TYPE= (both
mapped through the highlight table), or None."""
return (self._hilite(a)
or _HIGHLIGHTS.get(str(a.get("type", "")).strip().lower()))
def _hp_intensity(self, a):
"""The DisplayIntensity an <hp> phrase forces via INTENS=HIGH|LOW|NON (or
INTENSE=%varname, resolved from a dialog variable like other %var attrs),
or None when neither is present. 3270 has no *sub-normal* level, so
LOW→NORMAL (documented); HIGH→HIGH, NON→NON_DISPLAY.
Unlike colour/highlight (which ride an SA order inside one field), an
intensity lives only in the BASIC field-attribute byte, set at a field
start (SF). So a phrase that changes it can't be an SA run — it forces the
enclosing line to SPLIT into separate fields (see _emit_flow_runs_intens).
A value that maps to None (or plain NORMAL from LOW) needs no split; the
common colour/highlight <hp> is untouched."""
raw = a.get("intense", a.get("intens"))
if raw is None:
return None
v = str(raw).strip()
if v.startswith("%"): # INTENSE=%var → dialog variable
v = str((self._subs or {}).get(v[1:].upper(), ""))
return {
"high": DisplayIntensity.HIGH,
"low": DisplayIntensity.NORMAL,
"non": DisplayIntensity.NON_DISPLAY,
}.get(v.strip().lower())
def _begin_hp(self, a):
"""Start an inline <hp> run: bank the text captured so far as a plain run,
then capture the phrase as an emphasised run. The enclosing text element
becomes a mixed-content Text.rich field (see _finalize_runs). Each run is
``(text, color, highlight, intensity)``; a plain run's emphasis is all
None. INTENSITY (unlike colour/highlight) can't ride an SA order, so it is
carried separately and, when non-normal, splits the line (see _emit_info /
_emit_flow_runs_intens)."""
if self._runs is None:
self._runs = []
self._runs.append(("".join(self._chars), None, None, None))
self._chars = []
self._hp = (self._text_colour(a), self._hp_hilite(a), self._hp_intensity(a))
def _end_hp(self):
"""Close the open <hp>: bank its text as an emphasised run."""
color, hilite, intensity = self._hp
self._runs.append(("".join(self._chars), color, hilite, intensity))
self._chars = []
self._hp = None
def _finalize_runs(self):
"""Bank the trailing text and return the mixed-content runs (dropping empty
ones), or None when the element carried no inline <hp>."""
if self._runs is None:
return None
if self._hp is not None: # tolerate an <hp> left open at flush
self._end_hp()
else:
self._runs.append(("".join(self._chars), None, None, None))
self._chars = []
return [r for r in self._runs if r[0]] or None
# ── SGML event handling ──────────────────────────────────────────────────
def handle_starttag(self, tag, attrs):
a = {k: v for k, v in attrs}
# Inside a non-rendering block (<comment>/<copyr>/<compopt>/<source>):
# suppress all nested markup (only raw text is accumulated). A <panel>/
# <help> can't be inside such a block — since these directives are often
# coded WITHOUT an end tag (before the panel), the panel ends the block;
# any other tag (a sibling directive or nested markup) is dropped.
if self._skip is not None:
if tag not in ("panel", "help"):
return
self._close_skip()
# fall through to process the <panel>/<help>
# The panel's title text (between <panel ...> and its first child) ends
# at the first child tag (which we pass so an <ab> can push the title
# below the action bar).
if self._panel_title is not None:
self._finalize_panel_title(tag)
# A group box's title (the text between <region GRPBOX> and its first child)
# ends at that first child tag — bank it and stop capturing (#125).
if self._grpbox_pending is not None:
self._finalize_grpbox_title()
# Inline/annotating tags (<hp>/<ps>/<scrfld>/…) and non-rendering
# directive blocks dispatch BEFORE the implicit flush below: they do not
# close the open content element. A handler returns True when it consumed
# the tag (an <hp>/<rp> outside a text element declines and falls through
# to the ordinary block handling).
inline = self._START_INLINE.get(tag)
if inline is not None and inline(self, tag, a):
return
# Implicit end tags: a new block element closes the open content element
# (DTL omits most end tags). <dtafldd> (a field's prompt/description) and
# <lit> (a literal run inside e.g. an <xlati> external) are exceptions —
# they are inline children that must not close their parent.
if tag not in ("dtafldd", "lit") and self._tag is not None:
self._emit_current()
handler = self._START_HANDLERS.get(tag)
if handler is not None:
handler(self, tag, a)
# An unregistered tag renders nothing (the implicit flush above still
# closed the open content element, as a new block element does).
# ── start handlers: inline / annotating tags ─────────────────────────────
# Dispatched from handle_starttag via _START_INLINE, BEFORE the implicit
# flush — these tags do not close the open content element. Each returns
# True when it consumed the tag; False falls through to block handling.
def _inline_start_skip(self, tag, a):
# Non-rendering blocks. <comment> (a comment), <copyr> (copyright),
# <compopt> (ISPDTLC compiler options) and <generate> (a build-time
# directive that generates panels/messages from a model) have no
# host-display effect in this display server, so their content is
# dropped. <source> ()INIT/)PROC logic also renders nothing, but its raw
# text is kept for the ZSEL selection routing (see _close_skip). #119.
self._skip = [tag, [], a]
return True
def _inline_start_hp(self, tag, a):
# An inline <hp> (highlighted phrase) inside a text element does NOT close
# it — it emphasises a phrase *within* one field. Bank the runs and return
# before the implicit flush (see _begin_hp / _finalize_runs). A <rp>
# (reference phrase — a hypertext link to another help panel) is the same
# kind of inline emphasis; with no explicit emphasis it renders underlined,
# the CUA point-and-shoot link style.
if not (self._tag in _TEXT_TAGS or self._tag == "divider"):
return False
self._begin_hp(a)
if tag == "rp" and self._hp == (None, None, None):
self._hp = (None, Highlight.UNDERSCORE, None)
return True
def _inline_start_varsub(self, tag, a):
# <varsub var=NAME> substitutes a dialog variable inside message text: emit
# an ISPF ``&NAME.`` reference into the text being captured, resolved at
# display time (MessageCatalog.format) exactly like a literal &NAME would be.
var = a.get("var")
if var:
self.handle_data(f"&{var}.")
return True
def _inline_start_ps(self, tag, a):
# <ps> (point-and-shoot): an inline phrase whose text the user can select by
# cursor — placing the cursor on it and pressing Enter sets VAR to VALUE
# (before )PROC). Like <hp>/<rp> it does NOT close its parent and its text
# stays part of the parent's content; the (var, value) is banked here and
# mapped to the parent's row when the parent is emitted (_emit_current).
# VALUE=* on a <ps> in a <choice> means "the choice's number" (resolved in
# _emit_choice). The point-and-shoot text is color-emphasised on real ISPF
# colour terminals; in host/mono it renders like the surrounding text.
var = a.get("var")
if var:
self._pending_ps = (var, str(a.get("value", "")))
# CSRGRP (cursor group) and DEPTH (rows the phrase spans) have no
# host-display effect on a text terminal; record them as metadata.
if "csrgrp" in a or "depth" in a:
self.screen.ps_meta.append(
{"var": var, "csrgrp": a.get("csrgrp"),
"depth": self._opt_int(a.get("depth"))})
return True
def _inline_start_chofld(self, tag, a):
# <chofld> (choice data field): an input field within a <choice> row. The
# text captured before it is the choice description; the text after it is the
# field's own description. Both are banked and laid out when the choice is
# emitted (_emit_choice); like <dtafldd> it does not close its parent.
if self._tag == "choice":
self._chofld_choicetext = "".join(self._chars)
self._chars = []
self._pending_chofld = a
return True
def _inline_start_scrfld(self, tag, a):
# <scrfld> (scrollable field): annotates the enclosing <dtafld>/<lstcol>,
# making it horizontally scrollable — DISPLEN is the field's logical data
# length (wider than the on-screen window, which stays the field's
# entwidth/colwidth), and the indicator attributes name scroll-status
# variables. It does not close its parent (attached when the field/column is
# emitted); finalise an open <dtafldd> capture first so a description isn't
# swallowed.
if self._in_dtafldd and isinstance(self._dtafldd, list):
self._in_dtafldd, self._dtafldd = False, "".join(self._dtafldd)
self._pending_scrfld = a
return True
def _inline_start_assignl(self, tag, a):
# <assignl>/<assigni> (assignment list): a value→result table attached to
# the enclosing <dtafld>. Like <scrfld> it annotates the field without
# closing it — each <assigni value=v result=r> adds a mapping; the finished
# list is attached when the <dtafld> is emitted (_attach_assignl). It is the
# surface syntax for an ISPF )PROC `&destvar = TRANS(&field v,'r' …)`
# assignment (see #55, docs/dtl-action-routing-plan.md Phase 2 PR B).
# Finalise an open <dtafldd> capture first so a description isn't swallowed.
if self._in_dtafldd and isinstance(self._dtafldd, list):
self._in_dtafldd, self._dtafldd = False, "".join(self._dtafldd)
self._assignl = {"destvar": a.get("destvar"), "pairs": []}
self._pending_assignl = self._assignl
return True
def _inline_start_assigni(self, tag, a):
# A VALUE with no RESULT assigns the empty string (ISPF's TRANS default);
# a stray <assigni> outside an <assignl> carries nowhere, so drop it.
if self._assignl is not None and a.get("value") is not None:
self._assignl["pairs"].append((a.get("value"), a.get("result", "")))
return True
# ── start handlers: text flow & lists ────────────────────────────────────
# Block-level text: captured content elements (paragraphs, list items,
# headings, instructions, …), the list containers that shape them, and the
# <textline>/<textseg> title builder.
def _start_list(self, tag, a):
# <ul>/<ol> mark each item with a bullet/number; <sl> (simple list)
# indents its items with no marker (see _emit_listitem). TEXT= gives
# the list a heading line above its items; INDENT shifts the whole list
# right; SPACE sets the item-text indentation (YES → 3 cols, else 4),
# inherited by every <li> that does not carry its own SPACE (#123).
# ISPDTLC also inserts a leading blank line before the list, ahead of any
# heading (COMPACT/NOSKIP suppress it — #210).
self._skip_blank_before(a)
ctx = self._areas[-1] if self._areas else None
indent = self._opt_int(a.get("indent"), 0)
heading = str(a.get("text", "")).strip()
if ctx is not None and heading:
self.screen.add(Text(ctx["row"], ctx["col"] + indent, heading,
role="text"))
ctx["row"] += 2 # heading + a blank line before the items
self._lists.append({"type": tag, "n": 0,
"indent": indent,
"space": self._space_indent(a)})
def _start_notel(self, tag, a):
# A note list: a "Notes:" heading (TEXT= override, INTENS/COLOR/HILITE
# style it), a blank line, then NUMBERED <li> items (1. 2. …).
# ISPDTLC inserts a leading blank line before the heading (COMPACT/
# NOSKIP suppress it — #210).
self._skip_blank_before(a)
ctx = self._areas[-1] if self._areas else None
if ctx is not None:
heading = (a.get("text") or "Notes:").strip()
indent = self._opt_int(a.get("indent"), 0)
self.screen.add(Text(ctx["row"], ctx["col"] + indent, heading,
_intensity(a, "intens"), color=self._text_colour(a),
highlight=self._hilite(a), role="text"))
ctx["row"] += 2 # heading + blank line before the items
# SPACE sets the item-text indentation: YES → 3 columns, else 4.
self._lists.append({"type": "ol", "n": 0,
"space": self._space_indent(a)})
def _start_deflist(self, tag, a):
# A definition/parameter list carries its term-column width (tsize)
# and break style; <dt>/<dd> (<pt>/<pd>) entries lay out against it.
# ISPDTLC inserts a blank line before the list (COMPACT/NOSKIP suppress).
self._skip_blank_before(a)
# TSIZE='n' | 's1 s2 … sn' → one width per definition-term COLUMN; a
# multi-column list codes one <dt> per width (see _emit_defitem).
tsizes = [int(p) for p in str(a.get("tsize", "")).split() if p.isdigit()] \
or [self._DL_TSIZE]
self._lists.append({
"type": tag, "n": 0,
"tsizes": tsizes,
"tsize": tsizes[0], # first-column width (single-column paths)
"col": 0, # current term-column index in the entry
"seg_row": None, # next <dtseg> stacking row for this column
"break": a.get("break", "none").lower(),
"compact": _bool_attr(a, "compact"), # no blank after a <ddhd> header
"indent": self._opt_int(a.get("indent"), 0), # shift the list right
# FORMAT positions the DT term within its TSIZE column.
"format": str(a.get("format", "start")).strip().lower(),
# DIVEND=YES draws a dashed rule across the list when it closes.
"divend": _bool_attr(a, "divend"),
"pending": None,
})
def _start_textline(self, tag, a):
# <textline> builds the panel/help title from its <textseg> segments,
# replacing the tag's own title text (see _emit_textline). The empty
# title captured before it was just flushed to nothing above.
self._textline = []
def _start_defdiv(self, tag, a):
# A vertical `|` between definition-term (or -heading) columns; the
# preceding <dt>/<dthd> was flushed just above, so its column state is set.
self._emit_defdiv(tag)
def _start_content(self, tag, a):
# A captured content element (paragraph, list item, instruction, field,
# choice, …): bank the tag and start capturing its text; the element is
# emitted when the next block tag (or its end tag) closes it.
if tag == "info":
# <info indent=n> shifts its whole content right; the flow picks
# this up in _resolve_pos until the matching </info> (or box end).
self._info_indent = self._opt_int(a.get("indent"), 0)
self._tag, self._attrs, self._chars = tag, a, []
# A new content tag closes any still-open <dtafldd> (SGML omits the
# end tag), so the dtafldd capture state must not leak into it.
self._in_dtafldd, self._dtafldd = False, None
# ── start handlers: panel & layout structure ─────────────────────────────
# The flow-box roots and containers: <panel>/<help> (and their <pandef>/
# <helpdef> defaults), <area>/<region>, <dtacol>, <fig>, plus the directly
# emitted layout elements <divider>, <da>+<attr> and <ga>.
def _start_pandef(self, tag, a):
# <pandef id=…> defines reusable panel defaults (HELP/DEPTH/WIDTH/
# KEYLIST/…) applied to any <panel PANDEF=id>. It renders nothing.
pid = str(a.get("id", "")).strip().lower()
if pid:
self._pandefs[pid] = {k: v for k, v in a.items() if k != "id"}
def _start_helpdef(self, tag, a):
# <helpdef id=…> is the help-panel analogue of <pandef>: shared help
# defaults (HELP/DEPTH/WIDTH/KEYLIST/…) inherited by any <help HELPDEF=id>.
# It renders nothing (#54).
hid = str(a.get("id", "")).strip().lower()
if hid:
self._helpdefs[hid] = {k: v for k, v in a.items() if k != "id"}
def _start_panel(self, tag, a):
# A <panel PANDEF=id> / <help HELPDEF=id> inherits the named default
# block's attributes — the panel's own attributes win (setdefault fills
# only what it omits). A panel carries PANDEF, a help panel HELPDEF (#54);
# in practice only one is present, so applying both is harmless.
for defaults in (self._pandefs.get(str(a.get("pandef", "")).strip().lower()),
self._helpdefs.get(str(a.get("helpdef", "")).strip().lower())):
if defaults:
for k, v in defaults.items():
a.setdefault(k, v)
# A top-level <help> is itself a (help) panel — same flow root. The
# title is the panel's content text (panel-title-text), captured into
# screen.title by _finalize_panel_title — not an attribute.
self.screen.help = a.get("help")
# TITLINE=NO keeps the title as metadata but suppresses its on-screen
# line (default YES); see _finalize_panel_title.
self._titline = _bool_attr(a, "titline", default=True)
# PANEL CURSOR=field-name names the field the cursor starts in; the
# replacement for the non-standard field-level cursor= (resolved in
# close(), once every field has been emitted).
self._panel_cursor = a.get("cursor")
# Window/key-list metadata (#125). KEYLIST names the panel's key-list;
# WINDOW=YES marks it a pop-up; WINTITLE is the pop-up's title; CURSOR is
# the start field. None of these change the rendered field stream — they
# are recorded on the Screen so the server/dialog can act on them (frame a
# window, activate a key-list, …). Reached both directly and via
# <pandef>/<helpdef> inheritance (the setdefault above), so honouring them
# here covers both paths.
if "keylist" in a:
self.screen.keylist_ref = a.get("keylist")
if "window" in a:
self.screen.window = _bool_attr(a, "window", default=False)
if "wintitle" in a:
self.screen.window_title = a.get("wintitle")
if a.get("cursor"):
self.screen.cursor_field = a.get("cursor")
# Panel classification / codepage metadata (#125, #117). MENU (a
# selection menu), ACTBAR (force an action-bar area), CCSID (codepage)
# and EXPAND=xy (the two field-expansion characters) have no host-display
# effect on this single-byte text server — recorded so the dialog/
# compiler can act on them. IMAP (image map) is GUI-only (dropped).
if "menu" in a:
self.screen.menu = _bool_attr(a, "menu", default=True)
if "actbar" in a:
self.screen.actbar = _bool_attr(a, "actbar", default=True)
if a.get("ccsid"):
self.screen.ccsid = a.get("ccsid")
if a.get("expand"):
self.screen.expand = a.get("expand")
if self._override_cols is not None:
self.screen.width = self._override_cols
elif "width" in a:
w = self._panel_dim(a["width"], self._WIDTH_MIN, self._WIDTH_MAX)
if w is not None:
self.screen.width = w
if self._override_rows is not None:
self.screen.depth = self._override_rows
elif "depth" in a:
d = self._panel_dim(a["depth"], self._DEPTH_MIN, self._DEPTH_MAX)
if d is not None:
self.screen.depth = d
# TMARGIN/BMARGIN reserve rows at the top/bottom of the panel: the whole
# panel (title + body) starts TMARGIN rows down, and content is kept out
# of the last BMARGIN rows. Both default to 0, so an unmarked panel is
# byte-for-byte unchanged. #125.
self._tmargin = self._opt_int(a.get("tmargin"), 0) or 0
self._bmargin = self._opt_int(a.get("bmargin"), 0) or 0
# The panel itself is the root flow box: every element flows down
# from the top (or from the top margin).
self._areas.append(
{"row": self._tmargin, "col": 1, "fldgap": 1, "explicit": True,
"parent": None}
)
self._panel_title = [] # capture the title text that follows
def _start_area(self, tag, a):
# A flow box that transparently continues the enclosing flow: its
# content flows after the parent's, and the parent resumes after it.
# DIR=HORIZ lays the box's children left-to-right instead of stacking
# them top-to-bottom (side-by-side region columns).
parent = self._areas[-1] if self._areas else None
explicit = False
# INDENT shifts the box's content that many columns to the right of its
# origin (a <region indent=n>), nesting cumulatively.
base_col = parent["col"] if parent else 1
# MARGINW insets an <area>'s content horizontally (an AREA-only margin;
# measured from the borderless origin, so the CUA default collapses to 0
# — this text server draws no area border for the margin to sit inside).
marginw = int(a["marginw"]) if (tag == "area" and "marginw" in a) else 0
indent = base_col + (int(a["indent"]) if "indent" in a else 0) + marginw
# MARGIND reserves blank rows above (and, at close, below) an <area>'s
# content — again 0 by default with no border.
margind = int(a["margind"]) if (tag == "area" and "margind" in a) else 0
row = (parent["row"] if parent else 0) + margind
# <region GRPBOX=YES> frames its content in a group box: a GE box border
# (like the pull-down / other borders) with an optional title on the top
# edge (#125). The border is drawn at the box's close (once its content
# extent is known); here we just reserve the top-border row and inset the
# content one column past the left border. Only regions (not areas) can be
# group boxes, and only when GRPBOX is on — a plain box is unchanged.
grpbox = tag == "region" and _bool_attr(a, "grpbox", default=False)
box = {
"row": row, "row0": row, "maxbottom": row,
"col": indent,
"fldgap": parent["fldgap"] if parent else 1,
"dir": str(a.get("dir", "vert")).strip().lower(),
"start_idx": len(self.screen.items),
"explicit": explicit,
"parent": parent,
# WIDTH=n fixes the box's column width: a rule inside it spans
# exactly WIDTH, and a horiz sibling starts WIDTH+gap to its right
# regardless of the box's actual content (so a full-width divider
# inside a left column doesn't shove the right column off-screen).
"width": self._opt_int(a.get("width")) if "width" in a else None,
# DIV draws a divider as the box's last line when it closes: SOLID/
# DASH a dashed rule, BLANK a spacer, TEXT the divider text (FORMAT
# positioned). NONE (default) draws nothing. #125.
"div": str(a.get("div", "none")).strip().lower(),
"divtext": " ".join(str(a.get("text", "")).split()),
"divformat": str(a.get("format", "start")).strip().lower(),
# DEPTH=n reserves a fixed height: the box occupies at least n rows
# (the parent resumes DEPTH rows below its start), padding with blank
# rows when the content is shorter. DEPTH=* / absent → the content's
# own height (unchanged). #125.
"depth": (self._opt_int(a["depth"])
if "depth" in a and str(a["depth"]).strip() != "*"
else None),
# EXTEND=ON|FORCE grows the box to fill the remaining panel depth
# (its bottom edge reaches the last usable row); OFF (default) uses
# the content's own height. #125.
"extend": str(a.get("extend", "off")).strip().lower(),
# MARGIND also reserves blank rows below the content (see close).
"margind": margind,
# A box that transparently continues the parent's flow inherits its
# content state, so the first paragraph below a panel title still
# gets the CUA title/body separator. An explicitly-positioned box
# starts fresh.
"had_content": bool(parent and not explicit
and parent.get("had_content")),
}
if grpbox:
box["grpbox"] = True
box["gb_row0"] = row # the top-border row
box["gb_col"] = indent # border's left column
box["gb_width"] = self._opt_int(a.get("grpwidth")) # GRPWIDTH, or None
# GRPBXVAR/GRPBXMAT conditionally draw the box: the border shows only
# when the named dialog variable's value matches GRPBXMAT (default
# "1"), exactly like CHOICE's CHECKVAR/MATCH. When the value is known
# (a substitution is supplied) and does not match, the box is not
# framed — the content flows as a plain region. LOCATION=TITLE routes
# the group heading to the panel-title line instead of the box edge.
box["gb_var"] = a.get("grpbxvar")
box["gb_match"] = str(a.get("grpbxmat", "1"))
box["gb_location"] = str(a.get("location", "default")).strip().lower()
box["gb_title_chars"] = []
box["gb_title"] = ""
box["row"] = box["row0"] = box["maxbottom"] = row + 1 # content below top
box["col"] = indent + 2 # inset past │ + a pad column
self._grpbox_pending = box # capture the group-box title
self._areas.append(box)
def _start_dtacol(self, tag, a):
# A data-column flow box: like <area>, but it also carries default
# prompt/entry widths (PMTWIDTH/ENTWIDTH) that its <dtafld>s inherit
# so their captions and entries line up in a column.
parent = self._areas[-1] if self._areas else None
row = parent["row"] if parent else 0
self._areas.append({
"row": row, "row0": row, "maxbottom": row,
"col": parent["col"] if parent else 1,
# FLDSPACE sets the gap between a child field's prompt and its entry
# (the flow's fldgap); absent → inherit the parent box's gap (#122).
"fldgap": (int(a["fldspace"]) if "fldspace" in a