# tabs/sandbox_tab.py import subprocess import tempfile import os import threading import asyncio from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, QHeaderView, QMessageBox, QFormLayout, QDialog, QTextEdit, QRadioButton, QButtonGroup, QSpinBox ) from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt import edge_tts import database from tts_utils import parse_text_for_edgetts, get_configured_tts_rate class TextEditorDialog(QDialog): """A pop-up modal containing a large text field workspace for copy-pasting extra text blocks or drafting HTML content.""" def __init__(self, title, initial_text="", parent=None): super().__init__(parent) self.setWindowTitle(title) self.resize(650, 450) layout = QVBoxLayout(self) info_label = QLabel("Edit text or raw HTML below (supports tables, lists, and inline styles):") info_label.setStyleSheet("color: #7F8C8D; font-size: 12px;") layout.addWidget(info_label) self.editor = QTextEdit() self.editor.setPlainText(initial_text) # Monospaced font for clean HTML readability self.editor.setStyleSheet("font-family: monospace; font-size: 13px; background-color: #2C3E50; color: #ECF0F1; padding: 8px;") layout.addWidget(self.editor) btn_layout = QHBoxLayout() self.btn_save = QPushButton("Save / Apply") self.btn_save.setStyleSheet("background-color: #27AE60; color: white; font-weight: bold; padding: 6px 14px;") self.btn_save.clicked.connect(self.accept) self.btn_cancel = QPushButton("Cancel") self.btn_cancel.setStyleSheet("padding: 6px 14px;") self.btn_cancel.clicked.connect(self.reject) btn_layout.addStretch() btn_layout.addWidget(self.btn_cancel) btn_layout.addWidget(self.btn_save) layout.addLayout(btn_layout) def get_text(self): return self.editor.toPlainText().strip() class SandboxTab(QWidget): data_mutated = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) self.selected_translation_id = None # Local item memory caching for instant search lookups self.cached_records = [] self.current_notes_content = "" self.current_anki_notes_content = "" main_layout = QVBoxLayout(self) main_layout.setContentsMargins(30, 20, 30, 20) main_layout.setSpacing(15) # --- SECTION 1: FORM INPUT CRADLE --- form_container = QWidget() form_layout = QFormLayout(form_container) form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) form_layout.setSpacing(10) # --- English Input Row --- english_widget = QWidget() english_layout = QHBoxLayout(english_widget) english_layout.setContentsMargins(0, 0, 0, 0) english_layout.setSpacing(8) self.txt_english = QLineEdit() self.txt_english.setPlaceholderText("Enter English phrase (filters grid real-time)...") self.txt_english.setStyleSheet("padding: 6px; font-size: 14px;") self.txt_english.textChanged.connect(self.apply_live_grid_filter) self.btn_edit_english = QPushButton("โœ๏ธ Edit English Block") self.btn_edit_english.setStyleSheet("padding: 6px 12px; font-weight: bold; background-color: #34495E; color: white; border-radius: 4px;") self.btn_edit_english.clicked.connect(self.open_english_editor) english_layout.addWidget(self.txt_english, stretch=1) english_layout.addWidget(self.btn_edit_english, stretch=0) # --- Spanish Input Row --- spanish_widget = QWidget() spanish_layout = QHBoxLayout(spanish_widget) spanish_layout.setContentsMargins(0, 0, 0, 0) spanish_layout.setSpacing(8) self.txt_spanish = QLineEdit() self.txt_spanish.setPlaceholderText("Enter Spanish phrase (filters grid real-time)...") self.txt_spanish.setStyleSheet("padding: 6px; font-size: 14px;") self.txt_spanish.textChanged.connect(self.apply_live_grid_filter) self.btn_edit_spanish = QPushButton("โœ๏ธ Edit Spanish Block") self.btn_edit_spanish.setStyleSheet("padding: 6px 12px; font-weight: bold; background-color: #34495E; color: white; border-radius: 4px;") self.btn_edit_spanish.clicked.connect(self.open_spanish_editor) spanish_layout.addWidget(self.txt_spanish, stretch=1) spanish_layout.addWidget(self.btn_edit_spanish, stretch=0) self.txt_context = QLineEdit() self.txt_context.setPlaceholderText("Context e.g., Camino 2027 (filters grid real-time)...") self.txt_context.setStyleSheet("padding: 6px; font-size: 14px;") self.txt_context.textChanged.connect(self.apply_live_grid_filter) self.txt_tags = QLineEdit() self.txt_tags.setPlaceholderText("Comma separated tags (filters grid real-time)...") self.txt_tags.setStyleSheet("padding: 6px; font-size: 14px;") self.txt_tags.textChanged.connect(self.apply_live_grid_filter) # --- Sort Order Field (Added after Tags) --- self.spn_sort_order = QSpinBox() self.spn_sort_order.setRange(0, 999999) self.spn_sort_order.setValue(0) self.spn_sort_order.setStyleSheet("padding: 6px; font-size: 14px;") # Modal Editor Row Buttons for Extended Notes editor_buttons_layout = QHBoxLayout() self.btn_edit_notes = QPushButton("๐Ÿ“ Edit Notes Block") self.btn_edit_notes.clicked.connect(self.open_notes_editor) self.btn_edit_anki_notes = QPushButton("๐Ÿ—‚๏ธ Edit Anki Notes Block") self.btn_edit_anki_notes.clicked.connect(self.open_anki_notes_editor) editor_buttons_layout.addWidget(self.btn_edit_notes) editor_buttons_layout.addWidget(self.btn_edit_anki_notes) # Add container widgets to QFormLayout rows form_layout.addRow(QLabel("English Phrase:"), english_widget) form_layout.addRow(QLabel("Spanish Translation:"), spanish_widget) form_layout.addRow(QLabel("Source Context:"), self.txt_context) form_layout.addRow(QLabel("Tags:"), self.txt_tags) form_layout.addRow(QLabel("Sort Order:"), self.spn_sort_order) form_layout.addRow(QLabel("Extended Data Fields:"), editor_buttons_layout) main_layout.addWidget(form_container) # --- INLINE AUDIO CONTROL + GENDER SELECTION PANEL --- audio_panel = QHBoxLayout() audio_panel.setSpacing(15) self.btn_test_en = QPushButton("๐Ÿ”Š Test English Voice") self.btn_test_en.clicked.connect(self.audition_english) self.btn_test_es = QPushButton("๐Ÿ”Š Test Spanish Voice") self.btn_test_es.clicked.connect(self.audition_spanish) gender_label = QLabel("Speaker Gender:") self.rb_female = QRadioButton("Female") self.rb_male = QRadioButton("Male") self.rb_female.setChecked(True) self.gender_group = QButtonGroup(self) self.gender_group.addButton(self.rb_female) self.gender_group.addButton(self.rb_male) audio_panel.addWidget(self.btn_test_en) audio_panel.addWidget(self.btn_test_es) audio_panel.addSpacing(20) audio_panel.addWidget(gender_label) audio_panel.addWidget(self.rb_female) audio_panel.addWidget(self.rb_male) audio_panel.addStretch() main_layout.addLayout(audio_panel) # --- ACTION CONTROL BAR --- actions_layout = QHBoxLayout() self.btn_save_record = QPushButton("๐Ÿ“ฅ Save Transaction") self.btn_save_record.clicked.connect(self.commit_form_entry) self.btn_save_record.setStyleSheet("background-color: #27AE60; color: white; font-weight: bold; padding: 8px 16px;") self.btn_clear_form = QPushButton("๐Ÿงน Reset Fields") self.btn_clear_form.clicked.connect(self.clear_form_fields) self.btn_delete_record = QPushButton("๐Ÿ—‘๏ธ Delete Selected") self.btn_delete_record.clicked.connect(self.remove_target_record) self.btn_delete_record.setStyleSheet("background-color: #C0392B; color: white;") actions_layout.addWidget(self.btn_save_record) actions_layout.addWidget(self.btn_clear_form) actions_layout.addWidget(self.btn_delete_record) actions_layout.addStretch() main_layout.addLayout(actions_layout) # --- VIEWPORT GRID TABLE --- self.table = QTableWidget() self.table.setColumnCount(7) self.table.setHorizontalHeaderLabels(["ID", "English", "Spanish", "Context", "Tags", "Sort Order", "Gender"]) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.cellClicked.connect(self.populate_form_from_grid) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) main_layout.addWidget(self.table) self.reload_table_display() @pyqtSlot() def open_english_editor(self): dlg = TextEditorDialog("Edit English Phrase / HTML Block", self.txt_english.text(), self) if dlg.exec(): self.txt_english.setText(dlg.get_text()) @pyqtSlot() def open_spanish_editor(self): dlg = TextEditorDialog("Edit Spanish Translation / HTML Block", self.txt_spanish.text(), self) if dlg.exec(): self.txt_spanish.setText(dlg.get_text()) @pyqtSlot() def open_notes_editor(self): dlg = TextEditorDialog("Edit Grammar / Core Notes Block", self.current_notes_content, self) if dlg.exec(): self.current_notes_content = dlg.get_text() @pyqtSlot() def open_anki_notes_editor(self): dlg = TextEditorDialog("Edit Anki Specialized Meta Field", self.current_anki_notes_content, self) if dlg.exec(): self.current_anki_notes_content = dlg.get_text() def clear_form_fields(self): self.txt_english.blockSignals(True) self.txt_spanish.blockSignals(True) self.txt_context.blockSignals(True) self.txt_tags.blockSignals(True) self.selected_translation_id = None self.txt_english.clear() self.txt_spanish.clear() self.txt_context.clear() self.txt_tags.clear() self.spn_sort_order.setValue(0) self.current_notes_content = "" self.current_anki_notes_content = "" self.rb_female.setChecked(True) self.txt_english.blockSignals(False) self.txt_spanish.blockSignals(False) self.txt_context.blockSignals(False) self.txt_tags.blockSignals(False) self.apply_live_grid_filter() @pyqtSlot() def commit_form_entry(self): en_t = self.txt_english.text().strip() es_t = self.txt_spanish.text().strip() ctx_t = self.txt_context.text().strip() tag_t = self.txt_tags.text().strip() sort_val = self.spn_sort_order.value() gender_t = "Male" if self.rb_male.isChecked() else "Female" if not en_t or not es_t: QMessageBox.warning(self, "Validation Alert", "English and Spanish phrase properties cannot remain blank.") return if self.selected_translation_id is None: database.insert_translation_record( es_t, en_t, ctx_t, tag_t, self.current_notes_content, gender_t, self.current_anki_notes_content, sort_val ) else: database.update_translation_record( self.selected_translation_id, es_t, en_t, ctx_t, tag_t, self.current_notes_content, gender_t, self.current_anki_notes_content, sort_val ) self.clear_form_fields() self.reload_table_display() self.data_mutated.emit() @pyqtSlot() def remove_target_record(self): if self.selected_translation_id is None: QMessageBox.warning(self, "Selection Missing", "Please select a row from the grid viewport before attempting deletion.") return confirm = QMessageBox.question( self, "Confirm Deletion", "Are you sure you want to permanently delete this translation record?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if confirm == QMessageBox.StandardButton.Yes: database.delete_translation_record(self.selected_translation_id) self.clear_form_fields() self.reload_table_display() self.data_mutated.emit() def populate_form_from_grid(self, row, col): self.selected_translation_id = int(self.table.item(row, 0).text()) record = database.get_translation_by_id(self.selected_translation_id) if record: self.txt_english.blockSignals(True) self.txt_spanish.blockSignals(True) self.txt_context.blockSignals(True) self.txt_tags.blockSignals(True) self.txt_english.setText(record["en_text"]) self.txt_spanish.setText(record["es_text"]) self.txt_context.setText(record.get("source_context", "")) self.txt_tags.setText(record.get("tags", "")) self.spn_sort_order.setValue(record.get("sort_order", 0)) self.current_notes_content = record.get("notes", "") self.current_anki_notes_content = record.get("anki_notes", "") if record.get("gender") == "Male": self.rb_male.setChecked(True) else: self.rb_female.setChecked(True) self.txt_english.blockSignals(False) self.txt_spanish.blockSignals(False) self.txt_context.blockSignals(False) self.txt_tags.blockSignals(False) def reload_table_display(self): self.cached_records = database.get_all_translations_explicit() self.apply_live_grid_filter() @pyqtSlot() def apply_live_grid_filter(self): filter_en = self.txt_english.text().lower().strip() filter_es = self.txt_spanish.text().lower().strip() filter_ctx = self.txt_context.text().lower().strip() filter_tags = self.txt_tags.text().lower().strip() self.table.setRowCount(0) visible_row_idx = 0 for r in self.cached_records: match_en = filter_en in (r.get("en_text") or "").lower() match_es = filter_es in (r.get("es_text") or "").lower() match_ctx = filter_ctx in (r.get("source_context") or "").lower() match_tags = filter_tags in (r.get("tags") or "").lower() if match_en and match_es and match_ctx and match_tags: self.table.insertRow(visible_row_idx) self.table.setItem(visible_row_idx, 0, QTableWidgetItem(str(r["translation_id"]))) self.table.setItem(visible_row_idx, 1, QTableWidgetItem(r["en_text"])) self.table.setItem(visible_row_idx, 2, QTableWidgetItem(r["es_text"])) self.table.setItem(visible_row_idx, 3, QTableWidgetItem(r.get("source_context", ""))) self.table.setItem(visible_row_idx, 4, QTableWidgetItem(r.get("tags", ""))) self.table.setItem(visible_row_idx, 5, QTableWidgetItem(str(r.get("sort_order", 0)))) self.table.setItem(visible_row_idx, 6, QTableWidgetItem(r.get("gender", "Female"))) visible_row_idx += 1 def _async_edge_speech_worker(self, text, voice, rate_modifier): """Background thread worker to download neural audio and play it without freezing the UI.""" async def stream_audio(): temp_file = os.path.join(tempfile.gettempdir(), "sandbox_audition.mp3") try: communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate_modifier) await communicate.save(temp_file) if os.path.exists(temp_file): subprocess.run(["afplay", temp_file]) except Exception as e: print(f"Sandbox Audition Error: {e}") asyncio.run(stream_audio()) @pyqtSlot() def audition_english(self): raw_txt = self.txt_english.text().strip() spoken_text = parse_text_for_edgetts(raw_txt) if not spoken_text.strip(): return settings = database.load_all_settings() or {} rate_string = get_configured_tts_rate(settings) threading.Thread( target=self._async_edge_speech_worker, args=(spoken_text, "en-US-EmmaNeural", rate_string), daemon=True ).start() @pyqtSlot() def audition_spanish(self): raw_txt = self.txt_spanish.text().strip() spoken_text = parse_text_for_edgetts(raw_txt) if not spoken_text.strip(): return voice = "es-ES-AlvaroNeural" if self.rb_male.isChecked() else "es-ES-ElviraNeural" settings = database.load_all_settings() or {} rate_string = get_configured_tts_rate(settings) threading.Thread( target=self._async_edge_speech_worker, args=(spoken_text, voice, rate_string), daemon=True ).start()