QScintilla based text editor in PyQt5 with clickable functions and variables
Asked Answered
D

4

9

I am trying to make a simple texteditor with basic syntax highlighting, code completion and clickable functions & variables in PyQt5. My best hope to achieve this is using the QScintilla port
for PyQt5.
I have found the following QScintilla-based texteditor example on the Eli Bendersky website (http://eli.thegreenplace.net/2011/04/01/sample-using-qscintilla-with-pyqt, Victor S. has adapted it to PyQt5). I think this example is a good starting point:

#-------------------------------------------------------------------------
# qsci_simple_pythoneditor.pyw
#
# QScintilla sample with PyQt
#
# Eli Bendersky ([email protected])
# This code is in the public domain
#-------------------------------------------------------------------------
import sys

import sip
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.Qsci import QsciScintilla, QsciLexerPython


class SimplePythonEditor(QsciScintilla):
    ARROW_MARKER_NUM = 8

    def __init__(self, parent=None):
        super(SimplePythonEditor, self).__init__(parent)

        # Set the default font
        font = QFont()
        font.setFamily('Courier')
        font.setFixedPitch(True)
        font.setPointSize(10)
        self.setFont(font)
        self.setMarginsFont(font)

        # Margin 0 is used for line numbers
        fontmetrics = QFontMetrics(font)
        self.setMarginsFont(font)
        self.setMarginWidth(0, fontmetrics.width("00000") + 6)
        self.setMarginLineNumbers(0, True)
        self.setMarginsBackgroundColor(QColor("#cccccc"))

        # Clickable margin 1 for showing markers
        self.setMarginSensitivity(1, True)
#        self.connect(self,
#            SIGNAL('marginClicked(int, int, Qt::KeyboardModifiers)'),
#            self.on_margin_clicked)
        self.markerDefine(QsciScintilla.RightArrow,
            self.ARROW_MARKER_NUM)
        self.setMarkerBackgroundColor(QColor("#ee1111"),
            self.ARROW_MARKER_NUM)

        # Brace matching: enable for a brace immediately before or after
        # the current position
        #
        self.setBraceMatching(QsciScintilla.SloppyBraceMatch)

        # Current line visible with special background color
        self.setCaretLineVisible(True)
        self.setCaretLineBackgroundColor(QColor("#ffe4e4"))

        # Set Python lexer
        # Set style for Python comments (style number 1) to a fixed-width
        # courier.
        #

        lexer = QsciLexerPython()
        lexer.setDefaultFont(font)
        self.setLexer(lexer)

        text = bytearray(str.encode("Arial"))
# 32, "Courier New"         
        self.SendScintilla(QsciScintilla.SCI_STYLESETFONT, 1, text)

        # Don't want to see the horizontal scrollbar at all
        # Use raw message to Scintilla here (all messages are documented
        # here: http://www.scintilla.org/ScintillaDoc.html)
        self.SendScintilla(QsciScintilla.SCI_SETHSCROLLBAR, 0)

        # not too small
        self.setMinimumSize(600, 450)

    def on_margin_clicked(self, nmargin, nline, modifiers):
        # Toggle marker for the line the margin was clicked on
        if self.markersAtLine(nline) != 0:
            self.markerDelete(nline, self.ARROW_MARKER_NUM)
        else:
            self.markerAdd(nline, self.ARROW_MARKER_NUM)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    editor = SimplePythonEditor()
    editor.show()
    editor.setText(open(sys.argv[0]).read())
    app.exec_()

Just copy-paste this code into an empty .py file, and run it. You should get the following simple texteditor appearing on your display:

enter image description here

Notice how perfect the syntax highlighting is! QScintilla certainly did some parsing on the background to achieve that.
Is it possible to make clickable functions & variables for this texteditor? Every self-respecting IDE has it. You click on a function, and the IDE jumps to the function definition. The same for variables. I would like to know:

  • Does QScintilla support clickable functions & variables?
  • If not, is it possible to import another python module that implements this feature in the QScintilla texteditor?

EDIT :
λuser noted the following:

Clickable function names require full parsing with a much deeper knowledge of a programming language [..]
This is way beyond the scope of Scintilla/QScintilla. Scintilla provides a way to react when the mouse clicks somewhere on the text, but the logic of "where is the definition of a function" is not in Scintilla and probably never will be.
However, some projects are dedicated to this task, like ctags. You could simply write a wrapper around this kind of tool.

I guess that writing such wrapper for ctags is now on my TODO list. The very first step is to get a reaction (Qt signal) when the user clicks on a function or variable. And perhaps the function/variable should turn a bit blueish when you hover with the mouse over it, to notify the user that it is clickable. I already tried to achieve this, but am held back by the shortage of QScintilla documentation.

So let us trim down the question to: How do you make a function or variable in the QScintilla texteditor clickable (with clickable defined as 'something happens')


EDIT :
I just returned to this question now - several months later. I have been cooperating with my friend Matic Kukovec to design a website about QScintilla. It is a beginner-friendly tutorial on how to use it:

enter image description here

https://qscintilla.com/

I hope this initiative fills the gap of lacking documentation.

Drain answered 12/10, 2016 at 15:20 Comment(4)
There are two separate issues here. The first issue is clicking on the text and identifying the symbol at a given position. This is supported to the extent that all the low-level functionailty exists in QScintilla/Scintilla for you to write the implementation yourself. The second issue is linking symbols to definitions, which is a feature normally found in IDEs rather than "a simple text editor". There is no direct support for this, and even if you use something like ctags, it is still going to be a lot of work incorporating into a text editor.Sargasso
Hi @Sargasso , thank you so much for your help. I'm not afraid of lots of work, as long as I get started somehow. What holds me back at this moment is making the functions and variables 'clickable' in the sense that 'something happens'. Once I get that, I can proceed to making a wrapper for ctags. Do you have an idea on how to get this first step done? QScintilla documentation is very poor...Drain
I think you should aim lower to start with. Set up a custom context menu handler, and then try something like wordAtPoint to get the symbol at the given QPoint. (PS: the way to grok the documentation, is to read the Scintilla docs first and then go back to the QScintilla docs to see what high-level APIs are provided. The SciTE docs are also sometimes useful).Sargasso
In order to use PyQt5.Qsci, I have to execute pip install QScintilla or I will got ModuleNotFoundError: No module named 'PyQt5.Qsci' error, see riverbankcomputing.com/software/qscintilla, pypi.org/project/QScintillaIntervalometer
P
2

A way to use Pyqt5 with option with clickable functions and variables. Your script that have the clickable part Quoted out, would look like this in PyQt5 with a custom signal.

PyQt4 SIGNAL

self.connect(self,SIGNAL('marginClicked(int, int, Qt::KeyboardModifiers)'), 
self.on_margin_clicked)

PyQt5 SIGNAL

self.marginClicked.connect(self.on_margin_clicked)

PyQt5

import sys

import sip
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.Qsci import QsciScintilla, QsciLexerPython


class SimplePythonEditor(QsciScintilla):
    ARROW_MARKER_NUM = 8

    def __init__(self, parent=None):
        super(SimplePythonEditor, self).__init__(parent)

        # Set the default font
        font = QFont()
        font.setFamily('Courier')
        font.setFixedPitch(True)
        font.setPointSize(10)
        self.setFont(font)
        self.setMarginsFont(font)

        # Margin 0 is used for line numbers
        fontmetrics = QFontMetrics(font)
        self.setMarginsFont(font)
        self.setMarginWidth(0, fontmetrics.width("00000") + 6)
        self.setMarginLineNumbers(0, True)
        self.setMarginsBackgroundColor(QColor("#cccccc"))

        # Clickable margin 1 for showing markers
        self.setMarginSensitivity(1, True)
        self.marginClicked.connect(self.on_margin_clicked)
        self.markerDefine(QsciScintilla.RightArrow,
            self.ARROW_MARKER_NUM)
        self.setMarkerBackgroundColor(QColor("#ee1111"),
            self.ARROW_MARKER_NUM)

        # Brace matching: enable for a brace immediately before or after
        # the current position
        #
        self.setBraceMatching(QsciScintilla.SloppyBraceMatch)

        # Current line visible with special background color
        self.setCaretLineVisible(True)
        self.setCaretLineBackgroundColor(QColor("#ffe4e4"))

        # Set Python lexer
        # Set style for Python comments (style number 1) to a fixed-width
        # courier.
        #

        lexer = QsciLexerPython()
        lexer.setDefaultFont(font)
        self.setLexer(lexer)

        text = bytearray(str.encode("Arial"))
# 32, "Courier New"
        self.SendScintilla(QsciScintilla.SCI_STYLESETFONT, 1, text)

        # Don't want to see the horizontal scrollbar at all
        # Use raw message to Scintilla here (all messages are documented
        # here: http://www.scintilla.org/ScintillaDoc.html)
        self.SendScintilla(QsciScintilla.SCI_SETHSCROLLBAR, 0)

        # not too small
        self.setMinimumSize(600, 450)

    def on_margin_clicked(self, nmargin, nline, modifiers):
        # Toggle marker for the line the margin was clicked on
        if self.markersAtLine(nline) != 0:
            self.markerDelete(nline, self.ARROW_MARKER_NUM)
        else:
            self.markerAdd(nline, self.ARROW_MARKER_NUM)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    editor = SimplePythonEditor()
    editor.show()
    editor.setText(open(sys.argv[0]).read())
    app.exec_()
Pelisse answered 16/11, 2016 at 13:34 Comment(0)
A
3

Syntax highlighting is just a matter of running a lexer on the source file to find tokens, then attribute styles to it. A lexer has a very basic understanding of a programming language, it only understands what is a number literal, a keyword, an operator, a comment, a few others and that's all. This is a somewhat simple job that can be performed with just regular expressions.

On the other hand, clickable function names requires requires full parsing with a much deeper knowledge of a programming language, e.g. is this a declaration of a variable or a use, etc. Furthermore, this may require parsing other source files not opened by current editor.

This is way beyond the scope of Scintilla/QScintilla. Scintilla provides a way to react when the mouse clicks somewhere on the text, but the logic of "where is the definition of a function" is not in Scintilla and probably never will be.

However, some projects are dedicated to this task, like ctags. You could simply write a wrapper around this kind of tool.

Abomb answered 12/10, 2016 at 15:45 Comment(1)
Thank you very much for your interesting comment. I suppose that writing such a wrapper requires at least that a function or variable is 'clickable' inside the QScintilla texteditor. How do you make a word clickable?Drain
P
2

A way to use Pyqt5 with option with clickable functions and variables. Your script that have the clickable part Quoted out, would look like this in PyQt5 with a custom signal.

PyQt4 SIGNAL

self.connect(self,SIGNAL('marginClicked(int, int, Qt::KeyboardModifiers)'), 
self.on_margin_clicked)

PyQt5 SIGNAL

self.marginClicked.connect(self.on_margin_clicked)

PyQt5

import sys

import sip
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.Qsci import QsciScintilla, QsciLexerPython


class SimplePythonEditor(QsciScintilla):
    ARROW_MARKER_NUM = 8

    def __init__(self, parent=None):
        super(SimplePythonEditor, self).__init__(parent)

        # Set the default font
        font = QFont()
        font.setFamily('Courier')
        font.setFixedPitch(True)
        font.setPointSize(10)
        self.setFont(font)
        self.setMarginsFont(font)

        # Margin 0 is used for line numbers
        fontmetrics = QFontMetrics(font)
        self.setMarginsFont(font)
        self.setMarginWidth(0, fontmetrics.width("00000") + 6)
        self.setMarginLineNumbers(0, True)
        self.setMarginsBackgroundColor(QColor("#cccccc"))

        # Clickable margin 1 for showing markers
        self.setMarginSensitivity(1, True)
        self.marginClicked.connect(self.on_margin_clicked)
        self.markerDefine(QsciScintilla.RightArrow,
            self.ARROW_MARKER_NUM)
        self.setMarkerBackgroundColor(QColor("#ee1111"),
            self.ARROW_MARKER_NUM)

        # Brace matching: enable for a brace immediately before or after
        # the current position
        #
        self.setBraceMatching(QsciScintilla.SloppyBraceMatch)

        # Current line visible with special background color
        self.setCaretLineVisible(True)
        self.setCaretLineBackgroundColor(QColor("#ffe4e4"))

        # Set Python lexer
        # Set style for Python comments (style number 1) to a fixed-width
        # courier.
        #

        lexer = QsciLexerPython()
        lexer.setDefaultFont(font)
        self.setLexer(lexer)

        text = bytearray(str.encode("Arial"))
# 32, "Courier New"
        self.SendScintilla(QsciScintilla.SCI_STYLESETFONT, 1, text)

        # Don't want to see the horizontal scrollbar at all
        # Use raw message to Scintilla here (all messages are documented
        # here: http://www.scintilla.org/ScintillaDoc.html)
        self.SendScintilla(QsciScintilla.SCI_SETHSCROLLBAR, 0)

        # not too small
        self.setMinimumSize(600, 450)

    def on_margin_clicked(self, nmargin, nline, modifiers):
        # Toggle marker for the line the margin was clicked on
        if self.markersAtLine(nline) != 0:
            self.markerDelete(nline, self.ARROW_MARKER_NUM)
        else:
            self.markerAdd(nline, self.ARROW_MARKER_NUM)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    editor = SimplePythonEditor()
    editor.show()
    editor.setText(open(sys.argv[0]).read())
    app.exec_()
Pelisse answered 16/11, 2016 at 13:34 Comment(0)
D
1

I got a helpful answer from Matic Kukovec through mail, that I would like to share here. Matic Kukovec made an incredible IDE based on QScintilla: https://github.com/matkuki/ExCo. Maybe it will inspire more people to dig deeper into QScintilla (and clickable variables and functions).


Hotspots make text clickable. You have to style it manualy using the QScintilla.SendScintilla function. Example function I used in my editor Ex.Co. ( https://github.com/matkuki/ExCo ):

def style_hotspot(self, index_from, length, color=0xff0000):
    """Style the text from/to with a hotspot"""
    send_scintilla = 
    #Use the scintilla low level messaging system to set the hotspot
    self.SendScintilla(PyQt4.Qsci.QsciScintillaBase.SCI_STYLESETHOTSPOT, 2, True)
    self.SendScintilla(PyQt4.Qsci.QsciScintillaBase.SCI_SETHOTSPOTACTIVEFORE, True, color)
    self.SendScintilla(PyQt4.Qsci.QsciScintillaBase.SCI_SETHOTSPOTACTIVEUNDERLINE, True)
    self.SendScintilla(PyQt4.Qsci.QsciScintillaBase.SCI_STARTSTYLING, index_from, 2)
    self.SendScintilla(PyQt4.Qsci.QsciScintillaBase.SCI_SETSTYLING, length, 2)

This makes text in the QScintilla editor clickable when you hover the mouse over it. The number 2 in the above functions is the hotspot style number. To catch the event that fires when you click the hotspot, connect to these signals:

QScintilla.SCN_HOTSPOTCLICK
QScintilla.SCN_HOTSPOTDOUBLECLICK
QScintilla.SCN_HOTSPOTRELEASECLICK

For more details look at Scintilla hotspot documentation: http://www.scintilla.org/ScintillaDoc.html#SCI_STYLESETHOTSPOT and QScintilla hotspot events: http://pyqt.sourceforge.net/Docs/QScintilla2/classQsciScintillaBase.html#a5eff383e6fa96cbbaba6a2558b076c0b


First of all, a big thank you to Mr. Kukovec! I have a few questions regarding your answer:

(1) There are a couple of things I don't understand in your example function.

def style_hotspot(self, index_from, length, color=0xff0000):
    """Style the text from/to with a hotspot"""
    send_scintilla =     # you undefine send_scintilla?
    #Use the scintilla low level messaging system to set the hotspot
    self.SendScintilla(..) # What object does 'self' refer to in this
    self.SendScintilla(..) # context?
    self.SendScintilla(..)

(2) You say "To catch the event that fires when you click the hotspot, connect to these signals:"

QScintilla.SCN_HOTSPOTCLICK
QScintilla.SCN_HOTSPOTDOUBLECLICK
QScintilla.SCN_HOTSPOTRELEASECLICK

How do you actually connect to those signals? Could you give one example? I'm used to the PyQt signal-slot mechanism, but I never used it on QScintilla. It would be a big help to see an example :-)

(3) Maybe I missed something, but I don't see where you define in QScintilla that functions and variables (and not other things) are clickable in the source code?

Thank you so much for your kind help :-)

Drain answered 12/10, 2016 at 19:37 Comment(0)
O
1

Have a look at the following documentation: https://qscintilla.com/#clickable_text

There are two ways to make things clickable in Qscintilla - you can use hotspots or indicators. hotspots require you to override the default behavior of underlying lexer, but indicators are more convenient for your use case, I think.

I suggest you have a look at indicators which can help you to make text clickable and you can define event handlers that get executed when it gets clicked. https://qscintilla.com/#clickable_text/indicators

Orthotropous answered 16/12, 2019 at 14:43 Comment(7)
Thank you @Rida. It's funny to get back to this question after some years. Being frustrated about bad documentation on QScintilla, I teamed up with Matic Kukovec to write documentation from scratch, leading to this website qscintilla.com which we built together. It's nice to see you link to our website now ;-)Drain
Your last comment made my day <3 ... It is so funny indeed!! .. Your documentation is great, thank you both for this great effort :DOrthotropous
I am developing a desktop application using "pyqt4". The application contains an XML editor implemented on top of Qscintilla. However I have an issue whenever I click a hyperlink-alike text defined via indicators. The "indicatorClicked" event is invoked, but when I execute SCI_GOTOLINE API inside it, it goes to the desirable line properly but unluckily, for some reason, it selects the text from the clicked text position till the destination line. For me, it seems mouse does not get released! I tried also to use "indicatorReleased" event with no luck! Do u have any idea how to resolve this?Orthotropous
Hi @Rida, I just sent your question to my friend Matic. I'll let you know his answer :-). Anyhow, I would suggest to try it in PyQt5 and with the latest QScintilla installation. PyQt4 is already quite old.Drain
@Rida Yes, we had the exact same problem. There are two solutions: Use a QTimer.singleShot inside the indicatorReleased event to execute the SCI_GOTOLINE after a short delay (I use 50ms), or inside the indicatorReleased event use: while mouse_state != data.Qt.NoButton: PyQt5.QTest.qWait(1) to wait until there is no muse button pressed, and then execute SCI_GOTOLINE. Hope it helps.Graduate
Thanks a million guys. "QTimer.singleShot(50, lambda: self.__go_to_line(line_number))" worked as charm However, The mouse_state based solution did not work, since mouse_button is indeed equals to "NoButton" all the time. I will create a new question and post your answers on it, so others can benefit from it.Orthotropous
I posted it as a separate question for better issue documentation: #59381263Orthotropous

© 2022 - 2024 — McMap. All rights reserved.