1 Commits

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
2 changed files with 212 additions and 3 deletions
+29 -1
View File
@@ -86,6 +86,25 @@ class TeletextCanvas(QWidget):
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
# 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):
if not self.page: return 0
@@ -384,7 +403,16 @@ class TeletextCanvas(QWidget):
if draw_bg:
# If double height, draw taller background
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
if draw_fg:
+183 -2
View File
@@ -3,13 +3,14 @@ from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QListWidget, QListWidgetItem, QComboBox, QLabel, QLineEdit, QPushButton,
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.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 .renderer import TeletextCanvas, create_blank_packet
from .charsets import get_char
import copy
import sys
import os
@@ -113,6 +114,61 @@ class MosaicDialog(QDialog):
close_btn.clicked.connect(self.accept)
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):
def __init__(self):
super().__init__()
@@ -130,6 +186,11 @@ class MainWindow(QMainWindow):
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
self.central_widget = QWidget()
self.setCentralWidget(self.central_widget)
@@ -471,6 +532,13 @@ class MainWindow(QMainWindow):
paste_action.triggered.connect(self.paste_page_content)
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.triggered.connect(self.delete_page)
edit_menu.addAction(delete_page_action)
@@ -684,6 +752,118 @@ class MainWindow(QMainWindow):
except Exception as 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):
if not self.current_page:
return
@@ -778,6 +958,7 @@ class MainWindow(QMainWindow):
def push_undo_state(self):
if not self.current_page: return
self.canvas.clear_highlight()
# Push deep copy of current page
snapshot = copy.deepcopy(self.current_page)
self.undo_stack.append(snapshot)