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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user