image


Перевод туториала по фреймворку для построения TUI (текстовых интерфейсов). Кроссплатформенность, возможность вывода в веб, а также олдскульность.


Добро пожаловать в учебное пособие по Textual!


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


Если хотите, чтобы люди что-то делали, сделайте это весёлым.
— Will McGugan (создатель Rich и Textual)

Видеоуроки


К этому учебному пособию прилагаются видеоуроки, содержащие тот же контент.


Приложение-секундомер


Мы сделаем приложение-секундомер (stopwatch). Оно будет отображать список секундомеров с кнопками для запуска, остановки и сброса значения. Пользователь также сможет добавлять и удалять секундомеры при необходимости.


Это будет простое, но полностью функциональное приложение — можете его распространять, если хотите!


Вот так будет выглядеть готовое приложение:



Попробуйте!


Следующая картинка это не скриншот, а полноценное интерактивное приложение Textual, работающее в браузере.


Попробовать приложение online.

В оригинале тут был <iframe> но во избежание хабраэффекта я оставил от него только ссылку:



Подсказка: Если вам интересно, как публиковать приложения Textual в вебе, смотрите textual-web.

Получаем код


Если хотите попробовать готовое приложение Stopwatch и посмотреть его код, для начала установите Textual, после чего склонируйте репозиторий Textual:


git clone https://github.com/Textualize/textual.git

Когда репозиторий склонируется, перейдите в каталог docs/examples/tutorial и запустите stopwatch.py.


cd textual/docs/examples/tutorial
python stopwatch.py

Аннотации типов (кратко)


Подсказка: Аннотации типов в Textual совершенно не обязательны. Мы включили их в код примера, но хотите ли вы их использовать — решайте сами.

Мы большие поклонники аннотаций типов Python в Textualize. Если вы с ними ещё не сталкивались, кратко скажем, что это способ указать типы данных, параметров и возвращаемых значений. Аннотация типа позволяет таким инструментам, как mypy, находить ошибки до запуска вашего кода.


Следующая функция содержит аннотации типов:


def repeat(text: str, count: int) -> str:
    """Repeat a string a given number of times."""
    return text * count

Типы параметров указываются после двоеточия. Так, text: str показывает, что text должен быть строкой, а count: int означает, что count должен быть целым числом.


Тип возвращаемого значения указывается после ->. Так, -> str: показывает, что метод возвращает строку.


Класс App


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


stopwatch01.py


from textual.app import App, ComposeResult
from textual.widgets import Header, Footer

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Если вы запустите этот код, то увидите примерно следующее:



Нажмите клавишу D для переключения между светлым и тёмным режимом.



Нажмите Ctrl+C, чтобы выйти из приложения и вернуться в командную строку.


Рассмотрим класс App поближе


Давайте более подробно изучим stopwatch01.py.


from textual.app import App, ComposeResult
from textual.widgets import Header, Footer

Первая строка импортирует класс App, который будет базовым для нашего приложения. Вторая импортирует два встроенных виджета: Footer, который показывает в нижней части экрана панель с привязанными клавишами, и Header, который показывает заголовок в верхней части экрана. Виджеты — это повторно используемые компоненты, отвечающие за управление частью экрана. В этом руководстве мы рассмотрим, как их создавать.


Следующие строки определяют само приложение:


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

Класс App — это место, где находится большая часть логики приложений Textual. Он отвечает за загрузку конфигурации, настройку виджетов, обработку ключей и многое другое.


Вот что в нём задаётся:


  • BINDINGS это список кортежей, которые отображают (или привязывают) клавиши к действиям вашего приложения. Первый элемент кортежа это клавиша, второй — имя действия, последний — краткое описание. Пока что у нас только одна привязка, отображающая клавишу D на действие "toggle_dark". Подробнее см. key bindings в руководстве.
  • compose() это метод, где из виджетов конструируется пользовательский интерфейс. Вообще, метод compose() должен возвращать последовательность виджетов, но проще возвращать их по одному через yield (что превращает метод в генератор). В примере мы возвращаем оба импортированных класса, т.е. Header() и Footer().
  • action_toggle_dark() определяет метод действия. Действия — это методы, начинающиеся с action_, за которым идёт имя действия. Приведённый выше список BINDINGS приказывает Textual запустить это действие, когда пользователь нажмёт клавишу D. За подробностями обращайтесь к actions в руководстве.

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Последние три строки создают экземпляр приложения и вызывают метод run(), который переводит ваш терминал в режим приложения, запускает приложение и ждёт до тех пор, пока вы не выйдете с помощью Ctrl+C. Это происходит в блоке __name__ == "__main__", чтобы можно было как запустить приложение с помощью python stopwatch01.py так и импортировать его как часть более крупного проекта.


Проектирование UI с помощью виджетов


В комплекте с Textual есть набор встроенных виджетов, таких как Header и Footer, которые универсальны и могут использоваться повторно. Для секундомера нам нужно будет создать несколько пользовательских виджетов. Прежде чем мы этим займёмся, давайте сначала набросаем дизайн приложения, чтобы знать, к чему мы стремимся.



Пользовательские виджеты


Нам нужен виджет Stopwatch, собранный из таких дочерних виджетов:


  • Кнопка «Start»
  • Кнопка «Stop»
  • Кнопка «Reset»
  • Отображение времени

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


Давайте добавим их в приложение. Пока что это просто скелет, но по ходу дела он обрастёт нужными функциями.


stopwatch02.py


from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.widgets import Button, Footer, Header, Static

class TimeDisplay(Static):
    """A widget to display elapsed time."""

class Stopwatch(Static):
    """A stopwatch widget."""

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay("00:00:00.00")

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

В этом коде мы импортировали два новых виджета: Button, который создаёт нажимаемую кнопку и Static, являющийся базовым классом для простых контролов. Также мы импортировали ScrollableContainer из textual.containers который (как подсказывает его имя) является виджетом, содержащим другие виджеты.


Мы объявили пустой виджет TimeDisplay, унаследованный от Static. Наполним его позже.


Класс виджета Stopwatch также унаследован от Static. У него есть метод compose(), который возвращает последовательность дочерних виджетов, состоящую из трёх объектов Button и одного TimeDisplay. Эти виджеты будут формировать секундомер, как в нашем наброске.


Кнопки


Конструктор Button принимает текст (label), который будет отображаться на кнопке ("Start", "Stop" или "Reset"). Кроме того, некоторые кнопки получают следующие параметры:


  • id это идентификатор, по которому мы можем найти кнопку и применить к ней стили. Подробности будут позже.
  • variant это строка, выбирающая стиль по умолчанию. Вариант "success" сделает кнопку зелёной, а "error" — красной.

Композиция виджетов


Чтобы добавить виджеты в наше приложение, нам сначала нужно вернуть их из метода compose().


Новая строка в StopwatchApp.compose() создаёт единственный объект ScrollableContainer, который содержит прокручиваемый список секундомеров. Когда классы содержат другие виджеты (например, ScrollableContainer), они обычно принимают свои дочерние виджеты в качестве позиционных аргументов. Мы хотим запустить приложение с тремя секундомерами, поэтому создаем три экземпляра Stopwatch и передаем их конструктору контейнера.


Приложение без стилей


Давайте посмотрим, что случится, когда мы запустим stopwatch02.py.



Элементы приложения-секундомера уже есть. Кнопки интерактивны, и вы можете прокручивать контейнер, но это не похоже на наш набросок. Это потому, что нам еще предстоит применить к нашим новым виджетам стили.


Написание CSS для Textual


У каждого виджета есть объект styles с набором атрибутов, которые влияют на его внешний вид. Вот как вы можете установить для виджета белый текст на синем фоне:


self.styles.background = "blue"
self.styles.color = "white"

Хотя стили и можно устанавливать таким образом, необходимость в этом возникает редко. Textual поддерживает CSS (Cascading Style Sheets): технологию, используемую в веб-браузерах. CSS-файлы это файлы данных, загружаемые вашим приложением и содержащие информацию о стилях, которые применяются к вашим виджетам.


Информация: Диалект CSS, используемый в Textual, сильно проще вебовского CSS и гораздо легче изучается.

CSS упрощает итеративный дизайн вашего приложения и делает возможным живое редактирование — вы можете редактировать CSS и видеть изменения без перезапуска приложения!


Давайте добавим CSS-файл к нашему приложению.


stopwatch03.py


from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.widgets import Button, Footer, Header, Static

class TimeDisplay(Static):
    """A widget to display elapsed time."""

class Stopwatch(Static):
    """A stopwatch widget."""

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay("00:00:00.00")

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch03.tcss"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Добавление классовой переменной CSS_PATH приказывает Textual загрузить следующий файл при запуске приложения:


stopwatch03.tcss

Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 1;
}

TimeDisplay {
    content-align: center middle;
    text-opacity: 60%;
    height: 3;
}

Button {
    width: 16;
}

#start {
    dock: left;
}

#stop {
    dock: left;
    display: none;
}

#reset {
    dock: right;
}

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



Это уже больше похоже на наш набросок. Давайте посмотрим, как Textual использует stopwatch03.tcss для применения стилей.


Основы CSS


CSS-файлы содержат некоторое количество блоков объявлений. Вот первый такой блок из stopwatch03.tcss:


Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 1;
}

Первая строка говорит Textual, что стиль должен применяться к виджету Stopwatch. Строки между фигурными скобками содержат непосредственно стили.


Вот как этот CSS-код изменяет внешний вид виджета Stopwatch.



  • layout: horizontal выстраивает дочерние виджеты по горизонтали слева направо.
  • background: $boost устанавливает цвет фона в значение $boost. Префикс $ означает предварительно заданный цвет из встроенной темы. Цвет можно задавать и другими способами, например, "blue" или rgb(20,46,210).
  • height: 5 устанавливает высоту виджета равной 5 строкам текста.
  • margin: 1 устанавливает внешний отступ вокруг виджета Stopwatch в 1 знакоместо, чтобы между виджетами в списке было небольшое расстояние.
  • min-width: 50 устанавливает минимальную ширину нашего виджета в 50 знакомест.
  • padding: 1 устанавливает внутренний отступ вокруг дочерних виджетов в 1 знакоместо.

Вот остаток stopwatch03.tcss, с оставшимися блоками объявлений:


TimeDisplay {
    content-align: center middle;
    opacity: 60%;
    height: 3;
}

Button {
    width: 16;
}

#start {
    dock: left;
}

#stop {
    dock: left;
    display: none;
}

#reset {
    dock: right;
}

Блок TimeDisplay выравнивает текст по центру (content-align), немного приглушает его (opacity), и задаёт ему высоту (height) в 3 строки.


Блок Button выставляет ширину (width) кнопок в 16 знакомест.


Последние три блока имеют немного другой формат. Если объявление начинается с #, то стили будут применяться к виджетам с соответствующим атрибутом id. Мы задали ID виджетов Button, когда возвращали их в compose. Например, у первой кнопки id="start", чему соответствует #start в CSS.


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


Вы могли обратить внимание, что для кнопки остановки (#stop в CSS) задано display: none;. Это указывает Textual не отображать кнопку. Мы сделали это, потому что не хотим, чтобы кнопка остановки отображалась, когда таймер не запущен. Аналогично, нам не нужно, чтобы кнопка запуска отображалась, когда таймер запущен. Как работать с таким динамическим интерфейсом, мы увидим в следующем разделе.


Динамический CSS


Мы хотим, чтобы наш виджет Stopwatch имел два состояния: состояние по умолчанию с кнопками Start и Reset; и состояние started с кнопкой Stop. Также, когда секундомер запущен, он должен иметь зелёный фон и выделенный жирным шрифтом текст.



Мы можем достичь этого с помощью классов CSS. Не путайте с классами Python, класс CSS это что-то вроде тега, который вы можете добавить к виджету, чтобы изменить его стили.


Вот новый CSS:


stopwatch04.tcss


Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 1;
}

TimeDisplay {
    content-align: center middle;
    text-opacity: 60%;
    height: 3;
}

Button {
    width: 16;
}

#start {
    dock: left;
}

#stop {
    dock: left;
    display: none;
}

#reset {
    dock: right;
}

.started {
    text-style: bold;
    background: $success;
    color: $text;
}

.started TimeDisplay {
    text-opacity: 100%;
}

.started #start {
    display: none
}

.started #stop {
    display: block
}

.started #reset {
    visibility: hidden
}

Эти новые правила начинаются с префикса .started. Символ . указывает, что .started относится к классу CSS с именем "started". Новые стили будут применены только к виджетам, имеющим этот CSS-класс.


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


.started #start {
    display: none
}

Селектор .started соответствует любому виджету с CSS-классом "started". А #start соответствует дочернему виджету с идентификатором "start". Таким образом, правило соответствует кнопке Start для секундомеров в запущенном состоянии.


Правило равно "display: none", то есть, Textual не будет отрисовывать кнопку.


Манипулирование классами


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


Вы можете добавлять и удалять классы CSS с помощью методов add_class() и remove_class(). Мы будем использовать их для привязки состояния started к кнопкам Start и Stop.


Следующий код запустит или остановит секундомеры при нажатии кнопки.


stopwatch04.py


from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.widgets import Button, Footer, Header, Static

class TimeDisplay(Static):
    """A widget to display elapsed time."""

class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        if event.button.id == "start":
            self.add_class("started")
        elif event.button.id == "stop":
            self.remove_class("started")

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay("00:00:00.00")

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch04.tcss"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Метод on_button_pressed является обработчиком события. Обработчики событий — это методы, вызываемые Textual в ответ на событие, такое как нажатие клавиши, щелчок мыши и т.д. Обработчики событий начинаются с on_, за которым следует название события, которое они будут обрабатывать. Следовательно, on_button_pressed будет обрабатывать нажатие кнопки.


Если вы запустите stopwatch04.py сейчас, то сможете переключаться между двумя состояниями по нажатию первой кнопки:



Реактивные атрибуты


В Textual регулярно всплывает тема обновления содержимого виджета время от времени. Это возможно: вы можете вызвать refresh() для отображения новых данных. Однако предпочтительно делать это автоматически с помощью реактивных атрибутов.


Реактивный атрибут можно объявить с помощью функции reactive. Давайте воспользуемся ею, чтобы создать таймер, который отображает прошедшее время и постоянно обновляет его.


stopwatch05.py


from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.reactive import reactive
from textual.widgets import Button, Footer, Header, Static

class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.set_interval(1 / 60, self.update_time)

    def update_time(self) -> None:
        """Method to update the time to the current time."""
        self.time = monotonic() - self.start_time

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        if event.button.id == "start":
            self.add_class("started")
        elif event.button.id == "stop":
            self.remove_class("started")

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch04.tcss"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Create child widgets for the app."""
        yield Header()
        yield Footer()
        yield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Мы добавили в виджет TimeDisplay два реактивных атрибута: start_time будет содержать время (в секундах) запуска секундомера, а time — время, которое будет отображаться на Stopwatch.


Оба атрибута доступны в self, как если бы вы добавили их в __init__. Если вы присвоите значение любому из этих атрибутов, виджет обновится автоматически.


Информация: Функция monotonic импортирована из модуля time стандартной библиотеки. Она похожа на time.time, но не возвращается назад, если изменится системное время.

Первый аргумент reactive — необязательное значение по умолчанию для атрибута или вызываемый объект, который возвращает значение по умолчанию. В качестве значения по умолчанию для start_time мы передали функцию monotonic, которая будет вызвана для инициализации атрибута текущим временем при добавлении TimeDisplay в приложение. Атрибут time по умолчанию имеет простое значение с плавающей запятой, поэтому self.time будет инициализирован значением 0.


Метод on_mount — это обработчик событий, вызываемый при первом добавлении виджета в приложение (монтировании в терминологии Textual). В этом методе мы вызываем set_interval(), чтобы создать таймер, вызывающий self.update_time шестьдесят раз в секунду. Этот метод вычисляет время, прошедшее с момента запуска виджета, и помещает его значение в self.time, что подводит нас к одной из сверхспособностей Reactive.


Если вы реализуете метод, который начинается с watch_, за которым следует имя реактивного атрибута, то при изменении атрибута он будет автоматически вызван. Такие методы известны как следящие методы.


Когда мы обновляем self.time 60 раз в секунду, это приводит к неявному вызову watch_time, который преобразует прошедшее время в строку и обновляет виджет вызовом self.update. Поскольку это происходит автоматически, нам не нужно передавать начальный аргумент в TimeDisplay.


В итоге виджеты Stopwatch показывают время, прошедшее с момента создания виджета:



Мы увидели, как обновлять виджеты с помощью таймера, но ещё нужно подключить кнопки, чтобы управлять секундомерами независимо.


Подключение кнопок


Нам нужно уметь запускать, останавливать и сбрасывать каждый секундомер независимо от остальных. Этого можно добиться, добавив несколько методов к классу TimeDisplay.


stopwatch06.py


from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.reactive import reactive
from textual.widgets import Button, Footer, Header, Static

class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self) -> None:
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self) -> None:
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0

class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch04.tcss"
    BINDINGS = [("d", "toggle_dark", "Toggle dark mode")]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch())

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Вот какие изменения сделаны в TimeDisplay.


  • Мы добавили реактивный атрибут total, который хранит общее время, прошедшее между нажатиями на кнопки старта и останова.
  • В вызов set_interval добавился аргумент pause=True, который запускает таймер в режиме паузы (когда таймер на паузе, он не идёт, пока не будет вызван метод resume()). Это потому, что мы не хотим, чтобы время обновлялось, пока пользователь не нажмёт кнопку старта.
  • Метод update_time теперь добавляет total к текущему времени, чтобы учесть время между любыми предыдущими нажатиями на кнопки старта и останова.
  • Мы сохранили результат set_interval, который возвращает объект таймера. Позднее мы используем его, чтобы продолжить работу таймера при следующем запуске секундомера.
  • Мы добавили методы start(), stop() и reset().

Дополнительно к методу on_button_pressed класса Stopwatch добавилось немного кода для управления отображением времени, когда пользователь нажимает кнопку. Рассмотрим его подробнее:


    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

Этот код добавляет отсутствовавшие функции и делает наше приложение полезным. Мы сделали следующие изменения.


  • Первая строка получает id нажатой кнопки. Мы можем использовать его, чтобы определить, что именно делать.
  • Вторая строка вызывает query_one, чтобы получить ссылку на виджет TimeDisplay.
  • Мы вызываем метод TimeDisplay, соответствующий нажатой кнопке.
  • Мы добавляем класс "started", если секундомер запущен (self.add_class("started")), и удаляем его (self.remove_class("started")), если он остановлен. Это обновляет внешний вид секундомера через CSS.

Если вы запустите stopwatch06.py, то сможете использовать секундомеры независимо.



Единственное, что нам осталось реализовать, это возможность добавлять и удалять секундомеры.


Динамические виджеты


Приложение Stopwatch создаёт виджеты при запуске через метод compose. Нам также понадобится создавать новые виджеты во время работы приложения и удалять их, когда они больше не нужны. Это можно сделать, вызвав mount(), чтобы добавить виджет и remove(), чтобы убрать его.


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


stopwatch.py


from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.reactive import reactive
from textual.widgets import Button, Footer, Header, Static

class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self):
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self):
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0

class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch.tcss"

    BINDINGS = [
        ("d", "toggle_dark", "Toggle dark mode"),
        ("a", "add_stopwatch", "Add"),
        ("r", "remove_stopwatch", "Remove"),
    ]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield ScrollableContainer(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")

    def action_add_stopwatch(self) -> None:
        """An action to add a timer."""
        new_stopwatch = Stopwatch()
        self.query_one("#timers").mount(new_stopwatch)
        new_stopwatch.scroll_visible()

    def action_remove_stopwatch(self) -> None:
        """Called to remove a timer."""
        timers = self.query("Stopwatch")
        if timers:
            timers.last().remove()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark

if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

Вот суть изменений:


  • У объекта ScrollableContainer в StopWatchApp добавился id "timers".
  • Добавлен метод action_add_stopwatch для добавления нового секундомера.
  • Добавлен метод action_remove_stopwatch для удаления секундомера.
  • Добавлена привязка горячих клавиш для этих действий.

Метод action_add_stopwatch создаёт и монтирует новый секундомер. Обратите внимание на вызов query_one() с CSS-селектором "#timers", который возвращает контейнер таймеров по его ID. После монтирования новый секундомер появится в терминале. Последняя строчка в action_add_stopwatch вызывает scroll_visible(), который прокручивает контейнер так, чтобы сделать новый Stopwatch видимым (если нужно).


Функция action_remove_stopwatch вызывает query() с CSS-селектором "Stopwatch", который возвращает все виджеты Stopwatch. Если они есть, то вызывается last() для получения последнего секундомера и remove() для его удаления.


Если вы запустите stopwatch.py, то сможете добавлять новые секундомеры клавишей A и удалять их клавишей R.



Что дальше?


Поздравляем вас с созданием первого приложения на Textual! В этом руководстве было рассмотрено много нового. Если вы относитесь к тому типу людей, которые предпочитают изучать фреймворк с помощью программирования, не стесняйтесь. Можете доработать stopwatch.py или просмотреть примеры.


Прочтите руководство для получения полной информации о том, как создавать сложные приложения TUI с помощью Textual.

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