Coverage for vibefpmgui/punch_card.py: 57%
141 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""A hidden command (type "punch card" in the Command Palette): the line at
2the cursor as an IBM punched card, as an 029 keypunch would have punched it.
4Fixed-form Fortran's columns come from these cards: 1–5 the statement
5number, 6 a continuation mark, 7–72 the statement, 73–80 left for a
6sequence number (so a dropped deck could be sorted again). Each column
7holds one character, as holes in its rows (12, 11, 0–9, top to bottom):
8the Hollerith code of the IBM 029 (1964). The 029 had capital letters
9only, and no [ ] { } ~ ^ \\ ` — those columns are left blank.
10"""
12from __future__ import annotations
14from qtpy.QtCore import QPointF, QRectF, Qt
15from qtpy.QtGui import QColor, QFont, QPainter, QPen, QPolygonF
16from qtpy.QtWidgets import (
17 QDialog,
18 QDialogButtonBox,
19 QLabel,
20 QSizePolicy,
21 QVBoxLayout,
22 QWidget,
23)
25COLUMNS = 80
26ROWS = (12, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9) # top to bottom
27TAB = 8 # (a tab: spaces to the next multiple of 8, as a keypunch operator would have typed)
29# Fortran's fields on the card: (first column, last column, name, short name for a narrow card).
30FIELDS = ((1, 5, "STATEMENT NUMBER", "STMT NO."), (6, 6, "CONT", "C"), (7, 72, "FORTRAN STATEMENT", "FORTRAN"),
31 (73, 80, "IDENTIFICATION", "IDENT."))
33_SPECIAL = {
34 " ": (), "&": (12,), "-": (11,), "/": (0, 1),
35 "¢": (12, 2, 8), ".": (12, 3, 8), "<": (12, 4, 8), "(": (12, 5, 8), "+": (12, 6, 8), "|": (12, 7, 8),
36 "!": (11, 2, 8), "$": (11, 3, 8), "*": (11, 4, 8), ")": (11, 5, 8), ";": (11, 6, 8), "¬": (11, 7, 8),
37 ",": (0, 3, 8), "%": (0, 4, 8), "_": (0, 5, 8), ">": (0, 6, 8), "?": (0, 7, 8),
38 ":": (2, 8), "#": (3, 8), "@": (4, 8), "'": (5, 8), "=": (6, 8), '"': (7, 8),
39}
42def holes(ch: str) -> tuple[int, ...] | None:
43 """The rows an IBM 029 punches for a character (upper case: it had no
44 lower case); None: it can't punch it."""
45 ch = ch.upper()
46 if ch.isdigit() and ch.isascii():
47 return (int(ch),)
48 if "A" <= ch <= "I":
49 return 12, ord(ch) - ord("A") + 1
50 if "J" <= ch <= "R":
51 return 11, ord(ch) - ord("J") + 1
52 if "S" <= ch <= "Z":
53 return 0, ord(ch) - ord("S") + 2
54 return _SPECIAL.get(ch)
57def card_text(line: str) -> tuple[str, list[str], str]:
58 """A line as punched: its first 80 columns (tabs as spaces, upper case),
59 the characters an 029 couldn't punch (left blank), and what didn't fit."""
60 expanded = ""
61 for ch in line:
62 expanded += " " * (TAB - len(expanded) % TAB) if ch == "\t" else ch
63 punched, left_off = expanded[:COLUMNS], expanded[COLUMNS:]
64 unpunchable = sorted({ch for ch in punched if holes(ch) is None})
65 return punched.upper(), unpunchable, left_off
68class PunchCard(QWidget):
69 """The card: cream card stock, the cut corner, each row's digits printed
70 faintly, the holes, the characters printed along the top edge (as the
71 029 did), Fortran's fields ruled and named, and the column numbers."""
73 STOCK = QColor("#efe4c2")
74 INK = QColor("#2f2a22")
75 PRINT = QColor(160, 120, 70, 150) # (the card's own printing: faint)
76 RULE = QColor(170, 60, 50, 160) # (Fortran's fields)
78 def __init__(self, text: str = "", parent=None):
79 super().__init__(parent)
80 self.text = text
81 self.setMinimumSize(760, 340)
82 self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
84 def set_text(self, text: str):
85 self.text = text
86 self.update()
88 def hole_rects(self) -> list[tuple[int, int, QRectF]]:
89 """Each hole: (column (1-based), row, where it's drawn)."""
90 card, column_width, row_height, top = self._layout()
91 out = []
92 for i, ch in enumerate(self.text[:COLUMNS]):
93 for row in holes(ch) or ():
94 x = card.left() + 0.035 * card.width() + i * column_width
95 y = top + ROWS.index(row) * row_height
96 out.append((i + 1, row, QRectF(x + column_width * 0.22, y + row_height * 0.18,
97 column_width * 0.56, row_height * 0.64)))
98 return out
100 def _layout(self) -> tuple[QRectF, float, float, float]:
101 """The card (7⅜ × 3¼ inches), a column's width, a row's height, row 12's top."""
102 ratio = 7.375 / 3.25
103 width = min(self.width() - 8.0, (self.height() - 8.0) * ratio)
104 card = QRectF((self.width() - width) / 2, (self.height() - width / ratio) / 2, width, width / ratio)
105 column_width = card.width() * 0.93 / COLUMNS
106 top = card.top() + card.height() * 0.13 # (room above row 12 for the printed characters)
107 row_height = card.height() * 0.78 / len(ROWS) # (and below row 9 for the fields' names)
108 return card, column_width, row_height, top
110 def paintEvent(self, _event):
111 painter = QPainter(self)
112 painter.setRenderHint(QPainter.Antialiasing)
113 card, column_width, row_height, top = self._layout()
114 left = card.left() + 0.035 * card.width()
115 # The card, its top-left corner cut (to see at once that a card in the deck is the wrong way round).
116 cut = card.height() * 0.12
117 outline = QPolygonF([QPointF(card.left() + cut, card.top()), card.topRight(), card.bottomRight(),
118 card.bottomLeft(), QPointF(card.left(), card.top() + cut)])
119 painter.setPen(QPen(QColor(0, 0, 0, 60), 1))
120 painter.setBrush(self.STOCK)
121 painter.drawPolygon(outline)
122 # Fortran's fields: ruled, and named under row 9 (the short name where the long one doesn't fit).
123 small = QFont("Courier New")
124 small.setStyleHint(QFont.TypeWriter)
125 small.setPixelSize(max(5, int(column_width * 0.62)))
126 painter.setFont(small)
127 bottom = top + len(ROWS) * row_height
128 for first, last, name, short in FIELDS:
129 x0, x1 = left + (first - 1) * column_width, left + last * column_width
130 painter.setPen(QPen(self.RULE, 1))
131 if first > 1:
132 painter.drawLine(QPointF(x0, top - row_height * 0.2), QPointF(x0, bottom + row_height * 0.55))
133 label = QRectF(x0, bottom + row_height * 0.05, x1 - x0, row_height * 0.5)
134 fits = [n for n in (name, short) if painter.fontMetrics().horizontalAdvance(n) <= label.width() + 2]
135 if fits:
136 painter.drawText(label, Qt.AlignCenter, fits[0])
137 # Each row's digit printed in every column (rows 0–9), and the column numbers under row 0.
138 painter.setPen(self.PRINT)
139 for row in range(10):
140 y = top + ROWS.index(row) * row_height
141 for column in range(COLUMNS):
142 painter.drawText(QRectF(left + column * column_width, y, column_width, row_height),
143 Qt.AlignCenter, str(row))
144 tiny = QFont(small)
145 tiny.setPixelSize(max(4, int(column_width * 0.42)))
146 painter.setFont(tiny)
147 for row in (0,): # (under row 0: under row 9, the fields' names)
148 y = top + (ROWS.index(row) + 0.72) * row_height
149 for column in range(COLUMNS):
150 painter.drawText(QRectF(left + column * column_width, y, column_width, row_height * 0.4),
151 Qt.AlignCenter, str(column + 1))
152 # The holes: the dark behind the card, through it.
153 painter.setPen(Qt.NoPen)
154 painter.setBrush(self.INK)
155 for _column, _row, rect in self.hole_rects():
156 painter.drawRect(rect)
157 # The characters, printed along the top edge as the 029 printed them.
158 typed = QFont(small)
159 typed.setPixelSize(max(6, int(column_width * 0.95)))
160 typed.setBold(True)
161 painter.setFont(typed)
162 painter.setPen(self.INK)
163 for i, ch in enumerate(self.text[:COLUMNS]):
164 if holes(ch):
165 painter.drawText(QRectF(left + i * column_width, card.top() + card.height() * 0.02, column_width,
166 card.height() * 0.09), Qt.AlignCenter, ch)
167 painter.end()
170class PunchCardDialog(QDialog):
171 """The card for a line of a file; ↑ and ↓ go through the file's lines."""
173 def __init__(self, lines: list[str], index: int, name: str, parent=None):
174 super().__init__(parent)
175 self.setWindowTitle("Punch Card")
176 self.lines, self.index, self.name = lines, index, name
177 self.card = PunchCard()
178 self.caption = QLabel()
179 self.caption.setWordWrap(True)
180 self.caption.setTextFormat(Qt.RichText)
181 buttons = QDialogButtonBox(QDialogButtonBox.Close)
182 buttons.rejected.connect(self.reject)
183 layout = QVBoxLayout(self)
184 layout.addWidget(self.card, 1)
185 layout.addWidget(self.caption)
186 layout.addWidget(buttons)
187 self.resize(1000, 520)
188 self.show_line(index)
190 def show_line(self, index: int):
191 self.index = max(0, min(index, len(self.lines) - 1)) if self.lines else 0
192 line = self.lines[self.index] if self.lines else ""
193 punched, unpunchable, left_off = card_text(line)
194 self.card.set_text(punched)
195 notes = [(f"Line {self.index + 1} of {self.name}, as an IBM 029 keypunch would have punched it "
196 "(1964: capital letters only; ↑ ↓ for the other lines).")]
197 if unpunchable: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 notes.append(f"Not on an 029's keyboard, left blank: {' '.join(unpunchable)}")
199 if left_off: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 notes.append(f"Past column 80, on the next card: {len(left_off)} more characters")
201 self.caption.setText("<br>".join(f"<span style='color:gray'>{_escape(n)}</span>" for n in notes))
203 def keyPressEvent(self, event):
204 if event.key() in (Qt.Key_Up, Qt.Key_Down):
205 self.show_line(self.index + (1 if event.key() == Qt.Key_Down else -1))
206 return
207 super().keyPressEvent(event)
210def _escape(text: str) -> str:
211 return text.replace("&", "&").replace("<", "<").replace(">", ">")