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
6 changed files with 298 additions and 126 deletions
-117
View File
@@ -1,117 +0,0 @@
name: Build and Release
on:
push:
branches: [ "main", "dev" ]
tags: [ "v*" ]
pull_request:
branches: [ "main", "dev" ]
permissions:
contents: write
jobs:
build-linux:
name: Build Linux
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install System Dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon-x11-0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-xinerama0 \
libxcb-xinput0 \
libxcb-xfixes0 \
libxcb-shape0 \
libgl1 \
libegl1 \
libdbus-1-3 \
libx11-xcb1
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Build Executable
run: |
python build_app.py
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: TeletextEditor-Linux
path: dist/TeletextEditor_Linux
build-windows:
name: Build Windows
runs-on: ubuntu-latest
container:
image: tobix/pywine:3.10
steps:
- name: Install Node.js
run: |
apt-get clean
apt-get update
apt-get install -y --fix-missing nodejs npm
- name: Checkout
uses: actions/checkout@v3
- name: Install Dependencies
run: |
wine python -m pip install --upgrade pip
wine pip install -r requirements.txt
- name: Build Executable
run: |
wine python build_app.py
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: TeletextEditor-Windows
path: dist/TeletextEditor_Windows.exe
release:
name: Create Release
needs: [build-linux, build-windows]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Download Linux Artifact
uses: actions/download-artifact@v3
with:
name: TeletextEditor-Linux
path: dist
- name: Download Windows Artifact
uses: actions/download-artifact@v3
with:
name: TeletextEditor-Windows
path: dist
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
dist/TeletextEditor_Linux
dist/TeletextEditor_Windows.exe
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+49
View File
@@ -0,0 +1,49 @@
name: Build Linux
on: [push, pull_request]
jobs:
build:
name: Build Linux
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install System Dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libxkbcommon-x11-0 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-randr0 \
libxcb-render-util0 \
libxcb-xinerama0 \
libxcb-xinput0 \
libxcb-xfixes0 \
libxcb-shape0 \
libgl1 \
libegl1 \
libdbus-1-3 \
libx11-xcb1
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Build Executable
run: |
pyinstaller --onefile --windowed --name TeletextEditor_Linux --paths src --add-data "app_icon.png:." src/main.py
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: TeletextEditor-Linux
path: dist/TeletextEditor_Linux
+33
View File
@@ -0,0 +1,33 @@
name: Build Windows
on: [push, pull_request]
jobs:
build:
name: Build Windows
runs-on: ubuntu-latest
container:
image: tobix/pywine:3.10
steps:
- name: Install Node.js
run: |
apt-get clean
apt-get update
apt-get install -y --fix-missing nodejs npm
- name: Checkout
uses: actions/checkout@v3
- name: Install Dependencies
run: |
wine python -m pip install --upgrade pip
wine pip install -r requirements.txt
- name: Build Executable
run: |
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
with:
name: TeletextEditor-Windows
path: dist/TeletextEditor_Windows.exe
+4 -6
View File
@@ -11,11 +11,9 @@ def clean_build_dirs():
print(f"Cleaning {d}...") print(f"Cleaning {d}...")
shutil.rmtree(d) shutil.rmtree(d)
for f in os.listdir("."): spec_file = "TeletextEditor_Linux.spec" if platform.system() == "Linux" else "TeletextEditor_Windows.spec"
if f.endswith(".spec"): if os.path.exists(spec_file):
print(f"Removing {f}...") os.remove(spec_file)
os.remove(f)
def build(): def build():
system = platform.system() system = platform.system()
@@ -24,7 +22,7 @@ def build():
# Determine separator for --add-data # Determine separator for --add-data
sep = ";" if system == "Windows" else ":" sep = ";" if system == "Windows" else ":"
# Base PyInstaller command configuration for Linux and Windows builds # Base command
base_cmd = [ base_cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--onefile", "--onefile",
+29 -1
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
@@ -384,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:
+183 -2
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)
@@ -471,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)
@@ -684,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
@@ -778,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)