Привет, земляне! В процессе разработки приложения с графическим интерфейсом используются графические элементы для ввода и вывода информации, большинство фреймворков для разработки приложений c GUI имеют в своем составе элементы для ввода элементарных данных, такие как: целые и вещественные числа, строки, логические значения и т.д. Для большинства задач их вполне хватает, но так бывает не всегда.

Нет мы не будем писать код по отрисовке элемента, все гораздо проще, мы сделаем композицию из встроенных элементов. По моему скромному мнению, такой подход хорош тем, что позволяет раздробить код на независимые элементы, это упростит тестирование и разработку за счет простого интерфейса взаимодействия и возможности повторного использования кода. Работы будут проводиться следующими инструментами: Python и PySide2(Qt5), данные принцип действует для реализаций для других языков программирования и фреймворков, например, подобные манипуляции я проводил с Tkinter.

В данной статье я покажу как сделать элемент для ввода списка строк, его я позаимствовал из проекта программного комплекса, над которым работаю в настоящее время. Узнать о нем можно в моем телеграм канале, все ссылки в конце статьи.

Для взаимодействия с внешней программой класс элемента будет иметь интерфейс:

  • методы для установки/извлечения списка;

  • виджет отправляет сигналы, когда строка: добавлена, изменена, удалена.

Для пользователя он будет представлен как поле со списком строк, справа от которого будут кнопки для управления. Кнопки не имеют иконок, только текст, в некоторых случаях использованы специальные символы (взяты здесь).

Собственно так выглядит элемент
Собственно так выглядит элемент

Следуя изложенным вводным, класс компонента будет иметь следующее объявление:

class StringListInput(QWidget):
  string_added = Signal(str)	
  string_changed= Signal(int)
  string_deleted = Signal(str)
  
  def __init__(self, parent = None):
  
  def get_list(self) -> list[str]:
      
  def set_list(self, lst: Sequence[str]) -> None:
  
  def _on_add_item(self):
  
  def _on_edit_item(self):
  
  def _on_del_item(self):
  
  def _on_move_item(self, direction: str):
  
  def _get_text_dialog (self, title:str, label:str, text:str="")
  
  def setToolTip(self, arg__1:str) -> None
  
  def toolTip(self) -> str:

Класс компонента унаследован от класса QWidget и имеет

Методы:

  •   __init__(self, parent = None) – инициализирует компонент: создает композицию из встроенных виджетов, для нее используется виджеты типа QPushButton и QListWidget, к виджетам подключаются обработчики сигналов;

  • get_list(self) -> list[str] – возвращает список строк, который содержится в объекте типа QListWidget;

  • set_list(self, lst: Sequence[str]) -> None  - устанавливает список строк в объекте типа QListWidget;

  • _get_text_dialog(self, title:str, label:str, text:str="") – вспомогательный метод для получения строки, введенной пользователем с помощью диалогового окна;

  • setToolTip (self, arg__1:str), toolTop (self) – методы установки/получения текста всплывающей подсказки.

Методы – обработчики сигналов (Слоты):

  • _on_add_item(self) – функция добавления строки, вызывается при нажатии на кнопку «+» , ввод осуществляется в диалоговом окне;

  • _on_edit_item(self): - функция редактирования выделенной строки, вызывается при нажатии на  кнопку «✎» , редактирование осуществляется в диалоговом окне;

  • _on_del_item(self): - функция удаления выделенной строки, вызывается при нажатии на кнопку «-»;

  • _on_move_item(self, direction: str): - функция для изменения положения выделенной строки, если параметру direction передано значение “up”, то строка смещается вверх, “down” – вниз, вызывается при нажатии на кнопки «⮝», «⮟» ·    

Сигналы:

  • string_added – отправляет текст вновь добавленной строки;

  • string_changed – отравляет номер строки, которая была изменена;

  • string_deleted - отправляет текст удаленной строки.

1 Инициализация виджета

Процесс инициализации выполним двумя способами: напишем код макета вручную и сделаем макет в QtDesigner.

1.1 Написание кода вручную

Код метода __init__() будет иметь следующий вид:

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

    self.listWidget = QListWidget()
    self.listWidget.setFont(QApplication.instance().font())

    self.listWidget.itemDoubleClicked.connect(self._on_edit_item)
    horizontalLayout.addWidget(self.listWidget)
    horizontalLayout.setMargin(0)

    verticalLayout = QVBoxLayout()

    add_btn = QPushButton("+")
    add_btn.clicked.connect(self._on_add_item)
    verticalLayout.addWidget(add_btn)

    edit_btn = QPushButton("✎")
    edit_btn.clicked.connect(self._on_edit_item)
    verticalLayout.addWidget(edit_btn)

    delete_btn = QPushButton("−")
    delete_btn.clicked.connect(self._on_del_item)
    verticalLayout.addWidget(delete_btn)

    verticalSpacer_2 = QSpacerItem(20, 15, QSizePolicy.Minimum, QSizePolicy.Fixed)
    verticalLayout.addItem(verticalSpacer_2)

    moveup_btn = QPushButton("⮝")
    moveup_btn.clicked.connect(lambda :self._on_move_item("up"))
    verticalLayout.addWidget(moveup_btn)

    movedown_btn = QPushButton("⮟")
    movedown_btn.clicked.connect(lambda: self._on_move_item("down"))
    verticalLayout.addWidget(movedown_btn)

    verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)
    verticalLayout.addItem(verticalSpacer)
    horizontalLayout.addLayout(verticalLayout)

В первой строке мы вызываем метод __init__() класса QWidget, в остальном код типичен для верстки макета виджета в Qt, поэтому не стану заострять на этом внимание. Отмечу, что listWidget (экземпляр класса QWidgetList) должен быть атрибутом экземпляра создаваемого класса, чтобы он был доступен в других методах. Также не забываем кнопочкам привязать свой обработчик.

1.2 Верстка виджета в QtDesigner

Скажу честно, что отдаю предпочтению данному способу, так как двигать виджеты мышкой куда приятнее, и результат видно сразу. Не буду останавливаться на верстке макета в программе, скажу только, что в качестве основы следует выбрать Widget.

Макет в окне QtDesigner
Макет в окне QtDesigner

Рекомендую у внешнего макета (layout, компоновщик) значения Margin… выкрутить в ноль, чтобы компонент в дальнейшем не выделялся относительно остальных.

Margin лучше установить в ноль
Margin лучше установить в ноль

Генерируем на основе полученного макета код и сохраняем его на диске.

Импортируем полученный класс формы, в моем случае ему было присвоено имя Ui_Form. Текст для метода __init__() стал значительно короче.

class StringListInputDesigned(StringListInput):
     def __init__(self, parent = None):
        super(StringListInput, self).__init__(parent)
        self.ui = Ui_Form()
        self.ui.setupUi(self)
        self.listWidget = self.ui.listWidget
        self.listWidget.setFont(QApplication.instance().font())

        self.ui.add_btn.clicked.connect(self._on_add_item)
        self.ui.edit_btn.clicked.connect(self._on_edit_item)
        self.listWidget.itemDoubleClicked.connect(self._on_edit_item)
        self.ui.delete_btn.clicked.connect(self._on_del_item)
        self.ui.moveup_btn.clicked.connect(lambda :self._on_move_item("up"))
        self.ui.movedown_btn.clicked.connect(lambda :self._on_move_item("down"))

На гитхаб есть полный код примера (ссылки в конце статьи), в нем я сделал два класса, сейчас речь идет о StringListInputDesigned, он унаследован от StringListInput (для него мы писали код макета вручную). Наследование избавит нас от дублирования кода.

Итак, в коде первым делом снова вызываем аналогичный метод __init__() класса QWidget; self.ui = Ui_Form() и self.ui.setupUi(self) производит установку макета, и снова listWidget должен стать атрибутом экземпляра, иначе вызов других методов приведет к ошибке, его мы берем из сгенерированного кода, который мы присвоили атрибуту ui (компонент будет иметь имя, которое вы ему присвоите); в оконцовке виджетам присваиваем обработчики сигналов.

2 Работа над остальными методами

Виджет мы собрали, осталось проработать логику работы, но для начала напишем вспомогательный метод для ввода текста через диалоговое окно.

2.1 Метод получения текста, введенного пользователем

def _get_text_dialog(self, title: str, label: str, text: str = "") -> str:
    """ Получить текст, введенный пользователем в диалоговом окне
    """
    dlg = QInputDialog(self)
    dlg.setInputMode(QInputDialog.TextInput)
    dlg.setWindowTitle(title)
    dlg.setLabelText(label)
    dlg.setTextValue(text)
    dlg.resize(600, 0)
    res = dlg.exec()
    if res == QDialog.Accepted:
        return dlg.textValue()
    else:
        return text

Метод принимает на вход следующие параметры:

  • title – заголовок диалогового окна;

  • label – подпись над полем ввода;

  •   text – текст по умолчанию;  

и возвращает введенную строку. Можно было и вопрос решить вызовом статического метода getText() у класса QInputDialog, но меня не устроило то, что окно по дефолту очень узкое. Думаю, код метода не требует детальных пояснений, тут просто создается экземпляр QInputDialog и настраивается требуемым образом, если пользователь нажмет «Ок», то метод вернет введенный текст, иначе первоначальный текст.

2.2 Метод добавления строки

Здесь все просто, вызываем метод _get_text_dialog(), чтобы получить введенную пользователем строку; добавляем ее в виджет listWidget, передав текст методу addItem(); ставим выделение на новом элемент и передаем текст сигналу string_added.

@Slot()
def _on_add_item(self):
    text = self._get_text_dialog(QCoreApplication.translate("input_widgets", "Add string"),
                                 QCoreApplication.translate("input_widgets", "Enter the line text"))
    if text:
        self.listWidget.addItem(text)
        self.listWidget.setCurrentRow(self.listWidget.count() - 1)
        self.string_added.emit(text)

2.3 Метод редактирования строки

@Slot()
def _on_edit_item(self):
    row = self.listWidget.currentRow()
    if row < 0: return
    item: QListWidgetItem = self.listWidget.item(row)
    prev_text: str = item.text()

    text = self._get_text_dialog(QCoreApplication.translate("input_widgets", "Edit string"),
                                 QCoreApplication.translate("input_widgets", "Edit the line text"),
                                 text=item.text())

    if text and text != prev_text:
        item.setText(text)
        self.string_changed.emit(row)

Вызов self.listWidget.currentRow() вернет номер выделенной строки, если ни одна строка не выделена, то вернет -1; производится проверка, что одна из строк выделена, если результат ложный, то функция прерывается; self.listWidget.item(<номер строки>) вернет объект типа QListWidgetItem, чтобы получить текст нужно вызвать метод text(); текст сохраняется в переменной prev_text для дальнейшего использования; вызываем метод _get_text_dialog(), передав ему редактируемый текст и получаем измененную строку (или нет, зависит от действий пользователя); проверяем, чтобы строка включала в себя символы и чтобы она не была равна значению до редактирования (сохранен в переменной prev_text),  если проверка прошла успешно – обновляем текст элемента и передаем номер строки сигналу string_changed. Прошу заметить, методом setText() объекта типа QListWidgetItem мы изменили текст в модели, при это не касаясь listWidget. QListWidgetItem и QListWidget работают по принципу модель – представление, при этом достаточно только изменить объект модели, в представлении данные обновятся автоматически.

2.4 Метод удаления строки

С этим все еще проще.

@Slot()
def _on_del_item(self):
    row = self.listWidget.currentRow()
    if row < 0:
        return

    self.string_deleted.emit(self.listWidget.takeItem(row).text())

Сперва получаем номер выделенной строки; прерываем функцию, если строка не выделена; по номеру строки извлекаем объект QListWidgetItem, извлекаем из него текст и передаем его сигналу string_deleted. Метод takeItem(<номер строки>) возвращает объект и удаляет его из модели.

2.5 Метод перемещения строки

@Slot()
def _on_move_item(self, direction: str):
    row = self.listWidget.currentRow()
    if row < 0:
        return
    count = self.listWidget.count()

    if direction == "up":
        if row > 0:
            item = self.listWidget.takeItem(row)
            self.listWidget.insertItem(row - 1, item)
            self.listWidget.setCurrentRow(row - 1)

    elif direction == "down":
        if row < count - 1:
            item = self.listWidget.takeItem(row)
            self.listWidget.insertItem(row + 1, item)
            self.listWidget.setCurrentRow(row + 1)

Аргумент direction принимает строку, которая указывает направление смещения, “up”- вверх, “down”- вниз.

Снова получаем номер выделенной строки; прерываем функцию, если строка не выделена; вызываем метод count(), чтобы получить количество строк в виджете; если поступила команда «смещать  вверх», то проверяем, что номер строки больше нуля, иначе выше смещать некуда, извлекаем объект  QListWidgetItem вызовом метода takeItem(<номер строки>), методом insertItem(<номер строки >, <объект QListWidgetItem >) вставляем извлеченный элемент в строку на одну позицию выше; если поступила команда «смещать  вниз», то выполняем практически аналогичные действия, что и для противоположного направления.

2.6 Методы получения/установки списка строк

Итак, мы проработали методы, которые сделают наш композитный виджет полностью юзабельным. Осталось добавить интерфейс, чтобы внешняя программа могла им манипулировать, в принципе можно получить доступ к любому элементу нашего виджета, в том числе и к атрибуту listWidget и извлечь данные из него, но думаю так делать не стоит.

def get_list(self) -> list[str]:
    """Получить список строк"""
    extracted_list: list[str] = []
    for i in range(self.listWidget.count()):
        extracted_list.append(self.listWidget.item(i).text())
    return extracted_list

Тут мы используем уже знакомые нам методы, чтобы обойти все элементы списка у listWidget и извлечь из них текст и поместить в фиктивный список, а затем вернуть его.

def set_list(self, lst: Sequence[str]) -> None:
    """Установить список строк"""
    self.listWidget.clear()
    self.listWidget.addItems(lst)

Тут просто: удаляем все содержимое listWidget и устанавливаем содержимое списка, переданного аргументу lst.

2.7 Методы установки/получения всплывающей подсказки

Методы setToolTip() и toolTip() наследуются от QWidget, я решил их переопределить, поскольку если это не сделать, то подсказка будет появляться при наведении курсора на все область нашего виджета, мне нужно, чтобы она появлялась только при наведении на listWidget.

def setToolTip (self, arg__1: str) -> None:
    """Установить всплывающую подсказку """
    self.listWidget.setToolTip(arg__1)

def toolTip(self) -> str:
    """Вернуть текст всплывающей подсказки """
    return self.listWidget.toolTip()

Вместо заключения

Вот мы и разобрались с вопросом касательно того, как делать композитные виджеты в Qt5 (PySide2) из тех элементов, что встроены в фреймворк. Такой подход позволяет создавать комплексные виджеты, отвечающие специфическим нуждам, и которые будут иметь простой интерфейс для взаимодействия с внешней программой, также это позволяет упростить разработку и тестирование кода, поскольку код будет поделен на независимые компоненты.

Использование композитный виджетов в QtDesigner

Негативным момент в данной истории является то, что в QtDesigner пользовательские виджеты использовать можно, но только условно: они не будут отображены в окне и настраивать их будет невозможно. Вместо этого есть возможность преобразовать встроенный виджет в наш собственный, просто указав имя класса виджета и путь к нему.

Покажу это на примере одного из моих проектов. На рисунке ниже показано диалоговое окно (слева) для редактирования задания в программе GIF Builder, в самом верху есть две пустые области, это и есть те самые преобразованные виджеты, справа от окна показаны элементы, которые будут визуализированы при запуске программы.

Так выглядят преобразованные виджеты в QtDesigner и в запущенной программе
Так выглядят преобразованные виджеты в QtDesigner и в запущенной программе

Для преобразования вставьте встроенный объект Widget в нужно место, затем кликнете по нему правой кнопкой, затем клик по пункту «Преобразовать в…», и нужно указать фактическое имя класса и путь к нему. Тут есть один нюанс, запись производится в формате для языка C++. В поле «Имя преобразованного класса» записывается фактическое имя класса, в поле «Заголовочный файл» записывается путь для импорта класса. В данном случае вместо «.», как у Python нужно писать символ «/». В данном окне отображаются преобразованные классы, в моем случае у меня их два: QInputPath и QInputSourceFilePathes.

Окно с преобразования виджетов
Окно с преобразования виджетов

Импорт в сгенерированном коде для питона будет выглядеть так:

from ..InputPath.GBInputPath import QInputPath
from ..InputPath.GBInputSourceFilePathes import QInputSourceFilePathes

это относительный импорт, который означает, что класс QInputPath находится в модуле GBInputPath, тот в пакете InputPath, который находится на два уровня выше.

Для сравнения код импорта для «плюсов» будет сгенерирован следующий:

#include "//InputPath/GBInputPath"
#include "//InputPath/GBInputSourceFilePathes"

Сгенерированный код будет работать без проблем, только дополнительную конфигурацию виджетов придется делать ручками, только не советую делать это в файле со сгенерированным кодом, так как в процессе разработки придется вносить изменения в форму и генерировать код повторно, и все изменения будут потеряны.

У меня на этом все, надеюсь статья будет полезна, если есть вопросы или предложения не стесняйтесь выражать их в комментариях.

Ссылки

Код из данной статьи на гитхаб

GIF Builder - пакетный конвертер изображений и видео в gif (страница проекта, репозиторий гитхаб)

Заходите на мой сайт.

Подписывайте на телеграм канал , там я публикую сведения, которые публиковать на Хабре было бы неприлично.

Комментарии (2)


  1. IronMesh Автор
    00.00.0000 00:00

    Что-то хабр глюканул и вставил список со ссылками из моей прошлой статьи, хотя при редактировании текст ничего не отображено


    1. moderator
      00.00.0000 00:00

      Проверим