Initial commit: Core Teletext Editor functionality
This commit is contained in:
BIN
src/teletext/__pycache__/charsets.cpython-312.pyc
Normal file
BIN
src/teletext/__pycache__/charsets.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/teletext/__pycache__/io.cpython-312.pyc
Normal file
BIN
src/teletext/__pycache__/io.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/teletext/__pycache__/models.cpython-312.pyc
Normal file
BIN
src/teletext/__pycache__/models.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/teletext/__pycache__/renderer.cpython-312.pyc
Normal file
BIN
src/teletext/__pycache__/renderer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/teletext/__pycache__/ui.cpython-312.pyc
Normal file
BIN
src/teletext/__pycache__/ui.cpython-312.pyc
Normal file
Binary file not shown.
60
src/teletext/charsets.py
Normal file
60
src/teletext/charsets.py
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
"""
|
||||
Teletext Character Sets (G0).
|
||||
Maps the specific code points (0x23, 0x24, 0x40, 0x5B-0x5E, 0x60, 0x7B-0x7E)
|
||||
to Unicode characters based on the National Option (3 bits).
|
||||
"""
|
||||
|
||||
# Default (English) - Option 000
|
||||
ENGLISH = {
|
||||
0x23: '#', 0x24: '$', 0x40: '@',
|
||||
0x5B: '[', 0x5C: '\\', 0x5D: ']', 0x5E: '^',
|
||||
0x5F: '_', 0x60: '`',
|
||||
0x7B: '{', 0x7C: '|', 0x7D: '}', 0x7E: '~'
|
||||
}
|
||||
|
||||
# Swedish/Finnish/Hungarian - Option 010 (2)
|
||||
SWEDISH_FINNISH = {
|
||||
0x23: '#', 0x24: '¤', 0x40: 'É',
|
||||
0x5B: 'Ä', 0x5C: 'Ö', 0x5D: 'Å', 0x5E: 'Ü',
|
||||
0x5F: '_', 0x60: 'é',
|
||||
0x7B: 'ä', 0x7C: 'ö', 0x7D: 'å', 0x7E: 'ü'
|
||||
}
|
||||
|
||||
# German - Option 001 (1)
|
||||
GERMAN = {
|
||||
0x23: '#', 0x24: '$', 0x40: '§',
|
||||
0x5B: 'Ä', 0x5C: 'Ö', 0x5D: 'Ü', 0x5E: '^',
|
||||
0x5F: '_', 0x60: '`',
|
||||
0x7B: 'ä', 0x7C: 'ö', 0x7D: 'ü', 0x7E: 'ß'
|
||||
}
|
||||
|
||||
# We can add more as needed.
|
||||
|
||||
SETS = [
|
||||
ENGLISH, # 000
|
||||
GERMAN, # 001
|
||||
SWEDISH_FINNISH, # 010
|
||||
ENGLISH, # Italian (011) - placeholder
|
||||
ENGLISH, # French (100) - placeholder
|
||||
ENGLISH, # Portuguese/Spanish (101) - placeholder
|
||||
ENGLISH, # Turkish (110) - placeholder
|
||||
ENGLISH, # Romania (111) - placeholder
|
||||
]
|
||||
|
||||
def get_char(byte_val, subset_idx):
|
||||
if subset_idx < 0 or subset_idx >= len(SETS):
|
||||
subset_idx = 0
|
||||
|
||||
mapping = SETS[subset_idx]
|
||||
|
||||
# If byte is in mapping, return mapped char.
|
||||
# Else return ASCII equivalent (for basic chars)
|
||||
|
||||
valid_byte = byte_val & 0x7F # Strip parity if present (though our packet data is 8-bit usually already stripping parity?)
|
||||
# Packet data we store is raw bytes. We should probably strip parity bit 7 before lookup.
|
||||
|
||||
if valid_byte in mapping:
|
||||
return mapping[valid_byte]
|
||||
|
||||
return chr(valid_byte)
|
||||
119
src/teletext/io.py
Normal file
119
src/teletext/io.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import os
|
||||
from typing import List
|
||||
from .models import Packet, Page, TeletextService
|
||||
|
||||
def load_t42(file_path: str) -> TeletextService:
|
||||
service = TeletextService()
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
while True:
|
||||
chunk = f.read(42)
|
||||
if not chunk:
|
||||
break
|
||||
if len(chunk) < 42:
|
||||
# Should not happen in a valid T42 stream, or we just ignore incomplete tail
|
||||
break
|
||||
|
||||
packet = Packet(chunk)
|
||||
service.all_packets.append(packet)
|
||||
|
||||
# Logic to group into pages.
|
||||
# This is non-trivial because packets for a page might be interleaved or sequential.
|
||||
# Standard implementation: Packets arrive in order. Row 0 starts a new page/subpage.
|
||||
|
||||
if packet.row == 0:
|
||||
# Start of a new page header.
|
||||
# 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):
|
||||
# Bytes: P1 P2 S1 S2 S3 S4 C1 C2 ...
|
||||
# All Hamming 8/4 encoded.
|
||||
|
||||
# For now, let's just create a new page entry for every Header we see,
|
||||
# or find the existing one if we want to support updates (but T42 usually is a stream capture).
|
||||
# If it's an editor file, it's likely sequential.
|
||||
|
||||
p_num, sub_code = parse_header(packet.data)
|
||||
|
||||
# Create new page
|
||||
new_page = Page(magazine=packet.magazine, page_number=p_num, sub_code=sub_code)
|
||||
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:
|
||||
# Packet without a header? Orphaned. Just keep in all_packets
|
||||
pass
|
||||
|
||||
return service
|
||||
|
||||
def save_t42(file_path: str, service: TeletextService):
|
||||
with open(file_path, 'wb') as f:
|
||||
# User requirement: "without rearranging the order of the packets"
|
||||
# Implies we should iterate the original list.
|
||||
# However, if we edit data, we modify the Packet objects in place.
|
||||
# If we Add/Delete packets, we need to handle that.
|
||||
|
||||
for packet in service.all_packets:
|
||||
# Reconstruct the 42 bytes from the packet fields
|
||||
# The packet.data (bytearray) should be mutable and edited by the UI.
|
||||
# packet.original_data (first 2 bytes) + packet.data
|
||||
|
||||
# Note: If we changed Magazine or Row, we'd need to re-encode the first 2 bytes.
|
||||
# For now, assume we primarily edit content (bytes 2-41).
|
||||
|
||||
header = packet.original_data[:2] # Keep original address for now
|
||||
# TODO: regenerating header if Mag/Row changed
|
||||
|
||||
f.write(header + packet.data)
|
||||
|
||||
def decode_hamming_8_4(byte_val):
|
||||
return ((byte_val >> 1) & 1) | \
|
||||
(((byte_val >> 3) & 1) << 1) | \
|
||||
(((byte_val >> 5) & 1) << 2) | \
|
||||
(((byte_val >> 7) & 1) << 3)
|
||||
|
||||
def parse_header(data: bytearray):
|
||||
# Data is 40 bytes.
|
||||
# Bytes 0-7 are Page Num (2), Subcode (4), Control (2) - ALL Hamming encoded.
|
||||
|
||||
# 0: Page Units (PU)
|
||||
# 1: Page Tens (PT)
|
||||
|
||||
pu = decode_hamming_8_4(data[0])
|
||||
pt = decode_hamming_8_4(data[1])
|
||||
|
||||
page_num = (pt & 0xF) * 10 + (pu & 0xF)
|
||||
|
||||
# Subcode: S1, S2, S3, S4
|
||||
# S1 (low), S2, S3, S4 (high)
|
||||
|
||||
s1 = decode_hamming_8_4(data[2])
|
||||
s2 = decode_hamming_8_4(data[3])
|
||||
s3 = decode_hamming_8_4(data[4])
|
||||
s4 = decode_hamming_8_4(data[5])
|
||||
|
||||
# Subcode logic is a bit complex with specific bit mapping for "Time" vs "Subcode"
|
||||
# But usually just combining them gives the raw subcode value.
|
||||
# S1: bits 0-3
|
||||
# S2: bits 4-6 (bit 4 is C4) -> actually S2 has 3 bits of subcode + 1 control bit usually?
|
||||
# Let's simplify and just concat them for a unique identifier.
|
||||
|
||||
sub_code = s1 | (s2 << 4) | (s3 << 8) | (s4 << 12)
|
||||
|
||||
return page_num, sub_code
|
||||
93
src/teletext/models.py
Normal file
93
src/teletext/models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
@dataclass
|
||||
class Packet:
|
||||
"""
|
||||
Represents a single Teletext packet (Row).
|
||||
In T42, we have 42 bytes:
|
||||
Byte 0-1: Magazine and Row Address (Hamming 8/4 encoding)
|
||||
Byte 2-41: Data bytes (40 bytes)
|
||||
"""
|
||||
original_data: bytes
|
||||
magazine: int = field(init=False)
|
||||
row: int = field(init=False)
|
||||
data: bytearray = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
if len(self.original_data) != 42:
|
||||
raise ValueError(f"Packet must be 42 bytes, got {len(self.original_data)}")
|
||||
|
||||
# Parse Magazine and Row from the first 2 bytes (Hamming 8/4)
|
||||
# MRAG (Magazine + Row Address Group)
|
||||
# Byte 0: P1 D1 P2 D2 P3 D3 P4 D4
|
||||
# Byte 1: P1 D1 P2 D2 P3 D3 P4 D4
|
||||
|
||||
b1 = self.original_data[0]
|
||||
b2 = self.original_data[1]
|
||||
|
||||
# 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)
|
||||
d2 = decode_hamming_8_4(b2)
|
||||
|
||||
# Magazine is 3 bits (Logic is specific: Mag 8 is encoded as 0)
|
||||
# Row is 5 bits.
|
||||
|
||||
# According to Spec (ETSI EN 300 706):
|
||||
# b1 encoded: M1 M2 M3 R1
|
||||
# b2 encoded: R2 R3 R4 R5
|
||||
|
||||
self.magazine = (d1 & 0b0111)
|
||||
if self.magazine == 0:
|
||||
self.magazine = 8
|
||||
|
||||
row_low_bit = (d1 >> 3) & 1
|
||||
row_high_bits = d2
|
||||
self.row = (row_high_bits << 1) | row_low_bit
|
||||
|
||||
self.data = bytearray(self.original_data[2:])
|
||||
|
||||
@property
|
||||
def is_header(self):
|
||||
return self.row == 0
|
||||
|
||||
@dataclass
|
||||
class Page:
|
||||
"""
|
||||
Represents a Teletext Page (e.g., 100).
|
||||
Can have multiple subpages.
|
||||
"""
|
||||
magazine: int
|
||||
page_number: int # 00-99
|
||||
sub_code: int = 0 # Subpage code (0000 to 3F7F hex usually, simplest is 0-99 equivalent)
|
||||
packets: List[Packet] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def full_page_number(self):
|
||||
return f"{self.magazine}{self.page_number:02d}"
|
||||
|
||||
@dataclass
|
||||
class TeletextService:
|
||||
"""
|
||||
Container for all pages.
|
||||
"""
|
||||
pages: List[Page] = field(default_factory=list)
|
||||
# We also keep a flat list of all packets to preserve order on save
|
||||
all_packets: List[Packet] = field(default_factory=list)
|
||||
|
||||
274
src/teletext/renderer.py
Normal file
274
src/teletext/renderer.py
Normal file
@@ -0,0 +1,274 @@
|
||||
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
from PyQt6.QtGui import QPainter, QColor, QFont, QImage, QBrush, QPen
|
||||
from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal
|
||||
|
||||
from .models import Page, Packet
|
||||
from .charsets import get_char
|
||||
|
||||
# Teletext Palette
|
||||
COLORS = [
|
||||
QColor(0, 0, 0), # Black
|
||||
QColor(255, 0, 0), # Red
|
||||
QColor(0, 255, 0), # Green
|
||||
QColor(255, 255, 0), # Yellow
|
||||
QColor(0, 0, 255), # Blue
|
||||
QColor(255, 0, 255), # Magenta
|
||||
QColor(0, 255, 255), # Cyan
|
||||
QColor(255, 255, 255) # White
|
||||
]
|
||||
|
||||
class TeletextCanvas(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setMinimumSize(480, 500) # 40x12 * 25x20 approx
|
||||
self.page: Page = None
|
||||
self.subset_idx = 0 # Default English
|
||||
|
||||
# Teletext is 40 columns x 25 rows
|
||||
# We will render to a fixed size QImage and scale it
|
||||
self.cols = 40
|
||||
self.rows = 25
|
||||
self.cell_w = 12
|
||||
self.cell_h = 20
|
||||
self.img_w = self.cols * self.cell_w
|
||||
self.img_h = self.rows * self.cell_h
|
||||
|
||||
self.buffer = QImage(self.img_w, self.img_h, QImage.Format.Format_RGB32)
|
||||
self.buffer.fill(Qt.GlobalColor.black)
|
||||
|
||||
# Font for text
|
||||
self.font = QFont("Courier New", 14)
|
||||
self.font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
self.font.setBold(True)
|
||||
|
||||
# Cursor state
|
||||
self.cursor_x = 0
|
||||
self.cursor_y = 0
|
||||
self.cursor_visible = True
|
||||
# Blinking cursor timer could be added, for now static inverted is fine or toggle on timer elsewhere
|
||||
|
||||
def set_cursor(self, x, y):
|
||||
self.cursor_x = max(0, min(self.cols - 1, x))
|
||||
self.cursor_y = max(0, min(self.rows - 1, y))
|
||||
self.update()
|
||||
|
||||
def move_cursor(self, dx, dy):
|
||||
self.cursor_x = max(0, min(self.cols - 1, self.cursor_x + dx))
|
||||
self.cursor_y = max(0, min(self.rows - 1, self.cursor_y + dy))
|
||||
self.update()
|
||||
|
||||
def set_page(self, page: Page):
|
||||
self.page = page
|
||||
self.cursor_x = 0
|
||||
self.cursor_y = 0
|
||||
self.redraw()
|
||||
self.update()
|
||||
|
||||
def handle_input(self, text):
|
||||
if not self.page:
|
||||
return
|
||||
|
||||
# Find packet for current row
|
||||
packet = None
|
||||
for p in self.page.packets:
|
||||
if p.row == self.cursor_y:
|
||||
packet = p
|
||||
break
|
||||
|
||||
if packet:
|
||||
# Edit the data
|
||||
# Data bytes start at index 0 of packet.data corresponding to Col 0?
|
||||
# Standard Teletext row is 40 chars. Packet data is 40 bytes.
|
||||
if 0 <= self.cursor_x < 40:
|
||||
# Get ASCII value
|
||||
# We need to Reverse Map chars to Teletext Bytes if we want full suppport
|
||||
# For now, just taking ord() for basic ASCII
|
||||
# TODO: reverse lookup for National Chars
|
||||
|
||||
# Check if text is a single char
|
||||
if len(text) == 1:
|
||||
byte_val = ord(text)
|
||||
# Simple filter
|
||||
if byte_val > 255: byte_val = 0x3F # ?
|
||||
|
||||
packet.data[self.cursor_x] = byte_val
|
||||
self.redraw()
|
||||
self.move_cursor(1, 0)
|
||||
else:
|
||||
# Create a packet if it doesn't exist for this row?
|
||||
# Creating new packets in a T42 stream is tricky (insertion).
|
||||
# For now, only edit existing rows.
|
||||
pass
|
||||
|
||||
def redraw(self):
|
||||
self.buffer.fill(Qt.GlobalColor.black)
|
||||
painter = QPainter(self.buffer)
|
||||
painter.setFont(self.font)
|
||||
|
||||
if not self.page:
|
||||
painter.setPen(Qt.GlobalColor.white)
|
||||
painter.drawText(10, 20, "No Page Loaded")
|
||||
painter.end()
|
||||
return
|
||||
|
||||
# Draw each packet
|
||||
# Initialize a grid of empty chars
|
||||
grid = [None] * 26 # 0-25
|
||||
|
||||
for p in self.page.packets:
|
||||
if 0 <= p.row <= 25:
|
||||
grid[p.row] = p
|
||||
|
||||
for r in range(25):
|
||||
packet = grid[r]
|
||||
self.draw_row(painter, r, packet)
|
||||
|
||||
painter.end()
|
||||
|
||||
def draw_row(self, painter, row, packet):
|
||||
# Default State at start of row
|
||||
fg = COLORS[7] # White
|
||||
bg = COLORS[0] # Black
|
||||
graphics_mode = False
|
||||
contiguous = True # Mosaic
|
||||
hold_graphics = False
|
||||
held_char = 0x20 # Space
|
||||
|
||||
y = row * self.cell_h
|
||||
|
||||
data = b''
|
||||
if packet:
|
||||
data = packet.data # 40 bytes
|
||||
else:
|
||||
data = b'\x20' * 40
|
||||
|
||||
# Header string for Row 0 columns 0-7
|
||||
header_prefix = ""
|
||||
if row == 0 and self.page:
|
||||
header_prefix = f"P{self.page.magazine}{self.page.page_number:02d}"
|
||||
# Pad to 8 chars
|
||||
header_prefix = header_prefix.ljust(8)
|
||||
|
||||
for c in range(40):
|
||||
x = c * self.cell_w
|
||||
|
||||
# Decide byte value
|
||||
if row == 0 and c < 8:
|
||||
# Use generated header prefix
|
||||
byte_val = ord(header_prefix[c])
|
||||
else:
|
||||
byte_val = data[c] if c < len(data) else 0x20
|
||||
|
||||
byte_val &= 0x7F # Strip parity
|
||||
|
||||
# Control Codes
|
||||
is_control = False
|
||||
|
||||
if byte_val < 0x20:
|
||||
is_control = True
|
||||
# Handle control codes
|
||||
if 0x00 <= byte_val <= 0x07: # Alpha Color
|
||||
fg = COLORS[byte_val]
|
||||
graphics_mode = False
|
||||
elif 0x10 <= byte_val <= 0x17: # Mosaic Color
|
||||
fg = COLORS[byte_val - 0x10]
|
||||
graphics_mode = True
|
||||
elif byte_val == 0x1C: # Black BG
|
||||
bg = COLORS[0]
|
||||
elif byte_val == 0x1D: # New BG
|
||||
bg = fg
|
||||
elif byte_val == 0x0C: # Normal Height
|
||||
pass
|
||||
elif byte_val == 0x0D: # Double Height
|
||||
pass # Not implemented yet
|
||||
elif byte_val == 0x19: # Contiguous Graphics
|
||||
contiguous = True
|
||||
elif byte_val == 0x1A: # Separated Graphics
|
||||
contiguous = False
|
||||
elif byte_val == 0x1E: # Hold Graphics
|
||||
hold_graphics = True
|
||||
elif byte_val == 0x1F: # Release Graphics
|
||||
hold_graphics = False
|
||||
|
||||
# Draw Background
|
||||
painter.fillRect(x, y, self.cell_w, self.cell_h, bg)
|
||||
|
||||
# Draw Foreground
|
||||
if is_control:
|
||||
# "Set-at" spacing attribute? Teletext control codes occupy a space
|
||||
# unless "Hold Graphics" replaces it with previous graphic char.
|
||||
if hold_graphics and graphics_mode:
|
||||
self.draw_mosaic(painter, x, y, held_char, fg, contiguous)
|
||||
else:
|
||||
# Draw space (nothing, since we filled BG)
|
||||
pass
|
||||
else:
|
||||
if graphics_mode:
|
||||
# Mosaic Graphics
|
||||
if (0x20 <= byte_val <= 0x3F) or (0x60 <= byte_val <= 0x7F):
|
||||
self.draw_mosaic(painter, x, y, byte_val, fg, contiguous)
|
||||
held_char = byte_val
|
||||
else:
|
||||
# Capital letter in graphics mode? Usually shows char?
|
||||
char = get_char(byte_val, self.subset_idx)
|
||||
painter.setPen(fg)
|
||||
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
||||
held_char = 0x20
|
||||
else:
|
||||
# Alphanumeric
|
||||
char = get_char(byte_val, self.subset_idx)
|
||||
painter.setPen(fg)
|
||||
painter.drawText(QRect(x, y, self.cell_w, self.cell_h), Qt.AlignmentFlag.AlignCenter, char)
|
||||
# Draw Cursor
|
||||
# Invert the cell at cursor position
|
||||
if self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
|
||||
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceXorDestination)
|
||||
# XOR with white (creates inversion)
|
||||
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
|
||||
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
|
||||
|
||||
def draw_mosaic(self, painter, x, y, char_code, color, contiguous):
|
||||
val = char_code & 0x7F
|
||||
bits = 0
|
||||
if val >= 0x20:
|
||||
bits = val - 0x20
|
||||
|
||||
blocks = [
|
||||
(0, 0), (1, 0), # Top
|
||||
(0, 1), (1, 1), # Mid
|
||||
(0, 2), (1, 2) # Bot
|
||||
]
|
||||
|
||||
bit_mask = [1, 2, 4, 8, 16, 64] # 64 is bit 6
|
||||
|
||||
bw = self.cell_w / 2
|
||||
bh = self.cell_h / 3
|
||||
|
||||
if not contiguous:
|
||||
bw -= 1
|
||||
bh -= 1
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QBrush(color))
|
||||
|
||||
for i in range(6):
|
||||
if bits & bit_mask[i]:
|
||||
bx = x + blocks[i][0] * (self.cell_w / 2)
|
||||
by = y + blocks[i][1] * (self.cell_h / 3)
|
||||
|
||||
if not contiguous:
|
||||
bx += 1
|
||||
by += 1
|
||||
|
||||
painter.drawRect(QRect(int(bx), int(by), int(bw), int(bh)))
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
target_rect = self.rect()
|
||||
scaled = self.buffer.scaled(target_rect.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.FastTransformation)
|
||||
|
||||
ox = (target_rect.width() - scaled.width()) // 2
|
||||
oy = (target_rect.height() - scaled.height()) // 2
|
||||
|
||||
painter.drawImage(ox, oy, scaled)
|
||||
152
src/teletext/ui.py
Normal file
152
src/teletext/ui.py
Normal file
@@ -0,0 +1,152 @@
|
||||
|
||||
import os
|
||||
from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QTreeWidget, QTreeWidgetItem, QFileDialog, QMenuBar, QMenu, QMessageBox)
|
||||
from PyQt6.QtGui import QAction, QKeyEvent
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from .io import load_t42, save_t42
|
||||
from .renderer import TeletextCanvas
|
||||
from .models import TeletextService, Page, Packet
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Teletext Editor")
|
||||
self.resize(1024, 768)
|
||||
|
||||
self.service = TeletextService()
|
||||
self.current_page: Page = None
|
||||
|
||||
# UI Components
|
||||
self.central_widget = QWidget()
|
||||
self.setCentralWidget(self.central_widget)
|
||||
|
||||
self.layout = QHBoxLayout(self.central_widget)
|
||||
|
||||
# Left Panel: Page Tree
|
||||
self.tree = QTreeWidget()
|
||||
self.tree.setHeaderLabel("Pages")
|
||||
self.tree.setFixedWidth(200)
|
||||
self.tree.itemClicked.connect(self.on_page_selected)
|
||||
self.layout.addWidget(self.tree)
|
||||
|
||||
# Center: Teletext Canvas
|
||||
self.canvas = TeletextCanvas()
|
||||
self.layout.addWidget(self.canvas, 1) # Expand
|
||||
|
||||
# Menus
|
||||
self.create_menus()
|
||||
|
||||
def create_menus(self):
|
||||
menu_bar = self.menuBar()
|
||||
|
||||
file_menu = menu_bar.addMenu("File")
|
||||
|
||||
open_action = QAction("Open T42...", self)
|
||||
open_action.triggered.connect(self.open_file)
|
||||
file_menu.addAction(open_action)
|
||||
|
||||
save_action = QAction("Save T42...", self)
|
||||
save_action.triggered.connect(self.save_file)
|
||||
file_menu.addAction(save_action)
|
||||
|
||||
exit_action = QAction("Exit", self)
|
||||
exit_action.triggered.connect(self.close)
|
||||
file_menu.addAction(exit_action)
|
||||
|
||||
view_menu = menu_bar.addMenu("View")
|
||||
|
||||
lang_menu = view_menu.addMenu("Language")
|
||||
langs = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
|
||||
for i, lang in enumerate(langs):
|
||||
action = QAction(lang, self)
|
||||
action.setData(i)
|
||||
action.triggered.connect(self.set_language)
|
||||
lang_menu.addAction(action)
|
||||
|
||||
def set_language(self):
|
||||
action = self.sender()
|
||||
if action:
|
||||
idx = action.data()
|
||||
self.canvas.subset_idx = idx
|
||||
self.canvas.redraw()
|
||||
self.canvas.update()
|
||||
|
||||
def open_file(self):
|
||||
fname, _ = QFileDialog.getOpenFileName(self, "Open T42", "", "Teletext Files (*.t42);;All Files (*)")
|
||||
if fname:
|
||||
try:
|
||||
self.service = load_t42(fname)
|
||||
self.populate_tree()
|
||||
QMessageBox.information(self, "Loaded", f"Loaded {len(self.service.pages)} pages.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to load file: {e}")
|
||||
|
||||
def save_file(self):
|
||||
fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42);;All Files (*)")
|
||||
if fname:
|
||||
try:
|
||||
save_t42(fname, self.service)
|
||||
QMessageBox.information(self, "Saved", "File saved successfully.")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to save file: {e}")
|
||||
|
||||
def populate_tree(self):
|
||||
self.tree.clear()
|
||||
|
||||
# Group by Magazine
|
||||
mags = {}
|
||||
for p in self.service.pages:
|
||||
if p.magazine not in mags:
|
||||
mags[p.magazine] = QTreeWidgetItem([f"Magazine {p.magazine}"])
|
||||
self.tree.addTopLevelItem(mags[p.magazine])
|
||||
|
||||
# Format: PPP-SS (Page-Subcode)
|
||||
# Create Item
|
||||
# Subcode is complicated to display "nicely" without decoding,
|
||||
# let's just show hex or raw for now if not standard 0000.
|
||||
|
||||
label = f"{p.page_number:02d} (Sub: {p.sub_code:04X})"
|
||||
item = QTreeWidgetItem([label])
|
||||
item.setData(0, Qt.ItemDataRole.UserRole, p)
|
||||
mags[p.magazine].addChild(item)
|
||||
|
||||
self.tree.expandAll()
|
||||
|
||||
def on_page_selected(self, item, column):
|
||||
page = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if isinstance(page, Page):
|
||||
self.current_page = page
|
||||
self.canvas.set_page(page)
|
||||
# Also set window focus to canvas or handle key events?
|
||||
self.canvas.setFocus()
|
||||
|
||||
# Input Handling (Editor Logic)
|
||||
def keyPressEvent(self, event: QKeyEvent):
|
||||
if not self.current_page:
|
||||
return
|
||||
|
||||
key = event.key()
|
||||
text = event.text()
|
||||
|
||||
# Navigation
|
||||
if key == Qt.Key.Key_Up:
|
||||
self.canvas.move_cursor(0, -1)
|
||||
elif key == Qt.Key.Key_Down:
|
||||
self.canvas.move_cursor(0, 1)
|
||||
elif key == Qt.Key.Key_Left:
|
||||
self.canvas.move_cursor(-1, 0)
|
||||
elif key == Qt.Key.Key_Right:
|
||||
self.canvas.move_cursor(1, 0)
|
||||
else:
|
||||
# Typing
|
||||
# Filter non-printable
|
||||
if text and len(text) == 1 and 32 <= ord(text) <= 126:
|
||||
self.canvas.handle_input(text)
|
||||
elif key == Qt.Key.Key_Backspace:
|
||||
# Move back and delete
|
||||
self.canvas.move_cursor(-1, 0)
|
||||
self.canvas.handle_input(' ')
|
||||
self.canvas.move_cursor(-1, 0) # Compensate for the auto-advance logic if any
|
||||
|
||||
Reference in New Issue
Block a user