Feature: Enhanced Editing, Hex Inspector, Color Shortcuts

- Enhanced Editing: Auto-create packets, Enter key support.
- Cursor: Fixed visibility (composition mode), click-to-move, immediate redraw.
- Hex Inspector: Added Hex Value display and input panel.
- Shortcuts: Added Color Insert buttons.
- Fixes: Resolved Hamming encoding for save, fixed duplicate spaces bug, fixed IndentationError.
This commit is contained in:
2025-12-28 21:57:44 +01:00
parent e2ed4dd1e2
commit 088ad1a320
6 changed files with 265 additions and 20 deletions

View File

@@ -62,24 +62,74 @@ def load_t42(file_path: str) -> TeletextService:
return service return service
def encode_hamming_8_4(value):
# Value is 4 bits (0-15)
d1 = (value >> 0) & 1
d2 = (value >> 1) & 1
d3 = (value >> 2) & 1
d4 = (value >> 3) & 1
# Parity bits (Odd parity default? Or standard Hamming?)
# Teletext spec:
# P1 = 1 + D1 + D2 + D4 (mod 2) -> Inverse of even parity check?
# Actually, simpler to look up or calculate.
# Let's match typical implementation:
# P1 (b0) covers 1,3,7 (D1, D2, D4)
# P2 (b2) covers 1,5,7 (D1, D3, D4)
# P3 (b4) covers 3,5,7 (D2, D3, D4)
# P4 (b6) covers all.
# Teletext uses ODD parity for the hamming bits usually?
# "Hamming 8/4 with odd parity"
p1 = 1 ^ d1 ^ d2 ^ d4
p2 = 1 ^ d1 ^ d3 ^ d4
p3 = 1 ^ d2 ^ d3 ^ d4
res = (p1 << 0) | (d1 << 1) | \
(p2 << 2) | (d2 << 3) | \
(p3 << 4) | (d3 << 5) | \
(d4 << 7)
# P4 (bit 6) makes total bits odd
# Count set bits so far
set_bits = bin(res).count('1')
p4 = 1 if (set_bits % 2 == 0) else 0
res |= (p4 << 6)
return res
def save_t42(file_path: str, service: TeletextService): def save_t42(file_path: str, service: TeletextService):
with open(file_path, 'wb') as f: 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: for packet in service.all_packets:
# Reconstruct the 42 bytes from the packet fields # Reconstruct header bytes from packet.magazine and packet.row
# The packet.data (bytearray) should be mutable and edited by the UI. # Byte 1: M1 M2 M3 R1
# packet.original_data (first 2 bytes) + packet.data # Byte 2: R2 R3 R4 R5
# Note: If we changed Magazine or Row, we'd need to re-encode the first 2 bytes. mag = packet.magazine
# For now, assume we primarily edit content (bytes 2-41). if mag == 8: mag = 0 # 0 encoded as 8
header = packet.original_data[:2] # Keep original address for now # Bits:
# TODO: regenerating header if Mag/Row changed # B1 data: M1(0) M2(1) M3(2) R1(3)
m1 = (mag >> 0) & 1
m2 = (mag >> 1) & 1
m3 = (mag >> 2) & 1
r1 = (packet.row >> 0) & 1
b1_val = m1 | (m2 << 1) | (m3 << 2) | (r1 << 3)
b1_enc = encode_hamming_8_4(b1_val)
# B2 data: R2(0) R3(1) R4(2) R5(3)
r2 = (packet.row >> 1) & 1
r3 = (packet.row >> 2) & 1
r4 = (packet.row >> 3) & 1
r5 = (packet.row >> 4) & 1
b2_val = r2 | (r3 << 1) | (r4 << 2) | (r5 << 3)
b2_enc = encode_hamming_8_4(b2_val)
header = bytes([b1_enc, b2_enc])
f.write(header + packet.data) f.write(header + packet.data)
def decode_hamming_8_4(byte_val): def decode_hamming_8_4(byte_val):

View File

@@ -6,6 +6,40 @@ from PyQt6.QtCore import Qt, QRect, QSize, pyqtSignal
from .models import Page, Packet from .models import Page, Packet
from .charsets import get_char from .charsets import get_char
# Helper to create a blank packet
def create_blank_packet(magazine: int, row: int) -> Packet:
# 42 bytes
# Bytes 0-1: Address (Hamming encoded)
# We need to compute Hamming for M and R.
# For now, let's just make a placeholder that "looks" okay or use 0x00 and let a future saver fix it.
# But wait, `Packet` expects 42 bytes input.
# Ideally we should implement the hamming encoder from `io.py` or similar.
# For now, we will create a Packet with dummy address bytes and correct mag/row properties set manually if needed,
# OR we just construct the bytes.
# Let's rely on the Packet class parsing... which is annoying if we can't easily encode.
# Hack: default to 0x00 0x00, then manually set .magazine and .row
data = bytearray(42)
# Set spaces
data[2:] = b'\x20' * 40
p = Packet(bytes(data))
# Override
p.magazine = magazine
p.row = row
p.data = bytearray(b'\x20' * 40)
# We really should set the address bytes correctly so saving works "naturally".
# But `save_t42` uses `packet.original_data[:2]`.
# So we MUST encode properly or `save_t42` needs to regenerate headers.
# Let's defer proper Hamming encoding to the Save step (as noted in TODO in io.py).
# For in-memory editing, this is fine.
return p
# Teletext Palette # Teletext Palette
COLORS = [ COLORS = [
QColor(0, 0, 0), # Black QColor(0, 0, 0), # Black
@@ -19,8 +53,11 @@ COLORS = [
] ]
class TeletextCanvas(QWidget): class TeletextCanvas(QWidget):
cursorChanged = pyqtSignal(int, int, int) # x, y, byte_val
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.setMouseTracking(True) # Just in case
self.setMinimumSize(480, 500) # 40x12 * 25x20 approx self.setMinimumSize(480, 500) # 40x12 * 25x20 approx
self.page: Page = None self.page: Page = None
self.subset_idx = 0 # Default English self.subset_idx = 0 # Default English
@@ -48,15 +85,57 @@ class TeletextCanvas(QWidget):
self.cursor_visible = True self.cursor_visible = True
# 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
def get_byte_at(self, x, y):
if not self.page: return 0
# Row 0 special logic (header) - returning what is displayed or raw?
# User probably wants RAW byte for editing.
packet = None
for p in self.page.packets:
if p.row == y:
packet = p
break
if packet and 0 <= x < 40:
return packet.data[x]
return 0
def emit_cursor_change(self):
val = self.get_byte_at(self.cursor_x, self.cursor_y)
self.cursorChanged.emit(self.cursor_x, self.cursor_y, val)
def set_cursor(self, x, y): def set_cursor(self, x, y):
self.cursor_x = max(0, min(self.cols - 1, x)) self.cursor_x = max(0, min(self.cols - 1, x))
self.cursor_y = max(0, min(self.rows - 1, y)) self.cursor_y = max(0, min(self.rows - 1, y))
self.redraw()
self.update() self.update()
self.emit_cursor_change()
def move_cursor(self, dx, dy): def move_cursor(self, dx, dy):
self.cursor_x = max(0, min(self.cols - 1, self.cursor_x + dx)) 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.cursor_y = max(0, min(self.rows - 1, self.cursor_y + dy))
self.redraw()
self.update() self.update()
self.emit_cursor_change()
def set_byte_at_cursor(self, byte_val):
if not self.page: return
packet = None
for p in self.page.packets:
if p.row == self.cursor_y:
packet = p
break
if packet:
if 0 <= self.cursor_x < 40:
packet.data[self.cursor_x] = byte_val
self.redraw()
self.emit_cursor_change()
else:
# Create packet if missing
self.handle_input(chr(byte_val)) # Lazy reuse or duplicate create logic
def set_page(self, page: Page): def set_page(self, page: Page):
self.page = page self.page = page
@@ -64,6 +143,7 @@ class TeletextCanvas(QWidget):
self.cursor_y = 0 self.cursor_y = 0
self.redraw() self.redraw()
self.update() self.update()
self.emit_cursor_change()
def handle_input(self, text): def handle_input(self, text):
if not self.page: if not self.page:
@@ -96,10 +176,24 @@ class TeletextCanvas(QWidget):
self.redraw() self.redraw()
self.move_cursor(1, 0) self.move_cursor(1, 0)
else: else:
# Create a packet if it doesn't exist for this row? # Create a packet if it doesn't exist for this row
# Creating new packets in a T42 stream is tricky (insertion). if 0 <= self.cursor_x < 40:
# For now, only edit existing rows. # Check if we should create it
pass # Only if input is valid char?
if len(text) == 1:
# Create new packet
new_packet = create_blank_packet(self.page.magazine, self.cursor_y)
self.page.packets.append(new_packet)
# Sort packets by row? Or just append. Renderer handles unordered.
# But for sanity, let's just append.
# Write the char
byte_val = ord(text)
if byte_val > 255: byte_val = 0x3F
new_packet.data[self.cursor_x] = byte_val
self.redraw()
self.move_cursor(1, 0)
def redraw(self): def redraw(self):
self.buffer.fill(Qt.GlobalColor.black) self.buffer.fill(Qt.GlobalColor.black)
@@ -223,11 +317,48 @@ class TeletextCanvas(QWidget):
# Draw Cursor # Draw Cursor
# Invert the cell at cursor position # Invert the cell at cursor position
if self.cursor_visible and c == self.cursor_x and row == self.cursor_y: if self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
painter.setCompositionMode(QPainter.CompositionMode.RasterOp_NotSourceXorDestination) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
# XOR with white (creates inversion) # Difference with white creates inversion
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255)) painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
def mousePressEvent(self, event):
self.setFocus()
# Calculate cell from mouse position
# Need to account for scaling?
# The widget sizes to fit, but we render to a fixed buffer then scale up.
# paintEvent scales buffer to self.rect().
# This is a bit tricky because paintEvent uses aspect-ratio scaling and centering.
# We need to reverse the mapping.
target_rect = self.rect()
# Re-calc scale and offset
# Note: This logic duplicates paintEvent, commonize?
# Simple approximation for MVP:
# If the window is roughly aspect ratio correct, direct mapping works.
# If not, we need offset.
# Let's get the exact rect used in paintEvent
scale_x = target_rect.width() / self.img_w
scale_y = target_rect.height() / self.img_h
scale = min(scale_x, scale_y)
dw = self.img_w * scale
dh = self.img_h * scale
ox = (target_rect.width() - dw) / 2
oy = (target_rect.height() - dh) / 2
mx = event.pos().x() - ox
my = event.pos().y() - oy
if 0 <= mx < dw and 0 <= my < dh:
col = int(mx / (self.cell_w * scale))
row = int(my / (self.cell_h * scale))
self.set_cursor(col, row)
def draw_mosaic(self, painter, x, y, char_code, color, contiguous): def draw_mosaic(self, painter, x, y, char_code, color, contiguous):
val = char_code & 0x7F val = char_code & 0x7F
bits = 0 bits = 0

View File

@@ -1,7 +1,10 @@
import os
from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, from PyQt6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QListWidget, QListWidgetItem, QComboBox, QLabel, QFileDialog, QMenuBar, QMenu, QMessageBox) QListWidget, QListWidgetItem, QComboBox, QLabel, QLineEdit, QPushButton, QFileDialog, QMenuBar, QMenu, QMessageBox)
# ... (imports remain)
# ... (imports remain)
from PyQt6.QtGui import QAction, QKeyEvent from PyQt6.QtGui import QAction, QKeyEvent
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
@@ -34,6 +37,19 @@ class MainWindow(QMainWindow):
self.page_list.itemClicked.connect(self.on_page_selected) self.page_list.itemClicked.connect(self.on_page_selected)
left_layout.addWidget(self.page_list) left_layout.addWidget(self.page_list)
# Hex Inspector
hex_layout = QVBoxLayout()
hex_label = QLabel("Hex Value:")
self.hex_input = QLineEdit()
self.hex_input.setMaxLength(2)
self.hex_input.setPlaceholderText("00")
self.hex_input.returnPressed.connect(self.on_hex_entered)
hex_layout.addWidget(hex_label)
hex_layout.addWidget(self.hex_input)
left_layout.addLayout(hex_layout)
left_layout.addStretch()
self.layout.addLayout(left_layout) self.layout.addLayout(left_layout)
# Center Area Layout (Top Bar + Canvas) # Center Area Layout (Top Bar + Canvas)
@@ -51,8 +67,30 @@ class MainWindow(QMainWindow):
center_layout.addLayout(top_bar) center_layout.addLayout(top_bar)
# Color Shortcuts
color_layout = QHBoxLayout()
colors = [
("Red", 0x01, "#FF0000"),
("Green", 0x02, "#00FF00"),
("Yellow", 0x03, "#FFFF00"),
("Blue", 0x04, "#0000FF"),
("Magenta", 0x05, "#FF00FF"),
("Cyan", 0x06, "#00FFFF"),
("White", 0x07, "#FFFFFF"),
]
for name, code, hex_color in colors:
btn = QPushButton(name)
btn.setStyleSheet(f"background-color: {hex_color}; font-weight: bold; color: black;")
btn.clicked.connect(lambda checked, c=code: self.insert_char(c))
color_layout.addWidget(btn)
color_layout.addStretch()
center_layout.addLayout(color_layout)
# Canvas # Canvas
self.canvas = TeletextCanvas() self.canvas = TeletextCanvas()
self.canvas.cursorChanged.connect(self.on_cursor_changed)
center_layout.addWidget(self.canvas, 1) # Expand center_layout.addWidget(self.canvas, 1) # Expand
self.layout.addLayout(center_layout, 1) self.layout.addLayout(center_layout, 1)
@@ -165,6 +203,28 @@ class MainWindow(QMainWindow):
self.canvas.set_page(page) self.canvas.set_page(page)
self.canvas.setFocus() self.canvas.setFocus()
def insert_char(self, char_code):
self.canvas.set_byte_at_cursor(char_code)
# Advance cursor
self.canvas.move_cursor(1, 0)
self.canvas.setFocus()
def on_cursor_changed(self, x, y, val):
self.hex_input.setText(f"{val:02X}")
def on_hex_entered(self):
text = self.hex_input.text()
try:
val = int(text, 16)
if 0 <= val <= 255:
# Update canvas input
# We can call handle_input with char, OR set byte directly.
# Direct byte set is safer for non-printable.
self.canvas.set_byte_at_cursor(val)
self.canvas.setFocus() # Return focus to canvas
except ValueError:
pass # Ignore invalid hex
# Input Handling (Editor Logic) # Input Handling (Editor Logic)
def keyPressEvent(self, event: QKeyEvent): def keyPressEvent(self, event: QKeyEvent):
if not self.current_page: if not self.current_page:
@@ -182,6 +242,10 @@ class MainWindow(QMainWindow):
self.canvas.move_cursor(-1, 0) self.canvas.move_cursor(-1, 0)
elif key == Qt.Key.Key_Right: elif key == Qt.Key.Key_Right:
self.canvas.move_cursor(1, 0) self.canvas.move_cursor(1, 0)
elif key == Qt.Key.Key_Return or key == Qt.Key.Key_Enter:
# Move to start of next line
self.canvas.cursor_x = 0
self.canvas.move_cursor(0, 1)
else: else:
# Typing # Typing
# Filter non-printable # Filter non-printable