feat: Implement global search functionality (Ctrl+F) with text and hex modes
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user