Пишем панель для i3 window manager на Qt

в 14:26, , рубрики: linux, panel, qt, Qt Software, Программирование, метки: ,

i3 — мой любимый тайловый менеджер окон. Но совсем недавно, занявшись очередной перекраской своего десктопа, я наткнулся на одну пренеприятнейшую вещь: функционала родной панели совсем не хватает для воплощения всех моих фантазий. В частности, она не умеет менять размер или изменять цвет границ. А что делает линуксоид, когда ПО его не устраивает и нет альтернатив(а их нет)? Правильно, патчит существующее, либо пишет своё. Разбираться с xcb, на котором написана стандартная панель у меня совершенно нет желания, поэтому я пошёл вторым путём. В качестве языка был выбран C++. Про фреймворк спросите у К.О.

Предисловие

Что вообще такое панель? Википедия на этот счёт говорит нам:
«Панель задач (англ. taskbar) — приложение, которое используется для запуска других программ или управления уже запущенными, и представляет собой панель инструментов. В частности используется для управления окнами приложений.»
Но в тайловых менеджерах окон всё не так просто. Окна в них не перекрываются друг другом. Менеджер выделяет каждому окну своё пространство (тайл), на котором это окно и растягивается. Для примера:

image

Видите? Все окна на виду. Так что вопрос о нужности панели задач в таких менеджерах отпадает сам собой. Но на одном экране много приложений не уместишь. Что делать? И тут на сцену выходит концепция нескольких рабочих столов. Это чертовски удобно! Нет необходимости менять фокус окон, их размер и положение каждую минуту. Переключился на первый рабочий стол — вот тебе комплект приложений для разработки, переключился на второй — держи файловый менеджер и плеер рядом с ним. И так далее. Теоретически, рабочих столов может быть бесконечно много, но пользоваться более 10 за раз бывает довольно проблематично.
Итак, наша панель будет управлять не окнами, а рабочими столами. Поэтому я предпочитаю употреблять просто слово «панель», а не словосочетание «панель задач».

Начинаем разработку

Как дружба начинается с улыбки, так и большинство Qt-приложений начинаются с виджета. Вот и мы не станем отходить от традиций и первым же делом напишем виджет для нашей будущей панели. Пока это будет просто класс, унаследованный от QWidget. С конструктором и деструктором.

class Q3Panel : public QWidget
{

Q_OBJECT

public:
	Q3Panel(QWidget *parent = 0);
	~Q3Panel();
};

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

setAttribute(Qt::WA_X11NetWmWindowTypeDock, true);

Просто добавьте это в конструктор нашего виджета. Тем самым вы установите свойство нашего окна _NET_WM_WINDOW_TYPE X11 в значение _NET_WM_WINDOW_TYPE_DOCK. Теперь это не просто окно, а док! Посмотрим:

image

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

void Q3Panel::setup(int height)
{
	QRect screen = QApplication::desktop()->screenGeometry();

	resize(screen.width(), height);

	int x, y;
	x = screen.left();
	y = position() == top ? screen.top() : screen.bottom();

	move(x, y);
}

В этом методе в переменную screen мы получаем геометрию так называемого root window. Вызов метода resize() делает ширину нашей панели равной ширине экрана, а высоту равной переданному параметру. В переменную x мы записываем левую координату нашего экрана, а в y — верхнюю или нижнюю, в зависимости от положения. Затем мы перемещаем нашу панель по координатам (x, y). position(), как вы уже наверное догадались, — один из двух методов для работы со скрытым свойством:

public:
	Position position() { return _position; };
	void setPosition(Position position) { _position = position; };

private:
	Position _position;

Теперь мы можем контролировать размер и положение нашей панели. Самое время переходить к следующему шагу.

Общение с i3

Вот мы и добрались до самой интересной части. Что-бы управлять нашим менеджером окон, нужно каким-то образом с ним общаться. К счастью, i3 из коробки поддерживает такой метод IPC, как unix socket'ы. И к ещё большему счастью, в Qt есть очень удобный класс для работы с ними — QLocalSocket.
Но для начала краткое описание протокола. Сообщение выглядит следующим образом:

<магическая строка> <размер сообщения> <тип сообщения> <сообщение>

Теперь обо всём по порядку. Магическая строка — это «i3-ipc». Её единственное предназначение — контроль версий протокола. За ней следует 32-битное число, в котором хранится размер сообщения. Затем точно такой-же 32-битный тип сообщения, а за ним и само сообщение. Тип сообщения может принимать следующие значения:

  • COMMAND (0) — сообщение содержит команду
  • GET_WORKSPACES (1) — получить список рабочих столов
  • SUBSCRIBE (2) — подписаться на определённое событие
  • GET_OUTPUTS (3) — получить список устройств вывода
  • GET_TREE (4) — получить список всех окон всех рабочих столов
  • GET_MARKS (5) — получить список идентификаторов контейнеров
  • GET_BAR_CONFIG (6) — получить конфигурацию панели из конфига i3

Используя всю эту функциональность, можно создать панель с кучей возможностей, но в рамках этой статьи мы ограничимся только отображением и переключением рабочих столов. Для этого нам понадобится 3 типа сообщений: COMMAND, GET_WORKSPACES и SUBSCRIBE. Теперь поподробнее о каждом. Ах да, чуть не забыл. Формат данных, которые i3 Принимает и отправляет — json.

COMMAND: Описывать все возможные команды я не стану — их слишком много. Для переключения рабочих столов нам понадобится всего одна: «workspace number X», где X — номер рабочего стола. Ответом на команду является ассоциотивный массив, который содержит всего одно свойство — «success», которое может принимать значения true или false. Пример ответа:

{ "success": true }

GET_WORKSPACES: тело сообщения пустое, ответом является список рабочих столов, каждый из которых содержит следующие свойства:

  • num (integer) — логический номер рабочего стола
  • name (string) — имя рабочего стола в кодировке UTF-8
  • visible (boolean) — отображается-ли рабочий стол. В случае с несколькими устройствами вывода одновременно может отображаться несколько рабочих столов
  • focused (boolean) — находится ли рабочий стол в фокусе. В одно и то же время в фокусе может находиться только один рабочий стол
  • urgent (boolean) — имеется ли на рабочем столе окно, требующие внимания пользователя (здесь я могу быть неправ, но понял именно так)
  • rect (map) — геометрия рабочего стола, содержащая координаты x и y, ширину и высоту
  • output (string) — устройство вывода, на котором рабочий стол отображается

Пример ответа:

[
 {
  "num": 0,
  "name": "1",
  "visible": true,
  "focused": true,
  "urgent": false,
  "rect": {
   "x": 0,
   "y": 0,
   "width": 1280,
   "height": 800
  },
  "output": "LVDS1"
 },
 {
  "num": 1,
  "name": "2",
  "visible": false,
  "focused": false,
  "urgent": false,
  "rect": {
   "x": 0,
   "y": 0,
   "width": 1280,
   "height": 800
  },
  "output": "LVDS1"
 }
]

SUBSCRIBE: i3 позволяет подписываться на события. Всего существует 2 типа события:

  • workspace (0) — события, связанные с рабочими столами. Тело содержит всего одно одно свойство — «change», которое может принимать значения:
    • focus — когда фокус переходит на другой рабочий стол
    • init — при создании нового рабочего стола
    • empty — при удалении пустого рабочего стола
  • output (1) — события, связанные с устройствами вывода.

Сообщение о событии полностью идентично стандартному сообщению с тем лишь различием, что старший бит типа сообщения установлен в 1. Итак, из двух типов событий нас интересует только первое. Его тело представляет собой ассоциативный массив с одним строковым свойством «changed», которое может принимать значения «focus», «init», «empty» и «urgent». Пример ответа:

{ "change": "focus" }

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

  1. Соединяемся
  2. Обновляем список рабочих столов
  3. Подписываемся на события «workspace»
  4. Ждём
  5. По приходу события обновляем список рабочих столов
  6. goto 4

Напомню, что идентификатором unix socket'а является файл в файловой системе. Его имя можно получить, либо прочитав свойство «I3_SOCKET_PATH» root window'а, либо вызвав i3 --get-socketpath. Я пошёл по пути наименьшего сопротивления:

QString I3Ipc::getSocketPath()
{
	QProcess i3process;
	i3process.start("i3 --get-socketpath", QIODevice::ReadOnly);
	if (!i3process.waitForFinished())
	{
		qDebug() << i3process.errorString();
		exit(EXIT_FAILURE);
	}

	return QString(i3process.readAllStandardOutput()).remove(QChar('n'));
}

Теперь, зная путь до файла сокета, можно присоединяться к серверу:

void I3Ipc::reconnect()
{
	mainSocket->abort();
	eventSocket->abort();

	QString socketPath = getSocketPath();
	mainSocket->connectToServer(socketPath);
	eventSocket->connectToServer(socketPath);

	if (!mainSocket->waitForConnected() || !eventSocket->waitForConnected())
	{
		qDebug() << "Connection timeout!";
		exit(EXIT_FAILURE);
	}

	subscribe();
}

И отправлять данные:

QByteArray I3Ipc::pack(int type, QString payload)
{
	QByteArray b;
	QDataStream s(&b, QIODevice::WriteOnly);
	s.setByteOrder(QDataStream::LittleEndian);
	
	s.writeRawData(I3_IPC_MAGIC, qstrlen(I3_IPC_MAGIC));
	s << (quint32) payload.size();
	s << (quint32) type;
	s.writeRawData(payload.toAscii().data(), payload.size());

	return b;
}

void I3Ipc::send(int type, QString payload)
{
	send(type, payload, mainSocket);
}
void I3Ipc::send(int type, QString payload, QLocalSocket* socket)
{
	socket->write(pack(type, payload));
}

Если вы внимательно прочитали часть про протокол, то этот код должен быть вам понятен. Пакуем данные и пишем в сокет. I3_IPC_MAGIC — константа из заголовочного файла <i3/ipc.h>, описывающего протокол.
По поводу setByteOrder(): стандартным для QDataStream является BigEndian порядок следования байт, а i3 ждёт данные в нативном, поэтому либо такой костыль, либо придётся отказываться от QDataStream в пользу массивов char'ов и memcpy(). Отправлять данные мы научились, теперь научимся принимать ответы:

void I3Ipc::read()
{
	QLocalSocket *socket = (QLocalSocket*)sender();

	if (socket->bytesAvailable() < (int) (qstrlen(I3_IPC_MAGIC) + sizeof(quint32) * 2))
		return;

	QDataStream s(socket);
	s.setByteOrder(QDataStream::LittleEndian);

	quint32 msgType, payloadSize;

	s.skipRawData(qstrlen(I3_IPC_MAGIC));
	s >> payloadSize;
	s >> msgType;

	while (socket->bytesAvailable() < payloadSize)
	{
		if (!socket->waitForReadyRead())
		{
			qDebug() << "Reading timeout!";
			exit(EXIT_FAILURE);
		}
	}

	char buf[payloadSize];
	s.readRawData(buf, payloadSize);
	QByteArray jsonPayload(buf, payloadSize);

	if (msgType >> 31)
	{
		if (msgType == I3_IPC_EVENT_WORKSPACE)
		{
			emit workspaceEvent();
		}
	} else {
		if (msgType == I3_IPC_REPLY_TYPE_WORKSPACES)
		{
			emit workspaceReply(jsonPayload);
		}
	}
}

Тут тоже всё предельно просто: ждём, пока накопится 14 байт (6 — магическая строка и два 4-байтовых числа), пропускаем 6 байт и читаем в переменные размер сообщения и его тип. Остаётся только дождаться самого сообщения и, в зависимости от типа, послать соответствующий сигнал.
Теперь после каждого события мы будем получать актуальный список рабочих столов в json-формате. Что-бы привести его в «нормальное» состояние, будем использовать библиотеку QJson. Во многих дистрибутивах она есть в репозиториях, а если и нет, никто не мешает собрать самому. Итак, подключаем:

LIBS += -lqjson

В .pro файл и

#include <qjson/parser.h>

В заголовочный файл. QJson предельно проста в использовании

void Q3Panel::workspaceReplySlot(const QByteArray jsonPayload)
{
	bool ok;
	QList<QVariant> workspacesList = jsonParser->parse(jsonPayload, &ok).toList();

	if (!ok)
	{
		qDebug() << "Parser error: " << jsonParser->errorString();
		return;
	}

	workspaces->clear();

	for (int i = 0; i < workspacesList.size(); ++i)
	{
		QMap<QString, QVariant> w = workspacesList.at(i).toMap();

		workspaces->insert(w.value("num").toUInt(),
		                   workspaceInfo(w.value("name").toString(),
		                                 w.value("focused").toBool(),
		                                 w.value("urgent").toBool()));
	}

	emit updateWorkspacesWidget(workspaces);
}

workspaces — хеш-таблица, в которой мы и храним информацию о рабочих столах. Её ключ — quint16 номер рабочего стола, а значение — вот такая структура:

struct workspaceInfo
{
	QString name;
	bool focused;
	bool urgent;

	workspaceInfo(QString _n, bool _f = 0, bool _u = 0)
	{
		name = _n;
		focused = _f;
		urgent = _u;
	}
};

Здесь всё просто и понятно. Получать информацию о рабочих столах умеем, хранить тоже. Осталась мелочь: отображать её и по нажатию на определённый рабочий стол посылать команду менеджеру окон. Можно пойти разными путями, я решил написать свой виджет, основанный на QHBoxLayout. Ничего сложного, храним хеш-таблицу, где ключом является номер рабочего стола, а значением — ссылка на кнопку WorkspaceButton. WorkspaceButton унаследована от QToolButton и не представляет ничего нового, кроме своей политики изменения размера и стиля. После каждого обновления списка рабочих столов необходимо обновлять и виджет. Можно было-бы просто удалять все кнопки и создавать заного, но мы пойдём несколько иным путём:

void WorkspacesWidget::updateWorkspacesWidgetSlot(const QHash<qint16, workspaceInfo> *workspaces)
{
	clearLayout();

	QHash<qint16, workspaceInfo>::const_iterator wi = workspaces->constBegin();
	while (wi != workspaces->constEnd())
	{
		if (buttons->contains(wi.key()))
		{
			buttons->value(wi.key())->setFocused(wi.value().focused);
		} else {
			addButton(wi.key(), wi.value().focused, wi.value().name);			
		}
		++wi;
	}

	QHash<qint16, WorkspaceButton*>::const_iterator bi = buttons->constBegin();
	QList<qint16> toDelete;
	while (bi != buttons->constEnd())
	{
		if (!workspaces->contains(bi.key()))
		{
			delete bi.value();
			toDelete << bi.key();
		} else {
			mainLayout->addWidget(bi.value());
		}
		++bi;
	}

	for (int i = 0; i < toDelete.size(); ++i)
	{
		buttons->remove(toDelete.at(i));
	}
}

void WorkspacesWidget::clearLayout()
{
	while (mainLayout->takeAt(0));
}

void WorkspacesWidget::addButton(quint16 num, bool focused, QString name)
{
	WorkspaceButton* newButton = new WorkspaceButton(name, focused);
	connect(newButton, SIGNAL(clicked()), this, SLOT(buttonClickedSlot()));

	buttons->insert(num, newButton);
}

Сначала мы очищаем QHBoxLayout и проходимся по элементам workspaces и, если кнопка для текущего рабочего стола уже есть, обновляем свойство focused. Если кнопки нет, добавляем её. Затем проверяем все элементы buttons на предмет того, существует ли ещё рабочий стол или уже нет. И, если существует, добавляем на QHBoxLayout. Если же нет, удаляем. Не знаю, насколько такой способ оптимален, но он мне показался гораздо лучше, чем каждый раз удалять и создавать заново все кнопки.
Всё! Вот она, наша панель:
image
Исправно работает, рабочие столы отображает, переключает. Но это всего-лишь базовый функционал, что-бы догнать и обогнать по функциональности стандартную панель, осталось добавить настройки, трей, меню и часы. Об этом в следующей статье, если, конечно, тема будет интересна.

Репозиторий с исходным кодом

Автор: Goryn


  1. Максим:

    Хорошая статья! Изучаю QT, так что в качестве тренировки самое оно! Спасибо!
    Когда следующая часть? :)

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js