mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
475 lines
16 KiB
Python
475 lines
16 KiB
Python
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||
#
|
||
# 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 <https://www.gnu.org/licenses/>.
|
||
|
||
"""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
|