Compare commits

...

15 Commits

Author SHA1 Message Date
06107a3d78 feat: Implement dynamic cursor height for double-height characters
All checks were successful
Build Linux / Build Linux (push) Successful in 1m27s
Build Windows / Build Windows (push) Successful in 4m45s
2026-02-06 17:31:39 +01:00
33e3ed2615 Fix NameError: import missing hamming functions in UI
All checks were successful
Build Linux / Build Linux (push) Successful in 1m47s
Build Windows / Build Windows (push) Successful in 5m21s
2026-02-05 13:03:34 +01:00
6ed8a79660 Fix AttributeError: Initialize language_names before use
All checks were successful
Build Linux / Build Linux (push) Successful in 1m28s
Build Windows / Build Windows (push) Successful in 4m51s
2026-01-31 13:38:13 +01:00
56657efa7c Implement 'Set Language' UI to persist language changes to page header
All checks were successful
Build Linux / Build Linux (push) Successful in 1m29s
Build Windows / Build Windows (push) Successful in 4m49s
2026-01-31 13:20:11 +01:00
fa195f2695 Implement session-based language overrides for the viewer
All checks were successful
Build Linux / Build Linux (push) Successful in 1m39s
Build Windows / Build Windows (push) Successful in 4m53s
2026-01-31 13:11:12 +01:00
988178f1c6 Fix header page number display: use hex formatting (P175 instead of P1117)
All checks were successful
Build Linux / Build Linux (push) Successful in 1m33s
Build Windows / Build Windows (push) Successful in 5m18s
2026-01-31 12:19:46 +01:00
71019bf399 Fix Windows build: use --collect-all PyQt6 to ensure platform plugins are bundled
All checks were successful
Build Linux / Build Linux (push) Successful in 1m30s
Build Windows / Build Windows (push) Successful in 4m46s
2026-01-31 11:52:45 +01:00
6a5f223a88 Fix Windows build: add missing PyQt6.sip hidden import
All checks were successful
Build Linux / Build Linux (push) Successful in 1m40s
Build Windows / Build Windows (push) Successful in 2m52s
2026-01-31 11:45:37 +01:00
274a6778b3 Fix Windows build: add missing pkgutil hidden import
All checks were successful
Build Linux / Build Linux (push) Successful in 1m37s
Build Windows / Build Windows (push) Successful in 2m52s
2026-01-31 11:14:18 +01:00
772827082e fix: prevent crash on save without loaded file and add status message
All checks were successful
Build Linux / Build Linux (push) Successful in 1m31s
Build Windows / Build Windows (push) Successful in 2m52s
2026-01-26 13:28:23 +01:00
f8a9ad0065 fix: track Black BG changes in background segments for correct DH backfill
All checks were successful
Build Linux / Build Linux (push) Successful in 1m30s
Build Windows / Build Windows (push) Successful in 2m49s
2026-01-26 13:23:26 +01:00
9726a82851 feat: add TTI export functionality
All checks were successful
Build Linux / Build Linux (push) Successful in 1m28s
Build Windows / Build Windows (push) Successful in 2m53s
2026-01-26 12:42:12 +01:00
233eed1ca7 fix: set desktop file name before QApplication init to resolve QDBusError
All checks were successful
Build Linux / Build Linux (push) Successful in 1m28s
Build Windows / Build Windows (push) Successful in 2m58s
2026-01-23 21:33:05 +01:00
4c3d860dc4 ui: replace file closed dialog with status bar message 2026-01-23 21:31:30 +01:00
670a2d9f8c ui: move graphics and color controls to right sidebar 2026-01-23 21:29:16 +01:00
6 changed files with 283 additions and 39 deletions

View File

@@ -23,7 +23,7 @@ jobs:
- name: Build Executable
run: |
wine pyinstaller --onefile --windowed --name TeletextEditor_Windows.exe --paths src --add-data "app_icon.png;." --icon=app_icon.ico src/main.py
wine pyinstaller --onefile --windowed --hidden-import=pkgutil --hidden-import=PyQt6.sip --collect-all PyQt6 --name TeletextEditor_Windows.exe --paths src --add-data "app_icon.png;." --icon=app_icon.ico src/main.py
- name: Upload Artifact
uses: actions/upload-artifact@v3

View File

@@ -27,6 +27,9 @@ def build():
sys.executable, "-m", "PyInstaller",
"--onefile",
"--windowed",
"--hidden-import=pkgutil",
"--hidden-import=PyQt6.sip",
"--collect-all", "PyQt6",
"--paths", "src",
f"--add-data=app_icon.png{sep}.",
"src/main.py"

View File

@@ -23,10 +23,10 @@ def main():
myappid = 'ddybing.teletexteditor.1.0' # arbitrary string
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
QApplication.setDesktopFileName("no.ddybing.TeletextEditor") # Helps Linux DEs group windows
app = QApplication(sys.argv)
app.setApplicationName("TeletextEditor")
app.setOrganizationName("DanielDybing")
app.setDesktopFileName("no.ddybing.TeletextEditor") # Helps Linux DEs group windows
# Debug Image Formats
supported_formats = [str(fmt, 'utf-8') for fmt in QImageReader.supportedImageFormats()]

View File

@@ -228,3 +228,65 @@ def parse_header(data: bytearray):
language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4)
return page_num, sub_code, language
def save_tti(file_path: str, page: Page):
"""
Saves a single Page object to a TTI file.
"""
with open(file_path, 'w', encoding='latin-1') as f:
# Header Info
# DS - Source? Description?
f.write(f"DS,Teletext Editor Export\n")
f.write(f"SP,{file_path}\n")
# PN - Page Number mpp00
# Typically TTI uses decimal integer for mppss?
# Or mppss as hex digits?
# Standard convention: mppss where m is 1-8, pp is 00-FF, ss is 00-99
# Example: Page 100 -> PN,10000
# Example: Page 1F0 -> PN,1F000
f.write(f"PN,{page.magazine}{page.page_number:02X}00\n")
# SC - Subcode ssss
f.write(f"SC,{page.sub_code:04X}\n")
# PS - Page Status
# 8000 is typical for "Transmission"
f.write(f"PS,8000\n")
# RE - Region (Language)
f.write(f"RE,{page.language}\n")
# Lines
# We need to construct the 40-char string for each row
# Row 0 is special (Header)
# Get all packets for this page
# Map row -> packet
rows = {}
for p in page.packets:
rows[p.row] = p
for r in range(26): # 0 to 25
if r in rows:
packet = rows[r]
# Packet data is 40 bytes (after the 2-byte header we stripped in Packet class? No wait)
# Packet.data in our model IS the 40 bytes of character data (we strip MRAG in __post_init__)
# So we just decode it as latin-1 to get the chars
# However, we must ensure we don't have newlines or nulls breaking the text file structure
# TTI format usually accepts raw bytes 0x00-0xFF if strictly handled, but often expects
# mapped control codes.
# Standard VBIT2 TTI handling treats it as a binary-safe string if mapped to char.
# Row 0 special handling: The first 8 bytes of Row 0 are usually header flags in the packet,
# but visually they are "P100 ".
# TTI usually expects the visual representation for the line content.
# But for transmission, we want the raw bytes.
# OL,r,String
data_str = packet.data.decode('latin-1')
f.write(f"OL,{r},{data_str}\n")
else:
# Empty line? Usually omitted or written as empty
pass

View File

@@ -252,6 +252,9 @@ class TeletextCanvas(QWidget):
held_char = 0x20 # Space
double_height = False
last_visible_idx = -1
bg_segments = [(0, bg)] # Track BG changes: (index, color)
y = row * self.cell_h
data = b''
@@ -263,7 +266,7 @@ class TeletextCanvas(QWidget):
# 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}"
header_prefix = f"P{self.page.magazine}{self.page.page_number:02X}"
# Pad to 8 chars
header_prefix = header_prefix.ljust(8)
@@ -311,12 +314,33 @@ class TeletextCanvas(QWidget):
graphics_mode = True
elif byte_val == 0x1C: # Black BG
bg = COLORS[0]
bg_segments.append((c, bg))
elif byte_val == 0x1D: # New BG
bg = fg
bg_segments.append((c, bg))
elif byte_val == 0x0C: # Normal Height
double_height = False
elif byte_val == 0x0D: # Double Height
double_height = True
# Backfill Height if we are in leading controls
if last_visible_idx == -1:
# Update occlusion mask for 0..c-1
for k in range(c):
next_occlusion_mask[k] = True
# Repaint 0..c-1 with DH
if draw_bg:
# Repaint each cell with its historical BG
for k in range(c):
# Resolve BG for k
cell_bg = bg_segments[0][1]
for idx, color in reversed(bg_segments):
if idx <= k:
cell_bg = color
break
painter.fillRect(k * self.cell_w, y, self.cell_w, self.cell_h * 2, cell_bg)
elif byte_val == 0x19: # Contiguous Graphics
contiguous = True
elif byte_val == 0x1A: # Separated Graphics
@@ -326,6 +350,23 @@ class TeletextCanvas(QWidget):
elif byte_val == 0x1F: # Release Graphics
hold_graphics = False
# Update visibility tracker
# If it's a control code, it's "invisible" (space) UNLESS Held Graphics draws something
visible_content = False
if is_control:
if hold_graphics and graphics_mode:
visible_content = True
else:
# Treat Space (0x20) as "invisible" for backfill purposes
# This allows backfilling height over leading spaces in banners
if byte_val > 0x20:
visible_content = True
if visible_content:
# If this is the first visible char, mark it
if last_visible_idx == -1:
last_visible_idx = c
# Record Double Height for next row
if double_height and not is_occluded:
next_occlusion_mask[c] = True
@@ -398,8 +439,8 @@ class TeletextCanvas(QWidget):
if draw_fg and self.cursor_visible and c == self.cursor_x and row == self.cursor_y:
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference)
# Difference with white creates inversion
# Note: Cursor follows double height? Probably just the active cell.
painter.fillRect(x, y, self.cell_w, self.cell_h, QColor(255, 255, 255))
h_cursor = self.cell_h * 2 if double_height else self.cell_h
painter.fillRect(x, y, self.cell_w, h_cursor, QColor(255, 255, 255))
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
return next_occlusion_mask

View File

@@ -6,9 +6,9 @@ from PyQt6.QtWidgets import (
QCheckBox, QDialog, QGridLayout
)
from PyQt6.QtGui import QAction, QKeyEvent, QPainter, QBrush, QColor
from PyQt6.QtCore import Qt, QRect
from PyQt6.QtCore import Qt, QRect, QTimer
from .io import load_t42, save_t42
from .io import load_t42, save_t42, save_tti, decode_hamming_8_4, encode_hamming_8_4
from .renderer import TeletextCanvas, create_blank_packet
import copy
import sys
@@ -121,10 +121,14 @@ class MainWindow(QMainWindow):
self.service = TeletextService()
self.current_page: Page = None
self.current_file_path = None
self.clipboard = [] # List of (row, data_bytes)
self.undo_stack = []
self.redo_stack = []
self.is_modified = False
self.language_overrides = {} # Session-based viewer overrides: (mag, pnum) -> lang_idx
self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
# UI Components
self.central_widget = QWidget()
@@ -183,13 +187,30 @@ class MainWindow(QMainWindow):
center_layout.addLayout(top_bar)
# Color Shortcuts
color_layout = QHBoxLayout()
# Middle Layout (Canvas + Right Sidebar)
middle_layout = QHBoxLayout()
center_layout.addLayout(middle_layout, 1)
# Canvas
self.canvas = TeletextCanvas()
self.canvas.cursorChanged.connect(self.on_cursor_changed)
middle_layout.addWidget(self.canvas, 1) # Expand
# Right Sidebar
right_sidebar = QWidget()
right_sidebar.setFixedWidth(160)
right_layout = QVBoxLayout(right_sidebar)
right_layout.setContentsMargins(5, 0, 0, 0)
middle_layout.addWidget(right_sidebar)
# Color Shortcuts
# Graphics Mode Toggle
self.chk_graphics = QCheckBox("Graphics")
self.chk_graphics.setToolTip("If checked, inserts Graphics Color codes (e.g. Red Graphics 0x11) instead of Alpha (0x01)")
color_layout.addWidget(self.chk_graphics)
right_layout.addWidget(self.chk_graphics)
colors_grid = QGridLayout()
colors_grid.setSpacing(5)
colors = [
("Black", 0x00, "#000000"),
@@ -202,9 +223,10 @@ class MainWindow(QMainWindow):
("White", 0x07, "#FFFFFF"),
]
row, col = 0, 0
for name, code, hex_color in colors:
btn = QPushButton(name)
btn.setFixedSize(60, 30) # Fixed size for uniformity
btn.setFixedSize(60, 30)
# Common style
style = f"background-color: {hex_color}; font-weight: bold; border: 1px solid #555; border-radius: 3px;"
@@ -220,20 +242,25 @@ class MainWindow(QMainWindow):
# Use separate method to handle graphics check
btn.clicked.connect(lambda checked, c=code: self.insert_color(c))
color_layout.addWidget(btn)
colors_grid.addWidget(btn, row, col)
col += 1
if col > 1:
col = 0
row += 1
right_layout.addLayout(colors_grid)
# Mosaics Button
btn_mosaic = QPushButton("Mosaics...")
btn_mosaic.clicked.connect(self.open_mosaic_dialog)
color_layout.addWidget(btn_mosaic)
right_layout.addWidget(btn_mosaic)
color_layout.addStretch()
center_layout.addLayout(color_layout)
right_layout.addSpacing(10)
# Background Controls
bg_layout = QHBoxLayout()
bg_label = QLabel("Background:")
bg_layout.addWidget(bg_label)
right_layout.addWidget(bg_label)
# New Background (0x1D)
btn_new_bg = QPushButton("New BG")
@@ -241,7 +268,7 @@ class MainWindow(QMainWindow):
btn_new_bg.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
btn_new_bg.setToolTip("Sets the current foreground color as the new background color (0x1D)")
btn_new_bg.clicked.connect(lambda: self.insert_char(0x1D))
bg_layout.addWidget(btn_new_bg)
right_layout.addWidget(btn_new_bg)
# Black Background (0x1C)
btn_black_bg = QPushButton("Black BG")
@@ -249,15 +276,13 @@ class MainWindow(QMainWindow):
btn_black_bg.setStyleSheet("background-color: black; color: white; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
btn_black_bg.setToolTip("Resets the background color to Black (0x1C)")
btn_black_bg.clicked.connect(lambda: self.insert_char(0x1C))
bg_layout.addWidget(btn_black_bg)
right_layout.addWidget(btn_black_bg)
bg_layout.addStretch()
center_layout.addLayout(bg_layout)
right_layout.addSpacing(10)
# Graphics Control
gfx_ctrl_layout = QHBoxLayout()
gfx_ctrl_label = QLabel("Graphics Control:")
gfx_ctrl_layout.addWidget(gfx_ctrl_label)
right_layout.addWidget(gfx_ctrl_label)
# Hold Graphics (0x1E)
btn_hold = QPushButton("Hold Gfx")
@@ -265,7 +290,7 @@ class MainWindow(QMainWindow):
btn_hold.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
btn_hold.setToolTip("Hold Graphics (0x1E): Displays the last graphic char in place of subsequent control codes.")
btn_hold.clicked.connect(lambda: self.insert_char(0x1E))
gfx_ctrl_layout.addWidget(btn_hold)
right_layout.addWidget(btn_hold)
# Release Graphics (0x1F)
btn_release = QPushButton("Release Gfx")
@@ -273,15 +298,29 @@ class MainWindow(QMainWindow):
btn_release.setStyleSheet("background-color: #CCCCCC; color: black; font-weight: bold; border: 1px solid #555; border-radius: 3px;")
btn_release.setToolTip("Release Graphics (0x1F): Ends the 'Hold Graphics' effect.")
btn_release.clicked.connect(lambda: self.insert_char(0x1F))
gfx_ctrl_layout.addWidget(btn_release)
right_layout.addWidget(btn_release)
gfx_ctrl_layout.addStretch()
center_layout.addLayout(gfx_ctrl_layout)
right_layout.addSpacing(10)
# Canvas
self.canvas = TeletextCanvas()
self.canvas.cursorChanged.connect(self.on_cursor_changed)
center_layout.addWidget(self.canvas, 1) # Expand
# Page Language Setting
lang_group_label = QLabel("Page Language:")
right_layout.addWidget(lang_group_label)
lang_layout = QHBoxLayout()
self.lang_combo = QComboBox()
self.lang_combo.addItems(self.language_names)
self.lang_combo.setToolTip("Select the National Option Character Set for this page.")
btn_set_lang = QPushButton("Set")
btn_set_lang.setFixedWidth(40)
btn_set_lang.clicked.connect(self.apply_language_change)
btn_set_lang.setToolTip("Apply this language setting to the page header.")
lang_layout.addWidget(self.lang_combo)
lang_layout.addWidget(btn_set_lang)
right_layout.addLayout(lang_layout)
right_layout.addStretch()
self.layout.addLayout(center_layout, 1)
@@ -304,8 +343,6 @@ class MainWindow(QMainWindow):
self.language_label = QLabel("Lang: English")
self.status_bar.addPermanentWidget(self.language_label)
self.language_names = ["English", "German", "Swedish/Finnish", "Italian", "French", "Portuguese/Spanish", "Turkish", "Romania"]
# Menus
self.create_menus()
@@ -313,6 +350,7 @@ class MainWindow(QMainWindow):
idx = self.canvas.subset_idx
if 0 <= idx < len(self.language_names):
self.language_label.setText(f"Lang: {self.language_names[idx]}")
self.lang_combo.setCurrentIndex(idx)
else:
self.language_label.setText(f"Lang: Unknown ({idx})")
@@ -371,6 +409,10 @@ class MainWindow(QMainWindow):
save_as_action.triggered.connect(self.save_as_file)
file_menu.addAction(save_as_action)
export_tti_action = QAction("Export to TTI...", self)
export_tti_action.triggered.connect(self.export_tti)
file_menu.addAction(export_tti_action)
close_action = QAction("Close File", self)
close_action.triggered.connect(self.close_file)
file_menu.addAction(close_action)
@@ -422,6 +464,79 @@ class MainWindow(QMainWindow):
if action:
idx = action.data()
self.canvas.subset_idx = idx
# Store session override for the current page (magazine + page number)
if self.current_page:
key = (self.current_page.magazine, self.current_page.page_number)
self.language_overrides[key] = idx
self.canvas.redraw()
self.canvas.update()
self.update_language_label()
def apply_language_change(self):
if not self.current_page:
return
idx = self.lang_combo.currentIndex()
if idx < 0: return
# Update the model
self.current_page.language = idx
# Also update the session override/viewer to match
self.canvas.subset_idx = idx
key = (self.current_page.magazine, self.current_page.page_number)
self.language_overrides[key] = idx
# Patch Row 0 packet data to persist language selection to file
# Language bits are in Byte 8 (Control Bits 2): C12, C13, C14
# We need to preserve C11 (bit 3 of encoded 4-bit val) which is "Inhibit Display" usually 0
# Find Row 0 packet
header_packet = None
for p in self.current_page.packets:
if p.row == 0:
header_packet = p
break
if header_packet and len(header_packet.data) > 8:
try:
old_val = decode_hamming_8_4(header_packet.data[8])
# Encoded nibble structure: D1(b0), D2(b1), D3(b2), D4(b3)
# D1 maps to C12
# D2 maps to C13
# D3 maps to C14
# D4 maps to C11
# io.py logic for reading:
# language = ((c_bits_2 & 1) << 1) | ((c_bits_2 & 2) >> 1) | (c_bits_2 & 4)
# i.e. Lang Bit 0 comes from D2, Lang Bit 1 comes from D1, Lang Bit 2 comes from D3
# So for writing:
# 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
d3 = l2
d4 = (old_val >> 3) & 1 # Preserve C11
new_val = d1 | (d2 << 1) | (d3 << 2) | (d4 << 3)
header_packet.data[8] = encode_hamming_8_4(new_val)
self.set_modified(True)
self.status_label.setText(f"Language set to {self.language_names[idx]} (saved to header).")
except Exception as e:
self.status_label.setText(f"Error setting language: {e}")
else:
self.status_label.setText("No header packet found to update.")
self.canvas.redraw()
self.canvas.update()
self.update_language_label()
@@ -460,9 +575,10 @@ class MainWindow(QMainWindow):
self.undo_stack.clear()
self.redo_stack.clear()
self.language_overrides.clear()
QMessageBox.information(self, "Closed", "File closed.")
self.status_label.setText("Ready")
self.status_label.setText("File closed.")
QTimer.singleShot(3000, lambda: self.status_label.setText("Ready"))
self.set_modified(False)
@@ -501,9 +617,9 @@ class MainWindow(QMainWindow):
def save_file(self) -> bool:
if not self.current_file_path:
fname, _ = QFileDialog.getSaveFileName(self, "Save T42", "", "Teletext Files (*.t42)")
if not fname: return False
self.current_file_path = fname
# User requested status message instead of Save As behavior for empty state
self.status_label.setText("No file loaded to save. Please use 'Save As...' or 'Open' first.")
return False
try:
self.progress_bar.setVisible(True)
@@ -529,6 +645,20 @@ class MainWindow(QMainWindow):
self.status_label.setText("Error saving file")
return False
def export_tti(self):
if not self.current_page:
QMessageBox.warning(self, "Export TTI", "No page selected to export.")
return
fname, _ = QFileDialog.getSaveFileName(self, "Export to TTI", "", "Teletext Text Image (*.tti)")
if not fname: return
try:
save_tti(fname, self.current_page)
QMessageBox.information(self, "Export Successful", f"Page exported to {os.path.basename(fname)}")
except Exception as e:
QMessageBox.critical(self, "Export Failed", f"Failed to export TTI: {e}")
def copy_page_content(self):
if not self.current_page:
return
@@ -736,6 +866,14 @@ class MainWindow(QMainWindow):
if isinstance(page, Page):
self.current_page = page
self.canvas.set_page(page)
# Apply session language override if it exists for this page
key = (page.magazine, page.page_number)
if key in self.language_overrides:
self.canvas.subset_idx = self.language_overrides[key]
self.canvas.redraw()
self.canvas.update()
self.update_language_label()
self.canvas.setFocus()