Files
2026-08-30 19:13:43 +08:00

403 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 Qt support for text editor."""
from __future__ import annotations
from Furious.Frozenlib import *
from Furious.Qt.Signals import connectWeakly
from Furious.Qt.TextEditorTheme import *
from PySide6 import QtCore
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from typing import Callable
import functools
__all__ = [
'AppQPlainTextEdit',
'AppQTextBrowser',
'DraculaTextEditor',
'DraculaJSONTextEditor',
'DraculaTextBrowser',
]
class SupportPointSizeSettings(Mixins.CleanupOnExit):
"""Store and validate support point size settings."""
def pointSizeSettingsEmpty(self):
"""Return the point size settings empty value used by the support point size settings."""
return self.pointSizeSettingsName == ''
def __init__(self, *args, **kwargs):
"""Initialize the SupportPointSizeSettings."""
self.pointSizeSettingsName = kwargs.pop('pointSizeSettingsName', '')
super().__init__(*args, **kwargs)
self.uniqueCleanup = False
self.restorePointSize()
def restorePointSize(self):
"""Restore point size."""
raise NotImplementedError
def cleanup(self):
"""Release resources owned by the support point size settings."""
raise NotImplementedError
class AppQPlainTextEdit(SupportPointSizeSettings, QPlainTextEdit):
"""Represent app q plain text edit."""
def __init__(self, *args, **kwargs):
"""Initialize the AppQPlainTextEdit."""
super().__init__(*args, **kwargs)
def restorePointSize(self):
"""Restore point size."""
if self.pointSizeSettingsEmpty():
return
try:
# Restore point size
font = self.font()
font.setPointSize(int(AppSettings.get(self.pointSizeSettingsName)))
self.setFont(font)
except Exception:
# Any non-exit exceptions
pass
@staticmethod
@functools.lru_cache(128)
def getIndent(line):
"""Return indent."""
indent = ''
for char in line:
if char.isspace():
indent += char
else:
break
return indent
def getPrevAndNextChar(self, cursor):
"""Return prev and next char."""
plainText = self.toPlainText()
cursor.movePosition(QTextCursor.MoveOperation.Left)
try:
prevChar = plainText[cursor.position()]
except Exception:
# Any non-exit exceptions
prevChar = ''
# Move the cursor to the next character position
cursor.movePosition(QTextCursor.MoveOperation.Right)
try:
nextChar = plainText[cursor.position()]
except Exception:
# Any non-exit exceptions
nextChar = ''
return prevChar + nextChar
def smartIndent(self, event):
"""Handle smart indent for the app q plain text edit."""
cursor = self.textCursor()
indent = self.getIndent(cursor.block().text())
# Do newline action
super().keyPressEvent(event)
# Add last line indent
cursor.insertText(indent)
self.setTextCursor(cursor)
def smartSymbolPair(self, event, pair):
"""Handle smart symbol pair for the app q plain text edit."""
plainText = self.toPlainText()
cursor = self.textCursor()
if (
cursor.position() < len(plainText)
and plainText[cursor.position()] == pair[0]
):
# Do pair0 action
super().keyPressEvent(event)
else:
# Do pair0 action
super().keyPressEvent(event)
# Do pair1 action
cursor.insertText(pair[1])
# Move to middle
cursor.movePosition(QTextCursor.MoveOperation.Left)
self.setTextCursor(cursor)
def smartBackspace(self, event):
"""Handle smart backspace for the app q plain text edit."""
cursor = self.textCursor()
chPair = self.getPrevAndNextChar(cursor)
if chPair == '""' or chPair == '{}' or chPair == '[]':
cursor.deleteChar()
cursor.deletePreviousChar()
else:
super().keyPressEvent(event)
def hideTabAndSpaces(self):
"""Hide tab and spaces."""
textOption = QTextOption()
self.document().setDefaultTextOption(textOption)
# Reset. Set
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
def showTabAndSpaces(self):
"""Show tab and spaces."""
textOption = QTextOption()
textOption.setFlags(QTextOption.Flag.ShowTabsAndSpaces)
self.document().setDefaultTextOption(textOption)
# Reset. Set
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
def keyPressEvent(self, event):
"""Handle a key press for the app q plain text edit."""
if (
event.key() == QtCore.Qt.Key.Key_Return
or event.key() == QtCore.Qt.Key.Key_Enter
):
self.smartIndent(event)
elif event.key() == QtCore.Qt.Key.Key_Backspace:
self.smartBackspace(event)
elif event.key() == QtCore.Qt.Key.Key_QuoteDbl:
self.smartSymbolPair(event, '""')
elif event.key() == QtCore.Qt.Key.Key_BraceLeft:
self.smartSymbolPair(event, '{}')
elif event.key() == QtCore.Qt.Key.Key_BracketLeft:
self.smartSymbolPair(event, '[]')
else:
super().keyPressEvent(event)
def wheelEvent(self, event):
"""Handle the wheel event."""
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
if delta > 0:
self.zoomIn()
if delta < 0:
self.zoomOut()
else:
super().wheelEvent(event)
def cleanup(self):
"""Release resources owned by the app q plain text edit."""
if self.pointSizeSettingsEmpty():
return
AppSettings.set(self.pointSizeSettingsName, str(self.font().pointSize()))
class AppQTextBrowser(SupportPointSizeSettings, QTextBrowser):
"""Represent app q text browser."""
def __init__(self, *args, **kwargs):
"""Initialize the AppQTextBrowser."""
super().__init__(*args, **kwargs)
def appendLine(self, line: str):
"""Append line."""
hScrollBar = self.horizontalScrollBar()
vScrollBar = self.verticalScrollBar()
scrollEnds = vScrollBar.maximum() - vScrollBar.value() <= 10
# Fix insertPlainText bug if user cursor is present
self.append(line.rstrip())
if scrollEnds:
vScrollBar.setValue(vScrollBar.maximum()) # Scrolls to the bottom
hScrollBar.setValue(0) # scroll to the left
def restorePointSize(self):
"""Restore point size."""
if self.pointSizeSettingsEmpty():
return
try:
# Restore point size
font = self.font()
font.setPointSize(int(AppSettings.get(self.pointSizeSettingsName)))
self.setFont(font)
except Exception:
# Any non-exit exceptions
pass
def wheelEvent(self, event):
"""Handle the wheel event."""
if event.modifiers() == QtCore.Qt.KeyboardModifier.ControlModifier:
delta = event.angleDelta().y()
if delta > 0:
self.zoomIn()
elif delta < 0:
self.zoomOut()
else:
super().wheelEvent(event)
def cleanup(self):
"""Release resources owned by the app q text browser."""
if self.pointSizeSettingsEmpty():
return
AppSettings.set(self.pointSizeSettingsName, str(self.font().pointSize()))
class DraculaTextEditor(Mixins.ThemeAware, AppQPlainTextEdit):
"""Represent the legacy-named, application-themed text editor."""
def __init__(self, *args, **kwargs):
"""Initialize the DraculaTextEditor."""
self._fontFamily = kwargs.pop('fontFamily', '')
super().__init__(*args, **kwargs)
self._modificationChangedCb = None
self._cursorPositionChangedCb = None
# Theme
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QPlainTextEdit', fontFamily=self._fontFamily
)
)
# Nuitka protects compiled bound methods passed directly to connect().
# Resolve these callbacks weakly so closed transient editors can release
# their Python wrappers as well as their native widget trees.
connectWeakly(
self.modificationChanged,
self,
'_handleModificationChanged',
)
connectWeakly(
self.cursorPositionChanged,
self,
'_handleCursorPositionChanged',
)
@QtCore.Slot(bool)
def _handleModificationChanged(self, changed):
"""Notify the registered modification callback for a real edit."""
if changed:
self.document().setModified(False)
if callable(self._modificationChangedCb):
self._modificationChangedCb()
@QtCore.Slot()
def _handleCursorPositionChanged(self):
"""Notify the registered cursor-position callback."""
if callable(self._cursorPositionChangedCb):
self._cursorPositionChangedCb(self.textCursor())
def themeChangedCallback(self, theme: str):
"""Refresh editor chrome after an application theme change."""
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QPlainTextEdit',
fontFamily=self._fontFamily,
theme=theme,
)
)
def registerCursorPositionChangedCb(self, callback: Callable[[QTextCursor], None]):
"""Handle register cursor position changed cb for the dracula text editor."""
self._cursorPositionChangedCb = callback
def registerModificationChangedCb(self, callback: Callable[[], None]):
"""Handle register modification changed cb for the dracula text editor."""
self._modificationChangedCb = callback
class DraculaJSONTextEditor(DraculaTextEditor):
"""Represent dracula JSON text editor."""
def __init__(self, *args, **kwargs):
"""Initialize the DraculaJSONTextEditor."""
super().__init__(*args, **kwargs)
self._syntaxHighlighter = DraculaJSONSyntaxHighlighter(self.document())
class DraculaTextBrowser(Mixins.ThemeAware, AppQTextBrowser):
"""Represent the legacy-named, application-themed text browser."""
def __init__(self, *args, **kwargs):
"""Initialize the DraculaTextBrowser."""
self._fontFamily = kwargs.pop('fontFamily', '')
super().__init__(*args, **kwargs)
self._syntaxHighlighter = DraculaLoggerSyntaxHighlighter(self.document())
# Theme
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QTextBrowser', fontFamily=self._fontFamily
)
)
def setSyntaxHighlightingEnabled(self, enabled: bool):
"""Enable or suspend automatic syntax highlighting for document edits."""
self._syntaxHighlighter.setHighlightingEnabled(enabled)
def rehighlightBlock(self, block):
"""Rehighlight one document block without rebuilding the document."""
self._syntaxHighlighter.rehighlightBlock(block)
def themeChangedCallback(self, theme: str):
"""Refresh browser chrome after an application theme change."""
self.setStyleSheet(
DraculaEditorTheme.getStyleSheet(
widgetName='QTextBrowser',
fontFamily=self._fontFamily,
theme=theme,
)
)