Окна на рабочем столе: кадры, ввод и трей
Программе с окном от операционной системы нужно три вещи: прямоугольник на экране, способ положить в него пиксели и поток событий о том, что сделал пользователь (нажал клавишу, щёлкнул мышью, изменил размер окна, нажал крестик). kola даёт ровно эти три вещи и ничего сверх них. Кнопок, полей ввода и шрифтов в стандартной библиотеке нет. Есть кадровый буфер, то есть участок памяти, где каждый пиксель окна — несколько байтов, которые программа пишет сама, есть события и есть значок в области уведомлений с небольшим меню. Ниже всё это разобрано по порядку: как открыть окно, нарисовать кадр, прочитать ввод, проверить оконную программу без экрана, как устроен трей и что лежит ниже, на уровне связи с дисплеем.
Капабилити Window
На рабочем столе программа не рисует прямо на экране. Экраном владеет отдельная программа: в Linux это композитор (Wayland) или X-сервер, в Windows и macOS — системный компонент. Программа подключается к нему, передаёт готовые изображения своего окна и получает от него ввод пользователя.
В kola право на всё это — капабилити sys.window типа Window. Как и в случае часов или файловой системы, main получает её в составе sys, а создать её больше никто не может. Поэтому функция, которой не передали &Window, открыть окно не в состоянии, и рецензенту достаточно взглянуть на её сигнатуру, чтобы в этом убедиться. Window — маркер полномочия: он даёт право и ничем не владеет, так что его можно бросить на любом пути.
Сам код окна находится в модуле стандартной библиотеки std::gui, написанном на kola. Программа, которая открывает окно, должна подключить его строкой use std::gui;. Если забыть, чекер так и скажет и подскажет исправление:
fn main(sys: Sys) -> u8 !{io, block} {
match sys.window.open(8u32, 8u32) {
Ok(w) => { w.close(); 0u8 },
Err(_) => 1u8,
}
}error[card §7]: `sys.window.open` is `std::gui::open`, and this program does not link `std::gui`
--> e.kl:2:11
|
2 | match sys.window.open(8u32, 8u32) {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^
= help: add `use std::gui;` — a window, its `Surface` and `WinErr` all live thereКак открыть окно и нарисовать кадр
sys.window.open(width, height) возвращает Result[WindowHandle, WinErr]. Каждая сторона должна быть от 1 до 1 000 000 пикселей (окно Wayland принимает не больше 16 384), иначе ответ — WinErr::Invalid. У WinErr три варианта: Unsupported (открыть окно негде, дисплея нет), Invalid (неверен сам запрос) и Busy (попробуйте позже). WindowHandle — ресурс: чекер следит, чтобы на каждом пути дескриптор либо закрывали вызовом w.close(), либо передавали дальше.
Пиксели окна — это кадр в разделяемой памяти. Писать в него когда угодно нельзя: кадр берут взаймы, рисуют и отдают обратно. w.surface() перемещает кадр из окна в Surface, линейное значение, а w.present(s) забирает его назад и показывает на экране. Между этими двумя вызовами кадр принадлежит программе, а у окна кадра нет, и в этом весь смысл: композитор никогда не читает недорисованное изображение, а программа никогда не пишет в память, которую он сейчас показывает.
Каждый пиксель занимает четыре байта в формате XRGB8888: это 32-битное слово, в младшем байте которого синий, затем зелёный, затем красный и байт, который не используется. На всех машинах, для которых собирает kola, слово хранится начиная с младшего байта, поэтому в памяти пиксель выглядит как B, G, R, X. Байты одной строки пикселей идут вплотную за байтами предыдущей; число байтов в строке называется шагом строки (stride), его возвращает s.stride() (всегда width * 4).
Самый дешёвый законченный пример — тест: тесты работают с симулированным окном, которому экран не нужен (подробнее об этом ниже):
use std::gui;
use std::gui::WindowEvent;
use std::gui::WinErr;
// Закрасить все пиксели одолженного кадра одним цветом. Пиксель — четыре
// байта в порядке B, G, R, X (XRGB8888 как 32-битное слово, младший байт первым).
fn fill(px: &mut [u8], r: u8, g: u8, b: u8) !{} {
let mut i = 0usize;
while i + 3usize < px.len {
px[i] = b;
px[i + 1usize] = g;
px[i + 2usize] = r;
px[i + 3usize] = 0u8;
i = i + 4usize;
}
}
test first_frame(mut sim: Sim) {
let mut w = sim.window.open(64u32, 48u32).unwrap();
match w.next_event() {
Some(WindowEvent::Resized) => {},
_ => { assert false; },
}
let mut s = w.surface().unwrap();
assert s.stride() == 256u32;
let mut px = s.frame.bytes();
assert px.len == 12288usize;
fill(px, 18u8, 52u8, 86u8);
w.present(s).unwrap();
let (width, height) = w.size();
assert width == 64u32;
assert height == 48u32;
w.close();
}
test bounds(mut sim: Sim) {
match sim.window.open(0u32, 10u32) {
Ok(w) => { w.close(); assert false; },
Err(WinErr::Invalid) => {},
Err(_) => { assert false; },
}
}Запускается он командой kola test (с --oracle интерпретатор сверяется со скомпилированной сборкой). У окна 64 на 48 шаг строки 256 байтов, а кадр занимает 12 288 байтов — это и проверяют два assert.
w.surface() может отказать двумя способами. Invalid значит, что кадр уже одолжен. Busy значит, что композитор ещё не закончил с предыдущим показанным кадром и всё ещё копирует его на экран. Это не ошибка в обычном смысле, а знак, что рисовать стоит чуть позже; цикл событий ниже так с ним и обращается.
Окно нельзя закрыть, пока кадр одолжен
Surface линеен, и израсходовать его может только present, поэтому закрыть окно, пока кадр ещё у программы, — не ошибка времени выполнения. Такой код просто не компилируется:
use std::gui;
test bad(mut sim: Sim) {
let mut w = sim.window.open(10u32, 10u32).unwrap();
let s = w.surface().unwrap();
w.close();
}error[card §2]: resource `s` is not consumed on this path
--> d.kl:4:5
|
4 | let s = w.surface().unwrap();
| ^^^
= help: no implicit destructors: move it on, return it, destructure it, or pass it to a consuming fnВ C с типичной оконной библиотекой та же ошибка оставляет указатель в освобождённую разделяемую память, и падение, если оно вообще случится, произойдёт позже и в другом месте. Здесь исправление видно прямо в точке ошибки: сначала показать кадр, потом закрыть окно.
События
w.next_event() возвращает Option[WindowEvent]. Он никогда не ждёт: если ничего не произошло, сразу отвечает None. Поэтому оконная программа — это цикл, который разбирает все накопившиеся события, перерисовывает окно, если что-то изменилось, и засыпает на несколько миллисекунд, когда делать нечего.
Событие | Что значит |
|---|---|
| Окну назначили размер (один раз при открытии, затем каждый раз, когда пользователь его меняет). |
| Пользователь нажал кнопку закрытия. Пока не вызван |
| Клавишу нажали или отпустили. |
| Мышь сдвинулась; |
| Кнопка мыши: 1 левая, 2 правая, 3 средняя, 4 и дальше — остальные. |
| Прокрутка колесом или тачпадом, в пикселях (в X11 и в нативных окнах один щелчок колеса — 10). |
| Символ, который дала печатная клавиша, в виде строки. |
| Текст от метода ввода, см. ниже. |
| Окно получило или потеряло фокус клавиатуры. |
mods — битовая маска модификаторов, зажатых в этот момент: 1 Shift, 2 Ctrl, 4 Alt, 8 Super (клавиша Windows или Command), 16 Caps Lock. Бит проверяется так: (mods & 2u32) != 0u32.
Клавиши приходят как коды evdev — номера, которые подсистема ввода ядра Linux присваивает каждой физической клавише, причём и в Windows, и в macOS тоже, так что одна таблица годится для всех платформ: Escape — 1, цифры от 1 до 0 — коды от 2 до 11, A — 30. Код обозначает место на клавиатуре, а не букву. На французской раскладке на клавише, которую Linux называет кодом 16, написано A, а на американской — Q. Чтобы получить символ, нужно событие Text. std::gui::us_char(key, mods) превращает код в ASCII-символ, который эта клавиша даёт в американской раскладке (0, если символа нет или зажат Ctrl или Alt). Для горячих клавиш этого достаточно, для ввода текста — нет.
Метод ввода (IME) — системный компонент, которым набирают текст на языках, где символов больше, чем клавиш, например на китайском или японском: пользователь нажимает несколько клавиш, видит предварительную строку и подтверждает её. ImePreedit(s, active) сообщает предварительную строку, пока её составляют, а ImeCommit(s) доставляет подтверждённый текст. Программе, которой хватает ASCII, оба события можно не разбирать.
Текстовые события несут свою строку в регионе heap, поэтому в ряду эффектов next_event указан alloc(heap), и у вызывающей функции он тоже должен быть.
Вот законченная программа: она открывает настоящее окно, заливает его одним цветом, при каждом щелчке левой кнопкой меняет синюю составляющую и завершается по Escape или по кнопке закрытия:
use std::gui;
use std::gui::WindowEvent;
use std::gui::WinErr;
use std::log;
fn main(sys: Sys) -> u8 !{io, block, alloc(heap), log} {
let mut w = match sys.window.open(320u32, 200u32) {
Ok(w) => w,
Err(WinErr::Unsupported) => { log::error(f"no display to open a window on"); return 1u8; },
Err(_) => { return 2u8; },
};
let mut shade = 0u8;
let mut dirty = true;
loop {
match w.next_event() {
Some(WindowEvent::CloseRequested) => { w.close(); return 0u8; },
Some(WindowEvent::Resized) => { dirty = true; },
Some(WindowEvent::KeyDown(1u32, _)) => { w.close(); return 0u8; },
Some(WindowEvent::PointerButton(1u8, true, _)) => { shade = shade +% 32u8; dirty = true; },
Some(_) => {},
None => {
if dirty {
match w.surface() {
Ok(s) => {
let mut s = s;
let mut px = s.frame.bytes();
let mut i = 0usize;
while i + 3usize < px.len {
px[i] = shade;
px[i + 1usize] = 96u8;
px[i + 2usize] = 160u8;
px[i + 3usize] = 0u8;
i = i + 4usize;
}
match w.present(s) { Ok(_) => { dirty = false; }, Err(_) => {} }
},
Err(WinErr::Busy) => {},
Err(_) => { w.close(); return 3u8; },
}
}
sys.clock.sleep(10u64);
},
}
}
}На несколько мест в ней стоит посмотреть внимательнее:
shade +% 32u8— сложение с переполнением по модулю. Обычный+наu8при переполнении вызывает трап, и здесь программа остановилась бы на восьмом щелчке.Ветка
matchне может связать имя какmut, поэтому ветка связываетs, а затем переобъявляет его:let mut s = s;.Err(WinErr::Busy)ничего не делает: кадр остаётся помеченным как устаревший, и цикл попробует снова после следующей паузы.Перерисовка происходит, только когда что-то изменилось. Перерисовывать на каждом проходе значило бы впустую занимать процессор.
Соберите программу командой kola build --link win win.kl и запустите на рабочем столе. Если дисплея нет совсем (на сервере, в SSH-сеансе), open отвечает Unsupported, программа пишет ошибку в журнал и завершается с кодом 1. kola ничего не изображает: вне теста не бывает невидимого окна, которое принимает кадры и сообщает об успехе.
Как проверить оконную программу без экрана
Под kola test и под kola run --sim капабилити окна — детерминированный двойник настоящего окна. Он держит кадр в обычной памяти, при открытии один раз сообщает Resized, а дальше сообщает только то, что тест положил в его очередь. Для этого есть два метода, и работают они только на двойнике (на настоящем окне оба возвращают false):
w.inject(e)ставит в очередь событиеe, и следующийnext_eventего вернёт.w.inject_size(width, height)меняет размер окна так, как это сделал бы композитор, и ставит в очередьResized(каждая сторона от 1 до 16 384).
Благодаря этому обработку событий оконной программы можно проверять на сборочном сервере, где дисплея нет:
use std::gui;
use std::gui::WindowEvent;
test keys_and_resize(mut sim: Sim) {
let mut w = sim.window.open(8u32, 8u32).unwrap();
let _ = w.next_event();
let k = w.inject(WindowEvent::KeyDown(30u32, 1u32));
let c = w.inject(WindowEvent::CloseRequested);
assert k && c;
match w.next_event() {
Some(WindowEvent::KeyDown(key, mods)) => {
assert key == 30u32;
assert gui::us_char(key, mods) == 65u8;
},
_ => { assert false; },
}
match w.next_event() {
Some(WindowEvent::CloseRequested) => {},
_ => { assert false; },
}
let resized = w.inject_size(100u32, 50u32);
assert resized;
match w.next_event() {
Some(WindowEvent::Resized) => {},
_ => { assert false; },
}
let (nw, nh) = w.size();
assert nw == 100u32;
assert nh == 50u32;
let s = w.surface().unwrap();
assert s.stride() == 400u32;
w.present(s).unwrap();
w.close();
}Клавиша 30 с Shift (mods 1) — заглавная A, код символа 65. После изменения размера следующий кадр шириной 100 пикселей, и шаг строки у него 400 байтов.
Откуда берётся окно
std::gui скрывает платформу, но знать, что он выбирает, всё же стоит: от этого зависит, что и где работает. sys.window.open пробует варианты в таком порядке:
Порядок | Бэкенд | Платформы | На чём написан |
|---|---|---|---|
1 | Клиент Wayland ( | Рабочие столы Linux с композитором Wayland | kola |
2 | Клиент X11 ( | Linux с X-сервером ( | kola |
3 | Нативное окно | Windows (Win32), macOS (AppKit) | библиотека платформы |
В Linux оба протокола — обычные сообщения через сокет, поэтому клиенты представляют собой обычный код на kola в std и одинаково работают под интерпретатором, C-бэкендом и собственным бэкендом. В Windows и macOS оконная система — это библиотека, а не протокол, и третий бэкенд вызывает её. Во всех трёх случаях программа видит одни и те же события и один и тот же формат кадра.
Из такого устройства следует несколько ограничений:
В Wayland и X11 событие
Textстроится по встроенной американской раскладке; описание раскладки из системы не читается. Сочетания с Ctrl и AltTextне дают.X-дисплей, доступный по TCP (
DISPLAY=host:0), — этоUnsupported; используются только локальные сокеты. Сервер, который требует cookie (файл Xauthority), поддерживается.В macOS окно должно обслуживаться из главного потока процесса, и нужен оконный сервер (в обычном SSH-сеансе его нет). Событий метода ввода в macOS здесь нет, а «мёртвая» клавиша (та, что добавляет диакритический знак к следующей букве) не даёт
Text.Первое событие нативного окна —
Resizedс реальным размером клиентской области, который может отличаться от запрошенного.
Трей
Трей, или область уведомлений, — полоска маленьких значков в углу панели рабочего стола: индикатор сети, мессенджер, часы. Программа может поместить туда свой значок с небольшим меню; это удобно для фоновых утилит, у которых главного окна нет вовсе.
sys.window.tray(icon, width, height, tip) возвращает Result[TrayHandle, WinErr]:
iconсодержитwidth * heightпикселей по четыре байта в порядке B, G, R, A, где A — непрозрачность (альфа). Цвета должны быть предумножены на альфу: полупрозрачный пиксель хранит половину своих красного, зелёного и синего. Каждая сторона — от 1 до 256.tip— текст всплывающей подсказки в UTF-8, не больше 127 байтов.t.item(id, label)добавляет пункт меню.id— ваш номер для этого пункта (1 и больше),label— не больше 255 байтов, а пустая подпись превращает пункт в разделитель. В меню помещается не больше 64 пунктов.t.next_event()возвращаетOption[TrayEvent], не дожидаясь событий:TrayEvent::Activated, когда пользователь щёлкает по самому значку, иTrayEvent::Item(id), когда он выбирает пункт меню.t.close()убирает значок; другого способа избавиться от линейногоTrayHandleнет.
В тесте у трея тоже есть двойник, и t.inject(e) ставит событие в его очередь:
use std::gui;
use std::gui::TrayEvent;
test tray_menu(mut sim: Sim) {
// значок 2 x 2: четыре непрозрачных пикселя, байты B, G, R, A
let icon: [u8; 16] = [86u8, 52u8, 18u8, 255u8, 86u8, 52u8, 18u8, 255u8,
86u8, 52u8, 18u8, 255u8, 86u8, 52u8, 18u8, 255u8];
let mut t = sim.window.tray(&icon, 2u32, 2u32, &b"kola demo").unwrap();
t.item(1u32, &b"Open").unwrap();
t.item(2u32, &b"").unwrap();
t.item(3u32, &b"Quit").unwrap();
let q = t.inject(TrayEvent::Item(3u32));
assert q;
match t.next_event() {
Some(TrayEvent::Item(id)) => { assert id == 3u32; },
_ => { assert false; },
}
match t.next_event() {
None => {},
Some(_) => { assert false; },
}
t.close();
}Настоящая программа с треем устроена так же, как оконный цикл: разбирает события, пока пользователь не выберет Quit, и ненадолго засыпает, когда событий нет. На каждой платформе трей реализован по-своему, и разница заметна:
Платформа | Механизм | Чего ожидать |
|---|---|---|
Windows |
| Меню открывается правой кнопкой; щелчок левой — |
macOS | Элемент строки меню со своим меню | Щелчок открывает меню, поэтому |
Linux, современные рабочие столы | StatusNotifierItem через D-Bus, шину сообщений рабочего стола ( | Значок уменьшается максимум до 64 на 64. |
Linux, старые панели | Значок, встроенный в системный трей X11 ( | Используется, когда на шине никто не принимает StatusNotifierItem. Значок не больше 32 на 32, а меню рисуется простым X-шрифтом, поэтому нормально выглядят только подписи в ASCII. |
Уровень ниже
Клиенты Wayland и X11 написаны на kola, поэтому под ними нужно несколько примитивов. Это методы Window, и ими может пользоваться и ваш код — например, если нужен клиент протокола, которого std::gui не знает. Ни один из них не говорит ни на каком протоколе, они только открывают каналы:
sys.window.connect_display()подключается к сокету композитора Wayland ($XDG_RUNTIME_DIR/$WAYLAND_DISPLAY) и возвращаетConn— тот же тип соединения, что и в сетевой части. Нужна для этого капабилитиWindow, а не сетевая выдача.sys.window.shm(len)создаёт участок разделяемой памяти, линейныйShm.shm.bytes()одалживает его как&mut [u8],shm.close()освобождает, аconn.write_fd(bytes, &shm)отправляет сообщение вместе с файловым дескриптором этой памяти — именно так клиент Wayland передаёт композитору свой кадр.sys.window.connect_x11()подключается к X-серверу, указанному в$DISPLAY, аsys.window.x11_authority(out)читает файл Xauthority с его cookie доступа.sys.window.connect_bus()подключается к сеансовой шине D-Bus, на которой работает трей в Linux.sys.window.native_open(width, height)иsys.window.native_tray(...)— нативное окно и трей Windows и macOS, на которых стоят окно и трей самогоstd::gui.
Под Sim и на платформах, где они неприменимы, все они отвечают Unsupported. Точные сигнатуры — в разделе GUI файла STD-CARD.md (kola help std-card).
Чего std::gui не делает
std::gui намеренно останавливается на пикселях и событиях. В нём нет виджетов, компоновки, отрисовки текста и декодирования изображений; программа, которой нужна кнопка, рисует прямоугольник и проверяет, попал ли в него щелчок. Нет перетаскивания, буфера обмена и согласования нескольких окон. Это задачи для библиотеки поверх него, и раз нижний слой — простой кадр и простое событие, такая библиотека оказывается обычным кодом на kola, который проверяется на двойнике точно так же, как примеры выше.