# Copyright (C) 2023 Loren Eteval # # 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 . from Furious.Core.TorRelay import TorRelay from Furious.Action.Import import ImportLinkAction, ImportJSONAction from Furious.Action.Export import ExportLinkAction, ExportJSONAction, ExportQRCodeAction from Furious.Core.Intellisense import Intellisense from Furious.Core.Configuration import Configuration from Furious.Gui.Action import Action, Seperator from Furious.Widget.Widget import ( HeaderView, MainWindow, Menu, MessageBox, PushButton, StyledItemDelegate, TableWidget, TabWidget, ZoomablePlainTextEdit, ) from Furious.Widget.IndentSpinBox import IndentSpinBox from Furious.Utility.Constants import ( APP, APPLICATION_NAME, APPLICATION_VERSION, APPLICATION_ABOUT_PAGE, APPLICATION_REPO_OWNER_NAME, APPLICATION_REPO_NAME, PLATFORM, GOLDEN_RATIO, Color, ) from Furious.Utility.Utility import ( Base64Encoder, StateContext, ServerStorage, Switch, SupportConnectedCallback, bootstrapIcon, swapListItem, moveToCenter, ) from Furious.Utility.Translator import Translatable, gettext as _ from Furious.Utility.Theme import DraculaTheme from PySide6 import QtCore from PySide6.QtGui import QDesktopServices, QFont, QTextOption from PySide6.QtWidgets import ( QAbstractItemView, QDialog, QFileDialog, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit, QSplitter, QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, ) from PySide6.QtNetwork import ( QNetworkAccessManager, QNetworkReply, QNetworkRequest, QNetworkProxy, ) import os import copy import ujson import logging import operator import functools logger = logging.getLogger(__name__) class NewBlankServerAction(Action): def __init__(self, **kwargs): super().__init__(_('New...'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_N, ) ) def triggeredCallback(self, checked): self.parent().importServer(_('Untitled'), '', syncStorage=True) class ImportFileAction(Action): def __init__(self, **kwargs): super().__init__( _('Import From File...'), icon=bootstrapIcon('folder2-open.svg'), **kwargs, ) self.openErrorBox = MessageBox( icon=MessageBox.Icon.Critical, parent=self.parent() ) def triggeredCallback(self, checked): filename, selectedFilter = QFileDialog.getOpenFileName( self.parent(), _('Import File'), filter=_('Text files (*.json);;All files (*)'), ) if filename: try: with open(filename, 'r', encoding='utf-8') as file: plainText = file.read() except Exception as ex: # Any non-exit exceptions self.openErrorBox.setWindowTitle(_('Error opening file')) self.openErrorBox.setText(_('Invalid configuration file.')) self.openErrorBox.setInformativeText(str(ex)) # Show the MessageBox and wait for user to close it self.openErrorBox.exec() else: self.parent().importServer( os.path.basename(filename), plainText, syncStorage=True ) class ImportLinkActionWithHotKey(ImportLinkAction): def __init__(self, **kwargs): # Cloned. With hot key super().__init__(**kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier, QtCore.Qt.Key.Key_V, ) ) class ImportJSONActionWithHotKey(ImportJSONAction): def __init__(self, **kwargs): # Cloned. With hot key super().__init__(**kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_J, ) ) class ExportLinkActionWithHotKey(ExportLinkAction): def __init__(self, **kwargs): # Cloned. With hot key super().__init__(**kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier, QtCore.Qt.Key.Key_C, ) ) class ExportQRCodeActionWithHotKey(ExportQRCodeAction): def __init__(self, **kwargs): # Cloned. With hot key super().__init__(**kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier, QtCore.Qt.Key.Key_Q, ) ) class ExportJSONActionWithHotKey(ExportJSONAction): def __init__(self, **kwargs): # Cloned. With hot key super().__init__(**kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier, QtCore.Qt.Key.Key_J, ) ) class SelectAllServerAction(Action): def __init__(self, **kwargs): super().__init__(_('Select All'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier, QtCore.Qt.Key.Key_A, ) ) class ScrollToActivatedServerAction(Action): def __init__(self, **kwargs): super().__init__(_('Scroll To Activated Server'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_G, ) ) def triggeredCallback(self, checked): if APP().ServerWidget.modified: self.parent().saveChangeFirst.exec() return activatedItem = self.parent().activatedItem self.parent().setCurrentItem(activatedItem) self.parent().scrollToItem(activatedItem) class JSONErrorBox(MessageBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.decodeError = '' def getText(self): return ( _('Please check your configuration is in valid JSON format:') + f'\n\n{self.decodeError}' ) def retranslate(self): with StateContext(self): self.setWindowTitle(_(self.windowTitle())) self.setText(self.getText()) # Ignore informative text, buttons self.moveToCenter() class SaveConfInfo(MessageBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.addButton(_('Reconnect'), MessageBox.ButtonRole.AcceptRole) self.addButton(_('OK'), MessageBox.ButtonRole.RejectRole) def questionReconnect(saveConfInfo): saveConfInfo.setWindowTitle(_('Hint')) saveConfInfo.setText( _( f'{APPLICATION_NAME} is currently connected.\n\n' f'The new configuration will take effect the next time you connect.' ) ) # Show the MessageBox and wait for user to close it choice = saveConfInfo.exec() if choice == MessageBox.ButtonRole.AcceptRole.value: # Reconnect APP().tray.ConnectAction.reconnectAction() else: # OK. Do nothing pass class SaveAsServerAction(Action): def __init__(self, **kwargs): super().__init__(_('Save'), icon=bootstrapIcon('save.svg'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_S ) ) self.saveErrorBox = JSONErrorBox(icon=MessageBox.Icon.Critical) self.saveConfInfo = SaveConfInfo(icon=MessageBox.Icon.Information) def save(self, successCallback=None): def saveSuccess(server): myServerList = self.parent().ServerList myFocusIndex = self.parent().currentFocus # Assert to be valid assert 0 <= myFocusIndex < len(myServerList) # Unchecked? myServerList[myFocusIndex]['config'] = server # Flush row self.parent().normalServerWidget.flushRow( myFocusIndex, myServerList[myFocusIndex] ) # Sync it ServerStorage.sync() self.parent().markAsSaved() if callable(successCallback): successCallback() if myFocusIndex == self.parent().activatedItemIndex: # Activated configuration modified if server and APP().tray.ConnectAction.isConnected(): questionReconnect(self.saveConfInfo) return True if not self.parent().modified: return True text = self.parent().plainTextEdit.toPlainText() if text == '': # Text is empty. Allowed. return saveSuccess('') try: myJSON = Configuration.toJSON(text) except ujson.JSONDecodeError as decodeError: self.saveErrorBox.decodeError = str(decodeError) self.saveErrorBox.setWindowTitle(_('Error saving configuration')) self.saveErrorBox.setText(self.saveErrorBox.getText()) # Show the MessageBox and wait for user to close it self.saveErrorBox.exec() return False except Exception: # Any non-exit exceptions self.saveErrorBox.setWindowTitle(_('Error saving configuration')) self.saveErrorBox.setText(_('Invalid server configuration.')) # Show the MessageBox and wait for user to close it self.saveErrorBox.exec() return False else: return saveSuccess(text) def triggeredCallback(self, checked): self.save() class SaveErrorBox(MessageBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.saveError = '' def getText(self): if self.saveError: return _('Invalid server configuration.') + f'\n\n{self.saveError}' else: return _('Invalid server configuration.') def retranslate(self): with StateContext(self): self.setWindowTitle(_(self.windowTitle())) self.setText(self.getText()) # Ignore informative text, buttons self.moveToCenter() class SaveAsFileAction(Action): def __init__(self, **kwargs): super().__init__(_('Save As...'), **kwargs) self.saveErrorBox = SaveErrorBox( icon=MessageBox.Icon.Critical, parent=self.parent() ) def triggeredCallback(self, checked): filename, selectedFilter = QFileDialog.getSaveFileName( self.parent(), _('Save File'), filter=_('Text files (*.json);;All files (*)'), ) if filename: try: with open(filename, 'w', encoding='utf-8') as file: file.write(self.parent().plainTextEdit.toPlainText()) except Exception as ex: # Any non-exit exceptions self.saveErrorBox.saveError = str(ex) self.saveErrorBox.setWindowTitle(_('Error saving file')) self.saveErrorBox.setText(self.saveErrorBox.getText()) # Show the MessageBox and wait for user to close it self.saveErrorBox.exec() class ExitAction(Action): def __init__(self, **kwargs): super().__init__(_('Exit'), **kwargs) def triggeredCallback(self, checked): self.parent().syncSettings() self.parent().questionSave() class UndoAction(Action): def __init__(self, **kwargs): super().__init__( _('Undo'), icon=bootstrapIcon('arrow-return-left.svg'), **kwargs, ) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Z ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.undo() class RedoAction(Action): def __init__(self, **kwargs): super().__init__( _('Redo'), icon=bootstrapIcon('arrow-return-right.svg'), **kwargs, ) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier | QtCore.Qt.KeyboardModifier.ShiftModifier, QtCore.Qt.Key.Key_Z, ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.redo() class CutAction(Action): def __init__(self, **kwargs): super().__init__(_('Cut'), icon=bootstrapIcon('scissors.svg'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_X, ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.cut() class CopyAction(Action): def __init__(self, **kwargs): super().__init__(_('Copy'), icon=bootstrapIcon('files.svg'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_C, ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.copy() class PasteAction(Action): def __init__(self, **kwargs): super().__init__(_('Paste'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_V, ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.paste() class SelectAllTextAction(Action): def __init__(self, **kwargs): super().__init__(_('Select All'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_A, ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.selectAll() class IndentAction(Action): def __init__(self, **kwargs): super().__init__(_('Indent...'), **kwargs) self.indentSpinBox = IndentSpinBox() self.indentFailure = JSONErrorBox(icon=MessageBox.Icon.Critical) def triggeredCallback(self, checked): choice = self.indentSpinBox.exec() if choice == QDialog.DialogCode.Accepted.value: myText = self.parent().plainTextEdit.toPlainText() if myText == '': # Empty. Do nothing return try: myJSON = ujson.loads(myText) myText = ujson.dumps( myJSON, indent=self.indentSpinBox.value(), ensure_ascii=False, escape_forward_slashes=False, ) except ujson.JSONDecodeError as decodeError: self.indentFailure.decodeError = str(decodeError) self.indentFailure.setWindowTitle(_('Error setting indent')) self.indentFailure.setText(self.indentFailure.getText()) # Show the MessageBox and wait for user to close it self.indentFailure.exec() except Exception: # Any non-exit exceptions self.indentFailure.setWindowTitle(_('Error setting indent')) self.indentFailure.setText(_('Invalid server configuration.')) # Show the MessageBox and wait for user to close it self.indentFailure.exec() else: self.parent().plainTextEdit.setPlainText(myText) else: # Do nothing pass class ZoomInAction(Action): def __init__(self, **kwargs): super().__init__(_('Zoom In'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Plus ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.zoomIn() class ZoomOutAction(Action): def __init__(self, **kwargs): super().__init__(_('Zoom Out'), **kwargs) self.setShortcut( QtCore.QKeyCombination( QtCore.Qt.KeyboardModifier.ControlModifier, QtCore.Qt.Key.Key_Minus ) ) def triggeredCallback(self, checked): self.parent().plainTextEdit.zoomOut() class ShowLogAction(Action): def __init__(self, **kwargs): super().__init__(_(f'Show {APPLICATION_NAME} Log...'), **kwargs) def triggeredCallback(self, checked): APP().logViewerWidget.showMaximized() class ShowTorRelayLogAction(Action): def __init__(self, **kwargs): super().__init__(_(f'Show {TorRelay.name()} Log...'), **kwargs) def triggeredCallback(self, checked): APP().torViewerWidget.showMaximized() class WhetherToUpdateInfoBox(MessageBox): def __init__(self, version='0.0.0', *args, **kwargs): super().__init__(*args, **kwargs) self.version = version def retranslate(self): with StateContext(self): self.setText(_('New version available: ') + self.version) self.setWindowTitle(_(self.windowTitle())) self.setInformativeText(_(self.informativeText())) # Ignore button text self.moveToCenter() def versionToNumber(version): major_weight = 10000 minor_weight = 100 patch_weight = 1 return functools.reduce( operator.add, list( int(ver) * weight for ver, weight in zip( version.split('.'), [major_weight, minor_weight, patch_weight] ) ), ) class CheckForUpdatesAction(Action): def __init__(self, **kwargs): super().__init__(_('Check For Updates'), **kwargs) self.networkAccessManager = QNetworkAccessManager(parent=self) self.networkReply = None self.checkForUpdateErrorBox = MessageBox( icon=MessageBox.Icon.Critical, parent=self.parent() ) self.whetherToUpdateInfoBox = WhetherToUpdateInfoBox( icon=MessageBox.Icon.Information, parent=self.parent() ) self.isLatestVersionInfoBox = MessageBox( icon=MessageBox.Icon.Information, parent=self.parent() ) def triggeredCallback(self, checked): proxyServer = APP().tray.ConnectAction.proxyServer if proxyServer: logger.info(f'check for updates uses proxy server {proxyServer}') # Checked. split should not throw exceptions proxyHost, proxyPort = proxyServer.split(':') # Checked. int(proxyPort) should not throw exceptions self.networkAccessManager.setProxy( QNetworkProxy( QNetworkProxy.ProxyType.HttpProxy, proxyHost, int(proxyPort) ) ) else: self.networkAccessManager.setProxy( QNetworkProxy(QNetworkProxy.ProxyType.DefaultProxy) ) apiUrl = ( f'https://api.github.com/repos/' f'{APPLICATION_REPO_OWNER_NAME}/{APPLICATION_REPO_NAME}/releases/latest' ) self.networkReply = self.networkAccessManager.get( QNetworkRequest(QtCore.QUrl(apiUrl)) ) @QtCore.Slot() def finishedCallback(): if self.networkReply.error() != QNetworkReply.NetworkError.NoError: logger.error( f'check for updates failed. {self.networkReply.errorString()}' ) self.checkForUpdateErrorBox.setWindowTitle(_(APPLICATION_NAME)) self.checkForUpdateErrorBox.setText(_('Check for updates failed.')) # Show the MessageBox and wait for user to close it self.checkForUpdateErrorBox.exec() else: logger.info('check for updates success') # b'...' data = ( str(self.networkReply.readAll())[2:-1] .encode('utf-8') .decode('unicode_escape') ) info = ujson.loads(data) if versionToNumber(info['tag_name']) > versionToNumber( APPLICATION_VERSION ): self.whetherToUpdateInfoBox.version = info['tag_name'] self.whetherToUpdateInfoBox.setWindowTitle(_(APPLICATION_NAME)) self.whetherToUpdateInfoBox.setStandardButtons( MessageBox.StandardButton.Yes | MessageBox.StandardButton.No ) self.whetherToUpdateInfoBox.setText( _('New version available: ') + info['tag_name'] ) self.whetherToUpdateInfoBox.setInformativeText( _('Go to download page?') ) # Show the MessageBox and wait for user to close it if ( self.whetherToUpdateInfoBox.exec() == MessageBox.StandardButton.Yes.value ): if QDesktopServices.openUrl(QtCore.QUrl(info['html_url'])): logger.info('open download page success') else: logger.error('open download page failed') else: # Do nothing pass else: self.isLatestVersionInfoBox.setWindowTitle(_(APPLICATION_NAME)) self.isLatestVersionInfoBox.setText( _(f'{APPLICATION_NAME} is already the latest version.') ) self.isLatestVersionInfoBox.setInformativeText( _(f'Thank you for using {APPLICATION_NAME}.') ) # Show the MessageBox and wait for user to close it self.isLatestVersionInfoBox.exec() self.networkReply.finished.connect(finishedCallback) class AboutAction(Action): def __init__(self, **kwargs): super().__init__(_('About'), **kwargs) def triggeredCallback(self, checked): if QDesktopServices.openUrl(QtCore.QUrl(APPLICATION_ABOUT_PAGE)): logger.info('open about page success') else: logger.error('open about page failed') class QuestionSaveBox(MessageBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle(_('Save Changes')) self.setText( _('The content has been modified. Do you want to save the changes?') ) self.button0 = self.addButton( _('Save'), MessageBox.ButtonRole.AcceptRole, ) self.button1 = self.addButton( _('Cancel'), MessageBox.ButtonRole.RejectRole, ) self.button2 = self.addButton( _('Discard'), MessageBox.ButtonRole.DestructiveRole, ) self.setDefaultButton(self.button0) class QuestionDeleteBox(MessageBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.isMulti = False self.possibleRemark = '' self.setWindowTitle(_('Delete')) self.setStandardButtons( MessageBox.StandardButton.Yes | MessageBox.StandardButton.No ) def getText(self): if self.isMulti: return _('Delete these configuration?') else: return _('Delete this configuration?') + f'\n\n{self.possibleRemark}' def retranslate(self): with StateContext(self): self.setWindowTitle(_(self.windowTitle())) self.setText(self.getText()) # Ignore informative text, buttons self.moveToCenter() class ProgressWaitBox(MessageBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle(_('Connecting')) self.setText(_('Connecting. Please wait.')) def retranslate(self): with StateContext(self): self.setWindowTitle(_(self.windowTitle())) self.setText(_(self.text())) # Ignore informative text, buttons self.moveToCenter() class SaveChangeFirst(MessageBox): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle(_('Hint')) self.setText(_('Please save any changes to continue.')) def retranslate(self): with StateContext(self): self.setWindowTitle(_(self.windowTitle())) self.setText(_(self.text())) # Ignore informative text, buttons self.moveToCenter() class NormalServerHorizontalHeader(HeaderView): class SortOrder: Ascending_ = False Descending = True def __init__(self, *args, **kwargs): super().__init__(QtCore.Qt.Orientation.Horizontal, *args, **kwargs) self.sectionClicked.connect(self.handleSectionClicked) self.sectionResized.connect(self.handleSectionResized) self.sortOrderTable = list( NormalServerHorizontalHeader.SortOrder.Ascending_ for i in range(self.parent().columnCount()) ) @QtCore.Slot(int) def handleSectionClicked(self, clickedIndex): if APP().ServerWidget.modified: self.parent().saveChangeFirst.exec() return activatedIndex = self.parent().activatedItemIndex if activatedIndex < 0: activatedServerId = None else: # Object is activated activatedServerId = id(self.parent().ServerList[activatedIndex]) # De-activated temporarily for sorting self.parent().activateItemByIndex(activatedIndex, activate=False) # Assertions assert len(self.parent().ServerList) == len(self.parent().ScrollList) # Save focus scroll bar value currentFocus = self.parent().currentFocus if currentFocus >= 0: self.parent().saveScrollBarValue(currentFocus) else: # No item selected. Save nothing pass # Prepare for scroll bar value restoration scrollBarValueTable = {} for index, server in enumerate(self.parent().ServerList): # Object id -> v, h scrollBarValueTable[id(server)] = self.parent().ScrollList[index] # Sort it self.parent().ServerList.sort( key=lambda x: NormalServerWidget.HEADER_LABEL_GET_FUNC[clickedIndex](x), reverse=self.sortOrderTable[clickedIndex], ) # Sync it ServerStorage.sync() # Flush it self.parent().flushAll() # Toggle. Sorting done self.sortOrderTable[clickedIndex] = not self.sortOrderTable[clickedIndex] # Restore corresponding scroll bar value. Later clicking will use them for index, server in enumerate(self.parent().ServerList): # Get v, h by Object id self.parent().ScrollList[index] = scrollBarValueTable[id(server)] if activatedServerId is not None: foundActivatedItem = False for index, server in enumerate(self.parent().ServerList): if activatedServerId == id(server): foundActivatedItem = True # Restore scroll bar value. Later clicking will use it if currentFocus >= 0: self.parent().restoreScrollBarValue(currentFocus) # Focus on focus self.parent().setCurrentItemByIndex(index) self.parent().activateItemByIndex(index, activate=True) break # Object id should be found assert foundActivatedItem else: # Restore scroll bar value. Later clicking will use it if currentFocus >= 0: self.parent().restoreScrollBarValue(currentFocus) # Focus on focus self.parent().setCurrentItemByIndex(currentFocus) @QtCore.Slot(int, int, int) def handleSectionResized(self, index, oldSize, newSize): # Keys are string when loaded from json self.parent().sectionSizeTable[str(index)] = newSize class NormalServerVerticalHeader(HeaderView): def __init__(self, *args, **kwargs): super().__init__(QtCore.Qt.Orientation.Vertical, *args, **kwargs) def toJSONMayBeNull(text): try: return ujson.loads(text) except Exception: # Any non-exit exceptions return {} class NormalServerWidget(Translatable, SupportConnectedCallback, TableWidget): # Might be extended in the future HEADER_LABEL = [ 'Remark', 'Protocol', 'Address', 'Port', 'Transport', 'TLS', ] # Corresponds to header label HEADER_LABEL_GET_FUNC = [ lambda server: server['remark'], lambda server: Intellisense.getCoreProtocol(toJSONMayBeNull(server['config'])), lambda server: Intellisense.getCoreAddr(toJSONMayBeNull(server['config'])), lambda server: Intellisense.getCorePort(toJSONMayBeNull(server['config'])), lambda server: Intellisense.getCoreTransport(toJSONMayBeNull(server['config'])), lambda server: Intellisense.getCoreTLS(toJSONMayBeNull(server['config'])), ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # MessageBox self.questionSaveBox = QuestionSaveBox( icon=MessageBox.Icon.Question, parent=self.parent() ) self.questionDel0Box = QuestionDeleteBox( icon=MessageBox.Icon.Question, parent=self.parent() ) self.saveConfInfoBox = SaveConfInfo( icon=MessageBox.Icon.Information, parent=self.parent() ) self.progressWaitBox = ProgressWaitBox( icon=MessageBox.Icon.Information, parent=self.parent() ) self.saveChangeFirst = SaveChangeFirst( icon=MessageBox.Icon.Information, parent=self.parent() ) # Shallow copy. Checked self.ServerList = self.parent().ServerList # Handy reference self.plainTextEdit = self.parent().plainTextEdit # Handy reference self.editorTab = self.parent().editorTab # Delegate self.delegate = StyledItemDelegate(parent=self) self.setItemDelegate(self.delegate) # Scroll bar value list. v, h self.ScrollList = list([0, 0] for i in range(len(self.ServerList))) # Must set before flush all self.setColumnCount(len(NormalServerWidget.HEADER_LABEL)) # Flush all data to table self.flushAll() # Install custom header self.setHorizontalHeader(NormalServerHorizontalHeader(self)) # Horizontal header resize mode for index in range(self.columnCount()): if index < self.columnCount() - 1: self.horizontalHeader().setSectionResizeMode( index, QHeaderView.ResizeMode.Interactive ) else: self.horizontalHeader().setSectionResizeMode( index, QHeaderView.ResizeMode.Stretch ) try: # Restore horizontal section size self.sectionSizeTable = ujson.loads(APP().ServerWidgetSectionSizeTable) # Block resize callback self.horizontalHeader().blockSignals(True) for key, value in self.sectionSizeTable.items(): self.horizontalHeader().resizeSection(int(key), value) # Unblock resize callback self.horizontalHeader().blockSignals(False) except Exception: # Any non-exit exceptions # Leave keys as strings since they will be # loaded as string from json self.sectionSizeTable = { str(row): self.horizontalHeader().defaultSectionSize() for row in range(self.columnCount()) } self.setHorizontalHeaderLabels( list(_(label) for label in NormalServerWidget.HEADER_LABEL) ) for column in range(self.horizontalHeader().count()): self.horizontalHeaderItem(column).setFont(QFont(APP().customFontName)) # Install custom header self.setVerticalHeader(NormalServerVerticalHeader(self)) # Selection self.setSelectionColor(Color.LIGHT_BLUE) self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) # No drag and drop self.setDragEnabled(False) self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) self.setDropIndicatorShown(False) self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction) contextMenuActions = [ Action( _('Move Up'), callback=lambda: self.moveUpSelectedItem(), parent=self, ), Action( _('Move Down'), callback=lambda: self.moveDownSelectedItem(), parent=self, ), Action( _('Duplicate'), callback=lambda: self.duplicateSelectedItem(), parent=self, ), Action( _('Delete'), callback=lambda: self.deleteSelectedItem(), parent=self, ), Seperator(), SelectAllServerAction( callback=lambda: self.selectAll(), parent=self, ), Seperator(), ScrollToActivatedServerAction(parent=self), Seperator(), ImportFileAction(parent=self), ImportLinkActionWithHotKey(parent=self), ImportJSONActionWithHotKey(parent=self), Seperator(), ExportLinkActionWithHotKey(parent=self), ExportQRCodeActionWithHotKey(parent=self), ExportJSONActionWithHotKey(parent=self), ] self.contextMenu = Menu(*contextMenuActions) self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.handleCustomContextMenuRequested) # Signals self.currentItemChanged.connect(self.handleCurrentItemChanged) self.itemChanged.connect(self.handleItemChanged) self.itemSelectionChanged.connect(self.handleItemSelectionChanged) self.itemActivated.connect(self.handleItemActivated) self.doNotSwitchCallback = None if self.activatedItem is not None: self.setCurrentItem(self.activatedItem) # Activate it from settings self.activateItemByIndex(self.activatedItemIndex, activate=True) @property def activatedItemIndex(self): try: return int(APP().ActivatedItemIndex) except Exception: # Any non-exit exceptions return -1 @property def activatedItem(self): return self.item(self.activatedItemIndex, 0) @property def currentFocus(self): try: # Get current index from tab return int(self.editorTab.tabText(0).split(' - ')[0]) - 1 except Exception: # Any non-exit exceptions return -1 def flushRow(self, row, server): for column in range(self.columnCount()): oldItem = self.item(row, column) newItem = QTableWidgetItem( NormalServerWidget.HEADER_LABEL_GET_FUNC[column](server) ) if oldItem is None: # Item does not exists newItem.setFont(QFont(APP().customFontName)) else: # Use existing newItem.setFont(oldItem.font()) newItem.setForeground(oldItem.foreground()) if column == 0: # Remark is editable newItem.setFlags( QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEditable ) else: newItem.setFlags( QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable ) self.setItem(row, column, newItem) def flushAll(self): if self.rowCount() == 0: shouldInsertRow = True else: shouldInsertRow = False for row, server in enumerate(self.ServerList): if shouldInsertRow: self.insertRow(row) self.flushRow(row, server) def saveScrollBarValue(self, index): assert len(self.ScrollList) == len(self.ServerList) if 0 <= index < len(self.ScrollList): self.ScrollList[index] = [ self.plainTextEdit.verticalScrollBar().value(), self.plainTextEdit.horizontalScrollBar().value(), ] def restoreScrollBarValue(self, index): assert len(self.ScrollList) == len(self.ServerList) if 0 <= index < len(self.ScrollList): self.plainTextEdit.verticalScrollBar().setValue(self.ScrollList[index][0]) self.plainTextEdit.horizontalScrollBar().setValue(self.ScrollList[index][1]) def appendScrollBarEntry(self): self.ScrollList.append([0, 0]) def setPlainText(self, text, block=False, disabled=False): if block: self.plainTextEdit.blockSignals(True) self.plainTextEdit.setPlainText(text) self.plainTextEdit.blockSignals(False) else: self.plainTextEdit.setPlainText(text) self.plainTextEdit.setDisabled(disabled) def syncRemarkByIndex(self, index): assert self.rowCount() == len(self.ServerList) if self.ServerList[index]['remark'] != self.item(index, 0).text(): self.ServerList[index]['remark'] = self.item(index, 0).text() # Only one tab currently. Refresh tab title self.editorTab.setTabText( 0, f'{index + 1} - {self.ServerList[index]["remark"]}' ) remarkModified = True else: remarkModified = False # Avoid unnecessary encode/write, etc. if remarkModified: ServerStorage.sync() def activateItemByIndex(self, index, activate=True): super().activateItemByIndex(index, activate) if activate: APP().ActivatedItemIndex = str(index) def setCurrentItemByIndex(self, index): self.setCurrentItem(self.item(index, 0)) def swapItem(self, index0, index1): swapListItem(self.ServerList, index0, index1) swapListItem(self.ScrollList, index0, index1) # Server storage sync is done by caller!!! self.flushRow(index0, self.ServerList[index0]) self.flushRow(index1, self.ServerList[index1]) if index0 == self.activatedItemIndex: # De-activate self.activateItemByIndex(index0, activate=False) # Activate self.activateItemByIndex(index1, activate=True) elif index1 == self.activatedItemIndex: # De-activate self.activateItemByIndex(index1, activate=False) # Activate self.activateItemByIndex(index0, activate=True) def selectMultipleRow(self, indexes, clearCurrentSelection): if clearCurrentSelection: self.selectionModel().clearSelection() selection = self.selectionModel().selection() for index in indexes: selection.select( self.model().index(index, 0), self.model().index(index, self.columnCount() - 1), ) self.selectionModel().select( selection, QtCore.QItemSelectionModel.SelectionFlag.Select ) def moveUpItemByIndex(self, index): if index <= 0 or index >= len(self.ServerList): # The top item, or does not exist. Do nothing return self.swapItem(index, index - 1) def moveUpSelectedItem(self): if APP().ServerWidget.modified: self.saveChangeFirst.exec() return indexes = self.selectedIndex if len(indexes) == 0: # Nothing selected. Do nothing return currentFocus = self.currentFocus if currentFocus >= 0: self.saveScrollBarValue(currentFocus) for index in indexes: self.moveUpItemByIndex(index) # Sync it ServerStorage.sync() # Don't use setCurrentItemXXX since it will # trigger handleCurrentItemChanged. Just switch context # and change index self.switchContext(indexes[-1] - 1) self.blockSignals(True) self.setCurrentIndex(self.indexFromItem(self.item(indexes[-1] - 1, 0))) self.blockSignals(False) self.selectMultipleRow( list(index - 1 for index in indexes), clearCurrentSelection=True ) def moveDownItemByIndex(self, index): if index < 0 or index >= len(self.ServerList) - 1: # The bottom item, or does not exist. Do nothing return self.swapItem(index, index + 1) def moveDownSelectedItem(self): if APP().ServerWidget.modified: self.saveChangeFirst.exec() return indexes = self.selectedIndex if len(indexes) == 0: # Nothing selected. Do nothing return currentFocus = self.currentFocus if currentFocus >= 0: self.saveScrollBarValue(currentFocus) for index in indexes[::-1]: self.moveDownItemByIndex(index) # Sync it ServerStorage.sync() # Don't use setCurrentItemXXX since it will # trigger handleCurrentItemChanged. Just switch context # and change index self.switchContext(indexes[0] + 1) self.blockSignals(True) self.setCurrentIndex(self.indexFromItem(self.item(indexes[0] + 1, 0))) self.blockSignals(False) self.selectMultipleRow( list(index + 1 for index in indexes), clearCurrentSelection=True ) def duplicateSelectedItem(self): if APP().ServerWidget.modified: self.saveChangeFirst.exec() return indexes = self.selectedIndex if len(indexes) == 0: # Nothing selected. Do nothing return for index in indexes: if 0 <= index < len(self.ServerList): APP().ServerWidget.importServer(**self.ServerList[index]) # Sync it ServerStorage.sync() def deleteSelectedItem(self): if APP().ServerWidget.modified: self.saveChangeFirst.exec() return indexes = self.selectedIndex if len(indexes) == 0: # Nothing selected. Do nothing return self.questionDel0Box.isMulti = bool(len(indexes) > 1) self.questionDel0Box.possibleRemark = ( f'{indexes[0] + 1} - {self.ServerList[indexes[0]]["remark"]}' ) self.questionDel0Box.setText(self.questionDel0Box.getText()) if self.questionDel0Box.exec() == MessageBox.StandardButton.No.value: # Do not delete return if self.activatedItemIndex in indexes: takedActivated = True else: takedActivated = False for i in range(len(indexes)): takedRow = indexes[i] - i self.blockSignals(True) self.removeRow(takedRow) self.blockSignals(False) self.ServerList.pop(takedRow) self.ScrollList.pop(takedRow) if not takedActivated and takedRow < self.activatedItemIndex: APP().ActivatedItemIndex = str(self.activatedItemIndex - 1) # Sync it ServerStorage.sync() if takedActivated: if APP().tray.ConnectAction.isConnected(): # Trigger disconnect APP().tray.ConnectAction.trigger() APP().ActivatedItemIndex = str(-1) # Don't use setCurrentItemXXX since the # selection may not be changed. Just switch context # and change index # Don't use currentFocus since it may have been deleted. Get # current row from self self.switchContext(self.currentRow()) self.blockSignals(True) self.setCurrentIndex(self.indexFromItem(self.item(self.currentRow(), 0))) self.blockSignals(False) def setEmptyItem(self): # Click empty. self.setPlainText('', block=True, disabled=True) # Only one tab currently. Clear tab title self.editorTab.setTabText(0, '') self.blockSignals(True) self.setCurrentIndex(self.indexFromItem(None)) self.blockSignals(False) def switchContext(self, index): self.doNotSwitchCallback = None if index < 0 or index >= len(self.ServerList): # Click empty. self.setEmptyItem() else: self.setPlainText(self.ServerList[index]['config'], block=True) # Only one tab currently. Refresh tab title self.editorTab.setTabText( 0, f'{index + 1} - {self.ServerList[index]["remark"]}' ) # Restore scroll bar self.restoreScrollBarValue(index) @QtCore.Slot(QTableWidgetItem, QTableWidgetItem) def handleCurrentItemChanged(self, currItem, prevItem): currRow = -1 if currItem is None else currItem.row() prevRow = -1 if prevItem is None else prevItem.row() def doNotSwitchCallback(): self.blockSignals(True) self.setCurrentIndex(self.indexFromItem(prevItem)) self.blockSignals(False) def doNotSwitch(): self.doNotSwitchCallback = doNotSwitchCallback self.saveScrollBarValue(prevRow) if APP().ServerWidget is not None and APP().ServerWidget.modified: choice = self.questionSaveBox.exec() if choice == MessageBox.ButtonRole.AcceptRole.value: # Save if APP().ServerWidget.SaveAsServerAction.save( successCallback=lambda: self.switchContext(currRow), ): pass else: # Do not switch doNotSwitch() elif choice == MessageBox.ButtonRole.DestructiveRole.value: # Discard self.switchContext(currRow) APP().ServerWidget.markAsSaved() # Fake saved elif choice == MessageBox.ButtonRole.RejectRole.value: # Cancel. Do not switch doNotSwitch() else: self.switchContext(currRow) @QtCore.Slot(QTableWidgetItem) def handleItemChanged(self, item): self.syncRemarkByIndex(item.row()) @QtCore.Slot() def handleItemSelectionChanged(self): if callable(self.doNotSwitchCallback): self.doNotSwitchCallback() @QtCore.Slot(QTableWidgetItem) def handleItemActivated(self, item): if self.activatedItemIndex == item.row(): # Same item activated. Do nothing return if APP().ServerWidget is not None and APP().ServerWidget.modified: self.saveChangeFirst.exec() return if APP().tray.ConnectAction.isConnecting(): # Show the MessageBox asynchronously self.progressWaitBox.open() return if self.activatedItemIndex >= 0: # De-activate self.activateItemByIndex(self.activatedItemIndex, activate=False) newIndex = item.row() # Activate self.activateItemByIndex(newIndex, activate=True) if APP().tray.ConnectAction.isConnected(): APP().tray.ConnectAction.reconnectAction() @QtCore.Slot(QtCore.QPoint) def handleCustomContextMenuRequested(self, point): self.contextMenu.exec(self.mapToGlobal(point)) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key.Key_Delete: self.deleteSelectedItem() else: super().keyPressEvent(event) def connectedCallback(self): self.setSelectionColor(Color.LIGHT_RED_) # Reactivate with possible color self.activateItemByIndex(self.activatedItemIndex) def disconnectedCallback(self): self.setSelectionColor(Color.LIGHT_BLUE) # Reactivate with possible color self.activateItemByIndex(self.activatedItemIndex) def retranslate(self): self.setHorizontalHeaderLabels( list(_(label) for label in NormalServerWidget.HEADER_LABEL) ) class EditConfigurationWidget(MainWindow): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle(_('Edit Server Configuration')) self.setWindowIcon(bootstrapIcon('rocket-takeoff-window.svg')) # MessageBox self.questionSaveBox = QuestionSaveBox( icon=MessageBox.Icon.Question, parent=self ) self.restoreErrorBox = MessageBox(parent=self) try: self.StorageObj = ServerStorage.toObject(APP().Configuration) # Check model. Shallow copy self.ServerList = self.StorageObj['model'] except Exception: # Any non-exit exceptions self.StorageObj = ServerStorage.init() # Shallow copy self.ServerList = self.StorageObj['model'] # Clear it ServerStorage.clear() Configuration.corruptedError(self.restoreErrorBox) # Modified flag self.modified = False self.modifiedMark = ' *' # Initialize PlainTextEdit @QtCore.Slot(bool) def handleModification(changed): if changed: self.plainTextEdit.document().setModified(False) self.markAsModified() self.plainTextEdit = ZoomablePlainTextEdit(parent=self) self.plainTextEdit.modificationChanged.connect(handleModification) self.plainTextEdit.setDisabled(True) self.showTabAndSpacesIfNecessary() self.lineColumnLabel = QLabel('1:1 0') self.statusBar().addPermanentWidget(self.lineColumnLabel) @QtCore.Slot() def handleCursorPositionChanged(): cursor = self.plainTextEdit.textCursor() self.lineColumnLabel.setText( f'{cursor.blockNumber() + 1}:{cursor.columnNumber() + 1} {cursor.position()}' ) self.plainTextEdit.cursorPositionChanged.connect(handleCursorPositionChanged) # Theme self.plainTextEdit.setStyleSheet( DraculaTheme.getStyleSheet( widgetName='QPlainTextEdit', fontFamily=APP().customFontName, ) ) self.theme = DraculaTheme(self.plainTextEdit.document()) try: font = self.plainTextEdit.font() font.setPointSize(int(APP().ServerWidgetPointSize)) self.plainTextEdit.setFont(font) except Exception: # Any non-exit exceptions pass self.serverEditorTabWidget = QWidget() self.serverEditorTabWidgetLayout = QVBoxLayout(self.serverEditorTabWidget) self.serverEditorTabWidgetLayout.addWidget(self.plainTextEdit) # Advance for Normal Server Widget self.editorTab = QTabWidget(self) # Note: Set tab text later self.editorTab.addTab(self.serverEditorTabWidget, '') self.normalServerWidget = NormalServerWidget(parent=self) # Buttons self.moveUpButton = PushButton(_('Move Up')) self.moveUpButton.clicked.connect( lambda: self.normalServerWidget.moveUpSelectedItem() ) self.moveDownButton = PushButton(_('Move Down')) self.moveDownButton.clicked.connect( lambda: self.normalServerWidget.moveDownSelectedItem() ) self.duplicateButton = PushButton(_('Duplicate')) self.duplicateButton.clicked.connect( lambda: self.normalServerWidget.duplicateSelectedItem() ) self.deleteButton = PushButton(_('Delete')) self.deleteButton.clicked.connect( lambda: self.normalServerWidget.deleteSelectedItem() ) # Button Layout self.buttonWidget = QWidget() self.buttonWidgetLayout = QGridLayout(parent=self.buttonWidget) self.buttonWidgetLayout.addWidget(self.moveUpButton, 0, 0) self.buttonWidgetLayout.addWidget(self.moveDownButton, 0, 1) self.buttonWidgetLayout.addWidget(self.duplicateButton, 1, 0) self.buttonWidgetLayout.addWidget(self.deleteButton, 1, 1) self.normalServerTabWidget = QWidget() self.normalServerTabWidgetLayout = QVBoxLayout(self.normalServerTabWidget) self.normalServerTabWidgetLayout.addWidget(self.normalServerWidget) self.normalServerTabWidgetLayout.addWidget(self.buttonWidget) # Note: Reserved for future extension # self.subscriptionWidget = QWidget(parent=self) # # self.subscriptionTabWidget = QWidget() # self.subscriptionTabWidgetLayout = QVBoxLayout(self.subscriptionTabWidget) # self.subscriptionTabWidgetLayout.addWidget(self.subscriptionWidget) self.serverTab = TabWidget(self) self.serverTab.addTab(self.normalServerTabWidget, _('Server')) # Note: Reserved for future extension # self.serverTab.addTab(self.subscriptionTabWidget, '') self.splitter = QSplitter(QtCore.Qt.Orientation.Horizontal) self.splitter.addWidget(self.serverTab) self.splitter.addWidget(self.editorTab) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 1) self.fakeCentralWidget = QWidget() self.fakeCentralWidgetLayout = QHBoxLayout(self.fakeCentralWidget) self.fakeCentralWidgetLayout.addWidget(self.splitter) self.setCentralWidget(self.fakeCentralWidget) # Menu actions fileMenu = { 'name': 'File', 'actions': [ NewBlankServerAction(parent=self), Seperator(), SelectAllServerAction( callback=lambda: self.normalServerWidget.selectAll(), parent=self, ), Seperator(), ScrollToActivatedServerAction(parent=self.normalServerWidget), Seperator(), ImportFileAction(parent=self), ImportLinkActionWithHotKey(parent=self), ImportJSONActionWithHotKey(parent=self), Seperator(), ExportLinkActionWithHotKey(parent=self), ExportQRCodeActionWithHotKey(parent=self), ExportJSONActionWithHotKey(parent=self), Seperator(), SaveAsServerAction(parent=self), SaveAsFileAction(parent=self), Seperator(), ExitAction(parent=self), ], } editMenu = { 'name': 'Edit', 'actions': [ UndoAction(parent=self), RedoAction(parent=self), Seperator(), CutAction(parent=self), CopyAction(parent=self), PasteAction(parent=self), Seperator(), SelectAllTextAction(parent=self), Seperator(), IndentAction(parent=self), ], } viewMenu = { 'name': 'View', 'actions': [ ZoomInAction(parent=self), ZoomOutAction(parent=self), ], } helpMenu = { 'name': 'Help', 'actions': [ ShowLogAction(parent=self), ShowTorRelayLogAction(parent=self), Seperator(), CheckForUpdatesAction(parent=self), AboutAction(parent=self), ], } # Menus for menuDict in (fileMenu, editMenu, viewMenu, helpMenu): menuName = menuDict['name'] menuObjName = f'_{menuName}Menu' menu = Menu(*menuDict['actions'], title=_(menuName), parent=self.menuBar()) for action in menuDict['actions']: if isinstance(action, Action): if hasattr(self, f'{action}'): logger.warning(f'{self} already has action {action}') setattr(self, f'{action}', action) # Set reference setattr(self, menuObjName, menu) self.menuBar().addMenu(menu) if PLATFORM != 'Darwin': self.setWidthAndHeight() def show(self): super().show() if PLATFORM == 'Darwin': self.setWidthAndHeight() def setWidthAndHeight(self): try: self.setGeometry( 100, 100, *list(int(size) for size in APP().ServerWidgetWindowSize.split(',')), ) except Exception: # Any non-exit exceptions self.setGeometry(100, 100, 1568, 1568 * GOLDEN_RATIO) moveToCenter(self) def importServer(self, remark, config, syncStorage=False): server = {'remark': remark, 'config': config} self.ServerList.append(server) if syncStorage: # Sync it ServerStorage.sync() row = self.normalServerWidget.rowCount() self.normalServerWidget.insertRow(row) self.normalServerWidget.appendScrollBarEntry() self.normalServerWidget.flushRow(row, server) item = QTableWidgetItem(str(row + 1)) item.setFont(QFont(APP().customFontName)) if len(self.ServerList) == 1: # The first one. Click it self.normalServerWidget.setCurrentItemByIndex(0) # Try to be user-friendly in some extreme cases if not APP().tray.ConnectAction.isConnected(): # Activate automatically self.normalServerWidget.activateItemByIndex(0) def showTabAndSpacesIfNecessary(self): textOption = QTextOption() if APP().ShowTabAndSpacesInEditor == Switch.ON_: textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces) self.plainTextEdit.document().setDefaultTextOption(textOption) # Reset. Set self.plainTextEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth) self.plainTextEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) @property def activatedItemIndex(self): return self.normalServerWidget.activatedItemIndex @property def selectedIndex(self): return self.normalServerWidget.selectedIndex @property def rowCount(self): return self.normalServerWidget.rowCount() @property def currentFocus(self): return self.normalServerWidget.currentFocus def saveScrollBarValue(self, index): self.normalServerWidget.saveScrollBarValue(index) def restoreScrollBarValue(self, index): self.normalServerWidget.restoreScrollBarValue(index) def questionSave(self): # Save current scroll bar value currentFocus = self.currentFocus if currentFocus >= 0: self.saveScrollBarValue(currentFocus) else: # No item selected. Save nothing pass if self.modified: choice = self.questionSaveBox.exec() if choice == MessageBox.ButtonRole.AcceptRole.value: # Save # If save success, close the window. return self.SaveAsServerAction.save(successCallback=lambda: self.hide()) if choice == MessageBox.ButtonRole.DestructiveRole.value: # Discard self.hide() self.normalServerWidget.setEmptyItem() self.markAsSaved() # Fake saved return True if choice == MessageBox.ButtonRole.RejectRole.value: # Cancel. Do nothing return False else: self.hide() return True def markAsModified(self): self.modified = True self.setWindowTitle(_('Edit Server Configuration') + self.modifiedMark) def markAsSaved(self): self.modified = False self.setWindowTitle(_('Edit Server Configuration')) def closeEvent(self, event): event.ignore() self.syncSettings() self.questionSave() def syncSettings(self): APP().ServerWidgetWindowSize = ( f'{self.geometry().width()},{self.geometry().height()}' ) APP().ServerWidgetSectionSizeTable = ujson.dumps( self.normalServerWidget.sectionSizeTable, ensure_ascii=False, escape_forward_slashes=False, ) APP().ServerWidgetPointSize = str(self.plainTextEdit.font().pointSize()) def retranslate(self): with StateContext(self): if self.modified: # Keep modified mark self.setWindowTitle(_('Edit Server Configuration') + self.modifiedMark) else: self.setWindowTitle(_(self.windowTitle()))