8 Commits

Author SHA1 Message Date
daniel c2b06775c5 feat: Implement global search functionality (Ctrl+F) with text and hex modes
Build Linux / Build Linux (push) Failing after 0s
Build Windows / Build Windows (push) Failing after 0s
2026-05-26 00:30:30 +02:00
daniel 28028bcda4 feat: Add keyboard shortcuts for Open, Save, and Save As
Build Linux / Build Linux (push) Failing after 0s
Build Windows / Build Windows (push) Failing after 1s
2026-05-25 23:27:22 +02:00
Daniel Dybing a15ba67b1a feat: Optimize .t42 loading and improve decoder fidelity
Build Linux / Build Linux (push) Successful in 1m29s
Build Windows / Build Windows (push) Successful in 4m46s
2026-02-21 20:44:26 +01:00
Daniel Dybing 18fef7b049 fix: Align CRC-16 calculation with ETSI EN 300 706 and improve retrieval
Build Linux / Build Linux (push) Successful in 1m28s
Build Windows / Build Windows (push) Successful in 4m52s
2026-02-08 19:51:28 +01:00
daniel 9b846970b8 fix: Align CRC calculation with ETSI EN 300 706 standard
Build Linux / Build Linux (push) Successful in 1m29s
Build Windows / Build Windows (push) Successful in 4m45s
2026-02-07 14:27:37 +01:00
daniel de296b4711 fix(ci): Add robust apt flags for Windows build container
Build Linux / Build Linux (push) Successful in 1m30s
Build Windows / Build Windows (push) Successful in 5m20s
2026-02-07 10:57:44 +01:00
daniel 84d1094d16 fix: Update CRC calc to use 0 init and full 16-bit stored value
Build Linux / Build Linux (push) Successful in 1m27s
Build Windows / Build Windows (push) Failing after 17s
2026-02-07 10:47:29 +01:00
daniel 6a6df63980 feat: Add CRC checksum calculation and display
Build Linux / Build Linux (push) Successful in 1m34s
Build Windows / Build Windows (push) Successful in 4m42s
2026-02-07 10:12:25 +01:00
5 changed files with 515 additions and 150 deletions
+2 -1
View File
@@ -10,8 +10,9 @@ jobs:
steps: steps:
- name: Install Node.js - name: Install Node.js
run: | run: |
apt-get clean
apt-get update apt-get update
apt-get install -y nodejs npm apt-get install -y --fix-missing nodejs npm
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
+86 -74
View File
@@ -5,69 +5,82 @@ from .models import Packet, Page, TeletextService
def load_t42(file_path: str, progress_callback: Optional[Callable[[int, int], None]] = None) -> TeletextService: def load_t42(file_path: str, progress_callback: Optional[Callable[[int, int], None]] = None) -> TeletextService:
service = TeletextService() service = TeletextService()
if not os.path.exists(file_path):
return service
total_bytes = os.path.getsize(file_path) total_bytes = os.path.getsize(file_path)
# Each packet is 42 bytes
total_packets = total_bytes // 42 total_packets = total_bytes // 42
processed_packets = 0 processed_packets = 0
# Magazine buffers: magazine -> {row_num: Packet}
magazine_buffers = {m: {} for m in range(1, 9)}
# Active page lookup: magazine -> Page object (for O(1) access)
active_pages = {m: None for m in range(1, 9)}
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
while True: while True:
chunk = f.read(42) chunk = f.read(42)
if not chunk: if not chunk: break
break if len(chunk) < 42: break
if len(chunk) < 42:
# Should not happen in a valid T42 stream, or we just ignore incomplete tail
break
processed_packets += 1 processed_packets += 1
if progress_callback and processed_packets % 100 == 0: if progress_callback and processed_packets % 500 == 0:
progress_callback(processed_packets, total_packets) progress_callback(processed_packets, total_packets)
packet = Packet(chunk) packet = Packet(chunk)
service.all_packets.append(packet) service.all_packets.append(packet)
# Logic to group into pages. mag = packet.magazine
# This is non-trivial because packets for a page might be interleaved or sequential. buffer = magazine_buffers[mag]
# Standard implementation: Packets arrive in order. Row 0 starts a new page/subpage.
if packet.row == 0: if packet.row == 0:
# Start of a new page header. p_num, sub_code, control_bits, language = parse_header(packet.data)
# Byte 2-9 of header contain Page Number, Subcode, Control bits etc.
# We need to parse the header to identify the page.
# Header format (after Mag/Row): # Check Erase Page bit (C4 is bit 0 of control_bits)
# Bytes: P1 P2 S1 S2 S3 S4 C1 C2 ... erase_page = bool(control_bits & 1)
# All Hamming 8/4 encoded.
# For now, let's just create a new page entry for every Header we see, if erase_page:
# or find the existing one if we want to support updates (but T42 usually is a stream capture). magazine_buffers[mag] = {0: packet}
# If it's an editor file, it's likely sequential. buffer = magazine_buffers[mag]
p_num, sub_code, language = parse_header(packet.data)
# Create new page
new_page = Page(magazine=packet.magazine, page_number=p_num, sub_code=sub_code, language=language)
new_page.packets.append(packet)
service.pages.append(new_page)
else:
# Add to the "current" page of this magazine.
# We need to track the current active page for each magazine.
# A simplistic approach: add to the last page added that matches the magazine ??
# Robust approach: Maintain a dict of current_pages_by_magazine.
# Let's find the last page in service that matches the packet's magazine
# This is O(N) but N (pages) is small.
target_page = None
for p in reversed(service.pages):
if p.magazine == packet.magazine:
target_page = p
break
if target_page:
target_page.packets.append(packet)
else: else:
# Packet without a header? Orphaned. Just keep in all_packets buffer[0] = packet
pass
# Create snapshot
new_page = Page(
magazine=mag,
page_number=p_num,
sub_code=sub_code,
control_bits=control_bits,
language=language
)
# Efficient cloning: use the existing Packet objects where possible,
# but we MUST clone the data bytearray if we plan to edit it later.
for r_num, pkt in sorted(buffer.items()):
# Create a new packet shell sharing the original_data but with its own data bytearray
cloned_pkt = Packet(pkt.original_data)
cloned_pkt.data = bytearray(pkt.data)
new_page.packets.append(cloned_pkt)
service.pages.append(new_page)
active_pages[mag] = new_page # Update active page lookup
elif 1 <= packet.row <= 31:
# Update the running buffer
buffer[packet.row] = packet
# Update the active snapshot immediately
target_page = active_pages[mag]
if target_page:
# Update row in the current active page
found_row = False
for i, p in enumerate(target_page.packets):
if p.row == packet.row:
target_page.packets[i] = packet
found_row = True
break
if not found_row:
target_page.packets.append(packet)
return service return service
@@ -182,52 +195,51 @@ def decode_hamming_8_4(byte_val):
(((byte_val >> 7) & 1) << 3) (((byte_val >> 7) & 1) << 3)
def parse_header(data: bytearray): def parse_header(data: bytearray):
# Data is 40 bytes. # Data is 40 bytes (after MRAG).
# Bytes 0-7 are Page Num (2), Subcode (4), Control (2) - ALL Hamming encoded. # Byte 0: Page Units (PU)
# Byte 1: Page Tens (PT)
# 0: Page Units (PU) # Byte 2: Subcode S1 (bits 0-3)
# 1: Page Tens (PT) # Byte 3: Subcode S2 (bits 4-6), C4 (bit 7)
# Byte 4: Subcode S3 (bits 8-11)
# Byte 5: Subcode S4 (bits 12-13), C5 (bit 14), C6 (bit 15)
# Byte 6: C7-C10
# Byte 7: C11-C14 (C12-C14 are Language)
pu = decode_hamming_8_4(data[0]) pu = decode_hamming_8_4(data[0])
pt = decode_hamming_8_4(data[1]) pt = decode_hamming_8_4(data[1])
# Use BCD/Hex-like storage: High nibble is Tens, Low nibble is Units. # Page number: pt (tens), pu (units). 0x00 to 0xFF.
# This preserves Hex pages (A-F) without colliding with decimal pages.
# E.g. Page 1FF -> Tens=F(15), Units=F(15) -> 0xFF (255)
# Page 12E -> Tens=2, Units=E(14) -> 0x2E (46)
# Page 134 -> Tens=3, Units=4 -> 0x34 (52)
# 0x2E != 0x34. No collision.
page_num = ((pt & 0xF) << 4) | (pu & 0xF) page_num = ((pt & 0xF) << 4) | (pu & 0xF)
# Subcode: S1, S2, S3, S4 # Subcode (13 bits)
# S1 (low), S2, S3, S4 (high)
s1 = decode_hamming_8_4(data[2]) s1 = decode_hamming_8_4(data[2])
s2 = decode_hamming_8_4(data[3]) s2 = decode_hamming_8_4(data[3])
s3 = decode_hamming_8_4(data[4]) s3 = decode_hamming_8_4(data[4])
s4 = decode_hamming_8_4(data[5]) s4 = decode_hamming_8_4(data[5])
# Subcode logic is a bit complex with specific bit mapping for "Time" vs "Subcode" sub_code = (s1 & 0xF) | \
# But usually just combining them gives the raw subcode value. ((s2 & 0x7) << 4) | \
# S1: bits 0-3 ((s3 & 0xF) << 7) | \
# S2: bits 4-6 (bit 4 is C4) -> actually S2 has 3 bits of subcode + 1 control bit usually? ((s4 & 0x3) << 11)
# Let's simplify and just concat them for a unique identifier.
sub_code = s1 | (s2 << 4) | (s3 << 8) | (s4 << 12) # Control bits C4-C14
c4 = (s2 >> 3) & 1
c5 = (s4 >> 2) & 1
c6 = (s4 >> 3) & 1
# Control bits C12, C13, C14 are in Byte 8 (index 8) c_7_10 = decode_hamming_8_4(data[6])
# They determine the National Option (Language) c_11_14 = decode_hamming_8_4(data[7])
c_bits_2 = decode_hamming_8_4(data[8])
# Fix for Language Detection: # bitmask starting at index 0 for C4
# It seems C12 and C13 are swapped in the Hamming decoding or file format relative to expected values. control_bits = c4 | (c5 << 1) | (c6 << 2) | \
# C12 is bit 0, C13 is bit 1. ((c_7_10 & 0xF) << 3) | \
# We swap them so D1 maps to C13 (Swedish bit) and D2 maps to C12 (German bit). ((c_11_14 & 0xF) << 7)
# Original: language = c_bits_2 & 0b111
language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4) # Language (C12, C13, C14)
# c_11_14: bit 0:C11, bit 1:C12, bit 2:C13, bit 3:C14
language = (c_11_14 >> 1) & 0x7
return page_num, sub_code, language return page_num, sub_code, control_bits, language
def save_tti(file_path: str, page: Page): def save_tti(file_path: str, page: Page):
""" """
+120 -19
View File
@@ -1,6 +1,13 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
def decode_hamming_8_4(byte_val):
# Extract data bits: bits 1, 3, 5, 7
return ((byte_val >> 1) & 1) | \
(((byte_val >> 3) & 1) << 1) | \
(((byte_val >> 5) & 1) << 2) | \
(((byte_val >> 7) & 1) << 3)
@dataclass @dataclass
class Packet: class Packet:
""" """
@@ -27,22 +34,6 @@ class Packet:
b2 = self.original_data[1] b2 = self.original_data[1]
# De-interleave Hamming bits to get M (3 bits) and R (5 bits) # De-interleave Hamming bits to get M (3 bits) and R (5 bits)
# This is the "basic" interpretation.
# For a robust editor we assume the input T42 is valid or we just store bytes.
# But we need Mag/Row to organize pages.
# Decode Hamming 8/4 logic is complex to implementation from scratch correctly
# without a reference, but usually D1, D2, D3, D4 are at bit positions 1, 3, 5, 7
# (0-indexed, where 0 is LSB).
# Let's perform a simple extraction assuming no bit errors for now.
def decode_hamming_8_4(byte_val):
# Extract data bits: bits 1, 3, 5, 7
return ((byte_val >> 1) & 1) | \
(((byte_val >> 3) & 1) << 1) | \
(((byte_val >> 5) & 1) << 2) | \
(((byte_val >> 7) & 1) << 3)
d1 = decode_hamming_8_4(b1) d1 = decode_hamming_8_4(b1)
d2 = decode_hamming_8_4(b2) d2 = decode_hamming_8_4(b2)
@@ -74,9 +65,13 @@ class Page:
Can have multiple subpages. Can have multiple subpages.
""" """
magazine: int magazine: int
page_number: int # 00-99 page_number: int # 00-99 (Hex storage: 0x00-0xFF)
sub_code: int = 0 # Subpage code (0000 to 3F7F hex usually, simplest is 0-99 equivalent) sub_code: int = 0 # 13-bit subcode (0000 to 3F7F hex)
language: int = 0 # National Option (0-7)
# Control bits C4-C14
control_bits: int = 0
language: int = 0 # National Option (0-7, from C12-C14)
packets: List[Packet] = field(default_factory=list) packets: List[Packet] = field(default_factory=list)
@property @property
@@ -84,6 +79,112 @@ class Page:
# Format as Hex to support A-F pages # Format as Hex to support A-F pages
return f"{self.magazine}{self.page_number:02X}" return f"{self.magazine}{self.page_number:02X}"
def get_control_bit(self, n: int) -> bool:
""" Returns value of control bit Cn (4-14) """
if 4 <= n <= 14:
return bool((self.control_bits >> (n - 4)) & 1)
return False
def set_control_bit(self, n: int, value: bool):
""" Sets value of control bit Cn (4-14) """
if 4 <= n <= 14:
if value:
self.control_bits |= (1 << (n - 4))
else:
self.control_bits &= ~(1 << (n - 4))
def calculate_crc(self) -> int:
"""
Calculates the CRC-16 checksum for the page.
According to ETSI EN 300 706 (Section 9.6.1 & Figure 13):
- G(x) = x^16 + x^12 + x^9 + x^7 + 1 (Poly 0x1281)
- Initial value: 0.
- Processed bits b8 to b1 (MSB first for stored bytes).
- Total 1024 bytes (32 packets * 32 bytes).
- Packet X/0: Bytes 14 to 37 (24 bytes) + 8 spaces.
- Packets X/1 to X/25: Bytes 14 to 45 (32 bytes).
- Packets X/26 to X/31: 32 spaces each.
"""
crc = 0
poly = 0x1281
# Helper to update CRC with a byte (MSB first)
def update_crc(c, val):
v = (val << 8) & 0xFFFF
for _ in range(8):
if (c ^ v) & 0x8000:
c = (c << 1) ^ poly
else:
c = c << 1
v <<= 1
c &= 0xFFFF
return c
# Organize packets by row
rows = {p.row: p for p in self.packets}
for r in range(32): # Process 32 slots (0-31)
start, end = 0, 0
padding = 0
if r == 0:
# Row 0: Bytes 14-37 (24 bytes)
start, end = 8, 32 # data[8..31]
padding = 8
elif 1 <= r <= 25:
# Rows 1-25: Bytes 14-45 (32 bytes)
start, end = 8, 40 # data[8..39]
padding = 0
else:
# Rows 26-31: 32 spaces each
padding = 32
# Process packet data if available
if r in rows and start < end:
p_data = rows[r].data
for i in range(start, end):
byte_val = (p_data[i] & 0x7F) if i < len(p_data) else 0x20
crc = update_crc(crc, byte_val)
elif start < end:
# Missing packet but slot exists
for _ in range(start, end):
crc = update_crc(crc, 0x20)
# Add padding for this slot
for _ in range(padding):
crc = update_crc(crc, 0x20)
return crc
def get_stored_crc(self) -> Optional[int]:
"""
Attempts to retrieve the stored CRC from Packet 27/0 if present.
Returns None if not found.
"""
# Look for Packet 27
for p in self.packets:
if p.row == 27:
# Check Designation Code (Byte 0)
try:
if len(p.data) >= 40:
b0 = p.data[0]
# Decode Hamming 8/4
designation = decode_hamming_8_4(b0)
# Packets X/27/0 to X/27/3 exist, but only X/27/0 has the CRC.
# We also check if b0 is raw 0 as a fallback for some captures.
if designation == 0 or b0 == 0:
# Packet 27/0
# Checksum is in bytes 38 and 39 (TBytes 44 and 45).
hi = p.data[38]
lo = p.data[39]
crc = (hi << 8) | lo
return crc
except:
pass
return None
@dataclass @dataclass
class TeletextService: class TeletextService:
""" """
+53 -24
View File
@@ -86,6 +86,25 @@ class TeletextCanvas(QWidget):
self.cursor_is_graphics = False # Tracked during draw self.cursor_is_graphics = False # Tracked during draw
# Blinking cursor timer could be added, for now static inverted is fine or toggle on timer elsewhere # Blinking cursor timer could be added, for now static inverted is fine or toggle on timer elsewhere
# Highlight state (for search results)
self.highlight_row = -1
self.highlight_start_x = -1
self.highlight_end_x = -1
def set_highlight(self, row, start_x, end_x):
self.highlight_row = row
self.highlight_start_x = start_x
self.highlight_end_x = end_x
self.redraw()
self.update()
def clear_highlight(self):
self.highlight_row = -1
self.highlight_start_x = -1
self.highlight_end_x = -1
self.redraw()
self.update()
def get_byte_at(self, x, y): def get_byte_at(self, x, y):
if not self.page: return 0 if not self.page: return 0
@@ -214,10 +233,18 @@ class TeletextCanvas(QWidget):
painter.end() painter.end()
return return
# Draw each packet # Check Control Bits for "Inhibit Display" (C10)
# Initialize a grid of empty chars # In our bitmask (from parse_header):
grid = [None] * 26 # 0-25 # C4:0, C5:1, C6:2, C7:3, C8:4, C9:5, C10:6, C11:7, C12:8, C13:9, C14:10
inhibit_display = bool((self.page.control_bits >> 6) & 1)
if inhibit_display:
painter.setPen(Qt.GlobalColor.gray)
painter.drawText(10, 20, f"Page {self.page.full_page_number} - INHIBIT DISPLAY (C10 set)")
painter.end()
return
# Organize each packet by row
grid = [None] * 26 # 0-25
for p in self.page.packets: for p in self.page.packets:
if 0 <= p.row <= 25: if 0 <= p.row <= 25:
grid[p.row] = p grid[p.row] = p
@@ -243,6 +270,10 @@ class TeletextCanvas(QWidget):
# Output mask for the next row # Output mask for the next row
next_occlusion_mask = [False] * 40 next_occlusion_mask = [False] * 40
# Check for Suppress Header (C7)
# C7:3, so bit 3 of control_bits
suppress_header = bool((self.page.control_bits >> 3) & 1)
# Default State at start of row # Default State at start of row
fg = COLORS[7] # White fg = COLORS[7] # White
bg = COLORS[0] # Black bg = COLORS[0] # Black
@@ -272,29 +303,18 @@ class TeletextCanvas(QWidget):
for c in range(40): for c in range(40):
x = c * self.cell_w x = c * self.cell_w
# If this cell is occluded by the row above, skip drawing and attribute processing?
# Spec says "The characters in the row below are ignored."
# Ideally we shouldn't even process attributes, but for simple renderer we just skip draw.
# However, if we skip attribute processing, state (fg/bg) won't update.
# Teletext attributes are serial.
# BUT, if the row above covers it, the viewer sees the row above.
# Does the hidden content affect the *rest* of the row?
# Likely yes, attributes usually propagate.
# But the spec says "ignored". Let's assume we skip *everything* for this cell visually,
# but maybe we should technically maintain state?
# For "Double Height" visual correctness, skipping drawing is the key.
# We will Process attributes (to keep state consistent) but Skip Drawing if occluded.
# Wait, if we process attributes, we might set double_height=True for the NEXT row?
# If this cell is occluded, it shouldn't trigger DH for the next row.
is_occluded = occlusion_mask[c] is_occluded = occlusion_mask[c]
# Decide byte value # Decide byte value
if row == 0 and c < 8: if row == 0:
# Use generated header prefix if c < 8:
byte_val = ord(header_prefix[c]) # Column 0-7: Header prefix
byte_val = ord(header_prefix[c])
elif suppress_header and c < 32:
# Column 8-31: Hide header if C7 set
byte_val = 0x20
else:
byte_val = data[c] if c < len(data) else 0x20
else: else:
byte_val = data[c] if c < len(data) else 0x20 byte_val = data[c] if c < len(data) else 0x20
@@ -383,7 +403,16 @@ class TeletextCanvas(QWidget):
if draw_bg: if draw_bg:
# If double height, draw taller background # If double height, draw taller background
h_bg = self.cell_h * 2 if double_height else self.cell_h h_bg = self.cell_h * 2 if double_height else self.cell_h
painter.fillRect(x, y, self.cell_w, h_bg, bg)
# Check for highlight
is_highlighted = (row == self.highlight_row and self.highlight_start_x <= c <= self.highlight_end_x)
if is_highlighted:
# Draw highlight color (e.g. Dark Yellow / Orange to contrast white text)
highlight_color = QColor(180, 150, 0)
painter.fillRect(x, y, self.cell_w, h_bg, highlight_color)
else:
painter.fillRect(x, y, self.cell_w, h_bg, bg)
# Draw Foreground # Draw Foreground
if draw_fg: if draw_fg:
+252 -30
View File
@@ -3,13 +3,14 @@ from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QListWidget, QListWidgetItem, QComboBox, QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem, QComboBox, QLabel, QLineEdit, QPushButton,
QFileDialog, QMenuBar, QMenu, QMessageBox, QStatusBar, QProgressBar, QApplication, QFileDialog, QMenuBar, QMenu, QMessageBox, QStatusBar, QProgressBar, QApplication,
QCheckBox, QDialog, QGridLayout QCheckBox, QDialog, QGridLayout, QRadioButton, QFrame
) )
from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor
from PyQt6.QtCore import Qt, QRect, QTimer from PyQt6.QtCore import Qt, QRect, QTimer, pyqtSignal
from .io import load_t42, save_t42, save_tti, decode_hamming_8_4, encode_hamming_8_4 from .io import load_t42, save_t42, save_tti, decode_hamming_8_4, encode_hamming_8_4
from .renderer import TeletextCanvas, create_blank_packet from .renderer import TeletextCanvas, create_blank_packet
from .charsets import get_char
import copy import copy
import sys import sys
import os import os
@@ -113,6 +114,61 @@ class MosaicDialog(QDialog):
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
self.layout().addWidget(close_btn) self.layout().addWidget(close_btn)
class FindDialog(QDialog):
findNext = pyqtSignal(str, bool) # query, is_hex
findPrev = pyqtSignal(str, bool) # query, is_hex
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Find")
self.setFixedWidth(350)
self.setLayout(QVBoxLayout())
# Query Input
self.query_input = QLineEdit()
self.query_input.setPlaceholderText("Search text or hex (e.g. 1C 02)...")
self.layout().addWidget(QLabel("Find:"))
self.layout().addWidget(self.query_input)
# Mode Selection
mode_layout = QHBoxLayout()
self.radio_text = QRadioButton("Text")
self.radio_text.setChecked(True)
self.radio_hex = QRadioButton("Hex")
mode_layout.addWidget(self.radio_text)
mode_layout.addWidget(self.radio_hex)
mode_layout.addStretch()
self.layout().addLayout(mode_layout)
# Navigation Buttons
nav_layout = QHBoxLayout()
self.btn_prev = QPushButton("Previous")
self.btn_prev.clicked.connect(self.on_find_prev)
self.btn_next = QPushButton("Next")
self.btn_next.clicked.connect(self.on_find_next)
self.btn_next.setDefault(True)
nav_layout.addWidget(self.btn_prev)
nav_layout.addWidget(self.btn_next)
self.layout().addLayout(nav_layout)
# Status Label
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: gray;")
self.layout().addWidget(self.status_label)
def on_find_next(self):
query = self.query_input.text()
if query:
self.findNext.emit(query, self.radio_hex.isChecked())
def on_find_prev(self):
query = self.query_input.text()
if query:
self.findPrev.emit(query, self.radio_hex.isChecked())
def set_status(self, text):
self.status_label.setText(text)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -130,6 +186,11 @@ class MainWindow(QMainWindow):
self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"] self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
# Search State
self.find_dialog = None
self.search_results = [] # List of (Page, row, col, length)
self.current_search_index = -1
# UI Components # UI Components
self.central_widget = QWidget() self.central_widget = QWidget()
self.setCentralWidget(self.central_widget) self.setCentralWidget(self.central_widget)
@@ -320,6 +381,16 @@ class MainWindow(QMainWindow):
lang_layout.addWidget(btn_set_lang) lang_layout.addWidget(btn_set_lang)
right_layout.addLayout(lang_layout) right_layout.addLayout(lang_layout)
right_layout.addSpacing(10)
# CRC Checksum
crc_label = QLabel("CRC Checksum:")
right_layout.addWidget(crc_label)
self.lbl_crc_info = QLabel("Page: ----\nCalc: ----")
self.lbl_crc_info.setStyleSheet("font-family: monospace; font-weight: bold;")
right_layout.addWidget(self.lbl_crc_info)
right_layout.addStretch() right_layout.addStretch()
self.layout.addLayout(center_layout, 1) self.layout.addLayout(center_layout, 1)
@@ -346,6 +417,30 @@ class MainWindow(QMainWindow):
# Menus # Menus
self.create_menus() self.create_menus()
def update_crc_display(self):
if not self.current_page:
self.lbl_crc_info.setText("Page: ----\nCalc: ----")
self.lbl_crc_info.setStyleSheet("font-family: monospace; font-weight: bold;")
return
calc_crc = self.current_page.calculate_crc()
stored_crc = self.current_page.get_stored_crc()
stored_str = f"{stored_crc:04X}" if stored_crc is not None else "----"
calc_str = f"{calc_crc:04X}"
# Highlight if match
if stored_crc is not None:
if stored_crc == calc_crc:
style = "font-family: monospace; font-weight: bold; color: green;"
else:
style = "font-family: monospace; font-weight: bold; color: red;"
else:
style = "font-family: monospace; font-weight: bold;"
self.lbl_crc_info.setStyleSheet(style)
self.lbl_crc_info.setText(f"Page: {stored_str}\nCalc: {calc_str}")
def update_language_label(self): def update_language_label(self):
idx = self.canvas.subset_idx idx = self.canvas.subset_idx
if 0 <= idx < len(self.language_names): if 0 <= idx < len(self.language_names):
@@ -398,14 +493,17 @@ class MainWindow(QMainWindow):
file_menu = menu_bar.addMenu("File") file_menu = menu_bar.addMenu("File")
open_action = QAction("Open T42...", self) open_action = QAction("Open T42...", self)
open_action.setShortcut("Ctrl+O")
open_action.triggered.connect(self.open_file) open_action.triggered.connect(self.open_file)
file_menu.addAction(open_action) file_menu.addAction(open_action)
save_action = QAction("Save T42...", self) save_action = QAction("Save T42...", self)
save_action.setShortcut("Ctrl+S")
save_action.triggered.connect(self.save_file) save_action.triggered.connect(self.save_file)
file_menu.addAction(save_action) file_menu.addAction(save_action)
save_as_action = QAction("Save As...", self) save_as_action = QAction("Save As...", self)
save_as_action.setShortcut("Ctrl+Shift+S")
save_as_action.triggered.connect(self.save_as_file) save_as_action.triggered.connect(self.save_as_file)
file_menu.addAction(save_as_action) file_menu.addAction(save_as_action)
@@ -434,6 +532,13 @@ class MainWindow(QMainWindow):
paste_action.triggered.connect(self.paste_page_content) paste_action.triggered.connect(self.paste_page_content)
edit_menu.addAction(paste_action) edit_menu.addAction(paste_action)
edit_menu.addSeparator()
find_action = QAction("Find...", self)
find_action.setShortcut("Ctrl+F")
find_action.triggered.connect(self.open_find_dialog)
edit_menu.addAction(find_action)
delete_page_action = QAction("Delete Page", self) delete_page_action = QAction("Delete Page", self)
delete_page_action.triggered.connect(self.delete_page) delete_page_action.triggered.connect(self.delete_page)
edit_menu.addAction(delete_page_action) edit_menu.addAction(delete_page_action)
@@ -490,8 +595,9 @@ class MainWindow(QMainWindow):
self.language_overrides[key] = idx self.language_overrides[key] = idx
# Patch Row 0 packet data to persist language selection to file # Patch Row 0 packet data to persist language selection to file
# Language bits are in Byte 8 (Control Bits 2): C12, C13, C14 # Language bits are in Byte 7 (Control Bits C11-C14)
# We need to preserve C11 (bit 3 of encoded 4-bit val) which is "Inhibit Display" usually 0 # Byte 7 encoded structure: bit 0:C11, bit 1:C12, bit 2:C13, bit 3:C14
# National Option index corresponds to (C14 C13 C12)
# Find Row 0 packet # Find Row 0 packet
header_packet = None header_packet = None
@@ -500,36 +606,23 @@ class MainWindow(QMainWindow):
header_packet = p header_packet = p
break break
if header_packet and len(header_packet.data) > 8: if header_packet and len(header_packet.data) >= 8:
try: try:
old_val = decode_hamming_8_4(header_packet.data[8]) # Byte 7 contains C11, C12, C13, C14
# Encoded nibble structure: D1(b0), D2(b1), D3(b2), D4(b3) old_val = decode_hamming_8_4(header_packet.data[7])
# D1 maps to C12
# D2 maps to C13
# D3 maps to C14
# D4 maps to C11
# io.py logic for reading: l0 = (idx >> 0) & 1 # C12
# language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4) l1 = (idx >> 1) & 1 # C13
# i.e. Lang Bit 0 comes from D2, Lang Bit 1 comes from D1, Lang Bit 2 comes from D3 l2 = (idx >> 2) & 1 # C14
# So for writing: d1 = (old_val >> 0) & 1 # Preserve C11
# D1 = Lang Bit 1
# D2 = Lang Bit 0
# D3 = Lang Bit 2
l0 = (idx >> 0) & 1
l1 = (idx >> 1) & 1
l2 = (idx >> 2) & 1
d1 = l1
d2 = l0 d2 = l0
d3 = l2 d3 = l1
d4 = (old_val >> 3) & 1 # Preserve C11 d4 = l2
new_val = d1 | (d2 << 1) | (d3 << 2) | (d4 << 3) new_val = d1 | (d2 << 1) | (d3 << 2) | (d4 << 3)
header_packet.data[8] = encode_hamming_8_4(new_val) header_packet.data[7] = encode_hamming_8_4(new_val)
self.set_modified(True) self.set_modified(True)
self.status_label.setText(f"Language set to {self.language_names[idx]} (saved to header).") self.status_label.setText(f"Language set to {self.language_names[idx]} (saved to header).")
except Exception as e: except Exception as e:
@@ -659,6 +752,118 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Export Failed", f"Failed to export TTI: {e}") QMessageBox.critical(self, "Export Failed", f"Failed to export TTI: {e}")
def open_find_dialog(self):
if not self.find_dialog:
self.find_dialog = FindDialog(self)
self.find_dialog.findNext.connect(lambda q, h: self.execute_search(q, h, forward=True))
self.find_dialog.findPrev.connect(lambda q, h: self.execute_search(q, h, forward=False))
self.find_dialog.finished.connect(self.canvas.clear_highlight)
self.find_dialog.show()
self.find_dialog.raise_()
self.find_dialog.activateWindow()
self.find_dialog.query_input.setFocus()
self.find_dialog.query_input.selectAll()
def execute_search(self, query, is_hex, forward=True):
# If query or mode changed, restart search
is_new_search = False
if not hasattr(self, '_last_query') or self._last_query != query or self._last_is_hex != is_hex:
is_new_search = True
self._last_query = query
self._last_is_hex = is_hex
self.search_results = []
if is_hex:
# Parse hex query: "1C 02" -> b'\x1c\x02'
try:
clean_hex = query.replace(" ", "").replace("0x", "")
search_bytes = bytes.fromhex(clean_hex)
except ValueError:
self.find_dialog.set_status("Invalid hex string")
return
else:
search_text = query.lower()
# Iterate all pages
for page in self.service.pages:
# Determine language for this page
lang = page.language
key = (page.magazine, page.page_number)
if key in self.language_overrides:
lang = self.language_overrides[key]
# Check each packet
for packet in page.packets:
if packet.row > 25: continue
if is_hex:
# Hex search in raw data
# Strip parity for comparison? Teletext usually uses 7-bit + parity.
# The search should probably be parity-agnostic.
raw_data = bytes([b & 0x7F for b in packet.data])
idx = raw_data.find(search_bytes)
while idx != -1:
self.search_results.append((page, packet.row, idx, len(search_bytes)))
idx = raw_data.find(search_bytes, idx + 1)
else:
# Text search in decoded string
row_str = ""
for b in packet.data:
# Map byte to char, but treat control codes as spaces for search
bv = b & 0x7F
if bv < 0x20:
row_str += " "
else:
row_str += get_char(bv, lang)
idx = row_str.lower().find(search_text)
while idx != -1:
self.search_results.append((page, packet.row, idx, len(search_text)))
idx = row_str.lower().find(search_text, idx + 1)
if not self.search_results:
self.find_dialog.set_status("No matches found")
self.current_search_index = -1
self.canvas.clear_highlight()
return
self.current_search_index = 0
else:
# Continue search
if forward:
self.current_search_index = (self.current_search_index + 1) % len(self.search_results)
else:
self.current_search_index = (self.current_search_index - 1) % len(self.search_results)
self.find_dialog.set_status(f"Match {self.current_search_index + 1} of {len(self.search_results)}")
self.show_search_result(self.search_results[self.current_search_index])
def show_search_result(self, result):
page, row, col, length = result
# Switch to page
mag_pnum_key = (page.magazine, page.page_number)
# Find and select in list
for i in range(self.page_list.count()):
item = self.page_list.item(i)
if item.data(Qt.ItemDataRole.UserRole) == mag_pnum_key:
self.page_list.setCurrentItem(item)
# This triggers on_page_selected which populates subpages
break
# Select correct subpage
for i in range(self.subpage_combo.count()):
if self.subpage_combo.itemData(i) == page:
self.subpage_combo.setCurrentIndex(i)
break
# Highlight on canvas
self.canvas.set_highlight(row, col, col + length - 1)
# Also move cursor to start of match
self.canvas.set_cursor(col, row)
def copy_page_content(self): def copy_page_content(self):
if not self.current_page: if not self.current_page:
return return
@@ -712,6 +917,7 @@ class MainWindow(QMainWindow):
# Force redraw # Force redraw
self.canvas.redraw() self.canvas.redraw()
self.canvas.update() self.canvas.update()
self.update_crc_display()
self.status_label.setText(f"Pasted {modified_count} rows.") self.status_label.setText(f"Pasted {modified_count} rows.")
self.push_undo_state() # Push state after paste? NO, before! self.push_undo_state() # Push state after paste? NO, before!
# Wait, usually we push before modifying. # Wait, usually we push before modifying.
@@ -752,6 +958,7 @@ class MainWindow(QMainWindow):
def push_undo_state(self): def push_undo_state(self):
if not self.current_page: return if not self.current_page: return
self.canvas.clear_highlight()
# Push deep copy of current page # Push deep copy of current page
snapshot = copy.deepcopy(self.current_page) snapshot = copy.deepcopy(self.current_page)
self.undo_stack.append(snapshot) self.undo_stack.append(snapshot)
@@ -815,6 +1022,7 @@ class MainWindow(QMainWindow):
self.canvas.set_page(self.current_page) self.canvas.set_page(self.current_page)
self.canvas.redraw() self.canvas.redraw()
self.canvas.update() self.canvas.update()
self.update_crc_display()
def populate_list(self): def populate_list(self):
self.page_list.clear() self.page_list.clear()
@@ -848,9 +1056,21 @@ class MainWindow(QMainWindow):
self.subpage_combo.clear() self.subpage_combo.clear()
for i, p in enumerate(pages): for i, p in enumerate(pages):
# Display format: Index or Subcode? # Try to find the clock in Row 0 (last 8 characters)
# Subcode is often 0000. Index 1/N is clearer for editing. clock_str = ""
label = f"{i+1}/{len(pages)} (Sub {p.sub_code:04X})" for pkt in p.packets:
if pkt.row == 0:
# Bytes 32-39 of the 40-byte data are the clock
raw_clock = pkt.data[32:40].decode('latin-1', errors='replace')
# Strip parity from each char and filter non-printables
clock_str = "".join([chr(ord(c) & 0x7F) if 32 <= (ord(c) & 0x7F) <= 126 else " " for c in raw_clock])
break
label = f"{i+1}/{len(pages)} "
if clock_str.strip():
label += f"[{clock_str.strip()}] "
label += f"(Sub {p.sub_code:04X})"
self.subpage_combo.addItem(label, p) self.subpage_combo.addItem(label, p)
self.subpage_combo.blockSignals(False) self.subpage_combo.blockSignals(False)
@@ -875,6 +1095,7 @@ class MainWindow(QMainWindow):
self.canvas.update() self.canvas.update()
self.update_language_label() self.update_language_label()
self.update_crc_display()
self.canvas.setFocus() self.canvas.setFocus()
def insert_char(self, char_code): def insert_char(self, char_code):
@@ -899,6 +1120,7 @@ class MainWindow(QMainWindow):
self.hex_input.setText(f"{val:02X}") self.hex_input.setText(f"{val:02X}")
mode_str = "Graphics" if is_graphics else "Text" mode_str = "Graphics" if is_graphics else "Text"
self.mode_label.setText(f"Mode: {mode_str}") self.mode_label.setText(f"Mode: {mode_str}")
self.update_crc_display()
def on_hex_entered(self): def on_hex_entered(self):
text = self.hex_input.text() text = self.hex_input.text()