Пользовательский интерфейс в Qt

Доступность кастомных графических элементов, отрисованных через QPainter

Введение

Виджеты, рисующие своё содержимое полностью через QPainter (графики, диаграммы, кастомные индикаторы, canvas-подобные редакторы) представляют особую сложность для доступности — визуальная информация, переданная через цвет, форму, положение элементов на холсте, не имеет естественного текстового аналога, который мог бы быть автоматически извлечён Qt. В статье разберём подход к обеспечению доступности для содержательно сложного, полностью кастомно отрисованного виджета — простого линейного графика.

Концепция

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

Пример кода: Доступность кастомных графических

// linechartwidget.h — кастомный график с поддержкой доступности
#pragma once
#include <QWidget>
#include <QVector>
#include <QAccessibleWidget>

class LineChartWidget : public QWidget
{
    Q_OBJECT
public:
    explicit LineChartWidget(QWidget *parent = nullptr) : QWidget(parent)
    {
        setFocusPolicy(Qt::StrongFocus);
    }

    void setData(const QVector<double> &values)
    {
        m_values = values;
        m_selectedIndex = -1;
        update();
    }

    const QVector<double> &values() const { return m_values; }
    int selectedIndex() const { return m_selectedIndex; }

protected:
    void paintEvent(QPaintEvent *event) override; // отрисовка линии графика

    void keyPressEvent(QKeyEvent *event) override
    {
        if (m_values.isEmpty()) return;

        if (event->key() == Qt::Key_Right) {
            m_selectedIndex = qMin(m_selectedIndex + 1, m_values.size() - 1);
        } else if (event->key() == Qt::Key_Left) {
            m_selectedIndex = qMax(m_selectedIndex - 1, 0);
        } else {
            QWidget::keyPressEvent(event);
            return;
        }

        update();

        // Уведомляем подсистему доступности о смещении "виртуального" фокуса
        // на конкретную точку данных — реализуется через изменение значения
        QAccessibleValueChangeEvent valueEvent(this, m_values[m_selectedIndex]);
        QAccessible::updateAccessibility(&valueEvent);
    }

private:
    QVector<double> m_values;
    int m_selectedIndex = -1;
};
// linechartwidgetaccessible.h — интерфейс доступности с агрегированной сводкой и навигацией
#pragma once
#include <QAccessibleWidget>
#include "linechartwidget.h"
#include <numeric>
#include <algorithm>

class LineChartWidgetAccessible : public QAccessibleWidget
{
public:
    explicit LineChartWidgetAccessible(QWidget *widget)
        : QAccessibleWidget(widget, QAccessible::Graphic) {}

    QString text(QAccessible::Text textType) const override
    {
        auto *chart = static_cast<LineChartWidget *>(widget());
        const auto &values = chart->values();

        if (textType == QAccessible::Name) {
            if (values.isEmpty()) return "График без данных";

            const double minVal = *std::min_element(values.begin(), values.end());
            const double maxVal = *std::max_element(values.begin(), values.end());
            const double avgVal = std::accumulate(values.begin(), values.end(), 0.0) / values.size();
            const QString trend = values.last() > values.first() ? "восходящий" : "снижающийся";

            return QString("Линейный график, %1 точек данных. Минимум: %2, максимум: %3, "
                          "среднее: %4. Общий тренд: %5. Используйте стрелки влево/вправо "
                          "для навигации по отдельным точкам данных.")
                .arg(values.size()).arg(minVal).arg(maxVal).arg(avgVal, 0, 'f', 1).arg(trend);
        }

        if (textType == QAccessible::Value) {
            const int idx = chart->selectedIndex();
            if (idx < 0 || idx >= values.size()) return "Точка не выбрана";
            return QString("Точка %1 из %2: значение %3")
                .arg(idx + 1).arg(values.size()).arg(values[idx]);
        }

        return QAccessibleWidget::text(textType);
    }
};

Пояснения к коду

LineChartWidget реализует клавиатурную навигацию по отдельным точкам данных через стрелки влево/вправо, изменяя m_selectedIndex — это превращает изначально чисто визуальный график в структуру, которую можно «обходить» программно, что является необходимым предварительным условием для предоставления её содержимого screen reader пошагово, точка за точкой, а не только как единую неделимую визуальную картинку.

LineChartWidgetAccessible::text() для роли Name формирует развёрнутую агрегированную сводку — не пытаясь описать каждую точку линии (что было бы избыточно при большом количестве данных), а сообщая ключевые статистические характеристики (минимум, максимум, среднее, общий тренд) сразу при попадании фокуса на график, и явно инструктирует пользователя о возможности более детальной навигации через стрелки. Для роли Value возвращается информация о конкретно выбранной в данный момент точке — это значение динамически меняется при каждом нажатии стрелки, что и озвучивается через событие QAccessibleValueChangeEvent, отправленное в keyPressEvent().

Подводные камни

  • Описание сложного графика единственной длинной строкой при каждом попадании фокуса может быть избыточно многословным и утомительным при частом повторном фокусировании на одном и том же графике — для графиков, к которым пользователь возвращается неоднократно в рамках одной сессии работы, возможно стоит предусмотреть более компактное повторное объявление (например, сокращённую версию при повторном фокусе той же сессии), хотя это усложняет реализацию и не всегда оправдано для типичных сценариев использования.
  • Агрегированная статистика (минимум/максимум/среднее), не отражающая реально важный для конкретной задачи аспект данных. Для одних графиков важен общий тренд, для других — конкретные аномалии или выбросы, для третьих — сравнение с пороговым значением — универсальная сводка из примера хороша как базовое решение, но для специфичных по смыслу графиков (например, индикатор температуры с критичным порогом) стоит адаптировать содержание сводки под конкретный домен данных, а не использовать одну и ту же общую формулу для всех видов графиков.
  • Отсутствие визуальной индикации текущей выбранной через клавиатуру точки графика (m_selectedIndex) для зрячих пользователей, навигирующих с клавиатуры, но не использующих screen reader — реализация paintEvent() (не показанная полностью в примере) обязательно должна визуально подсвечивать выбранную точку (например, увеличенным маркером или вертикальной линией), синхронизируя визуальную и звуковую обратную связь для пользователей, которым нужна именно клавиатурная навигация без screen reader.
  • Чрезмерная сложность реализации полноценной доступности для каждого кастомного графического виджета в приложении с большим количеством разных типов визуализаций — для продуктов с обширной линейкой кастомных графиков создание подобной детальной поддержки доступности для каждого отдельного типа визуализации требует существенных трудозатрат; стоит приоритизировать усилия на действительно ключевых, часто используемых визуализациях, а не пытаться сразу обеспечить одинаково глубокую доступность для каждого мелкого графического элемента интерфейса.