-1

PyQt5: QAction -- QTextEdit.paste not working with QTabWidget?

Trying to learn python, I have started a project to write a tabbed markdown editor. In the code below everything works as expected, I can:

  • Add and Remove tabs
  • Use the keyboard to add content
  • Use the RMB context menu to add content
  • Load content from files

However, when I try to set the QAction: QTextEdit.paste I get the following error

Traceback (most recent call last):
  File "~/Documents/Python/Mq/bin/Mq-post.py", line 170, in <module>
    M = Main()
  File "~/Documents/Python/Mq/bin/Mq-post.py", line 40, in __init__
    self.initUi()
  File "~/Documents/Python/Mq/bin/Mq-post.py", line 47, in initUi
    configMainMenu(self)
  File "~/Documents/Python/Mq/bin/ConfigMenu.py", line 47, in configMainMenu
    Paste.triggered.connect(self.textArea.paste)
AttributeError: 'Main' object has no attribute 'textArea'

I have tried moving the definition for textArea from addTab to __init__, but, while no errors are reported, and the QTextEdit.paste action works as expected, I cannot create new tabs.

The code is as follows:

Main:

""" Mq editor main window """
import sys
from PyQt5.QtWidgets import (QMainWindow, QApplication, QWidget, QTabWidget,
                             QVBoxLayout, QFileDialog, QTextEdit)
from PyQt5.QtGui import (QIcon)

from ConfigMenu import (configMainMenu)
from Functions import (trimFileName, trimHome)


class Main(QMainWindow):
    """ Main app """

    def __init__(self):
        super().__init__()

        # Define initial window geometry
        self.setWindowTitle('Mq Editor')
        self.setWindowIcon(QIcon("Quill.png"))
        self.left = 0
        self.top = 0
        self.width = 1024
        self.height = 768
        self.setGeometry(self.left, self.top, self.width, self.height)

        # Setup a TabBar with movable closable tabs
        self.tabs = QTabWidget()
        self.tabs.tabCloseRequested.connect(self.closeTab)
        self.tabs.setTabsClosable(True)
        self.tabs.setMovable(True)

        # Define the Layout
        widget = QWidget(self)
        self.setCentralWidget(widget)
        self.layout = QVBoxLayout(widget)

        # Defined in add tab, See pylint W0201, (disable to get traceback 2)
        # self.textArea = None

        self.initUi()

    # #####################################

    def initUi(self):
        """ Set up user interface """
        # Create a menu bar
        configMainMenu(self)

        # Adding the tab widget to the layout needs to come after calling
        # 'configMainMenu' and 'createToolBar'
        self.layout.addWidget(self.tabs)

        # Create first tab
        self.addTab()

        # Open window in maximized size
        self.showMaximized()

    # #####################################

    def addTab(self):
        """ Create Tabs """

        # Add tab with new editor instance, (this is only place it works)
        self.textArea = QTextEdit()
        self.textArea.textChanged.connect(self.textChanged)
        self.textArea.createStandardContextMenu()

        self.tabs.addTab(self.textArea, "Untitled")

        # Switch to new tab
        index = self.tabs.count() - 1
        self.tabs.setCurrentIndex(index)
        self.tabs.setTabToolTip(index, "Untitled")

    # #####################################

    def closeTab(self, index):
        """ Close Tab Handler """
        self.tabs.removeTab(index)

    # #####################################

    def getActiveTab(self):
        """ Returns active tab 'index' and 'title' """
        # Get the active tabs index
        index = self.tabs.currentIndex()

        # Get the active tabs title
        title = self.tabs.tabText(index)
        return index, title

    # #####################################

    def setActiveTabTitle(self, title, toolTip):
        """ Sets the active tab 'title' and 'tooltip' """
        # Get the active tabs index
        index = self.tabs.currentIndex()

        # Set the active tabs title
        self.tabs.setTabText(index, title)
        self.tabs.setTabToolTip(index, toolTip)

    # #####################################

    def textChanged(self):
        """ textChanged handler """
        # Get the active tab's details
        index, title = self.getActiveTab()

        # Check if last char of title is marked as 'Text Changed'
        if title[-1] != '*':
            # Not marked as 'Text Changed'
            self.tabs.setTabText(index, title + '*')

    # #####################################

    def closeUnusedTabs(self):
        """ Tab house-keeping """
        tabIndex = self.tabs.count()
        while tabIndex > 0:
            index = tabIndex - 1
            self.tabs.setCurrentIndex(index)
            title = self.tabs.tabText(index)
            if title == "Untitled":
                self.closeTab(index)
            tabIndex = tabIndex - 1

    # #####################################

    def openFile(self):
        """ openFile handler """
        # Open file dialogue
        FileDialogue = QFileDialog.getOpenFileNames
        filePath, _ = FileDialogue(None, "Open File", "",
                                   "Markdown Files (*.md);;All Files (*)")

        # check 'filePath' is not empty
        if filePath:
            # Keep the GUI clean
            self.closeUnusedTabs()

            # Iterate 'filePath' and open files
            for i in filePath:

                # Having removed unused tabs, we just need to add a new tab for
                # each file 'i'
                self.addTab()

                # Read file 'i' and load into new tab instance of editor
                with open(i, "r", encoding="utf8") as f:
                    fileContents = f.read()
                    self.textArea.setPlainText(fileContents)

                # Get a trimmed path as the 'tab tooltip'
                toolTip = trimHome(i)

                # Get a trimmed file name to use as the 'tab title'
                tabTitle = trimFileName(i)

                # Set the new tabs 'title' and 'tootip'
                self.setActiveTabTitle(tabTitle, toolTip)

    def Paste(self):
        " Test  paste click handler "
        print("Hit Paste")


m = QApplication(sys.argv)
M = Main()
M.show()
sys.exit(m.exec_())

ConfigMenu:

""" Module to create a menu bar """

from PyQt5.QtWidgets import (QAction)


def configMainMenu(self):
    """ Creating a menu bar"""

    # mainMenu Definition
    mainMenu = self.menuBar()
    fileMenu = mainMenu.addMenu('File')
    editMenu = mainMenu.addMenu('Edit')

    # 'New'
    New = QAction('New', self)
    New.setShortcut('Ctrl+N')
    New.setStatusTip('New File')
    New.triggered.connect(self.addTab)
    fileMenu.addAction(New)

    # 'Close'
    Close = QAction('Close', self)
    Close.setShortcut('Ctrl+W')
    Close.setStatusTip('Close File')
    Close.triggered.connect(self.closeTab)
    fileMenu.addAction(Close)

    # 'Open'
    Open = QAction('Open', self)
    Open.setShortcut('Ctrl+O')
    Open.setStatusTip('Open')
    Open.triggered.connect(self.openFile)
    fileMenu.addAction(Open)

    # 'Paste'
    Paste = QAction('Paste', self)
    Paste.setShortcut('Ctrl+V')
    Paste.setStatusTip('Paste text')
    # ### The traceback leads here ###
    Paste.triggered.connect(self.textArea.paste)  # Fails
    # Paste.triggered.connect(self.Paste)         # For testing
    editMenu.addAction(Paste)

Functions:

""" Useful functions """

from pathlib import Path
from os.path import expanduser


def trimFileName(filePath):
    """ Returns size limited 'tab title' based on 'file name'  """

    # FRST: Get the file name without extension
    # Convert 'file path' to type 'path' without '.ext'
    filePath = Path(filePath).with_suffix('')

    # Convert the 'Path' back to 'string' and 'split' into a 'list'
    pathStr = str(filePath).split("/")

    # The file name is last element of the 'list'
    tabTitle = pathStr[-1]

    # SCOND: trim length to 12 characters.
    # Nte: we use the tab 'tooltip' to show 'path' and 'file name'
    length = len(tabTitle)
    if length > 12:
        tabTitle1 = tabTitle[0:3]
        tabTitle2 = tabTitle[length - 6:length]
        tabTitle = tabTitle1 + '..' + tabTitle2

    return tabTitle


def trimHome(filePath):
    """ Replace ${HOME} with '~' in 'filePath' """
    # Note: This also works on Windows :)

    home = expanduser("~")
    trimmedPath = filePath.replace(home, "~")

    return trimmedPath

Any advice or help to on how to implement an Edit Menu while maintaining the Tab functionality would be welcome.

Irvine
  • 11
  • 4

1 Answers1

1

Proposed Solution:

Intro:

Okay, finally, after four days of research and testing, I have a solution that seems to work

While the suggestion by @musicamante was interesting, it only allowed pasting into the first tab created. This, in fact, was a recurrent problem for the many "solutions" and "examples" I found during my research: They only pasted text into either the first or last tab created.

However, it did highlight the basic problem.

The problem:

  1. The textArea needs to be defined in the addTab function, but this means it is not directly addressable by the editMenu actions in either the mainMenu or ToolBar
  2. As originally written, the textArea for each tab is not uniquely identifiable.

The solution:

Part 1:

To address the first part of the problem, I created a function to handle All the editMenu triggered.connect signals:

def editFunctions(self, Fnct):
        """ edit Menu/ToolBar clickHandlers"""
        print(self.tabs.currentIndex())
        match Fnct:
            case "selectall": self.currentEditor.selectAll()
            case "paste": self.currentEditor.paste()
            case "copy": self.currentEditor.copy()
            case "undo": self.currentEditor.undo()
            case "redo": self.currentEditor.redo()
            case "cut": self.currentEditor.cut()

And to call the above function I used, triggered.connect signals of the following form:

Paste.triggered.connect(partial(self.editFunctions, "paste"))

Part 2:

This still left the problem of the textArea for each tab not being uniquely identifiable.

Firstly, I added a new signal to the *tabwidget" and created a list of available text areas, called editors

    def __init__(self):
        super().__init__()

        # Define initial window geometry
        self.setWindowTitle('Mq Editor')
        self.setWindowIcon(QIcon(c.Quill))
        self.left = 0
        self.top = 0
        self.width = 1024
        self.height = 768
        self.setGeometry(self.left, self.top, self.width, self.height)

        # Setup a TabBar with movable closable tabs
        self.tabs = QTabWidget()
        self.tabs.tabCloseRequested.connect(self.closeTab)
        self.tabs.currentChanged.connect(self.changeTextEditor)
        self.tabs.setTabsClosable(True)
        self.tabs.setMovable(True)
        self.editors = []

        # Define the Layout
        widget = QWidget(self)
        self.setCentralWidget(widget)
        self.layout = QVBoxLayout(widget)

        self.initUi()

This required a new function to handle the signal and a new attribute instance:

    def changeTextEditor(self, index):
        """ Set currentEditor to index of current tab """
        self.currentEditor = self.editors[index]

It also meant editing the addTab and closeTab functions to udate the new attributes

    def addTab(self):
        """ Create Tabs """

        # Add tab with new editor instance
        textArea = QTextEdit()
        textArea.textChanged.connect(self.textChanged)
        textArea.createStandardContextMenu()

        # Update list of editors
        self.currentEditor = textArea
        self.editors.append(self.currentEditor)

        self.tabs.addTab(textArea, "Untitled")

        # Switch to new tab
        index = self.tabs.count() - 1
        self.tabs.setCurrentIndex(index)
        self.tabs.setTabToolTip(index, "Untitled")
    def closeTab(self, index):
        """ Close Tab Handler """
        self.tabs.removeTab(index)

        # Update editors list
        del self.editors[index]

Finally, I needed to replace any instances of self.textArea with self.currentEditor

Summary:

My solution probably needs some further refinement, but, as things stand at the moment, it has passed all my initial tests and seems too work without error.

Irvine

Edit:

As I said it could do with some refinement, specifically: Use the QTabWidget.currentWidget() method.

    def editFunctions(self, Fnct):
        """ 'edit' Menu/ToolBar clickHandlers"""
        
        currentEditor = self.tabs.currentWidget()
        
        match Fnct:
            case "selectall": currentEditor.selectAll()
            case "paste": currentEditor.paste()
            case "copy": currentEditor.copy()
            case "undo": currentEditor.undo()
            case "redo": currentEditor.redo()
            case "cut": currentEditor.cut()

It is:

  • More flexible
  • Handles movable tabs
  • It only requires adding the new function to the original code without any further edits
Irvine
  • 11
  • 4