1

In my app I have a QTabWidget which holds a variable number of seemingly "identical" tabs with a variable number of widgets. I want, once the TAB (or shift-TAB) button is pressed, for the focus of the app to move to the next (or previous) tab, and focus on the corresponding widget of that tab (the one corresponding to the widget which had the focus until the key press).

What is the best way to go around this in a simple way? I tried using a QShortcut to catch the key-press but I can't seem to figure out a way to get the corresponding widget in the next or previous tab and focus on that.

Here's a minimal example of the code, which simply moves to the next tab but not to the corresponding widget:

import sys

from PyQt5 import QtCore, QtWidgets, QtGui
from PyQt5.QtWidgets import *


class tabdemo(QTabWidget):
    def __init__(self, num_tabs=2):
        super().__init__()

        shortcut = QtWidgets.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Tab), self)
        shortcut.activated.connect(self.on_tab)
        shortcut2 = QtWidgets.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Backtab), self)
        shortcut2.activated.connect(self.on_shift_tab)


        self.tabs = []
        for i in range(num_tabs):
            newtab = QWidget()
            self.tabs.append(newtab)
            self.addTab(newtab, f'Tab {i}')
            self.add_widgets_to(newtab)


    def add_widgets_to(self, tab):
        layout = QVBoxLayout()
        tab.setLayout(layout)

        layout.addWidget(QSpinBox())
        layout.addWidget(QCheckBox())

        gender = QHBoxLayout()
        gender.addWidget(QRadioButton("Male"))
        gender.addWidget(QRadioButton("Female"))
        layout.addLayout(gender)

    @QtCore.pyqtSlot()
    def on_tab(self):
        current_tab = self.currentIndex()
        self.setCurrentIndex((current_tab + 1) % self.count())
        # TODO find current widget in focus, and find the corresponding one in the next tab, and focus on that one... note that widgets could be complex (i.e., not direct children...)




    @QtCore.pyqtSlot()
    def on_shift_tab(self):
        print("do_something")
        current_tab = self.currentIndex()
        self.setCurrentIndex((current_tab - 1) % self.count())


def main():
    app = QApplication(sys.argv)
    ex = tabdemo()
    ex.show()
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()
CompareTwo
  • 115
  • 7
  • Added a minimal reproducible example – CompareTwo Jul 28 '21 at 15:35
  • 1
    Could you explain your phrase in more detail: *but not to the corresponding widget* .What are the corresponding widgets of each tab or is there any criteria to know which of the widgets is the corresponding one? – eyllanesc Jul 28 '21 at 15:43
  • The tabs are identical copies of one another: they're built with the same function, which creates new instances of the widgets that populate the tabs. A "corresponding widget" could maybe be at the same (x,y) position as the current one (except in the other tab)? Although this definition could run into problems with widgets like comboboxes... My idea was to figure out which widget/layout is currently in focus, somehow figure out its "child number" with respect to the tab, and then get the ith child of the next tab (for the same value of i). I couldn't find such functions though. – CompareTwo Jul 28 '21 at 16:45
  • 1
    From what I understand your tabs will have the same widgets and if we associate an index to each widget within the tab then if it is in the i-th tab and the j-th widget has the focus and if the TAB is pressed then It must be changed to the (i + 1)-th tab and the corresponding j-th widget of the current tab must have the focus? – eyllanesc Jul 28 '21 at 16:49
  • Yes, that's exactly it. One issue I'm seeing is that, since the i-th widget might actually be a "complex widget" or a layout which includes many other widgets, this might not work well without some sort of recursive solution? Perhaps using inheritence and adding the functionality to keep track of children as a mixin? I can expand on this issue if it's not clear – CompareTwo Jul 28 '21 at 17:05

2 Answers2

3

Since the OP indicates that each page will have identical components then an index can be associated so that the index of the tab can be obtained before changing it and then set the widget's focus then set the focus to the other corresponding widget.

import sys

from PyQt5.QtCore import pyqtSlot, Qt
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import (
    QApplication,
    QCheckBox,
    QHBoxLayout,
    QRadioButton,
    QShortcut,
    QSpinBox,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)


class Page(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)

        spinbox = QSpinBox()
        checkbox = QCheckBox()
        male_radio = QRadioButton("Male")
        female_radio = QRadioButton("Female")

        layout = QVBoxLayout(self)
        layout.addWidget(spinbox)
        layout.addWidget(checkbox)

        gender = QHBoxLayout()
        gender.addWidget(male_radio)
        gender.addWidget(female_radio)
        layout.addLayout(gender)

        for i, widget in enumerate((spinbox, checkbox, male_radio, female_radio)):
            widget.setProperty("tab_index", i)


class Tabdemo(QTabWidget):
    def __init__(self, num_tabs=2):
        super().__init__()

        shortcut = QShortcut(QKeySequence(Qt.Key_Tab), self)
        shortcut.activated.connect(self.next_tab)
        shortcut2 = QShortcut(QKeySequence(Qt.Key_Backtab), self)
        shortcut2.activated.connect(self.previous_tab)

        for i in range(num_tabs):
            page = Page()
            self.addTab(page, f"Tab {i}")

    @pyqtSlot()
    def next_tab(self):
        self.change_tab((self.currentIndex() + 1) % self.count())

    @pyqtSlot()
    def previous_tab(self):
        self.change_tab((self.currentIndex() - 1) % self.count())

    def change_tab(self, index):
        focus_widget = QApplication.focusWidget()
        tab_index = focus_widget.property("tab_index") if focus_widget else None
        self.setCurrentIndex(index)
        if tab_index is not None and self.currentWidget() is not None:
            for widget in self.currentWidget().findChildren(QWidget):
                i = widget.property("tab_index")
                if i == tab_index:
                    widget.setFocus(True)


def main():
    app = QApplication(sys.argv)
    ex = Tabdemo()
    ex.show()
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
eyllanesc
  • 235,170
  • 19
  • 170
  • 241
2

Building on eyllanesc's answer, I improved the functionality to:

  • Account for the scrollbar location (if exists)
  • Use a bi-directional dictionary (implemented here) instead of a linear lookup
  • Dynamically add all relevant widgets using the update_map() method instead of having to add each widget manually.

Posting in case anyone finds this useful.

from PyQt5.QtCore import Qt, pyqtSlot
from PyQt5.QtGui import QKeySequence
from PyQt5.QtWidgets import QWidget, QTabWidget, QShortcut, QApplication, QScrollArea


class BidirectionalDict(dict):
    def __init__(self, *args, **kwargs):
        super(BidirectionalDict, self).__init__(*args, **kwargs)
        self.inverse = {}
        for key, value in self.items():
            self.inverse.setdefault(value, []).append(key)

    def __setitem__(self, key, value):
        if key in self:
            self.inverse[self[key]].remove(key)
        super(BidirectionalDict, self).__setitem__(key, value)
        self.inverse.setdefault(value, []).append(key)

    def __delitem__(self, key):
        self.inverse.setdefault(self[key], []).remove(key)
        if self[key] in self.inverse and not self.inverse[self[key]]:
            del self.inverse[self[key]]
        super(BidirectionalDict, self).__delitem__(key)

    def get_first_inv(self, key):
        return self.inverse.get(key, [None])[0]


class Page(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.widgets_map = BidirectionalDict()
        # ... add your widgets ...
        self.update_map()


    def update_map(self):
        widgets = self.findChildren(QWidget)
        for i, widget in enumerate(widgets):
            self.widgets_map[i] = widget


class MyQTabWidget(QTabWidget):
    def __init__(self):
        super().__init__()

        shortcut = QShortcut(QKeySequence(Qt.Key_Tab), self)
        shortcut.activated.connect(self.next_tab)
        shortcut2 = QShortcut(QKeySequence(Qt.Key_Backtab), self)
        shortcut2.activated.connect(self.previous_tab)


    @pyqtSlot()
    def next_tab(self):
        self.change_tab((self.currentIndex() + 1) % self.count())

    @pyqtSlot()
    def previous_tab(self):
        self.change_tab((self.currentIndex() - 1) % self.count())

    def change_tab(self, new_tab_index):
        old_tab: Page = self.currentWidget()
        focus_widget = QApplication.focusWidget()
        widget_index = old_tab.widgets_map.get_first_inv(focus_widget) if focus_widget else None

        self.setCurrentIndex(new_tab_index)

        new_tab: Page = self.currentWidget()

        if new_tab is not None and widget_index is not None:
            corresponding_widget: QWidget = new_tab.widgets_map[widget_index]
            corresponding_widget.setFocus(True)

            # Move scrollbar to the corresponding position
            if hasattr(old_tab, 'scrollbar'):
                # Tabs are identical so new_tab must have scrollbar as well
                old_y = old_tab.scrollbar.verticalScrollBar().value()
                scrollbar: QScrollArea = new_tab.scrollbar
                scrollbar.verticalScrollBar().setValue(old_y)
CompareTwo
  • 115
  • 7