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 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)