# 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 ) from PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt import edge_tts import database class TextEditorDialog(QDialog): """A pop-up modal containing a large text field workspace for copy-pasting extra text blocks.""" def __init__(self, title, initial_text="", parent=None): super().__init__(parent) self.setWindowTitle(title) self.resize(500, 350) layout = QVBoxLayout(self) self.editor = QTextEdit() self.editor.setPlainText(initial_text) self.editor.setStyleSheet("font-family: Arial; font-size: 14px; padding: 5px;") layout.addWidget(self.editor) btn_layout = QHBoxLayout() self.btn_save = QPushButton("Save / Apply") self.btn_save.clicked.connect(self.accept) self.btn_cancel = QPushButton("Cancel") 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) 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.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.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) # Modal Editor Row Buttons 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) form_layout.addRow(QLabel("English Phrase:"), self.txt_english) form_layout.addRow(QLabel("Spanish Translation:"), self.txt_spanish) form_layout.addRow(QLabel("Source Context:"), self.txt_context) form_layout.addRow(QLabel("Tags:"), self.txt_tags) 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(6) self.table.setHorizontalHeaderLabels(["ID", "English", "Spanish", "Context", "Tags", "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) main_layout.addWidget(self.table) self.reload_table_display() @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() print(f"[DEBUG DIALOG CLOSE] Notes Block updated in memory: {self.current_notes_content}") @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() print(f"[DEBUG DIALOG CLOSE] Anki Notes Block updated in memory: {self.current_anki_notes_content}") 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.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() 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 print(f"\n[DEBUG DATABASE WRITE]") print(f" ID Selected: {self.selected_translation_id}") print(f" English: {en_t}") print(f" Spanish: {es_t}") print(f" Context: {ctx_t}") print(f" Tags: {tag_t}") print(f" Notes: {self.current_notes_content}") print(f" Anki Notes: {self.current_anki_notes_content}") print(f" Gender: {gender_t}\n") # Fixed argument sequence to match Beekeeper schema (gender position 7, anki_notes position 8) 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) 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) 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.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(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): txt = self.txt_english.text().strip() if not txt: return settings = database.load_all_settings() or {} # Fixed: Changed lookup from "playback_speed_multiplier" to "tts_playback_speed" config_speed = settings.get("tts_playback_speed", "1.0") try: pct = int((float(config_speed) - 1.0) * 100) rate_string = f"{'+' if pct >= 0 else ''}{pct}%" except Exception: rate_string = "+0%" threading.Thread( target=self._async_edge_speech_worker, args=(txt, "en-US-EmmaNeural", rate_string), daemon=True ).start() @pyqtSlot() def audition_spanish(self): txt = self.txt_spanish.text().strip() if not txt: return # Dynamically switch between neural voices depending on the active form state radio selection voice = "es-ES-AlvaroNeural" if self.rb_male.isChecked() else "es-ES-ElviraNeural" settings = database.load_all_settings() or {} config_speed = settings.get("tts_playback_speed", "1.0") try: pct = int((float(config_speed) - 1.0) * 100) print(f"[DEBUG Sandbox Speech] config_speed (Raw String): {config_speed}") print(f"[DEBUG Sandbox Speech] Calculated pct (Integer): {pct}") rate_string = f"{'+' if pct >= 0 else ''}{pct}%" except Exception: print(f"[DEBUG Sandbox Speech] Failed to parse speed calculation") rate_string = "+0%" threading.Thread( target=self._async_edge_speech_worker, args=(txt, voice, rate_string), daemon=True ).start()