/
Поиск
RU

Окна на рабочем столе: кадры, ввод и трей

Программе с окном от операционной системы нужно три вещи: прямоугольник на экране, способ положить в него пиксели и поток событий о том, что сделал пользователь (нажал клавишу, щёлкнул мышью, изменил размер окна, нажал крестик). 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. Поэтому оконная программа — это цикл, который разбирает все накопившиеся события, перерисовывает окно, если что-то изменилось, и засыпает на несколько миллисекунд, когда делать нечего.

Событие

Что значит

Resized

Окну назначили размер (один раз при открытии, затем каждый раз, когда пользователь его меняет). w.size() — новый размер; следующий w.surface() одолжит кадр этого размера.

CloseRequested

Пользователь нажал кнопку закрытия. Пока не вызван w.close(), ничего не закрывается.

KeyDown(key, mods), KeyUp(key, mods)

Клавишу нажали или отпустили. key — код клавиши Linux на любой платформе (см. ниже).

PointerMoved(x, y, mods)

Мышь сдвинулась; x и y — целые пиксели от левого верхнего угла окна.

PointerButton(button, pressed, mods)

Кнопка мыши: 1 левая, 2 правая, 3 средняя, 4 и дальше — остальные. pressed равно true при нажатии и false при отпускании.

Wheel(dx, dy, mods)

Прокрутка колесом или тачпадом, в пикселях (в X11 и в нативных окнах один щелчок колеса — 10).

Text(s)

Символ, который дала печатная клавиша, в виде строки.

ImePreedit(s, active), ImeCommit(s)

Текст от метода ввода, см. ниже.

Focused, Unfocused

Окно получило или потеряло фокус клавиатуры.

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 (std::gui::wayland)

Рабочие столы Linux с композитором Wayland

kola

2

Клиент X11 (std::gui::x11)

Linux с X-сервером ($DISPLAY)

kola

3

Нативное окно

Windows (Win32), macOS (AppKit)

библиотека платформы

В Linux оба протокола — обычные сообщения через сокет, поэтому клиенты представляют собой обычный код на kola в std и одинаково работают под интерпретатором, C-бэкендом и собственным бэкендом. В Windows и macOS оконная система — это библиотека, а не протокол, и третий бэкенд вызывает её. Во всех трёх случаях программа видит одни и те же события и один и тот же формат кадра.

Из такого устройства следует несколько ограничений:

  • В Wayland и X11 событие Text строится по встроенной американской раскладке; описание раскладки из системы не читается. Сочетания с Ctrl и Alt Text не дают.

  • 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

Shell_NotifyIcon со всплывающим меню

Меню открывается правой кнопкой; щелчок левой — Activated.

macOS

Элемент строки меню со своим меню

Щелчок открывает меню, поэтому Activated не приходит никогда. Для каждого действия заведите пункт меню.

Linux, современные рабочие столы

StatusNotifierItem через D-Bus, шину сообщений рабочего стола (std::gui::sni)

Значок уменьшается максимум до 64 на 64.

Linux, старые панели

Значок, встроенный в системный трей X11 (std::gui::xembed)

Используется, когда на шине никто не принимает 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, который проверяется на двойнике точно так же, как примеры выше.