В прошлой статье мы начали рассказ о рецептах, посвященных переводу Qt-приложений на рельсы High DPI, то есть адаптации этих приложений к мониторам высокой четкости.

Суть в том, что в ОС Windows давно есть такие настройки, как масштабирование шрифта и изображений (масштаб экрана), которые применяются для увеличения слишком малых элементов GUI на мониторах с высокой чёткостью (High DPI). Однако не каждое приложение, написанное на Qt, способно адекватно учитывать, применять этот самый масштаб экрана. Частая ситуация – приложение хорошо выглядит на Full HD (1920x1080), но стоит поставить монитор 4K (3840x2160) и увеличить масштаб экрана, то появляются многочисленные артефакты.

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

Исходный код примеров был протестирован на Qt5 и Qt6 (более точно – 5.15 и 6.2), на операционках Windows 10 и Windows 7. В основном в статье описаны реалии и будни Qt5 (поскольку именно на Qt5 проблема стоит особенно остро), но рецепты можно с успехом применять и на Qt6 – ничего от этого не поломается.

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

Пример 7. Колонки таблицы

Предположим, мы создаем текстовую таблицу, удобную для восприятия пользователя. Под удобством будем предполагать то, что размер колонок будет устанавливаться не по умолчанию, а будет адаптирован либо под длину заголовков колонок, либо под длину содержимого в ячейках колонки. Пусть для простоты под длину заголовков. В реальной жизни заполнение таблицы будет, скорее всего, динамическим (например, из базы данных), но мы для простоты сделаем статическое заполнение. Таким образом, в соответствии с рецептом 3 имеем право написать вот такой код:

Показать код
TableWidget::TableWidget(QWidget* parent)
	: QTableWidget(parent)
{
	setSelectionBehavior(QAbstractItemView::SelectRows);
	setSelectionMode(QAbstractItemView::ExtendedSelection);
	setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);
	setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
	verticalHeader()->setVisible(false);

	setRowCount(2);
	setColumnCount(3);

	QStringList headers;
	headers << "Short" << "Middle header" << "Long long long long header";
	setHorizontalHeaderLabels(headers);

	QFont font(horizontalHeader()->font());
	// A header will draw a bold font after row selecting. Take it into account.
	font.setBold(true); 
	QFontMetrics fm(font);
	int space = fm.horizontalAdvance("a"); // one symbol width

	for (int i = 0; i < headers.size(); i++)
	{
		int w = fm.horizontalAdvance(headers[i]) + 2 * space;
		setColumnWidth(i, w);
	}
}

Здесь мы использовали метрику шрифтов, чтобы высчитать такие размеры заголовков, чтобы текст (заголовков) всегда помещался в рамки.

Добавим еще пару строк кода, чтобы более-менее корректно отрисовывать такой табличный виджет:

Показать код
QSize TableWidget::sizeHint() const
{
	const double scale = screen()->logicalDotsPerInchX() / 96.0;
	return QSize(qRound(320 * scale), qRound(100 * scale));
}

Здесь мы довольно грубо нарушили верную логику: здесь считать размеры надо было из метрики шрифтов (и элементов стилей), а не из масштаба экрана, то есть применять рецепт 3 вместо рецепта 6. Что ж, реальная жизнь нам этого не простит, а для учебного примера пойдет.

В  результате получим вот такой виджет:

Windows7, масштаб экрана 100%,  150%.
Windows7, масштаб экрана 100%, 150%.

Казалось бы, все хорошо, виджет выглядит адекватно при любых масштабах экрана. Чего же боле?

Дело в том, что, в отличие от нашего примера, таблица будет заполняться динамически, может быть много строк и колонок, а текст в ячейках может быть как длинным, так и коротким. Пользователь неизбежно захочет менять размеры (имеем в виду ширину, конечно) колонок. А, поменяв, неизбежно захочет, чтобы размер колонок сохранялся между запусками приложения. А иначе зачем ему такая таблица с таким GUI? Таким образом, те размеры, которые мы научились считать из текста заголовков – это не более чем начальное приближение, а в процессе работы пользователь будет подстраивать GUI «под себя».

Итак, сохраняем настройки колонок в реестре:

TableWidget::~TableWidget()
{
	QSettings settings("Test company name", "Test product name");
	settings.setValue("TableColumns", horizontalHeader()->saveState());
}

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

	QSettings settings("Test company name", "Test product name");
	QVariant var = settings.value("TableColumns");
	if (!var.isNull())
		horizontalHeader()->restoreState(var.toByteArray());

И только теперь мы подошли к проблеме. Это всё будет прекрасно работать, но только до тех пор, пока масштаб экрана не изменится (а это произойдет, как только пользователь купит новый монитор 4K 27" и  увеличит для него масштаб экрана):

Windows7, масштаб экрана 100%,  150%.
Windows7, масштаб экрана 100%, 150%.

Получить такую картинку неудивительно, ведь из реестра грузятся те размеры колонок, которые были приемлемы для тех, «старых» размеров шрифтов.

 Решить эту проблему можно двумя путями. Рассмотрим их подробно.

Рецепт 7. Ведите несколько веток реестра для разных масштабов экрана.

Наиболее очевидно решение – для каждого масштаба экрана сделать свою ветку реестра, из которой бы загружалось прошлое значение. Для монитора 24" FullHD с масштабом экрана 100% будет своя ветка, а для 27" 4K с масштабом экрана 200%  – своя. Вот как это реализовывается. В конструкторе заменим код работы с реестром на следующий:

const int scale = qRound(screen()->logicalDotsPerInchX() / 96.0 * 100);
	QSettings settings("Test company name", "Test product name");
	QVariant var = settings.value(QString("TableColumns/scale%1").arg(scale));
	if (!var.isNull())
		horizontalHeader()->restoreState(var.toByteArray());

А деструктор заменим на это:

TableWidget::~TableWidget()
{
	const int scale = qRound(screen()->logicalDotsPerInchX() / 96.0 * 100);
	QSettings settings("Test company name", "Test product name");
	settings.setValue(QString("TableColumns/scale%1").arg(scale), horizontalHeader()->saveState());
}

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

Windows7, масштаб экрана 100%,  150%.
Windows7, масштаб экрана 100%, 150%.

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

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

Рецепт 8. Корректируйте размеры после загрузки их из реестра

Благодаря наличию функции QTableView::setColumnWidth, описанную проблему решить довольно просто – надо размеры колонок растянуть на коэффициент, равный отношению прошлого масштаба экрана (то есть в момент, когда закрывали приложение) к текущему масштабу. При этом вести вторую (и последующие) ветки в реестре не потребуется.

Таким образом, в деструктор надо добавить сохранение масштаба:

TableWidget::~TableWidget()
{
	QSettings settings("Test company name", "Test product name");
	settings.setValue("TableColumns", horizontalHeader()->saveState());

	const double scale = screen()->logicalDotsPerInchX() / 96.0;
	settings.setValue("Scale", scale); 
}

А в конструктор, в конец, надо добавить окончательную корректировку размеров (дополнительное растяжение размеров колонок) после вызова restoreState:

	QVariant vScale = settings.value("Scale");
	if (!vScale.isNull())
	{
		const double currentScale = screen()->logicalDotsPerInchX() / 96.0;
		double lastScale = vScale.toDouble();
		if (!qFuzzyCompare(currentScale, lastScale))
		{
			for (int i = 0; i < headers.size(); i++)
				setColumnWidth(i, qRound(columnWidth(i) * currentScale / lastScale));
		}
	}

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

Windows7, масштаб экрана 100%,  150%.
Windows7, масштаб экрана 100%, 150%.

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

На закрепление покажем еще один пример и то, как применять на нем этот рецепт.

Пример 8. Главный виджет и всё, что в нем

Давайте оторвемся от маленьких виджетов и попробуем замахнуться на что-то более крупное. Пусть это будет главный виджет приложения, производный от QMainWindow и содержащий внутри себя несколько QDockWidget`ов. Например, такой:

Показать код
MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags)
	: QMainWindow(parent, flags)
{
	QLabel* imageLabel = new QLabel;
	imageLabel->setPixmap(QPixmap("nature.jpg"));
	auto scrollArea = new QScrollArea;
	scrollArea->setWidget(imageLabel);

	auto dock1 = new QDockWidget("Dock1 Title");
	dock1->setWidget(new TableWidget);
	auto dock2 = new QDockWidget("Dock2 Title");
	dock2->setWidget(new QWidget);
	auto dock3 = new QDockWidget("Dock3 Title");
	dock3->setWidget(new QWidget);
	auto dock4 = new QDockWidget("Dock4 Title");
	dock4->setWidget(new ScaleWgt);

	setCentralWidget(scrollArea);
	addDockWidget(Qt::TopDockWidgetArea, dock1);
	addDockWidget(Qt::TopDockWidgetArea, dock2);
	addDockWidget(Qt::BottomDockWidgetArea, dock3);
	addDockWidget(Qt::BottomDockWidgetArea, dock4);

	setStyleSheet("QMainWindow::separator {background: gray;}");
}
Windows7, масштаб экрана 100%.
Windows7, масштаб экрана 100%.

Поскольку в этом приложении все сделано в соответствии с вышеприведенными базовыми рецептами (см. здесь), то всё выглядит адекватно при любых масштабах экрана. Однако пользователь неизбежно захочет менять размещение вкладок, то есть размеры и местоположение QDockWidget`ов. А, поменяв, неизбежно захочет, чтобы всё сохранялось между запусками приложения. Таким образом, полученное выше размещение вкладок – это не более чем начальное приближение, а в процессе работы пользователь будет подстраивать GUI «под себя».

 В соответствии с этим, сохраняем настройки gui в реестре:

MainWindow::~MainWindow()
{
	QSettings settings("Test company name", "Test product name");
	settings.setValue("geometry", saveGeometry());
	settings.setValue("windowState", saveState());
}

А в конструктор, в конец, надо добавить окончательную корректировку размеров:

	QSettings settings("Test company name", "Test product name");

	QVariant state = settings.value("windowState");
	if(!state.isNull())
		restoreState(state.toByteArray());

	QVariant geometry = settings.value("geometry");
	if (!geometry.isNull())
		restoreGeometry(geometry.toByteArray());

И мы снова подошли к проблеме. Купив новый монитор 4K и поставив его на замену старому FullHD (попутно подняв масштаб экрана, конечно), пользователь обнаружит, что его любимое приложение выглядит совсем не так хорошо, как раньше.

Windows7, масштаб экрана 100%,  150%.
Windows7, масштаб экрана 100%, 150%.

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

Итак, растянуть размеры окна довольно просто. Для этого в деструктор добавляем сохранение размеров плюс текущий (при вызове деструктора) масштаб экрана:

	const double scale = screen()->logicalDotsPerInchX() / 96.0;
	settings.setValue("Scale", scale);

	settings.setValue("MainWindow/Width", size().width());
	settings.setValue("MainWindow/Height", size().height());

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

double currentScale = screen()->logicalDotsPerInchX() / 96.0;
	QVariant vScale = settings.value("Scale");
	QVariant vW = settings.value("MainWindow/Width");
	QVariant vH = settings.value("MainWindow/Height");
	if (!vScale.isNull())
	{
		double lastScale = vScale.toDouble();
		if (!qFuzzyCompare(currentScale, lastScale) && !vW.isNull() && !vH.isNull())
		{
			resize(qRound(vW.toInt() * currentScale / lastScale), 
				qRound(vH.toInt() * currentScale / lastScale));
		}
	}

После этого размеры окна растянутся (на самом деле необязательно растянутся, но это отдельный случай, для простоты опустим его), а вот размеры вкладок изменятся, как повезёт:

Windows7, масштаб экрана 100%,  150%.
Windows7, масштаб экрана 100%, 150%.

Остается корректно установить размеры вкладок. К сожалению, нельзя для этого применять те же операции restoreState/saveState, что уже применяли. Однако достаточно применить функцию QMainWindow::resizeDocks. К сожалению, с ней придется повозиться. Функция капризная.

Для начала поместим все наши доки в вектор _docks (в конструкторе). Потом в деструкторе научимся запоминать прошлые размеры доков и то, какие вкладки закрыты:

Показать код
std::vector<QSize> dockSizes(_docks.size());
	for (size_t i = 0; i < _docks.size(); i++)
		dockSizes[i] = _docks[i]->size();

	QByteArray array((char*)&dockSizes[0], dockSizes.size() * sizeof(QSize));
	settings.setValue("MainWindow/Docks/All", array);

	std::vector<int> hidden = tabifiedHidden();
	array = hidden.empty() 
		? QByteArray() : QByteArray((char*)&hidden[0], hidden.size() * sizeof(int));
	settings.setValue("MainWindow/Docks/HiddenIndexes", array);

Наконец, в конструктор, в конец, дописываем код, который делает окончательную настройку вкладок:

Показать код
	QByteArray array = settings.value("MainWindow/Docks/All").toByteArray();
	QByteArray hiddenArray = settings.value("MainWindow/Docks/HiddenIndexes").toByteArray();
	std::vector<int> hiddenIndexes;
	if (!hiddenArray.isNull() && !hiddenArray.isEmpty())
	{
		int size = hiddenArray.size() / sizeof(int);
		hiddenIndexes.resize(size);
		memcpy(&hiddenIndexes[0], hiddenArray.data(), hiddenArray.size());
	}

	if (!array.isNull() && array.size() >= _docks.size() * sizeof(QSize))
	{
		double lastScale = vScale.toDouble();
		QList<int> vWidth;
		QList<int> vHeight;
		if (!qFuzzyCompare(currentScale, lastScale) && !vW.isNull() && !vH.isNull())
		{
			QList<QDockWidget*> ldw;
			for (size_t i = 0; i < _docks.size(); i++)
			{
				if (std::find(hiddenIndexes.begin(), hiddenIndexes.end(), i) != hiddenIndexes.end())
					continue;
				ldw.push_back(_docks[i]);
				QSize* ptrSize = (QSize*)array.data() + i;
				vWidth.push_back((*ptrSize).width() * currentScale / lastScale);
				vHeight.push_back((*ptrSize).height() * currentScale / lastScale);
			}

			resizeDocks(ldw, vWidth, Qt::Horizontal);
			resizeDocks(ldw, vHeight, Qt::Vertical);
		}
	}

Запоминание скрытых вкладок нам нужно просто для того, чтобы корректно работала функция QMainWindow::resizeDocks. Да, без этого было бы сильно проще. Функцию tabifiedHidden можно написать разными способами, все они будут так или иначе привязываться к реализации классов внутри QMainWindow, но ничего не поделаешь. Мне самой корректной реализацией показалась такая:

Показать код
std::vector<int> MainWindow::tabifiedHidden() const
{
	std::vector<int> result;
	QList <QTabBar*>lst = findChildren<QTabBar*>();
	foreach(QTabBar * tab, lst)
	{
		for (int i = 0; i < tab->count(); i++)
		{
			if (i == tab->currentIndex())
				continue;

			quintptr wId = qvariant_cast<quintptr>(tab->tabData(i));
			QDockWidget* widget = reinterpret_cast<QDockWidget*>(wId);
			auto iter = std::find(_docks.begin(), _docks.end(), widget);
			if (iter != _docks.end())
				result.push_back(std::distance(_docks.begin(), iter));
		}
	}
	return result;
}

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

Windows7, масштаб экрана 100%,  150%.
Windows7, масштаб экрана 100%, 150%.

Всем не болеть!

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


  1. kurumpa
    21.10.2021 18:22

    А что по поводу QML? Там везде умножать/делить тоже? Вербозность дичайшая же получится...