x01.DiamondIDE: hello ide

x01.DiamondIDE

虽然一直在用 vscode,但自己写一个也不错。通过比较,选择 Spyder 来学习。代码: x01.DiamondIDE

效果图

x01.DiamondIDE

1 Edit

1.1 hello DiamondIDE

使用 pip 安装所需模块 pyqt 等,自不待言。hello.py 代码如下:

from qtpy.QtWidgets import QApplication, QPlainTextEdit

app = QApplication(['x01.DiamondIDE'])
edit = QPlainTextEdit('hello DiamondIDE')
edit.show()
app.exec_()

终端输入: python3 hello.py 运行一下,OK!

1.2 添加测试

删除 hello.py, 添加 widgets/edit.py 如下:

# widgets/edit.py (c) 2021 by x01

from qtpy.QtWidgets import QPlainTextEdit, QApplication

class Edit(QPlainTextEdit):
    def __init__(self, parent=None):
        super().__init__(parent=parent)

def test_edit():
    app = QApplication(['x01.DiamondIDE'])
    edit = Edit()
    edit.setPlainText('Hello IDE!')
    edit.show()
    app.exec()

if __name__ == "__main__":
    test_edit()

添加 tests/test_edit.py 如下:

import os, sys 
RootDir = os.path.dirname(os.path.dirname(__file__))
sys.path.append(RootDir)

import widgets.edit as edit 

def test_edit():
    edit.test_edit()

先安装 pytest: python3 -m pip install -U pytest, 然后在终端运行测试: pytest, OK!
顺便添加 main.py,代码如下:

import os, sys 

CurrDir = os.path.dirname(__file__)
sys.path.append(CurrDir)

from widgets.edit import test_edit

def main():
    test_edit()

if __name__ == "__main__":
    main()

运行一下,OK!
注释 tests/test_edit.py 的 RootDir,添加 test.py 以在测试时统一添加路径,代码如下:

import os, sys 
RootDir = os.path.dirname(__file__)
sys.path.append(RootDir)

import pytest

if __name__ == "__main__":
    pytest.main()

运行一下,OK!

1.3 切入点

在 Python 的 site-packages 目录下新建 mypath.pth 文件,添加 x01.DiamondIDE 所在路径,以便导入。
Spyder 太大,还是以 CodeEditor 作为切入点。
widgets/edit.py 更改如下:

# widgets/edit.py (c) 2021 by x01

from PyQt5.QtGui import QColor, QFont, QPaintEvent, QPainter, QSyntaxHighlighter, QTextBlock, QTextCharFormat, QTextDocument, QTextFormat
from PyQt5.QtCore import QRect, Qt, QSize
from PyQt5.QtWidgets import QMainWindow, QTextEdit, QPlainTextEdit, QApplication, QWidget
from functools import namedtuple
import re

def get_span(match, key=None):
    if key is not None:
        start, end = match.span(key)
    else:
        start, end = match.span()
    start = len(match.string[:start])
    end = len(match.string[:end])
    return start, end

class Highlighter(QSyntaxHighlighter):
    HighlightingRule = namedtuple('HighlightingRule', ['pattern', 'format'])

    def __init__(self, parent: QTextDocument=None):
        super().__init__(parent)
        self.keywordFormat = QTextCharFormat()
        self.keywordFormat.setForeground(Qt.red)
        self.keywordFormat.setFontWeight(QFont.Bold)
        self.keywords = r'\b' + '(?P<keyword>' + '|'.join("class int char".split()) + ')' + r'\b'

    def highlightBlock(self, text:str):
        patterns = re.compile(self.keywords, re.S)
        match = patterns.search(text)
        index = 0
        while match:
            for key, value in list(match.groupdict().items()):
                if value:
                    start, end = get_span(match, key)
                    index += end - start 
                    self.setFormat(start, end-start, self.keywordFormat)
            match = patterns.search(text, match.end())


class LineNumberArea(QWidget):
    def __init__(self, editor=None):
        super().__init__(editor)
        self.editor = editor
        self.left_padding = 3
        self.right_padding = 6

    # override
    def sizeHint(self):
        return QSize(self.editor.LineNumberAreaWidth(), 0)

    def paintEvent(self, event):
        self.editor.LineNumberAreaPaintEvent(event)


class CodeEditor(QPlainTextEdit):
    def __init__(self, parent=None):
        super().__init__(parent=parent)
        self.line_number_area = LineNumberArea(self)
        self.line_number_enabled = True 

        #event
        self.blockCountChanged.connect(self.UpdateLineNumberAreaWidth)
        self.updateRequest.connect(self.UpdateLineNumberArea)
        self.cursorPositionChanged.connect(self.HighlightCurrentLine)

        self.UpdateLineNumberAreaWidth(0)
        self.HighlightCurrentLine()
        self.highlighting = Highlighter(self.document())

    def resizeEvent(self, event):
        super().resizeEvent(event)
        cr:QRect = self.contentsRect()
        self.line_number_area.setGeometry(QRect(cr.left(), cr.top(), self.LineNumberAreaWidth(), cr.height()))

    def LineNumberAreaWidth(self):
        width = 0
        if self.line_number_enabled:
            digits = 1
            count = max(1, self.blockCount())
            while count >= 10:
                count /= 10
                digits += 1
            fm = self.fontMetrics()
            width = fm.width('9') * digits + self.line_number_area.left_padding  + self.line_number_area.right_padding
        return width 

    def LineNumberAreaPaintEvent(self, event:QPaintEvent):
        if self.line_number_enabled:
            painter = QPainter(self.line_number_area)
            painter.fillRect(event.rect(), Qt.lightGray)

            block:QTextBlock  = self.firstVisibleBlock()
            block_number = block.blockNumber()
            top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top())
            bottom = top + round(self.blockBoundingRect(block).height())

            while block.isValid() and top <= event.rect().bottom():
                if block.isVisible() and bottom >= event.rect().top():
                    number = block_number + 1
                    painter.setPen(Qt.black)
                    painter.drawText(0, top, self.line_number_area.width() - self.line_number_area.right_padding, 
                            self.fontMetrics().height(), Qt.AlignRight, str(number))
                block = block.next()
                top = bottom 
                bottom = top + round(self.blockBoundingRect(block).height())
                block_number += 1


    def UpdateLineNumberAreaWidth(self, new_block_count=None):
        self.setViewportMargins(self.LineNumberAreaWidth(),0,0,0)

    def UpdateLineNumberArea(self, rect, dy):
        if self.line_number_enabled:
            if dy:
                self.line_number_area.scroll(0, dy)
            else:
                self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height())
            if rect.contains(self.viewport().rect()):
                self.UpdateLineNumberAreaWidth(0)

    def HighlightCurrentLine(self):
        extra = []
        if not self.isReadOnly():
            lineColor = QColor(Qt.yellow).lighter(160)
            selection = QTextEdit.ExtraSelection()
            selection.format.setBackground(lineColor)
            selection.format.setProperty(QTextFormat.FullWidthSelection, True)
            selection.cursor = self.textCursor()
            selection.cursor.clearSelection()
            extra.append(selection)
        self.setExtraSelections(extra)

def test_edit():
    app = QApplication(['x01.DiamondIDE'])
    ed = CodeEditor()
    ed.setPlainText('Hello IDE!')
    ed.show()
    app.exec_()

if __name__ == "__main__":
    test_edit()

运行一下,OK!现在已经可以显示行号和语法高亮了。

2 PyQode

运行 spyder 时 ipython console 出错:extra_extension 不能为 1。 在 ~/.ipython/profile_default/ipython_kernel_config.py 中,注释掉如下行即可:
# c.InteractiveShellApp.extra_extension = 1

发现 pyqode 基本上能够满足自动完成,代码折叠,语法高亮,智能提示等主要功能,故搬运之,运行一下,比想象的效果要好。

2.1 FoldPanel

QPlainTextEdit 的内容为 QTextDocument, 而 QTextDocument 由 QTextBlock 组成,处理语法高亮,代码折叠,实则是处理 QTextBlock。在 core/edit.py 中添加类 R 和 FoldPanel 如下:

class R:
    TabSize = 4

    '''
    QPlainTextEdit 的内容为 QDocument, 而 QDocument 由 QTextBlock 组成,
    处理语法高亮,代码折叠,实则是处理 QTextBlock
    '''
    # QTextBlock .userState
    #     bit0-15: syntax highlighter
    #     bit16-25: fold level
    #     bit26: fold trigger flag (折叠箭头所在行)
    #     bit27: fold trigger state (expanded or collapsed  折叠内容)
    @staticmethod
    def GetBlockState(block:QTextBlock):
        if block is None: return -1
        state = block.userState()
        if state == -1: return state 
        return state & 0x0000FFFF

    @staticmethod
    def SetBlockState(block:QTextBlock, state:int):
        if block is None: return
        user_state = block.userState()
        if user_state == -1:
            user_state = 0
        high = user_state & 0x7FFF0000
        state &= 0x0000FFFF
        state |= high
        block.setUserState(state)

    @staticmethod
    def GetBlockFoldLevel(block:QTextBlock):
        if block is  None: return 0
        state = block.userState()
        if state == -1: state = 0
        return (state & 0x03FF0000) >> 16   # bit16-25

    @staticmethod
    def SetBlockFoldLevel(block:QTextBlock, level:int):
        if block is None: return
        state = block.userState()
        if state == -1: state = 0 
        if level >= 0x3FF: level = 0x3FF 
        state &= 0x7C00FFFF
        state |= level << 16
        block.setUserState(state)

    @staticmethod 
    def IsBlockFoldTrigger(block:QTextBlock):
        if block is None: return False 
        state = block.userState()
        if state == -1: state = 0 
        return bool(state & 0x04000000)

    @staticmethod 
    def SetBlockFoldTrigger(block:QTextBlock,  trigger:bool):
        if block is None: return
        state = block.userState()
        if state == -1: state = 0 
        state &= 0x7BFFFFFF
        state |= int(trigger) << 26
        block.setUserState(state)

    @staticmethod
    def IsBlockCollapsed(block:QTextBlock):
        if block is None: return False
        state = block.userState()
        if state == -1: state = 0
        return bool(state & 0x08000000)

    @staticmethod
    def SetBlockCollapsed(block:QTextBlock, collapsed:bool):
        if block is None: return 
        state = block.userState()
        if state == -1: state = 0
        state &= 0x77FFFFFF
        state |= int(collapsed) << 27
        block.setUserState(state)


class FoldPanel():
    def __init__(self, edit:QPlainTextEdit=None):
        self.edit = edit
        self.document: QTextDocument = edit.document() 
    
    def UpdateBlocks(self):
        block = self.document.firstBlock()
        if block is None: return 
        last_block = self.document.lastBlock()
        prev_block = block 
        block = block.next()
        while block and block != last_block :
            self.ProcessBlock(prev_block, block)
            prev_block = block 
            block = block.next()

    def ProcessBlock(self, prev_block:QTextBlock, current_block:QTextBlock):
        text = current_block.text() 
        prev_fold_level = R.GetBlockFoldLevel(prev_block)
        if text.strip() == '':
            fold_level = prev_fold_level
        else:
            fold_level = self.DetectFoldLevel(prev_block, current_block)
            if fold_level > 0x03FF: fold_level = 0x03FF
        prev_fold_level = R.GetBlockFoldLevel(prev_block)
        if fold_level > prev_fold_level:
            block = current_block.previous()
            while block.isValid() and block.text().strip() == '':
                R.SetBlockFoldLevel(block, fold_level)
                block = block.previous()
            R.SetBlockFoldTrigger(block, True) # 上一层非空行为触发行

        if text.strip():
            R.SetBlockFoldTrigger(prev_block, fold_level > prev_fold_level)
        R.SetBlockFoldLevel(current_block, fold_level)

        prev :QTextBlock= current_block.previous()
        if (prev and prev.isValid() and prev.text().strip() == '' and 
                R.IsBlockFoldTrigger(prev)):
            R.SetBlockCollapsed(current_block, R.IsBlockCollapsed(prev))
            R.SetBlockFoldTrigger(prev, False)
            R.SetBlockCollapsed(prev, False) 

    def DetectFoldLevel(self, prev_block:QTextBlock, current_block:QTextBlock):
        text = current_block.text()
        curr_level = (len(text) - len(text.lstrip())) // R.TabSize
        prev_level = R.GetBlockFoldLevel(prev_block)
        if prev_level and curr_level > prev_level and not(self.StripComment(prev_block).endswith(':')):
            curr_level = prev_level
        # curr_level = self.ProcessDocstring(prev_block, current_block, curr_level)
        # curr_level = self.ProcessImport(prev_block, current_block, curr_level)
        return curr_level

    def StripComment(self, block:QTextBlock):
        text = block.text().strip() if block else ''
        i = text.find('#')
        if i != -1:
            text = text[:i].strip()
        return text 

测试一下:

def print_tree(ed):
    block:QTextBlock = ed.document().firstBlock()
    while block.isValid():
        trigger = R.IsBlockFoldTrigger(block)
        collapsed = R.IsBlockCollapsed(block)
        level = R.GetBlockFoldLevel(block)
        visible = 'V' if block.isVisible() else 'I'
        if trigger:
            flag = '+' if collapsed else '-'
            print(f'{block.blockNumber()+1}: {level}{flag}{visible}')
        # else:
        #     print(f'{block.blockNumber()+1}: {level}{visible}')
        block = block.next() 


def test_edit():
    app = QApplication(['x01.DiamondIDE'])
    ed = CodeEdit()
    text = ''
    with open(__file__, 'r') as f:
        text = f.read() 
    ed.setPlainText(text)
    
    ed.foldPanel.UpdateBlocks()
    print_tree(ed)
    
    ed.show()
    app.exec_()

if __name__ == "__main__":
    test_edit()

基本上可以达到预期的目的。

2.1.1 实现代码折叠与展开

参考 LineNumberArea, 初步实现 FoldPanel, 但主要折叠功能在 CodeEdit 中实现, 关键代码如下。

FoldBlockModel = namedtuple('FoldBlockModel', ['trigger', 'collapsed', 'level', 'block'])

class FoldPanel(QWidget):
    def __init__(self, editor: QPlainTextEdit = None):
        super().__init__(editor)
        self.editor = editor
        self.document: QTextDocument = editor.document()

    # override
    def sizeHint(self):
        return QSize(R.FoldPanelWidth, 0)

    def paintEvent(self, event: QPaintEvent):
        self.editor.FoldPanelPaintEvent(event)

class CodeEdit(QPlainTextEdit):
    
    # ContextMenu

    def InitContextMenu(self):
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.ContextMenuShow)

        self.context_menu = QtWidgets.QMenu('Folding', self)
        action = QtWidgets.QAction('Toggle Collapsed', self.context_menu)
        action.setShortcut('Shift+-')
        action.triggered.connect(self.ToggleCollapsed)
        self.context_menu.addAction(action)

    def ContextMenuShow(self):
        self.context_menu.exec(QCursor.pos())
        
    def ToggleCollapsed(self):
        self.UpdateBlocks()
        doc:QTextDocument = self.document()
        first = doc.firstBlock()
        line = self.textCursor().blockNumber()
        curr = doc.findBlockByNumber(line)
        if not curr.isValid(): return
        if R.IsBlockFoldTrigger(curr):
            models = self.GetCollapsedBlocks(curr)
            for m in models:
                isCollapsed = R.IsBlockCollapsed(m.block)
                m.block.setVisible(isCollapsed)
                R.SetBlockCollapsed(m.block, not isCollapsed)
        doc.adjustSize()

    def GetCollapsedBlocks(self, trigger_block):
        i = trigger_block.blockNumber()
        level = self.fold_models[i].level 
        end = i
        for m in self.fold_models[i+1:]:
            end += 1
            if m.level <= level: break
        if end > len(self.fold_models): end = len(self.fold_models)
        return self.fold_models[i+1:end]

    # FoldPanel

    def FoldPanelPaintEvent(self, event):
        painter = QPainter(self.foldPanel)
        painter.fillRect(event.rect(), Qt.green)

    def UpdateFoldPanel(self, rect, dy):
        self.UpdateBlocks()

    def UpdateBlocks(self):
        self.fold_models.clear()
        self.foldPanel.UpdateBlocks()
        block: QTextBlock = self.document().firstBlock()
        while block.isValid():
            trigger = R.IsBlockFoldTrigger(block)
            collapsed  = R.IsBlockCollapsed(block)
            level = R.GetBlockFoldLevel(block)
            self.fold_models.append(FoldBlockModel(trigger, collapsed, level, block))
            block = block.next()

OK!现在已经可以在快捷菜单中实现代码折叠与展开了。

2.1.2 画上箭头

在 FoldPanel 中添加上箭头,貌似还不错。core/edit.py 主要更改如下:

FoldBlockModel = namedtuple('FoldBlockModel', ['trigger', 'collapsed', 'level', 'block', 'triggered'])
FoldBlockModel.__new__.__defaults__ = (False, False, -1, None, False)

class FoldPanel:

    def mousePressEvent(self, e:QMouseEvent):
       self.editor.mousePressEvent(e)

class CodeEdit:

    def mousePressEvent(self, e: QMouseEvent):
        super(CodeEdit, self).mousePressEvent(e)
        if e.pos().x() > self.LineNumberAreaWidth()+R.FoldPanelWidth: return 

        block = self.GetTriggerBlock(e.pos())
        if block is None: return
        i = 0
        for m in self.fold_models[:]:
            if block == m.block and e.buttons() == Qt.LeftButton:
                self.ToggleFold(block)
                break
            i += 1
        self.foldPanel.scroll(0,1)
        self.foldPanel.scroll(0,-1)
        
    def GetTriggerBlock(self, pos):
        height = self.fontMetrics().height()
        for m in self.fold_models[:]:
            if m.trigger:
                top = self.blockBoundingGeometry(m.block).translated(self.contentOffset()).top() 
                if top <= pos.y() <= top + height:
                    return m.block 

    def FoldPanelPaintEvent(self, event):
        painter = QPainter(self.foldPanel)
        for m in self.fold_models:
            if m.trigger:
                top = self.blockBoundingGeometry(m.block).translated(self.contentOffset()).top()
                height = self.fontMetrics().height()
                path = R.RightOffIconPath if m.triggered else R.DownOffIconPath
                QIcon(path).paint(painter,0,top,R.FoldPanelWidth, height)
        self.document().adjustSize()

    def InitFoldBlockModels(self):
        self.fold_models.clear()
        self.foldPanel.UpdateBlocks()
        block: QTextBlock = self.document().firstBlock()
        while block.isValid():
            trigger = R.IsBlockFoldTrigger(block)
            collapsed  = R.IsBlockCollapsed(block)
            level = R.GetBlockFoldLevel(block)
            self.fold_models.append(FoldBlockModel(trigger, collapsed, level, block, False))
            block = block.next()

    def UpdateBlocks(self, n:int=0):
        models = self.fold_models[:]
        limit = len(models)
        i = 0
        self.fold_models.clear()
        self.foldPanel.UpdateBlocks()
        block: QTextBlock = self.document().firstBlock()
        while block.isValid():
            trigger = R.IsBlockFoldTrigger(block)
            collapsed  = R.IsBlockCollapsed(block)
            level = R.GetBlockFoldLevel(block)
            triggered = models[i].triggered if i < limit and models[i].block == block and trigger  else False
            self.fold_models.append(FoldBlockModel(trigger, collapsed, level, block, triggered))
            block = block.next()
            i += 1
        self.foldPanel.scroll(0,1)
        self.foldPanel.scroll(0,-1)

为了更新箭头,采取 self.foldPanel.scroll() 的方式,多少显得笨拙。 凡此种种,不及细言。

2.2 AutoComplete

采取字典的方式,初步实现"",'',(),{},[]的自动完成。

class AutoComplete:
    def __init__(self, editor):
        self.editor:CodeEdit = editor 
        self.MAPPING = {'"': '"', "'": "'", "(": ")", "{": "}", "[": "]"}

class CodeEdit:
    def KeyPressed(self, event):
        text = event.text()
        if text in self.MAPPING.keys():
            self.editor.InsertText(self.MAPPING[text])

    def InsertText(self, text, keep_pos=True):
        cursor:QTextCursor = self.textCursor()
        QTextCursor
        if keep_pos:
            s = cursor.selectionStart()
            e = cursor.selectionEnd()
        cursor.insertText(text)
        if keep_pos:
            cursor.setPosition(s)
            cursor.setPosition(e, cursor.KeepAnchor)
        self.setTextCursor(cursor)

2.3 Intellisense

根据文档的内容,初步实现智能提示。

class Intellisense:
    def __init__(self, editor):
        self.editor:CodeEdit = editor
        self.key =''
        self.results = {}

    def UpdateSource(self):
        source = self.editor.toPlainText().split('\n')
        for line in source:
            words = re.split(r'\W+', line)
            for word in words:
                if word == '': continue
                key = ''
                for c in word:
                    key += c
                    if self.results.get(key) is None:
                        self.results[key] =[word]
                    elif word in self.results[key] :
                        continue
                    else:
                        self.results[key].append(word)

    def ShowTips(self, event:QKeyEvent):
        if self.key == '': self.UpdateSource()
        key = event.text()
        if key in [' ', '(', ')', '[', ']', ',', '.', ';', '"', "'", ':', '+', '=']:
            self.key = '' 
            QtWidgets.QToolTip.hideText()
            return
        self.key += key 
        results = self.results
        if self.key in results.keys():
            print(self.key) 
            tip  = ''
            for word in results[self.key]:
                tip += '<p>'+word+'</p>'
            pos = QPoint(self.editor.cursorRect().x(), self.editor.cursorRect().y())
            pos = self.editor.mapToGlobal(pos)
            QtWidgets.QToolTip.showText(pos, tip, self.editor)

3. 添加主窗口

在 main.py 中添加 MainWindow, 代码如下:

import os
import platform
import sys
from PyQt5.QtWidgets import QAction, QApplication, QDesktopWidget, QFileDialog, QFrame, QLabel, QMainWindow, QMessageBox
from PyQt5.QtGui import QIcon, QKeySequence
from PyQt5.QtCore import QByteArray, QFile, QFileInfo, QSettings
from core.edit import CodeEdit

CurrDir = os.path.dirname(__file__)
sys.path.append(CurrDir)

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.filename = None 
        self.printer = None

        self.editor = CodeEdit()
        self.setCentralWidget(self.editor)
        
        self.sizeLabel = QLabel()
        self.sizeLabel.setFrameStyle(QFrame.StyledPanel | QFrame.Sunken)
        status = self.statusBar()
        status.setSizeGripEnabled(False)
        status.addPermanentWidget(self.sizeLabel)
        status.showMessage("Ready")

        fileNewAction = self.createAction("&New", self.FileNew, 
            QKeySequence.New, "filenew", "Create a file")
        fileOpenAction = self.createAction("&Open", self.FileOpen, 
            QKeySequence.Open, "fileopen", "Open a exist file")
        fileSaveAction = self.createAction("&Save", self.FileSave,
            QKeySequence.Save, "filesave", "Save the file")
        fileSaveAsAction = self.createAction("Save &as", self.FileSaveAs,
            icon="filesaveas", tip="Save file using a new filename")
        fileQuitAction = self.createAction("&Quit", self.close,
            "Ctrl+Q", "filequit", "Close the application")
        
        self.fileMenu = self.menuBar().addMenu("&File")
        self.fileMenuActions = (fileNewAction, fileOpenAction, 
            fileSaveAction, fileSaveAsAction, None, fileQuitAction)
        

        settings = QSettings()
        self.recentFiles = []
        if settings.value("RecentFiles"):
            self.recentFiles = settings.value("RecentFiles")
        if settings.value("MainWindow/Geometry"):
            self.restoreGeometry( QByteArray(settings.value("MainWindow/Geometry")) )
        if settings.value("MainWindow/State"):
            self.restoreState( QByteArray(settings.value("MainWindow/State")) )

        self.UpdateFileMenu()

        self.setWindowTitle("Image changer")
        self.resize(900,600)
        self.SetCenter()

    def SetCenter(self):
        screen = QDesktopWidget().screenGeometry()
        size = self.geometry()
        self.move((screen.width()-size.width())/2, (screen.height()-size.height())/4)

    def createAction(self, text, slot=None, shortcut=None, icon=None,
                    tip=None, checkable=False, signal="triggered"):
        action = QAction(text, self)
        actSignal = None
        if signal == "triggered":
            actSignal = action.triggered
        elif signal == "toggled":
            actSignal = action.toggled
        elif signal == "changed":
            actSignal = action.changed 
        elif signal == "hovered":
            actSignal = action.hovered
        else:
            actSignal = action.triggered

        if icon is not None:
            iconpath = os.path.join(CurrDir, 'core/images/'+icon+'.png')
            action.setIcon(QIcon(iconpath))
        if shortcut is not None:
            action.setShortcut(shortcut)
        if tip is not None:
            action.setToolTip(tip)
        if slot is not None:
            actSignal.connect(slot)
        if checkable:
            action.setCheckable(True)
        return action 

    def closeEvent(self, e):
        if self.IsSave():
            settings = QSettings()
            settings.setValue("LastFile", self.filename)
            settings.setValue("RecentFiles", self.recentFiles)
            settings.setValue("MainWindow/Geometry", self.saveGeometry())
            settings.setValue("MainWindow/State", self.saveState())
        else:
            e.ignore()

    def IsSave(self):
        if self.editor.dirty:
            reply = QMessageBox.question(self, "x01.DiamondIDE", "Save changed file?", 
                QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
            if reply == QMessageBox.Yes:
                return self.FileSave()
            elif reply == QMessageBox.No:
                return True
            else:
                return False
        return True

    # file actions

    def FileNew(self): 
        if not self.IsSave(): return
        self.editor.setPlainText('')
        self.editor.dirty = False 

    def FileOpen(self): 
        if not self.IsSave():
            return
        fname, ext = QFileDialog.getOpenFileName( self, "x01.DiamondIDE - Choose File", CurrDir,
            "Python files (*.py);;All files (*.*)" ) 
        self.LoadFile(filename=fname)

    def FileSave(self): 
        if self.filename is None:
            return self.FileSaveAs()
        else:
            with open(self.filename, 'w') as f:
                text = self.editor.toPlainText()
                f.write(text)
                self.addRecentFile(self.filename)
        return True 

    def FileSaveAs(self): 
        fname, ext = QFileDialog.getSaveFileName( self, "x01.DiamondIDE", CurrDir, 
            "Python files (*.py);;All files (*.*)" ) 
        with open(fname, 'w') as f:
            text = self.editor.toPlainText()
            f.write(text)
            self.recentFiles.append(fname)

    def addRecentFile(self, fname):
        if fname is None:
            return 
        if fname not in self.recentFiles:
            self.recentFiles.insert(0, fname)
            if len(self.recentFiles) > 9:
                self.recentFiles = self.recentFiles[:9]

    def updateStatus(self, message):
        self.statusBar().showMessage(message)

    def UpdateFileMenu(self):
        self.fileMenu.clear()
        self.AddActions(self.fileMenu, self.fileMenuActions[:-1])
        recentFiles = []
        for fname in self.recentFiles:
            if QFile.exists(fname):
                recentFiles.append(fname)
        if recentFiles:
            self.fileMenu.addSeparator()
            iconpath = os.path.join(CurrDir, 'core/images/icon.png')
            for i, fname in enumerate(recentFiles):
                action = QAction(QIcon(iconpath), 
                    "&{0} {1}".format(i+1, QFileInfo(fname).fileName()), self)    
                action.setData(fname)
                action.triggered.connect(self.LoadFile)
                self.fileMenu.addAction(action)
        self.fileMenu.addSeparator()
        self.fileMenu.addAction(self.fileMenuActions[-1])

    def LoadFile(self, trigger=False, filename=None):
        if not self.IsSave(): return
        if filename is None:
            action = self.sender()
            if isinstance(action, QAction):
                filename = str(action.data())
                if filename in self.recentFiles:
                    self.recentFiles.remove(filename)
                self.recentFiles.insert(0,filename)
            else:
                return 
        if filename:
            with open(filename, 'r') as f:
                text = f.read()
                self.editor.setPlainText(text)
            self.filename = filename
            self.addRecentFile(filename)
            self.UpdateFileMenu() 

    def AddActions(self, target, actions):
        for action in actions:
            if action is None:
                target.addSeparator()
            else:
                target.addAction(action)
        
if __name__ == "__main__":
    app = QApplication(sys.argv)
    win = MainWindow()
    win.show()
    sys.exit(app.exec())

运行一下,基本可用了.顺便把多余的文件删除掉,OK!

3.1 完善 AutoComplete

遇到自动完成字典中的值[')', '}', ']']时,光标移到下一字符,再删除该字符即可。改动代码如下:

class AutoComplete:
    def __init__(self, editor):
        self.editor: CodeEdit = editor
        self.map = {'"': '"', "'": "'", "(": ")", "{": "}", "[": "]"}
        self.avoid_duplicates = [')', '}', ']']
        self.next_chars = []
        self.curr_block_number = None 

    def KeyPressed(self, event:QKeyEvent):
        text = event.text()
        if text in self.map.keys():
            self.editor.InsertText(self.map[text])
            if self.map[text] in self.avoid_duplicates:
                self.next_chars.append(self.map[text])
                self.curr_block_number = self.editor.textCursor().blockNumber()

class CodeEdit:
    def keyPressEvent(self, e: QKeyEvent):
        super(CodeEdit, self).keyPressEvent(e)
        
        if self.autoComplete.curr_block_number != self.textCursor().blockNumber(): 
            self.autoComplete.next_chars.clear()

        if e.text() in self.autoComplete.next_chars and e.text() == self.GetRightChar(): 
            cursor:QTextCursor = self.textCursor()
            cursor.movePosition(cursor.NextCharacter,cursor.MoveAnchor)
            cursor.deletePreviousChar()
            self.setTextCursor(cursor)
            self.autoComplete.next_chars.remove(e.text())
            
        self.autoComplete.KeyPressed(e)

        self.intellisense.ShowTips(e)
        self.update()
        self.dirty = True 

posted on 2021-01-10 14:19  x01  阅读(304)  评论(0编辑  收藏  举报

导航