# Copyright (C) 2024–present Loren Eteval & contributors # # This file is part of Furious. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Provide the application window for text editor window.""" from __future__ import annotations from Furious.Frozenlib import * from Furious.Models import * from Furious.Plugins import configurationFromMapping from Furious.Repository import * from Furious.Qt import * from Furious.Qt import gettext as _ from Furious.Window.IndentDialog import * from PySide6 import QtCore from PySide6.QtGui import * from PySide6.QtWidgets import * import functools __all__ = ['TextEditorWindow'] registerAppSettings('ServerWidgetPointSize') class MBoxQuestionSave(AppQMessageBox): """Represent m box question save.""" def __init__(self, *args, **kwargs): """Initialize the MBoxQuestionSave.""" super().__init__(*args, **kwargs) self.setText(_('The content has been modified. Save changes?')) self.button0 = self.addButton(_('Save'), AppQMessageBox.ButtonRole.AcceptRole) self.button1 = self.addButton( _('Discard'), AppQMessageBox.ButtonRole.DestructiveRole ) self.button2 = self.addButton(_('Cancel'), AppQMessageBox.ButtonRole.RejectRole) self.setDefaultButton(self.button0) class MBoxJSONDecodeError(AppQMessageBox): """Represent m box JSON decode error.""" def __init__(self, *args, **kwargs): """Initialize the MBoxJSONDecodeError.""" super().__init__(*args, **kwargs) self.error = '' def customText(self): """Return the user-facing message text for the m box JSON decode error.""" return ( _('Please check if the configuration is in valid JSON format') + f'\n\n{self.error}' ) def retranslate(self): """Refresh translated text for the m box JSON decode error.""" self.setText(self.customText()) # Ignore informative text, buttons self.moveToCenter() class TextEditorWindow(AppQMainWindow): """Present the text editor window.""" DEFAULT_WINDOW_SIZE = QtCore.QSize(450, int(450 * GOLDEN_RATIO)) def __init__(self, *args, **kwargs): """Initialize the TextEditorWindow.""" super().__init__(*args, **kwargs) self.customWindowTitle = '' self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) self.setFixedSize(self.DEFAULT_WINDOW_SIZE) # Current editing index self.currentIndex = -1 self.modified = False self.modifiedMark = ' *' self.lineColumnLabel = QLabel('1:1 0') self.statusBar().addPermanentWidget(self.lineColumnLabel) def modificationCallback(): """Handle the modification callback.""" self.markAsModified() def cursorChangedCallback(cursor: QTextCursor): """Handle the cursor changed callback.""" self.lineColumnLabel.setText( f'{cursor.blockNumber() + 1}:{cursor.columnNumber() + 1} {cursor.position()}' ) self.jsonEditor = DraculaJSONTextEditor( fontFamily=AppFontName(), pointSizeSettingsName='ServerWidgetPointSize', ) self.jsonEditor.setLineWrapMode(DraculaJSONTextEditor.LineWrapMode.NoWrap) self.jsonEditor.registerModificationChangedCb(modificationCallback) self.jsonEditor.registerCursorPositionChangedCb(cursorChangedCallback) self.fileMenu = AppQMenu( AppQAction( _('Save'), icon=bootstrapIcon('save.svg'), callback=lambda: self.save(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_S, ), ), AppQSeparator(), AppQAction( _('Save As...'), callback=lambda: self.saveAsFile(), ), title=_('File'), parent=self, ) self.editMenu = AppQMenu( AppQAction( _('Undo'), icon=bootstrapIcon('arrow-return-left.svg'), callback=lambda: self.jsonEditor.undo(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Z, ), ), AppQAction( _('Redo'), icon=bootstrapIcon('arrow-return-right.svg'), callback=lambda: self.jsonEditor.redo(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier, QtCore.Qt.Key.Key_Z, ), ), AppQSeparator(), AppQAction( _('Cut'), icon=bootstrapIcon('scissors.svg'), callback=lambda: self.jsonEditor.cut(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_X, ), ), AppQAction( _('Copy'), icon=bootstrapIcon('files.svg'), callback=lambda: self.jsonEditor.copy(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_C, ), ), AppQAction( _('Paste'), callback=lambda: self.jsonEditor.paste(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_V, ), ), AppQSeparator(), AppQAction( _('Select All'), callback=lambda: self.jsonEditor.selectAll(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_A, ), ), AppQSeparator(), AppQAction( _('Indent...'), callback=lambda: self.setIndent(), ), title=_('Edit'), parent=self, ) self.viewMenu = AppQMenu( AppQAction( _('Zoom In'), callback=lambda: self.jsonEditor.zoomIn(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Plus, ), ), AppQAction( _('Zoom Out'), callback=lambda: self.jsonEditor.zoomOut(), shortcut=QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Minus, ), ), title=_('View'), parent=self, ) self.fileButton = AppQMenuPushButton( _('File'), icon=bootstrapIcon('file-earmark.svg'), popupMenu=self.fileMenu, ) self.editButton = AppQMenuPushButton( _('Edit'), icon=bootstrapIcon('pencil-square.svg'), popupMenu=self.editMenu, ) self.viewButton = AppQMenuPushButton( _('View'), icon=bootstrapIcon('eye.svg'), popupMenu=self.viewMenu, ) self.closeWindowButton = AppQPushButton( _('Close Window'), icon=bootstrapIcon('window-x.svg'), ) self.closeWindowButton.clicked.connect(self.close) actionLayout = QHBoxLayout() actionLayout.setContentsMargins(0, 0, 0, 0) actionLayout.setSpacing(8) actionLayout.addWidget(self.fileButton) actionLayout.addWidget(self.editButton) actionLayout.addWidget(self.viewButton) actionLayout.addStretch(1) actionLayout.addWidget(self.closeWindowButton) contentWidget = QWidget() contentWidget.setObjectName('TextEditorWindowContent') contentLayout = QVBoxLayout(contentWidget) contentLayout.setContentsMargins(10, 10, 10, 8) contentLayout.setSpacing(10) contentLayout.addLayout(actionLayout) contentLayout.addWidget(self.jsonEditor, 1) self.setCentralWidget(contentWidget) self.menuBar().hide() # The menus are no longer installed in a QMenuBar, so associate their # reusable actions with the window to preserve WindowShortcut behavior. for menu in (self.fileMenu, self.editMenu, self.viewMenu): for action in menu.actions(): if not action.isSeparator(): self.addAction(action) def markAsModified(self): """Handle mark as modified for the text editor window.""" self.modified = True self.setWindowTitle(self.customWindowTitle + self.modifiedMark) def markAsSaved(self): """Handle mark as saved for the text editor window.""" self.modified = False self.setWindowTitle(self.customWindowTitle) def setPlainText(self, text: str, blockSignals: bool): """Set plain text.""" if blockSignals: with Mixins.QBlockSignalContext(self.jsonEditor): self.jsonEditor.setPlainText(text) else: self.jsonEditor.setPlainText(text) def save(self, showChangesMethod='open') -> bool: """Save the text editor window.""" index = self.currentIndex if index < 0: # Should not reach here. Do nothing self.markAsSaved() return True plain = self.jsonEditor.toPlainText() try: jsonObject = JSONEncoder.decode(plain) except Exception as ex: # Any non-exit exceptions mbox = MBoxJSONDecodeError(icon=AppQMessageBox.Icon.Critical, parent=self) mbox.error = str(ex) mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) mbox.setText(mbox.customText()) # Show the MessageBox asynchronously mbox.open() return False else: old = Storage.UserServers()[index] connection = configurationFromMapping(jsonObject) new = old.replaceConnection(connection) old.deleted = True Storage.UserServers()[index] = new try: AppMainWindow().flushRow(index, new) except Exception: # Any non-exit exceptions pass if index == Storage.UserActivatedItemIndex(): showMBoxNewChangesNextTime(parent=self, method=showChangesMethod) self.markAsSaved() return True def saveAsFile(self): """Save as file.""" filename, selectedFilter = QFileDialog.getSaveFileName( None, _('Save File'), filter=_('Text files (*.json);;All files (*)') ) if filename: try: with open(filename, 'w', encoding='utf-8') as file: file.write(self.jsonEditor.toPlainText()) except Exception as ex: # Any non-exit exceptions mbox = AppQMessageBox(icon=AppQMessageBox.Icon.Critical, parent=self) mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) mbox.setText(_('Invalid server configuration')) mbox.setInformativeText(str(ex)) # Show the MessageBox asynchronously mbox.open() def setIndent(self): """Set indent.""" indentSpinBox = IndentDialog(parent=self) connectWeakly( indentSpinBox.finished, self, '_indentDialogFinished', sender=indentSpinBox, forwardSender=True, ) # Show the MessageBox asynchronously indentSpinBox.open() @QtCore.Slot(object, int) def _indentDialogFinished(self, indentSpinBox, code): """Apply the selected indentation without retaining the dialog.""" if not isinstance(indentSpinBox, IndentDialog): return if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted): return plain = self.jsonEditor.toPlainText() try: jsonObject = JSONEncoder.decode(plain) except Exception as ex: # Any non-exit exceptions mbox = MBoxJSONDecodeError(icon=AppQMessageBox.Icon.Critical, parent=self) mbox.error = str(ex) mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) mbox.setText(mbox.customText()) # Show the MessageBox asynchronously mbox.open() else: text = JSONEncoder.encode(jsonObject, indent=indentSpinBox.value()) self.setPlainText(text, False) def showTabAndSpaces(self): """Show tab and spaces.""" textOption = QTextOption() textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces) self.jsonEditor.document().setDefaultTextOption(textOption) # Reset. Set self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) def hideTabAndSpaces(self): """Hide tab and spaces.""" textOption = QTextOption() self.jsonEditor.document().setDefaultTextOption(textOption) # Reset. Set self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) self.jsonEditor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) def closeEvent(self, event: QtCore.QEvent): """Handle closure of the text editor window.""" if self.modified: # A close event arrives accepted by default. Keep the reusable # editor alive unless the user explicitly saves or discards. event.ignore() mbox = MBoxQuestionSave(icon=AppQMessageBox.Icon.Question, parent=self) mbox.setWindowModality(QtCore.Qt.WindowModality.WindowModal) def handleButtonClicked(button): """Handle button clicked.""" if button == mbox.button0: # Save if self.save(showChangesMethod='exec'): mbox.close() event.accept() else: event.ignore() elif button == mbox.button1: # Discard self.markAsSaved() mbox.close() event.accept() elif button == mbox.button2: # Cancel. Do nothing event.ignore() mbox.buttonClicked.connect(functools.partial(handleButtonClicked)) # Show the MessageBox and wait for the user to close it mbox.exec() if event.isAccepted(): super().closeEvent(event) else: super().closeEvent(event) def retranslate(self): # Do nothing """Refresh translated text for the text editor window.""" pass