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

Контрастность интерфейса и поддержка тем для слабовидящих пользователей

Введение

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

Концепция

Web Content Accessibility Guidelines (WCAG), хотя формально адресованы веб-контенту, дают универсально применимые количественные критерии и для десктопных приложений: коэффициент контрастности (соотношение яркости текста и фона) не менее 4.5:1 для обычного текста и 3:1 для крупного текста (уровень AA), и не менее 7:1/4.5:1 соответственно для более строгого уровня AAA. Расчёт коэффициента контрастности — стандартная, документированная формула на основе относительной яркости (relative luminance) цветов, которую можно реализовать программно для автоматической проверки палитры приложения, а не полагаться только на визуальную оценку дизайнером.

Пример кода: Контрастность интерфейса и

#include <QColor>
#include <QtMath>

// Реализация формулы относительной яркости и контрастности по WCAG
double relativeLuminance(const QColor &color)
{
    auto linearize = [](double channel) {
        channel /= 255.0;
        return (channel <= 0.03928) ? channel / 12.92 : qPow((channel + 0.055) / 1.055, 2.4);
    };

    const double r = linearize(color.red());
    const double g = linearize(color.green());
    const double b = linearize(color.blue());

    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

double contrastRatio(const QColor &foreground, const QColor &background)
{
    const double l1 = relativeLuminance(foreground);
    const double l2 = relativeLuminance(background);
    const double lighter = qMax(l1, l2);
    const double darker = qMin(l1, l2);
    return (lighter + 0.05) / (darker + 0.05);
}

bool meetsWcagAA(const QColor &foreground, const QColor &background, bool isLargeText = false)
{
    const double ratio = contrastRatio(foreground, background);
    return isLargeText ? (ratio >= 3.0) : (ratio >= 4.5);
}
// Автоматизированная проверка палитры приложения перед релизом (может быть частью теста)
#include <QtTest/QtTest>

class TestColorContrast : public QObject
{
    Q_OBJECT
private slots:
    void testPrimaryTextOnBackground()
    {
        const QColor textColor(51, 51, 51);       // основной текст #333333
        const QColor backgroundColor(255, 255, 255); // белый фон

        QVERIFY2(meetsWcagAA(textColor, backgroundColor),
                  "Основной текст не соответствует WCAG AA по контрастности");
    }

    void testSecondaryTextOnAccentBackground()
    {
        // Часто проблемный случай: светлый текст на акцентном цвете кнопки
        const QColor buttonTextColor(255, 255, 255);
        const QColor buttonBackgroundColor(120, 170, 230); // недостаточно тёмный синий

        QVERIFY2(meetsWcagAA(buttonTextColor, buttonBackgroundColor),
                  "Текст кнопки недостаточно контрастен на фоне акцентного цвета");
    }
};

QTEST_APPLESS_MAIN(TestColorContrast)
#include "test_colorcontrast.moc"
// Пример избегания "цвет как единственный индикатор" в QML
import QtQuick

Row {
    spacing: 8
    // НЕДОСТАТОЧНО: только цвет различает статусы, неразличимо для дальтоника
    // Rectangle { width: 12; height: 12; radius: 6; color: isOnline ? "green" : "red" }

    // ЛУЧШЕ: цвет дополнен формой/иконкой/текстом
    Rectangle {
        width: 12; height: 12; radius: 6
        color: isOnline ? "#4caf50" : "#f44336"
        border.width: isOnline ? 0 : 2  // дополнительное визуальное отличие через форму
        border.color: "white"
    }
    Text { text: isOnline ? "В сети" : "Не в сети" } // текстовое подтверждение статуса

    property bool isOnline: true
}

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

relativeLuminance() и contrastRatio() реализуют точную формулу WCAG для расчёта коэффициента контрастности между двумя цветами — нелинейное преобразование канала (linearize) учитывает нелинейность восприятия яркости человеческим глазом, и коэффициенты 0.2126/0.7152/0.0722 отражают разную чувствительность глаза к красному, зелёному и синему каналам соответственно (зелёный воспринимается значительно ярче при том же числовом значении канала).

TestColorContrast показывает, как превратить субъективную дизайнерскую оценку «выглядит достаточно контрастно» в автоматизированную, объективную проверку, интегрируемую в обычный набор тестов QTest — это позволяет ловить регрессии контрастности при изменении палитры приложения (например, при добавлении тёмной темы, разобранной в основном цикле статей про Qt Widgets) ещё на этапе CI, а не после жалоб пользователей. QML-пример демонстрирует принцип «не только цвет» — дублирование цветового индикатора статуса дополнительной текстовой подписью и отличием по форме (наличие/отсутствие белой обводки), что делает статус различимым даже для пользователя, не различающего красный и зелёный цвета.

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

  • Расчёт контрастности только для «основной» комбинации текст/фон без учёта состояний (hover, disabled, selected). Цвет текста на кнопке в обычном состоянии может быть достаточно контрастным, но при наведении (hover) или в неактивном состоянии (disabled) часто используются более светлые/приглушённые оттенки — именно эти, менее тщательно проверяемые состояния, часто оказываются недостаточно контрастными на практике.
  • Полагание исключительно на автоматическую проверку коэффициента контрастности без визуальной проверки реального восприятия. Формула WCAG даёт объективную числовую метрику, но не заменяет реальное тестирование с пользователями с разными типами нарушений зрения или хотя бы симуляторами дальтонизма — два цвета могут формально соответствовать порогу контрастности, но всё равно создавать субъективно неприятное или сложное для восприятия сочетание.
  • Жёстко закодированные цвета напрямую в QML/коде виджетов без централизованной системы токенов цвета. Если каждый компонент определяет свои цвета независимо, без единого источника правды (палитры, доступной для централизованной проверки и обновления), последовательная проверка контрастности всего приложения через автоматизированные тесты, как в примере, становится практически невозможной — нужна архитектура с централизованным набором именованных цветов, проверяемых один раз, а не разбросанных по всему коду.
  • Игнорирование контрастности неактивного (disabled) состояния элементов под предлогом «это и не должно быть заметным». Хотя неактивные элементы по дизайну часто намеренно менее контрастны для визуального отличия от активных, это не означает, что они должны быть полностью нечитаемыми для слабовидящего пользователя — разумный компромисс между визуальной иерархией (активное/неактивное) и минимальной читаемостью должен быть найден явно, а не оставлен на волю случая при выборе оттенка серого «на глаз».