Первая версия Lark DE

Композитор Wayland на smithay: сессия, xdg-shell, ввод, рендеринг через
OpenGL ES с отслеживанием повреждений. Протоколы xdg-decoration,
viewporter и presentation-time.

Окна в стиле Windows 11: заголовок с названием и кнопками, перетаскивание,
изменение размера за края и углы, сворачивание и разворачивание.

Панель задач с подписями окон, часами и переключателем рабочих областей.
Меню «Пуск» со списком приложений и поиском в текущей раскладке.
Переключение окон по Alt+Tab с подтверждением при отпускании Alt.

Собственный растровщик текста на fontdb + fontdue с гамма-коррекцией
покрытия: без хинтинга штрихи на мелком кегле выходят рваными.

Приложения запускаются в отдельной сессии D-Bus, иначе однопользовательские
программы находят уже запущенный экземпляр во внешней сессии и открывают
окно не в Lark.

127 тестов, clippy без замечаний.
This commit is contained in:
Niko Marmeladkov 2026-10-18 09:18:57 +03:00
commit 41fa466410
39 changed files with 13938 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
# Артефакты сборки
/target
# Резервные копии и временные файлы редакторов
*.swp
*.swo
*~
*.orig
*.rej
# Снимки экрана и журналы от scripts/dev-run.sh
/*.png
/*.ppm
/*.log
# Файлы окружения
.env
.env.local

3534
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

28
Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
name = "lark"
version = "0.1.0"
edition = "2021"
description = "Лёгкая среда рабочего стола на Rust для слабых устройств"
license = "MIT"
[dependencies]
smithay = { git = "https://github.com/Smithay/smithay", features = ["backend_drm", "backend_egl", "backend_libinput", "backend_udev", "backend_winit", "backend_x11", "desktop", "use_system_lib", "xwayland"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
libc = "0.2"
toml = "0.8"
serde = { version = "1", features = ["derive"] }
xkbcommon = "0.7"
fontdb = "0.22"
fontdue = "0.9"
fluent = "0.16"
unic-langid = "0.9"
dirs = "5"
wayland-server = "0.31"
wayland-protocols = { version = "0.32", features = ["server", "unstable", "staging"] }
winit = "0.30"
[[bin]]
name = "lark"
path = "src/main.rs"

83
README.md Normal file
View file

@ -0,0 +1,83 @@
# Lark DE
Лёгкая среда рабочего стола на Rust для слабых устройств.
## Что уже работает
- **Композитор Wayland** на smithay: сокет, `xdg-shell`, вывод, ввод,
рендеринг через OpenGL ES, отслеживание повреждений
- **Окон в стиле Windows 11**: заголовок с названием и кнопками,
перетаскивание, изменение размера за края и углы
- **Панель задач**: кнопка «Пуск» с логотипом, подписи окон, часы,
переключатель рабочих областей
- **Меню «Пуск»**: список приложений и поиск, набираемый с клавиатуры
в текущей раскладке
- **Управление окнами**: свернуть, развернуть, закрыть, `Alt+Tab`
- **Рабочие области**: четыре по умолчанию, переключение с клавиатуры
- **Свой растровщик текста** на `fontdb` + `fontdue`
- **Приложения запускаются в Lark**, а не во внешней сессии
Чего пока нет — XWayland, бэкенда DRM/KMS, индикаторов трея и приложений
Фазы 4. Подробности в [дорожной карте](docs/ROADMAP.md).
## Целевое железо
Оптимизация под слабые устройства (например, icl bi1011):
- Intel Celeron/Pentium (N-серия)
- 4–8 ГБ RAM
- Встроенная Intel UHD Graphics
- eMMC/SSD
## Технологический стек
| Компонент | Крейт | Состояние |
|-----------|-------|-----------|
| Композитор Wayland | smithay | используется |
| Рендеринг | smithay (`GlesRenderer`) | используется |
| Ввод и раскладка | smithay + xkbcommon | используется |
| Текст | fontdb + fontdue | используется |
| Конфигурация | toml + serde | используется |
| Логирование | tracing | используется |
| Мультиязычность | fluent | используется |
## Структура проекта
```
lark/
├── src/
│ ├── compositor/ # Wayland композитор, ввод, декорации, раскладка
│ ├── shell/ # Панель, меню «Пуск», переключатель окон, часы
│ ├── services/ # Фоновые службы (заготовки)
│ ├── apps/ # Свои приложения (заготовки)
│ ├── text.rs # Растровщик текста
│ └── config.rs # Конфигурация
├── locales/ # Переводы (.ftl)
├── docs/ # Документация
├── scripts/ # Проверка на виртуальном дисплее
└── tests/ # Интеграционные тесты
```
## Быстрый старт
```bash
cargo build --release
./target/release/lark
```
Проверка без дисплея:
```bash
./scripts/dev-run.sh # поднимает Xvfb и делает снимок экрана
```
Подробности — в [сборке и запуске](docs/BUILD.md).
## Документация
- [Архитектура](docs/ARCHITECTURE.md)
- [Дорожная карта](docs/ROADMAP.md)
- [Сборка и запуск](docs/BUILD.md)
## Лицензия
MIT

6
clippy.toml Normal file
View file

@ -0,0 +1,6 @@
# Пороги clippy, согласованные с целями проекта.
#
# Композитор устроен как цикл событий, поэтому функции с большим числом
# аргументов закономерны: состояние, рендерер, выход, трекер повреждений.
too-many-arguments-threshold = 10

146
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,146 @@
# Архитектура Lark DE
## Обзор
Lark DE — среда рабочего стола на Rust для слабых устройств. Интерфейс
повторяет Windows 11 при минимальном потреблении ресурсов.
Документ описывает и то, что уже работает, и то, что запланировано. Разница
отмечена явно: иначе план легко принять за текущее состояние.
## Компоненты
### 1. Композитор
Ядро системы — Wayland-композитор на smithay.
**Реализовано:**
- Сессия Wayland: сокет создаётся сам, окружение наследуется потомками
- Управление окнами: создание, фокус, подъём, закрытие, очистка реестра
- Рендеринг через `GlesRenderer` smithay с отслеживанием повреждений
- Ввод: клавиатура, указатель, прокрутка; раскладка через xkbcommon
- `xdg-shell` для приложений, `xdg-decoration`, `viewporter`,
`presentation-time`
- Декорации в стиле Windows 11: заголовок с названием, кнопки,
перетаскивание, изменение размера за края и углы
- Рабочие области и переключатель окон по `Alt+Tab`
**План:**
- XWayland для приложений X11
- Бэкенд DRM/KMS для запуска без окна winit
- Эффекты: тени, анимации
**Ключевые оптимизации:**
- Ленивая композиция (damage tracking)
- Кэш растеризованных глифов
- Переиспользование буферов
### 2. Shell
Интерфейс пользователя в стиле Windows 11.
**Реализовано:**
- **Панель** (~48px): кнопка «Пуск» с логотипом, подписи открытых окон,
часы, переключатель рабочих областей
- **Меню «Пуск»**: список приложений, поиск с клавиатуры
- **Переключатель окон**: `Alt+Tab` со списком названий
**План:**
- Системный трей: сеть, звук, батарея
- Центр уведомлений
- **Рабочий стол**: иконки, обои, контекстное меню
### 3. Службы
Фоновые службы для системной функциональности. Сейчас это заготовки:
потоки запускаются и завершаются, ничего не делая.
| Служба | Ответственность | Состояние |
|--------|-----------------|-----------|
| powerd | Питание, яркость, сон, батарея | заготовка |
| networkd | Сетевой апплет (NetworkManager) | заготовка |
| audiod | Аудио-микшер (PipeWire) | заготовка |
| sessiond | Автозапуск, блокировка, выход | заготовка |
| notificationd | Очередь уведомлений | заготовка |
Сетевой и аудио-апплеты требуют D-Bus. В тестовой среде этих служб нет,
поэтому заглушки показывали бы неверные значения — это хуже, чем ничего.
### 4. Приложения
Собственные приложения на Rust. Пока не написаны: пункты меню «Пуск»
запускают системные программы (`thunar`, `alacritty` и подобные), перебирая
кандидатов до первого найденного.
**План:**
- Файловый менеджер
- Терминал
- Центр настроек
### 5. i18n
Мультиязычность через Fluent (`.ftl`).
- Языки: русский (ru-RU), английский (en-US)
- Структура: `locales/{lang}/app.ftl`
## Схема взаимодействия
```
┌──────────────────────────────────────────┐
│ Lark (один процесс) │
│ │
│ ┌────────────┐ ┌───────────────┐ │
│ │ Shell │ │ Композитор │ │
│ │ панель, │◄──────►│ smithay, │ │
│ │ меню, часы│ │ GLES, ввод │ │
│ └────────────┘ └───────┬───────┘ │
│ ▲ │ │
│ └──── растеризация ────┘ │
│ текста (fontdue) │
└──────────────────┬───────────────────────┘
│ xdg-shell
▼
┌─────────────┐
│ Wayland │
│ клиенты │
└─────────────┘
```
Оболочка и композитор живут в одном процессе и обмениваются состоянием
напрямую: общий сокет, общий растровщик текста, готовая раскладка панели.
Отдельного канала между ними нет — это осознанное решение в пользу
простоты на текущем этапе.
## Рендеринг
Сейчас — `GlesRenderer` smithay поверх EGL. Тот же путь работает и на
llvmpipe программной растеризации, поэтому композитор запускается в
виртуальной машине без видеокарты.
Планировавшийся ранее wgpu не используется: smithay даёт готовый рендерер
с отслеживанием повреждений, а второй графический стек только удвоил бы
зависимости.
Свои элементы сцены (панель, подписи, декорации) рисуются как текстуры:
сплошная заливка — один буфер, текст — растр, построенный `fontdue`.
### Порядок элементов
Элементы сцены отрисовываются **в обратном порядке списка**: последний
элемент списка оказывается ниже всех. Поэтому фон панели добавляется
последним, а мелкие детали (логотип, точки рабочих областей) — раньше своей
заливки. На это есть тесты: ошибка здесь не видна в коде, только на экране.
## Конфигурация
- Формат: TOML
- Путь: `~/.config/lark/config.toml`
- Секции: `[compositor]`, `[desktop]`, `[panel]`, `[theme]`,
`[decoration]`, `[language]`, `[keyboard]`, `[input]`, `[seat]`

295
docs/BUILD.md Normal file
View file

@ -0,0 +1,295 @@
# Сборка и запуск Lark DE
## Требования
### Системные зависимости
```bash
# Debian/Ubuntu
sudo apt install \
libwayland-dev \
libxkbcommon-dev \
libdrm-dev \
libgbm-dev \
libpipewire-0.3-dev \
libdbus-1-dev \
libudev-dev \
libinput-dev
# Fedora
sudo dnf install \
wayland-devel \
libxkbcommon-devel \
libdrm-devel \
mesa-libgbm-devel \
pipewire-devel \
dbus-devel \
libudev-devel \
libinput-devel
```
### Rust
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
## Сборка
```bash
cargo build --release
```
## Запуск
Композитор открывает окно через winit, поэтому запускается и внутри
существующей X11/Wayland-сессии (удобно для разработки), и поверх DRM/KMS
на целевом устройстве.
Требуется переменная `XDG_RUNTIME_DIR`: сокет Wayland создаётся в ней.
```bash
# Вложенный запуск в текущей сессии
./target/release/lark
```
Композитор сам занимает свободный сокет `wayland-N` и сообщает его при
старте. Дочерние процессы (меню «Пуск», терминал) наследуют `WAYLAND_DISPLAY`,
поэтому запускать их нужно после инициализации композитора.
Завершение: `Ctrl+Esc` в окне композитора либо `SIGINT`/`SIGTERM`.
### Журнал
По умолчанию показываются сообщения Lark и предупреждения библиотек:
фильтр `lark=info,warn`. Без него smithay печатает на несколько экранов
список расширений OpenGL и параметры инициализации — это вытесняет всё, что
действительно нужно читать.
```bash
RUST_LOG=debug ./target/release/lark # подробно, включая smithay
RUST_LOG=lark=trace ./target/release/lark # только свой код, максимум
```
Вывод запущенных приложений в консоль композитора не попадает: иначе
приложение вместе со своей шиной D-Bus и запущенными ею службами засыпал
бы терминал сообщениями об ошибках, которые к Lark отношения не имеют.
## Горячие клавиши
| Сочетание | Действие |
|-----------|----------|
| `Ctrl+Esc` | Выход из сессии |
| `Super` | Свернуть активное окно |
| `Super+Shift` | Развернуть окно / вернуть к прежнему размеру |
| `Super+Delete` | Закрыть активное окно |
| `Super+1` … `Super+4` | Перейти на рабочую область |
| `Super+Shift+1` … `Super+Shift+4` | Перенести активное окно на область |
| `Ctrl+Super+←` / `Ctrl+Super+→` | Шаг по рабочим областям по кругу |
| `Alt+Tab` | Переключение окон (список, пока `Alt` удержан) |
| `Alt+Shift+Tab` | Переключение окон в обратную сторону |
| `Escape` (в переключателе) | Отменить переключение |
Клик по кнопке окна на панели задач переводит на него фокус, повторный
клик по активному окну сворачивает его, клик по свёрнутому — возвращает.
Ни то, ни другое не двигает окно по экрану.
### Переключение окон
`Alt+Tab` показывает список открытых окон текущей рабочей области. `Tab`
перебирает их по кругу, `Alt+Shift+Tab` — в обратную сторону. Фокус
переводится только при отпускании `Alt`: иначе быстрый пролёт по списку
переключал бы окна на каждом шаге и приложения мигали бы. `Escape` отменяет
переключение, клик по пункту списка выбирает его сразу.
Список строится по z-порядку сверху вниз, поэтому первым идёт текущее окно,
а следующее за ним — то, на которое переключится первое нажатие `Tab`.
## Окна
Заголовок рисует композитор (`[decoration] server_side = true`). В нём
показывается название окна, а кнопки справа сворачивают, разворачивают и
закрывают окно. Окно нельзя утащить за пределы рабочей области, поэтому
заголовок всегда остаётся на экране.
Окно меняет размер мышью за край рамки и за углы. Верх заголовка тянет размер
по вертикали, середина — перетаскивает окно; правый верхний угол отдан
кнопке закрытия, как в Windows 11.
## Панель задач
Панель содержит переключатель рабочих областей (слева), кнопку «Пуск» с
логотипом, кнопки открытых окон с названиями и часы (справа).
Кнопки окон подстраиваются под длину названия. Если подписи перестают
помещаться, они отбрасываются и остаются значки; если не помещаются сами
кнопки, лишние окна сворачиваются в счётчик `+N` — нажатие на счётчик
не поддерживается, окна всё равно доступны горячими клавишами.
Панель задач показывает только окна текущей рабочей области.
## Меню «Пуск»
Открывается кликом по кнопке «Пуск» или по `Super`. Содержит строку поиска и
список приложений.
| Действие | Результат |
|----------|-----------|
| Ввод символов | поиск по названию, без учёта регистра |
| `Backspace` | удалить символ; на пустой строке уходит в окно |
| `↑` / `↓` | перемещение выделения |
| `Enter` | запустить выделенное приложение |
| `Esc` | закрыть меню |
| Клик мимо меню | закрыть меню |
Приложения:
| Пункт | Что запускается |
|-------|-----------------|
| `Файлы` | `nautilus`, `thunar`, `pcmanfm`, `dolphin` — первый найденный; иначе `xdg-open ~/` |
| `Терминал` | `foot`, `alacritty`, `kitty`, `gnome-terminal`, `konsole`, `xfce4-terminal`, `xterm` — первый найденный |
| `Завершение сессии` | завершает Lark |
Пункт, у которого нет ни одного кандидата, в меню не показывается: лучше
отсутствие, чем ошибка по нажатию.
### Почему приложения запускаются через `dbus-run-session`
Lark часто запускают вложенно — внутри другой сессии (niri, GNOME). Окружение
композитора наследуется правильно, но этого мало: однопользовательские
программы (`thunar`, `gedo`, файловые менеджеры) через GApplication ищут уже
запущенный экземпляр и передают ему свои аргументы. Найденный экземпляр
принадлежит внешней сессии, и окно открывается **не в Lark**, а в ней.
Поэтому каждое приложение запускается в отдельной сессии D-Bus:
```
dbus-run-session -- thunar
```
Собственная шина отрезает приложение от внешней: запущенного там экземпляра
оно не находит и поднимает своё — уже в Lark. Если `dbus-run-session` в
системе нет, приложение запускается напрямую (тогда возможен возврат к
поведению выше).
## Рабочие области
Количество задаётся `[desktop] workspaces` (по умолчанию 4). Окно открывается
в текущей области; перенесённое окно остаётся открытым, но не показывается
на панели и не попадает на экран до возврата на его область.
## Текст и шрифты
Подписи рисует собственный растровщик на `fontdb` + `fontdue`. Шрифт
выбирается автоматически: перебираются системные гарнитуры и выбирается
первая, умеющая нарисовать кириллицу — иначе русский интерфейс превратился
бы в пустые прямоугольники. Моноширинные и знаковые шрифты пропускаются.
## Поддерживаемые протоколы
Помимо `wl_compositor`, `wl_subcompositor`, `wl_shm`, `xdg_wm_base`,
`wl_seat`, `wl_output` и `xdg-output`, регистрируются:
- `wp_viewporter` — рисование в поверхности меньше окна
- `wp_presentation` — обратная связь о времени показа кадра
- `xdg-decoration` — согласование декораций с клиентом
Не реализовано: XWayland и DRM/KMS, см. `docs/ROADMAP.md`.
### Проверка без дисплея
Скрипт поднимает Xvfb, запускает композитор с тестовым клиентом и делает
снимок экрана:
```bash
sudo apt install xvfb x11-apps imagemagick mesa-utils # eglgears_wayland
./scripts/dev-run.sh
```
Снимок сохраняется в `/tmp/lark-shot.png`. Скрипт сообщает, работает ли
клиент: если клиент завершился (например, не смог инициализировать графику),
окна на снимке не будет, и это не ошибка композитора.
Полезные переменные окружения:
| Переменная | Назначение |
|------------|------------|
| `LARK_TEST_DISPLAY` | дисплей Xvfb (по умолчанию `:78`) |
| `LARK_TEST_CLIENT` | тестовый клиент (по умолчанию `eglgears_wayland`) |
| `LARK_TEST_WAIT` | секунд до снимка (по умолчанию `6`) |
| `LARK_TEST_SHOT` | путь к снимку |
| `RUST_LOG` | уровень журналирования |
При проверке ввода с клавиатуры под Xvfb окно нужно сфокусировать: без
оконного менеджера фокус не ставится автоматически.
```bash
DISPLAY=:78 xdotool search --name "" | while read w; do xdotool windowfocus $w; done
```
### На реальном железе
```bash
# Из TTY, без графической сессии
./target/release/lark
```
## Тесты
```bash
cargo test
```
Тесты покрывают обработку ввода и трейты `KeyboardKeyEvent`,
`PointerButtonEvent`, распознавание сочетаний клавиш, геометрию декораций и
зон изменения размера, раскладку панели задач и меню «Пуск», переключатель
рабочих областей, растровщик текста и раскладку клавиатуры.
## Конфигурация
Файл конфигурации: `~/.config/lark/config.toml`. Создаётся автоматически
при первом запуске с значениями по умолчанию.
```toml
[compositor]
backend = "auto" # auto, vulkan, gl, cpu
socket = "wayland-1" # запасное имя сокета, если auto не подошёл
[desktop]
workspaces = 4 # число рабочих областей
background = "#0F0F17"
[panel]
position = "bottom" # bottom, top
centered = true
height = 48
transparent = true
[theme]
name = "dark" # light, dark
accent = "#0078D4"
[decoration]
server_side = true # заголовок рисует композитор
titlebar_height = 32
button_width = 46
[language]
code = "ru-RU" # ru-RU, en-US
[keyboard]
xkb_layout = "us" # us, ru, de, ...
xkb_variant = ""
xkb_model = "pc105"
xkb_rules = "evdev"
xkb_options = "" # например "grp:alt_shift_toggle"
repeat_delay = 25 # мс до автоповтора
repeat_rate = 600 # символов в секунду
[input]
touchpad = true
scroll_speed = 1.0
[seat]
name = "seat0"
```

94
docs/ROADMAP.md Normal file
View file

@ -0,0 +1,94 @@
# Дорожная карта Lark DE
## Фаза 1: Фундамент (композитор)
- [x] Инициализация Wayland сессии (сокет, xdg-output, проверка вложенным клиентом)
- [x] Обработка ввода: клавиатура, мышь, прокрутка
- [x] Протокол xdg-shell для приложений (создание, конфигурирование, коммиты)
- [x] Базовый рендеринг окон (GLES через smithay, damage tracking)
- [x] Управление окнами: каскадное размещение, фокус по клику, очистка при закрытии
- [x] Панель задач с кнопками окон и переключением фокуса
- [x] Сворачивание, разворачивание и закрытие окон
(Super, Super+Shift, Super+Delete; кнопки заголовка и панели)
- [x] Декорации окон в стиле Windows 11 (CSD): заголовок с подписью, кнопки,
перетаскивание
- [x] Изменение размера окон мышью за край рамки и за углы
- [x] Переключение окон по `Alt+Tab` со списком и подтверждением
- [x] Переключение рабочих областей (Super+1..4, Super+Shift+цифра, Ctrl+Super+стрелки)
- [x] Протоколы presentation-time, viewporter, xdg-decoration
- [ ] Интеграция XWayland
- [ ] Бэкенд DRM/KMS для запуска без окна winit
Не сделано в Фазе 1 и почему:
- **XWayland.** Требует отдельного запуска `Xwayland`-сервера, прокси поверхностей
X11 в сцену smithay и синхронизации окон двух протоколов. Это объём больше,
чем весь сделанный композитор, и проверить его можно только на X11-клиенте,
которого в тестовой системе нет.
- **DRM/KMS.** Бэкенд нужен для запуска без окна winit — то есть на «голом»
железе. В тестовой среде нет ни DRM-устройства, ни KMS-карты: код пришлось бы
писать вслепую и нечем было бы проверить. Требуется реальная машина с
видеокартой.
## Фаза 2: Shell (панель и меню)
- [x] Панель задач: кнопка «Пуск», кнопки окон, выбор по клику
- [x] Отрисовка подписей: собственный растровщик текста на fontdb + fontdue
(подписи окон, заголовков, часов, меню)
- [x] Меню Пуск со списком приложений и поиском
(поиск набирается с клавиатуры, учитывает раскладку)
- [x] Запуск приложений в Lark, а не во внешней сессии
(каждое приложение получает свою шину D-Bus)
- [x] Часы в системном трее (время и дата, локальная зона)
- [x] Переключатель рабочих областей на панели
- [ ] Системный трей: сеть, звук, батарея
- [ ] Уведомления
Не сделано в Фазе 2 и почему:
- **Сеть, звук, батарея.** Нужен доступ к NetworkManager/D-Bus, PulseAudio и
`/sys/class/power_supply`. В тестовой системе этих служб нет, а заглушки
показали бы неверные значения, что хуже отсутствия индикатора.
- **Уведомления.** Требует протокола `org.freedesktop.Notifications` и меню
уведомлений; в текущем виде это отдельный экран с историей, то есть
примерно половина Фазы 2.
## Фаза 3: Сервисы
- [ ] Управление питанием (яркость, сон, батарея)
- [ ] Сетевой апплет
- [ ] Аудио-микшер
- [ ] Автозапуск и управление сессией
## Фаза 4: Приложения
- [ ] Файловый менеджер
- [ ] Терминал
- [ ] Центр настроек
Приложения перечислены в меню «Пуск» и запускаются командами `xdg-open`,
`lark-terminal`, `lark-settings`. Сами приложения ещё не написаны.
## Фаза 5: Полировка
- [ ] Анимации и переходы
- [ ] Темы оформления (светлая/тёмная)
- [ ] Обои рабочего стола
- [x] Горячие клавиши
(см. таблицу в `BUILD.md`)
- [ ] Мультиязычность (ru, en)
Горячие клавиши отмечены выполненными в том смысле, что механизм и набор
сочетаний есть и работают. Назначение их пользователем в настройках — отдельная
задача.
## Стратегия тестирования
- Проверка композитора на виртуальном дисплее: `scripts/dev-run.sh`
поднимает Xvfb, запускает композитор с клиентом и сохраняет снимок экрана
- Модульные тесты: разбор событий ввода, распознавание сочетаний клавиш,
раскладка панели задач, меню «Пуск», геометрия декораций и зон изменения
размера, растровщик текста, раскладка клавиатуры — `cargo test`
- Разработка и тестирование в виртуальной машине (QEMU/KVM с virtio-gpu или llvmpipe)
- CI на слабых конфигурациях (2 vCPU, 2 ГБ RAM)
- Профилирование через perf и tracing

53
locales/en-US/app.ftl Normal file
View file

@ -0,0 +1,53 @@
# Lark DE — English locale
app-name = Lark DE
# Taskbar
panel-start = Start
panel-search = Search
panel-taskview = Task view
panel-widgets = Widgets
panel-settings = Settings
# System tray
tray-network = Network
tray-volume = Volume
tray-battery = Battery
tray-clock = Clock
# Start Menu
start-pinned = Pinned
start-all-apps = All apps
start-recommended = Recommended
start-search-placeholder = Type to search
start-power = Power
start-shutdown = Shut down
start-restart = Restart
start-sleep = Sleep
start-lock = Lock
start-signout = Sign out
# Notifications
notifications-title = Notifications
notifications-clear-all = Clear all
notifications-empty = No notifications
# Settings
settings-title = Settings
settings-appearance = Appearance
settings-theme = Theme
settings-theme-light = Light
settings-theme-dark = Dark
settings-language = Language
settings-compositor = Compositor
settings-animations = Animations
settings-shadows = Shadows
settings-transparency = Transparency
# Common
common-ok = OK
common-cancel = Cancel
common-apply = Apply
common-close = Close
common-back = Back
common-search = Search

53
locales/ru-RU/app.ftl Normal file
View file

@ -0,0 +1,53 @@
# Lark DE — Русская локаль
app-name = Lark DE
# Панель задач
panel-start = Пуск
panel-search = Поиск
panel-taskview = Представление задач
panel-widgets = Виджеты
panel-settings = Настройки
# Системный трей
tray-network = Сеть
tray-volume = Громкость
tray-battery = Батарея
tray-clock = Часы
# Меню Пуск
start-pinned = Закреплено
start-all-apps = Все приложения
start-recommended = Рекомендуемые
start-search-placeholder = Введите для поиска
start-power = Питание
start-shutdown = Завершение работы
start-restart = Перезагрузка
start-sleep = Сон
start-lock = Заблокировать
start-signout = Выйти
# Уведомления
notifications-title = Уведомления
notifications-clear-all = Очистить все
notifications-empty = Нет уведомлений
# Настройки
settings-title = Настройки
settings-appearance = Оформление
settings-theme = Тема
settings-theme-light = Светлая
settings-theme-dark = Тёмная
settings-language = Язык
settings-compositor = Композитор
settings-animations = Анимации
settings-shadows = Тени
settings-transparency = Прозрачность
# Общие
common-ok = OK
common-cancel = Отмена
common-apply = Применить
common-close = Закрыть
common-back = Назад
common-search = Поиск

120
scripts/dev-run.sh Executable file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Запускает Lark DE на виртуальном X-сервере и проверяет, что клиентское
# окно появилось: поднимает Xvfb, стартует `lark`, запускает клиента и
# делает снимок экрана.
#
# Переменные окружения:
# LARK_TEST_DISPLAY дисплей Xvfb (по умолчанию :78)
# LARK_TEST_RUNTIME каталог для сокета (по умолчанию /tmp/lark-test-run)
# LARK_TEST_SHOT путь к снимку (по умолчанию /tmp/lark-shot.png)
# LARK_TEST_CLIENT клиент (по умолчанию eglgears_wayland)
# LARK_TEST_WAIT секунд до снимка (по умолчанию 6)
# RUST_LOG уровень журналирования
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DISPLAY_NUM="${LARK_TEST_DISPLAY:-:78}"
RUNTIME_DIR="${LARK_TEST_RUNTIME:-/tmp/lark-test-run}"
SCREENSHOT="${LARK_TEST_SHOT:-/tmp/lark-shot.png}"
CLIENT="${LARK_TEST_CLIENT:-eglgears_wayland}"
WAIT_SECONDS="${LARK_TEST_WAIT:-6}"
LOG="${LARK_TEST_LOG:-/tmp/lark-dev-run.log}"
BIN="$ROOT/target/debug/lark"
if [[ ! -x "$BIN" ]]; then
echo "Сборка не найдена, выполните: cargo build" >&2
exit 1
fi
cleanup() {
[[ -n "${CLIENT_PID:-}" ]] && kill "$CLIENT_PID" 2>/dev/null
[[ -n "${LARK_PID:-}" ]] && kill "$LARK_PID" 2>/dev/null
wait 2>/dev/null
return 0
}
trap cleanup EXIT
# --- Виртуальный дисплей -----------------------------------------------------
if [[ ! -S "/tmp/.X11-unix/X${DISPLAY_NUM#:}" ]]; then
if ! command -v Xvfb >/dev/null 2>&1; then
echo "Xvfb не установлен: apt install xvfb" >&2
exit 1
fi
rm -f "/tmp/.X${DISPLAY_NUM#:}-lock"
Xvfb "$DISPLAY_NUM" -screen 0 1280x800x24 -nolisten tcp >/tmp/lark-xvfb.log 2>&1 &
for _ in $(seq 1 40); do
[[ -S "/tmp/.X11-unix/X${DISPLAY_NUM#:}" ]] && break
sleep 0.25
done
fi
# --- Каталог для сокета ------------------------------------------------------
mkdir -p "$RUNTIME_DIR"
chmod 700 "$RUNTIME_DIR"
rm -f "$RUNTIME_DIR"/wayland-*
# --- Композитор --------------------------------------------------------------
: >"$LOG"
DISPLAY="$DISPLAY_NUM" \
WAYLAND_DISPLAY= \
XDG_RUNTIME_DIR="$RUNTIME_DIR" \
RUST_LOG="${RUST_LOG:-lark=info}" \
"$BIN" >"$LOG" 2>&1 &
LARK_PID=$!
SOCKET=""
for _ in $(seq 1 60); do
SOCKET=$(ls "$RUNTIME_DIR" 2>/dev/null | grep -E '^wayland-[0-9]+$' | head -1)
[[ -n "$SOCKET" ]] && break
sleep 0.25
done
if [[ -z "$SOCKET" ]]; then
echo "Lark DE не создал сокет. Журнал:" >&2
cat "$LOG" >&2
exit 1
fi
echo "Lark DE слушает: $RUNTIME_DIR/$SOCKET"
# --- Клиент ------------------------------------------------------------------
CLIENT_LOG=/tmp/lark-client.log
: >"$CLIENT_LOG"
if ! command -v "$CLIENT" >/dev/null 2>&1; then
echo "Клиент $CLIENT не найден, снимок будет без окна" >&2
else
XDG_RUNTIME_DIR="$RUNTIME_DIR" WAYLAND_DISPLAY="$SOCKET" \
"$CLIENT" >"$CLIENT_LOG" 2>&1 &
CLIENT_PID=$!
# Клиент может завершиться сразу, если не смог инициализировать
# графику. Это стоит сообщить явно, иначе пустой снимок выглядит как
# ошибка композитора.
sleep "$WAIT_SECONDS"
if kill -0 "$CLIENT_PID" 2>/dev/null; then
echo "Клиент $CLIENT работает"
else
echo "ВНИМАНИЕ: клиент $CLIENT завершился, окно на снимке может отсутствовать" >&2
fi
fi
# --- Снимок ------------------------------------------------------------------
if command -v import >/dev/null 2>&1; then
if DISPLAY="$DISPLAY_NUM" import -window root "png:$SCREENSHOT" 2>/dev/null; then
echo "Снимок: $SCREENSHOT"
else
echo "Не удалось сделать снимок экрана" >&2
fi
fi
echo "Журнал Lark: $LOG"
if grep -qE '^Error' "$LOG"; then
echo "--- Журнал Lark ---" >&2
tail -20 "$LOG" >&2
fi
if [[ -s "$CLIENT_LOG" ]]; then
echo "--- Журнал клиента ---" >&2
tail -10 "$CLIENT_LOG" >&2
fi

27
src/apps/files.rs Normal file
View file

@ -0,0 +1,27 @@
//! Файловый менеджер.
/// Файловый менеджер.
pub struct FileManager {
current_path: String,
}
impl FileManager {
/// Создаёт новый файловый менеджер.
pub fn new() -> Self {
Self {
current_path: std::env::home_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "/".to_string()),
}
}
/// Переходит в директорию.
pub fn navigate_to(&mut self, path: impl Into<String>) {
self.current_path = path.into();
}
/// Возвращает текущий путь.
pub fn current_path(&self) -> &str {
&self.current_path
}
}

5
src/apps/mod.rs Normal file
View file

@ -0,0 +1,5 @@
//! Lark Apps — собственные приложения.
pub mod files;
pub mod terminal;
pub mod settings;

13
src/apps/settings.rs Normal file
View file

@ -0,0 +1,13 @@
//! Центр настроек.
/// Центр настроек.
pub struct Settings {
// TODO: Разделы настроек
}
impl Settings {
/// Создаёт новый центр настроек.
pub fn new() -> Self {
Self {}
}
}

13
src/apps/terminal.rs Normal file
View file

@ -0,0 +1,13 @@
//! Эмулятор терминала.
/// Терминал.
pub struct Terminal {
// TODO: PTY, буфер, рендеринг текста
}
impl Terminal {
/// Создаёт новый терминал.
pub fn new() -> Self {
Self {}
}
}

View file

@ -0,0 +1,718 @@
//! Декорации окон в стиле Windows 11 (CSD).
//!
//! Заголовок рисует композитор, а не клиент: окно в Wayland не имеет
//! «собственной рамки», поэтому без этого заголовка перетаскивать и
//! сворачивать окно было бы нечем.
//!
//! Поверхность клиента занимает всё окно целиком, а заголовок располагается
//! **над** ней. Поэтому рамка окна по высоте на заголовок больше, чем
//! bounding box поверхности.
use crate::config::DecorationConfig;
use smithay::backend::renderer::Color32F;
use smithay::utils::{Logical, Point, Rectangle, Size};
/// Ширина полосы захвата для изменения размера окна.
const RESIZE_BORDER: i32 = 6;
/// Действие, доступное по клику на декорацию.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecorationAction {
/// Заголовок без кнопки: окно следует перетаскивать.
Drag,
/// Свернуть окно.
Minimize,
/// Развернуть или вернуть окно.
ToggleMaximize,
/// Закрыть окно.
Close,
/// Изменить размер за левый край.
ResizeWest,
/// Изменить размер за правый край.
ResizeEast,
/// Изменить размер за верхний край.
ResizeNorth,
/// Изменить размер за нижний край.
ResizeSouth,
/// Изменить размер за левый верхний угол.
ResizeNorthWest,
/// Изменить размер за правый верхний угол.
ResizeNorthEast,
/// Изменить размер за левый нижний угол.
ResizeSouthWest,
/// Изменить размер за правый нижний угол.
ResizeSouthEast,
}
impl DecorationAction {
/// `true`, если действие меняет размер окна.
pub fn is_resize(self) -> bool {
matches!(
self,
DecorationAction::ResizeWest
| DecorationAction::ResizeEast
| DecorationAction::ResizeNorth
| DecorationAction::ResizeSouth
| DecorationAction::ResizeNorthWest
| DecorationAction::ResizeNorthEast
| DecorationAction::ResizeSouthWest
| DecorationAction::ResizeSouthEast
)
}
}
/// Размеры и цвета декораций.
#[derive(Debug, Clone)]
pub struct DecorationStyle {
/// Высота заголовка.
pub titlebar_height: i32,
/// Ширина кнопки заголовка.
pub button_width: i32,
/// Цвет фона заголовка активного окна.
pub active: Color32F,
/// Цвет фона заголовка неактивного окна.
pub inactive: Color32F,
/// Цвет кнопок заголовка.
pub button: Color32F,
}
impl DecorationStyle {
/// Строит стиль из настроек конфигурации.
pub fn from_config(config: &DecorationConfig, accent: Color32F, light: bool) -> Self {
// Светлая тема требует других оттенков по умолчанию: тёмный
// заголовок поверх светлого содержимого выглядит как ошибка.
let (active, inactive, button) = if light {
(
Color32F::new(0.93, 0.93, 0.95, 1.0),
Color32F::new(0.88, 0.88, 0.91, 1.0),
Color32F::new(0.80, 0.80, 0.84, 1.0),
)
} else {
(
Color32F::new(0.17, 0.17, 0.21, 1.0),
Color32F::new(0.13, 0.13, 0.16, 1.0),
Color32F::new(0.22, 0.22, 0.27, 1.0),
)
};
let _ = accent;
Self {
titlebar_height: config.titlebar_height as i32,
button_width: config.button_width as i32,
active,
inactive,
button,
}
}
}
/// Прямоугольник заголовка для окна с указанным bounding box.
///
/// Заголовок располагается над поверхностью, поэтому рамка окна
/// оказывается на `titlebar_height` выше, чем сам bounding box.
pub fn title_bar_rect(window: Rectangle<i32, Logical>, style: &DecorationStyle) -> Rectangle<i32, Logical> {
let height = style.titlebar_height.max(1);
Rectangle::new(
window.loc - Point::new(0, height),
Size::new(window.size.w, height),
)
}
/// Прямоугольники кнопок заголовка слева направо.
///
/// Windows 11 размещает кнопки справа: сначала сворачивание, затем
/// разворачивание, затем закрытие.
pub fn button_rects(
title_bar: Rectangle<i32, Logical>,
style: &DecorationStyle,
) -> [(DecorationAction, Rectangle<i32, Logical>); 3] {
let width = style.button_width.max(1);
let actions = [
DecorationAction::Minimize,
DecorationAction::ToggleMaximize,
DecorationAction::Close,
];
// Кнопки выравниваются по правому краю заголовка.
let total = width * actions.len() as i32;
let mut left = title_bar.loc.x + title_bar.size.w - total;
std::array::from_fn(|index| {
let action = actions[index];
let rect = Rectangle::new(
Point::new(left, title_bar.loc.y),
Size::new(width, title_bar.size.h),
);
left += width;
(action, rect)
})
}
/// Прямоугольник, который окно занимает вместе с заголовком.
///
/// Поверхность клиента начинается ниже заголовка, поэтому рамка шире окна
/// на высоту заголовка.
pub fn frame_rect(
window: Rectangle<i32, Logical>,
style: &DecorationStyle,
) -> Rectangle<i32, Logical> {
let height = style.titlebar_height.max(1);
Rectangle::new(
window.loc - Point::new(0, height),
Size::new(window.size.w, window.size.h + height),
)
}
/// Определяет, что находится под точкой в декорациях окна.
///
/// Возвращает `None`, если точка не попала ни в кнопку, ни в заголовок,
/// ни в полосу изменения размера.
pub fn hit_test(
window: Rectangle<i32, Logical>,
point: Point<f64, Logical>,
style: &DecorationStyle,
) -> Option<DecorationAction> {
let title_bar = title_bar_rect(window, style);
let p = point.to_i32_round();
// Кнопки проверяются первыми: они лежат поверх заголовка.
for (action, rect) in button_rects(title_bar, style) {
if rect.contains(p) {
return Some(action);
}
}
// Зоны изменения размера проверяются раньше заголовка: заголовок
// занимает верх рамки целиком, и без этого порядка верхний край окна
// был бы нерастягиваемым.
//
// Границы берутся от рамки, а не от поверхности клиента: рамка выше на
// заголовок, и по поверхности верхний край просто не существует.
let frame = frame_rect(window, style);
if let Some(action) = corner_action(frame, p) {
return Some(action);
}
if let Some(action) = edge_action(frame, p) {
return Some(action);
}
if title_bar.contains(p) {
return Some(DecorationAction::Drag);
}
None
}
/// Определяет изменение размера за углом рамки.
fn corner_action(
window: Rectangle<i32, Logical>,
point: Point<i32, Logical>,
) -> Option<DecorationAction> {
let far = Point::<i32, Logical>::new(
window.loc.x + window.size.w - 1,
window.loc.y + window.size.h - 1,
);
let west = (point.x - window.loc.x).abs() <= RESIZE_BORDER;
let east = (far.x - point.x).abs() <= RESIZE_BORDER;
let north = (point.y - window.loc.y).abs() <= RESIZE_BORDER;
let south = (far.y - point.y).abs() <= RESIZE_BORDER;
// Условия написаны явно для каждого угла: проверка «слева И сверху»
// без проверки самих углов срабатывала бы на любом краю.
if west
&& north
&& point.x <= window.loc.x + RESIZE_BORDER
&& point.y <= window.loc.y + RESIZE_BORDER
{
return Some(DecorationAction::ResizeNorthWest);
}
if east
&& north
&& point.x >= far.x - RESIZE_BORDER
&& point.y <= window.loc.y + RESIZE_BORDER
{
return Some(DecorationAction::ResizeNorthEast);
}
if west
&& south
&& point.x <= window.loc.x + RESIZE_BORDER
&& point.y >= far.y - RESIZE_BORDER
{
return Some(DecorationAction::ResizeSouthWest);
}
if east
&& south
&& point.x >= far.x - RESIZE_BORDER
&& point.y >= far.y - RESIZE_BORDER
{
return Some(DecorationAction::ResizeSouthEast);
}
None
}
/// Определяет изменение размера за стороной рамки.
fn edge_action(
window: Rectangle<i32, Logical>,
point: Point<i32, Logical>,
) -> Option<DecorationAction> {
let far = Point::<i32, Logical>::new(
window.loc.x + window.size.w - 1,
window.loc.y + window.size.h - 1,
);
// За пределами окна краёв нет: иначе зона захвата уходила бы на фон
// рабочего стола и окно цеплялось указателем само по себе.
if point.x < window.loc.x
|| point.x > far.x
|| point.y < window.loc.y
|| point.y > far.y
{
return None;
}
if point.x - window.loc.x <= RESIZE_BORDER && (point.y - window.loc.y) > RESIZE_BORDER
&& (far.y - point.y) > RESIZE_BORDER
{
return Some(DecorationAction::ResizeWest);
}
if far.x - point.x <= RESIZE_BORDER && (point.y - window.loc.y) > RESIZE_BORDER
&& (far.y - point.y) > RESIZE_BORDER
{
return Some(DecorationAction::ResizeEast);
}
if point.y - window.loc.y <= RESIZE_BORDER && (point.x - window.loc.x) > RESIZE_BORDER
&& (far.x - point.x) > RESIZE_BORDER
{
return Some(DecorationAction::ResizeNorth);
}
if far.y - point.y <= RESIZE_BORDER && (point.x - window.loc.x) > RESIZE_BORDER
&& (far.x - point.x) > RESIZE_BORDER
{
return Some(DecorationAction::ResizeSouth);
}
None
}
/// Место под подпись заголовка окна.
///
/// Сам текст приходит отдельно: он зависит от окна, а геометрия — только
/// от стиля.
#[derive(Debug, Clone, Copy)]
pub struct TitleLabel {
/// Левый край подписи в логических координатах.
pub x: i32,
/// Верхний край подписи в логических координатах.
pub y: i32,
/// Доступная ширина подписи: длинные заголовки обрезаются.
pub max_width: i32,
}
/// Список прямоугольников для отрисовки вместе с цветами.
pub fn paint_list(
window: Rectangle<i32, Logical>,
active: bool,
style: &DecorationStyle,
) -> Vec<(Rectangle<i32, Logical>, Color32F)> {
let title_bar = title_bar_rect(window, style);
let background = if active { style.active } else { style.inactive };
let mut paints = Vec::with_capacity(4);
// Кнопки идут первыми: элементы сцены рисуются в обратном порядке,
// поэтому заголовок, поставленный первым, перекрыл бы кнопки.
for (_, rect) in button_rects(title_bar, style) {
paints.push((rect, style.button));
}
paints.push((title_bar, background));
paints
}
/// Место под заголовок окна внутри рамки.
///
/// Кнопки занимают правый край, поэтому подписи остаётся всё остальное.
pub fn title_label(
window: Rectangle<i32, Logical>,
style: &DecorationStyle,
) -> TitleLabel {
let title_bar = title_bar_rect(window, style);
let buttons = button_rects(title_bar, style);
let left = buttons
.first()
.map(|(_, rect)| rect.loc.x)
.unwrap_or(title_bar.loc.x + title_bar.size.w);
// Небольшой отступ от краёв, иначе подпись упирается в рамку.
let inset = style.titlebar_height / 3;
TitleLabel {
x: title_bar.loc.x + inset,
y: title_bar.loc.y,
max_width: (left - title_bar.loc.x - inset * 2).max(0),
}
}
/// Тесты геометрии декораций.
#[cfg(test)]
mod tests {
use super::*;
fn style() -> DecorationStyle {
DecorationStyle::from_config(&DecorationConfig::default(), Color32F::new(0.0, 0.5, 1.0, 1.0), false)
}
fn window() -> Rectangle<i32, Logical> {
Rectangle::new(
Point::new(100, 200),
Size::new(800, 600),
)
}
/// Заголовок располагается над поверхностью окна.
#[test]
fn title_bar_sits_above_surface() {
let style = style();
let bar = title_bar_rect(window(), &style);
assert_eq!(bar.loc.x, 100, "заголовок выровнен по левому краю окна");
assert_eq!(bar.size.w, 800, "заголовок на всю ширину окна");
assert_eq!(bar.size.h, style.titlebar_height);
assert_eq!(bar.loc.y, 200 - style.titlebar_height);
}
/// Кнопки идут справа налево в порядке «свернуть, развернуть, закрыть».
#[test]
fn buttons_are_on_the_right_in_windows_order() {
let style = style();
let bar = title_bar_rect(window(), &style);
let buttons = button_rects(bar, &style);
assert_eq!(buttons[0].0, DecorationAction::Minimize);
assert_eq!(buttons[1].0, DecorationAction::ToggleMaximize);
assert_eq!(buttons[2].0, DecorationAction::Close);
// Самая правая кнопка — закрытие.
assert_eq!(
buttons[2].1.loc.x + buttons[2].1.size.w,
bar.loc.x + bar.size.w
);
// Кнопки не пересекаются и идут подряд.
assert_eq!(buttons[0].1.loc.x + buttons[0].1.size.w, buttons[1].1.loc.x);
assert_eq!(buttons[1].1.loc.x + buttons[1].1.size.w, buttons[2].1.loc.x);
}
/// Клик по свободной части заголовка означает перетаскивание.
#[test]
fn title_bar_click_starts_drag() {
let style = style();
let point = Point::new(200.0, 190.0);
assert_eq!(
hit_test(window(), point, &style),
Some(DecorationAction::Drag)
);
}
/// Клик по кнопке возвращает её действие, а не перетаскивание.
#[test]
fn button_click_returns_its_action() {
let style = style();
let bar = title_bar_rect(window(), &style);
let buttons = button_rects(bar, &style);
let close = buttons[2].1;
let center = Point::new(
(close.loc.x + close.size.w / 2) as f64,
(close.loc.y + close.size.h / 2) as f64,
);
assert_eq!(
hit_test(window(), center, &style),
Some(DecorationAction::Close)
);
}
/// Клик по содержимому окна не попадает в декорации.
#[test]
fn click_on_content_is_not_a_decoration() {
let style = style();
// Центр окна — это уже содержимое.
assert_eq!(
hit_test(window(), Point::new(500.0, 500.0), &style),
None
);
}
/// Клик выше заголовка не попадает в декорации.
#[test]
fn click_above_title_bar_misses() {
let style = style();
let bar = title_bar_rect(window(), &style);
let above = Point::new(200.0, (bar.loc.y - 5) as f64);
assert_eq!(hit_test(window(), above, &style), None);
}
/// Отрисовка покрывает заголовок и все три кнопки.
///
/// Заголовок обязан идти последним: элементы сцены рисуются в обратном
/// порядке, иначе он перекроет кнопки.
#[test]
fn paint_list_covers_title_bar_and_buttons() {
let style = style();
let paints = paint_list(window(), true, &style);
assert_eq!(paints.len(), 4, "три кнопки и заголовок");
let (title_rect, title_color) = paints.last().expect("заголовок");
assert_eq!(title_color, &style.active, "активное окно");
assert_eq!(title_rect.size.h, style.titlebar_height);
for (rect, color) in &paints[..3] {
assert_eq!(rect.size.h, style.titlebar_height, "кнопка во всю высоту");
assert_eq!(color, &style.button);
}
}
/// Неактивное окно окрашивается иначе.
#[test]
fn inactive_window_has_other_color() {
let style = style();
let active = paint_list(window(), true, &style);
let inactive = paint_list(window(), false, &style);
assert_ne!(active.last().unwrap().1, inactive.last().unwrap().1);
assert_eq!(inactive.last().unwrap().1, style.inactive);
}
/// Подпись размещается слева и не заезжает под кнопки.
#[test]
fn title_label_avoids_buttons() {
let style = style();
let label = title_label(window(), &style);
let bar = title_bar_rect(window(), &style);
let buttons = button_rects(bar, &style);
let buttons_left = buttons[0].1.loc.x;
assert!(label.x > bar.loc.x, "подпись не у левого края рамки");
assert!(
label.x + label.max_width <= buttons_left,
"подпись доходит до кнопок: {} > {buttons_left}",
label.x + label.max_width
);
assert!(label.max_width > 0, "для подписи есть место");
assert_eq!(label.y, bar.loc.y, "подпись в заголовке");
}
/// Узкое окно оставляет подписи меньше места, но не отрицательной.
#[test]
fn narrow_window_gives_no_room_for_title() {
let style = style();
let narrow = Rectangle::new(
Point::new(0, 0),
Size::new(style.button_width * 3, 100),
);
let label = title_label(narrow, &style);
assert_eq!(label.max_width, 0, "мест для подписи нет");
assert!(label.x >= 0, "подпись не уходит в минус");
}
/// Края окна предлагают изменение размера.
///
/// Проверяются все восемь направлений: без них окно нельзя было бы
/// растянуть ни в одну сторону.
#[test]
fn window_edges_resize() {
let style = style();
let frame = frame_rect(window(), &style);
let (left, top) = (frame.loc.x as f64, frame.loc.y as f64);
let (right, bottom) = (
(frame.loc.x + frame.size.w - 1) as f64,
(frame.loc.y + frame.size.h - 1) as f64,
);
let middle_x = (left + right) / 2.0;
let middle_y = (top + bottom) / 2.0;
let cases = [
(Point::new(left, middle_y), DecorationAction::ResizeWest),
(Point::new(right, middle_y), DecorationAction::ResizeEast),
(Point::new(middle_x, top), DecorationAction::ResizeNorth),
(Point::new(middle_x, bottom), DecorationAction::ResizeSouth),
];
for (point, expected) in cases {
assert_eq!(
hit_test(window(), point, &style),
Some(expected),
"точка {point:?}"
);
}
}
/// Верх заголовка тянет размер, а его середина — окно.
///
/// Заголовок занимает верх рамки целиком, поэтому порядок проверок
/// решает: без приоритета у зон размера окно нельзя было бы растянуть
/// вверх, а без приоритета у заголовка его нельзя было бы двигать.
#[test]
fn titlebar_splits_between_resize_and_drag() {
let style = style();
let frame = frame_rect(window(), &style);
let top = frame.loc.y as f64;
let middle_x = (frame.loc.x + frame.size.w / 2) as f64;
assert_eq!(
hit_test(window(), Point::new(middle_x, top), &style),
Some(DecorationAction::ResizeNorth),
"верх заголовка должен тянуть размер"
);
// Середина заголовка — обычное перетаскивание.
let bar_middle = top + style.titlebar_height as f64 / 2.0;
assert_eq!(
hit_test(window(), Point::new(middle_x, bar_middle), &style),
Some(DecorationAction::Drag),
"середина заголовка должна перетаскивать окно"
);
}
/// Углы тянутся по диагонали.
///
/// Правый верхний угол не проверяется: в стиле Windows 11 там кнопка
/// закрытия, и кнопка обязана выигрывать у зоны изменения размера.
#[test]
fn window_corners_resize_diagonally() {
let style = style();
let frame = frame_rect(window(), &style);
let (left, top) = (frame.loc.x as f64, frame.loc.y as f64);
let (right, bottom) = (
(frame.loc.x + frame.size.w - 1) as f64,
(frame.loc.y + frame.size.h - 1) as f64,
);
let cases = [
(Point::new(left, top), DecorationAction::ResizeNorthWest),
(Point::new(left, bottom), DecorationAction::ResizeSouthWest),
(Point::new(right, bottom), DecorationAction::ResizeSouthEast),
];
for (point, expected) in cases {
assert_eq!(
hit_test(window(), point, &style),
Some(expected),
"точка {point:?}"
);
}
}
/// Правый верхний угол принадлежит кнопке закрытия.
///
/// Это осознанное решение в пользу стиля Windows 11: без него кнопка
/// закрытия была бы недоступна мышью.
#[test]
fn top_right_corner_belongs_to_close_button() {
let style = style();
let frame = frame_rect(window(), &style);
let corner = Point::new(
(frame.loc.x + frame.size.w - 1) as f64,
frame.loc.y as f64,
);
assert_eq!(
hit_test(window(), corner, &style),
Some(DecorationAction::Close),
"угол должен принадлежать кнопке закрытия"
);
}
/// Углы важнее рёбер, иначе тянуть по диагонали было бы нечем.
#[test]
fn corners_take_priority_over_edges() {
let style = style();
let frame = frame_rect(window(), &style);
// Угол рамки: и сверху и слева одновременно.
let corner = Point::new(frame.loc.x as f64, frame.loc.y as f64);
let action = hit_test(window(), corner, &style).expect("угол должен ловиться");
assert!(action.is_resize(), "угол должен менять размер");
assert_eq!(action, DecorationAction::ResizeNorthWest);
}
/// Зона захвата не выходит за пределы окна.
///
/// Иначе указатель цеплял бы окно за его пределами и окно «прыгало» бы
/// при простом прохождении мимо.
#[test]
fn resize_zone_stays_inside_the_window() {
let style = style();
let frame = frame_rect(window(), &style);
// На два пикселя левее рамки.
let outside = Point::new(frame.loc.x as f64 - 2.0, frame.loc.y as f64 + 200.0);
assert_eq!(hit_test(window(), outside, &style), None);
// На два пикселя ниже рамки.
let below = Point::new(
(frame.loc.x + frame.size.w / 2) as f64,
(frame.loc.y + frame.size.h + 2) as f64,
);
assert_eq!(hit_test(window(), below, &style), None);
}
/// Содержимое окна не считается зоной изменения размера.
#[test]
fn content_is_not_a_resize_zone() {
let style = style();
let center = Point::new(500.0, 500.0);
assert_eq!(hit_test(window(), center, &style), None);
}
/// Все действия изменения размера распознаются как таковые.
#[test]
fn resize_actions_are_marked() {
for action in [
DecorationAction::ResizeWest,
DecorationAction::ResizeEast,
DecorationAction::ResizeNorth,
DecorationAction::ResizeSouth,
DecorationAction::ResizeNorthWest,
DecorationAction::ResizeNorthEast,
DecorationAction::ResizeSouthWest,
DecorationAction::ResizeSouthEast,
] {
assert!(action.is_resize(), "{action:?} должен быть изменением размера");
}
for action in [
DecorationAction::Drag,
DecorationAction::Minimize,
DecorationAction::ToggleMaximize,
DecorationAction::Close,
] {
assert!(!action.is_resize(), "{action:?} не должен быть изменением размера");
}
}
/// Рамка включает заголовок, поэтому она выше поверхности клиента.
#[test]
fn frame_includes_titlebar() {
let style = style();
let frame = frame_rect(window(), &style);
assert_eq!(frame.loc.y, window().loc.y - style.titlebar_height);
assert_eq!(frame.size.h, window().size.h + style.titlebar_height);
assert_eq!(frame.size.w, window().size.w);
}
}

418
src/compositor/input.rs Normal file
View file

@ -0,0 +1,418 @@
//! Обработка ввода: клавиатура, мышь, тачпад.
//!
//! Фактическое преобразование событий в протоколы Wayland выполняет
//! [`crate::compositor::App`]; здесь хранится состояние ввода, которое нужно
//! для горячих клавиш и оболочки.
use std::collections::HashSet;
use smithay::backend::input::Keycode;
use smithay::input::keyboard::ModifiersState;
/// Смещение между scancode winit и `Keycode` smithay.
const KEYCODE_OFFSET: u32 = 8;
/// Scancode клавиш-модификаторов в evdev.
const SCANCODE_LEFTCTRL: u32 = 29;
const SCANCODE_RIGHTCTRL: u32 = 97;
const SCANCODE_LEFTALT: u32 = 56;
const SCANCODE_RIGHTALT: u32 = 100;
const SCANCODE_LEFTSHIFT: u32 = 42;
const SCANCODE_RIGHTSHIFT: u32 = 54;
const SCANCODE_LEFTMETA: u32 = 125;
const SCANCODE_RIGHTMETA: u32 = 126;
/// Состояние клавиши.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyState {
Pressed,
Released,
}
/// Состояние кнопки мыши.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonState {
Pressed,
Released,
}
/// Комбинация клавиш (горячая клавиша).
pub type Shortcut = HashSet<Keycode>;
/// Действие, привязанное к горячей клавише.
pub type ShortcutAction = Box<dyn Fn() + Send + Sync>;
/// Действие, применяемое к активному окну.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowShortcut {
/// Свернуть активное окно.
Minimize,
/// Развернуть активное окно либо вернуть его к прежнему размеру.
ToggleMaximize,
/// Закрыть активное окно.
Close,
}
/// Результат обработки нажатия клавиши.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShortcutResult {
/// Сработала глобальная горячая клавиша.
Global,
/// Сработало сочетание для активного окна.
Window(WindowShortcut),
}
/// Менеджер ввода: хранит нажатые клавиши и зарегистрированные сочетания.
pub struct InputManager {
/// Клавиши, сейчас находящиеся в нажатом состоянии.
pressed: Shortcut,
/// Зарегистрированные сочетания и связанные с ними действия.
shortcuts: Vec<(Shortcut, ShortcutAction)>,
/// Сочетания, действующие на активное окно.
window_shortcuts: Vec<(Shortcut, WindowShortcut)>,
/// Сочетание, совпавшее при нажатии, но ещё не отпущенное.
fired: Option<Shortcut>,
/// Сочетание, на набор клавиш которого мы нацелены.
///
/// Обнуляется, если нажата клавиша, не входящая ни в одно сочетание:
/// это признак того, что пользователь набрал что-то другое.
armed: Option<Shortcut>,
}
/// Вид совпавшего сочетания.
#[derive(Clone, Copy)]
enum ShortcutKind {
/// Глобальная горячая клавиша, заданная индексом в списке.
Global(usize),
/// Действие над активным окном.
Window(WindowShortcut),
}
impl Default for InputManager {
fn default() -> Self {
Self::new()
}
}
impl InputManager {
/// Создаёт новый менеджер ввода.
pub fn new() -> Self {
Self {
pressed: HashSet::new(),
shortcuts: Vec::new(),
window_shortcuts: Vec::new(),
fired: None,
armed: None,
}
}
/// Регистрирует сочетание, действующее на активное окно.
pub fn register_window_shortcut(
&mut self,
binding: impl IntoIterator<Item = Keycode>,
action: WindowShortcut,
) {
self.window_shortcuts
.push((binding.into_iter().collect(), action));
}
/// Регистрирует горячую клавишу и действие для неё.
pub fn register_shortcut(
&mut self,
binding: impl IntoIterator<Item = Keycode>,
action: impl Fn() + Send + Sync + 'static,
) {
self.shortcuts
.push((binding.into_iter().collect(), Box::new(action)));
}
/// Состояние клавиш-модификаторов по нажатым клавишам.
///
/// Отдельный доступ к модификаторам у `KeyboardHandle` отсутствует, но
/// набор нажатых клавиш здесь уже есть, и он в точности их описывает.
pub fn modifiers(&self) -> ModifiersState {
let held = |scancode: u32| {
self.pressed
.contains(&Keycode::from(scancode + KEYCODE_OFFSET))
};
ModifiersState {
ctrl: held(SCANCODE_LEFTCTRL) || held(SCANCODE_RIGHTCTRL),
alt: held(SCANCODE_LEFTALT) || held(SCANCODE_RIGHTALT),
shift: held(SCANCODE_LEFTSHIFT) || held(SCANCODE_RIGHTSHIFT),
logo: held(SCANCODE_LEFTMETA) || held(SCANCODE_RIGHTMETA),
..ModifiersState::default()
}
}
/// Обрабатывает нажатие или отпускание клавиши.
///
/// Возвращает действие, сработавшее по сочетанию клавиш.
///
/// Сочетание срабатывает в момент **отпускания** последней клавиши, а не
/// нажатия: иначе Super сработает раньше, чем пользователь успеет
/// добавить Shift, и сочетание Super+Shift развалилось бы на Super.
pub fn handle_key(&mut self, key: Keycode, state: KeyState) -> Option<ShortcutResult> {
match state {
KeyState::Pressed => {
self.pressed.insert(key);
// Сочетание «собирается» только пока набор нажатых входит
// хотя бы в одно зарегистрированное сочетание. Сторонняя
// клавиша обрывает сборку: иначе Super+A сворачивал бы окно.
self.armed = self
.longest_superset()
.map(|(binding, _)| binding);
None
}
KeyState::Released => {
let already_fired = self.fired.clone();
// `armed` проверяется только на предмет «помехи»: если
// нажата посторонняя клавиша, сочетание не сработает вовсе.
let matched = self.armed.as_ref().and_then(|_| {
self.longest_match().filter(|(binding, _)| {
// После срабатывания Super+Shift отпускание Super
// не должно выполнить более короткое сочетание.
already_fired.as_ref().is_none_or(|fired| fired == binding)
})
});
let result = match matched {
Some((binding, ShortcutKind::Global(index))) => {
self.fired = Some(binding);
(self.shortcuts[index].1)();
Some(ShortcutResult::Global)
}
Some((binding, ShortcutKind::Window(action))) => {
self.fired = Some(binding);
Some(ShortcutResult::Window(action))
}
None => None,
};
self.armed = None;
self.pressed.remove(&key);
// Сочетание считается завершённым, когда отпущены все его
// клавиши: после этого новые сочетания снова допустимы.
if self
.fired
.as_ref()
.is_some_and(|binding| binding.iter().all(|k| !self.pressed.contains(k)))
{
self.fired = None;
}
result
}
}
}
/// Возвращает самое длинное сочетание, содержащее все нажатые клавиши.
///
/// `None` означает, что текущий набор не может стать ни одним из
/// зарегистрированных сочетаний.
fn longest_superset(&self) -> Option<(Shortcut, ShortcutKind)> {
let mut best: Option<(Shortcut, ShortcutKind)> = None;
let consider = |best: &mut Option<(Shortcut, ShortcutKind)>,
binding: &Shortcut,
kind: ShortcutKind| {
let fits = self.pressed.iter().all(|key| binding.contains(key));
if fits && best.as_ref().is_none_or(|(b, _)| b.len() < binding.len()) {
*best = Some((binding.clone(), kind));
}
};
for (index, (binding, _)) in self.shortcuts.iter().enumerate() {
consider(&mut best, binding, ShortcutKind::Global(index));
}
for (binding, action) in &self.window_shortcuts {
consider(&mut best, binding, ShortcutKind::Window(*action));
}
best
}
/// Возвращает самое длинное зарегистрированное сочетание, совпадающее с
/// текущим набором нажатых клавиш.
///
/// Длина сравнивается первой, иначе `Super` перехватил бы `Super+Shift`.
fn longest_match(&self) -> Option<(Shortcut, ShortcutKind)> {
let mut best: Option<(Shortcut, ShortcutKind)> = None;
for (index, (binding, _)) in self.shortcuts.iter().enumerate() {
if binding == &self.pressed
&& best.as_ref().is_none_or(|(b, _)| b.len() < binding.len())
{
best = Some((binding.clone(), ShortcutKind::Global(index)));
}
}
for (binding, action) in &self.window_shortcuts {
if binding == &self.pressed
&& best.as_ref().is_none_or(|(b, _)| b.len() < binding.len())
{
best = Some((binding.clone(), ShortcutKind::Window(*action)));
}
}
best
}
/// Обрабатывает движение мыши.
pub fn handle_pointer_motion(&mut self, _x: f64, _y: f64) {
// TODO: учёт области отслеживания указателя и ускорения
}
/// Обрабатывает нажатие или отпускание кнопки мыши.
pub fn handle_pointer_button(&mut self, _button: u32, _state: ButtonState) {
// TODO: перетаскивание окон
}
/// Освобождает все нажатые клавиши.
///
/// Вызывается при потере фокуса окна, иначе клиенты увидят «залипшие»
/// нажатия после возвращения фокуса.
pub fn release_all(&mut self) {
self.pressed.clear();
self.fired = None;
self.armed = None;
}
}
/// Тесты распознавания сочетаний клавиш.
///
/// Логика нетривиальна: сочетание должно срабатывать при отпускании клавиш,
/// причём одно сочетание не должно срабатывать дважды.
#[cfg(test)]
mod tests {
use super::*;
fn key_super() -> Keycode {
Keycode::from(133u32)
}
fn shift() -> Keycode {
Keycode::from(50u32)
}
fn delete() -> Keycode {
Keycode::from(119u32)
}
fn key_a() -> Keycode {
Keycode::from(46u32)
}
fn manager() -> InputManager {
let mut manager = InputManager::new();
manager.register_window_shortcut([key_super()], WindowShortcut::Minimize);
manager.register_window_shortcut([key_super(), shift()], WindowShortcut::ToggleMaximize);
manager.register_window_shortcut([key_super(), delete()], WindowShortcut::Close);
manager
}
/// Нажатие и отпускание Super должно свернуть окно.
#[test]
fn super_minimizes() {
let mut m = manager();
assert_eq!(m.handle_key(key_super(), KeyState::Pressed), None);
let result = m.handle_key(key_super(), KeyState::Released);
assert_eq!(result, Some(ShortcutResult::Window(WindowShortcut::Minimize)));
}
/// Super+Shift должен разворачивать, а не сворачивать.
#[test]
fn super_shift_toggles_maximize() {
let mut m = manager();
m.handle_key(key_super(), KeyState::Pressed);
m.handle_key(shift(), KeyState::Pressed);
// Отпускание Shift завершает сочетание.
let result = m.handle_key(shift(), KeyState::Released);
assert_eq!(
result,
Some(ShortcutResult::Window(WindowShortcut::ToggleMaximize))
);
// Отпускание Super не должно тут же выполнить короткое сочетание.
assert_eq!(m.handle_key(key_super(), KeyState::Released), None);
}
/// Super+Delete должен закрывать окно.
#[test]
fn super_delete_closes() {
let mut m = manager();
m.handle_key(key_super(), KeyState::Pressed);
m.handle_key(delete(), KeyState::Pressed);
let result = m.handle_key(delete(), KeyState::Released);
assert_eq!(result, Some(ShortcutResult::Window(WindowShortcut::Close)));
}
/// После завершения сочетания следующее нажатие работает как обычно.
#[test]
fn shortcut_can_be_repeated() {
let mut m = manager();
for _ in 0..2 {
m.handle_key(key_super(), KeyState::Pressed);
assert_eq!(
m.handle_key(key_super(), KeyState::Released),
Some(ShortcutResult::Window(WindowShortcut::Minimize))
);
}
}
/// Одиночная клавиша не должна вызывать ни одного сочетания.
#[test]
fn plain_key_does_not_trigger() {
let mut m = manager();
m.handle_key(key_a(), KeyState::Pressed);
assert_eq!(m.handle_key(key_a(), KeyState::Released), None);
}
/// Super с посторонней клавишей не должен срабатывать.
#[test]
fn super_with_unrelated_key_does_not_trigger() {
let mut m = manager();
m.handle_key(key_super(), KeyState::Pressed);
m.handle_key(key_a(), KeyState::Pressed);
// Ни при отпускании A, ни при отпускании Super.
assert_eq!(m.handle_key(key_a(), KeyState::Released), None);
assert_eq!(m.handle_key(key_super(), KeyState::Released), None);
}
/// Глобальные сочетаения выполняются и возвращают свой тип.
#[test]
fn global_shortcut_runs_action() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let counter = Arc::new(AtomicUsize::new(0));
let mut m = InputManager::new();
let c = Arc::clone(&counter);
m.register_shortcut([key_super(), delete()], move || {
c.fetch_add(1, Ordering::SeqCst);
});
m.handle_key(key_super(), KeyState::Pressed);
m.handle_key(delete(), KeyState::Pressed);
assert_eq!(counter.load(Ordering::SeqCst), 0, "сработать должно при отпускании");
assert_eq!(m.handle_key(delete(), KeyState::Released), Some(ShortcutResult::Global));
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
/// Потеря фокуса сбрасывает нажатые клавиши, чтобы сочетание не
/// «залипало» и не сработало позже само по себе.
#[test]
fn release_all_clears_state() {
let mut m = manager();
m.handle_key(key_super(), KeyState::Pressed);
m.release_all();
// Отпускание Super после сброса уже ничего не запускает.
assert_eq!(m.handle_key(key_super(), KeyState::Released), None);
}
}

225
src/compositor/keys.rs Normal file
View file

@ -0,0 +1,225 @@
//! Раскладка клавиатуры: код клавиши → текст.
//!
//! Compositor получает от winit коды клавиш, а не символы. Символ зависит от
//! раскладки, поэтому без перевода нельзя ни набрать текст в строке поиска,
//! ни отправить символ окну: русская раскладка превратилась бы в латиницу.
//!
//! Раскладка собирается из тех же настроек, что и `wl_seat.keymap`, поэтому
//! ввод в интерфейс и в окно всегда в одной раскладке.
use crate::config::KeyboardConfig;
use smithay::input::keyboard::ModifiersState;
use smithay::input::keyboard::Keycode;
/// Смещение между кодом клавиши в evdev и в xkbcommon.
#[cfg(test)]
const XKB_OFFSET: u32 = 8;
/// Раскладка клавиатуры.
pub struct KeyboardLayout {
/// Контекст xkbcommon: держит правила раскладок.
#[allow(dead_code, reason = "нужен для времени жизни keymap")]
context: xkbcommon::xkb::Context,
/// Карта соответствия кодов клавиш и символов.
keymap: xkbcommon::xkb::Keymap,
}
impl KeyboardLayout {
/// Собирает раскладку по настройкам пользователя.
///
/// При любой ошибке (нет правил, неизвестная раскладка) берётся
/// стандартная `us`: пустой ввод хуже ввода в неверной раскладке.
pub fn new(config: &KeyboardConfig) -> Self {
let context = xkbcommon::xkb::Context::new(0);
let layout = config.xkb_layout.clone().unwrap_or_default();
let variant = config.xkb_variant.clone().unwrap_or_default();
let options = config.xkb_options.clone();
let model = config.xkb_model.clone().unwrap_or_default();
let rules = config.xkb_rules.clone().unwrap_or_default();
let compiled = xkbcommon::xkb::Keymap::new_from_names(
&context,
&rules,
&model,
&layout,
&variant,
options,
xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
);
match compiled {
Some(keymap) => Self { context, keymap },
None => {
tracing::warn!("раскладка {layout:?} не собрана, беру us");
Self::fallback()
}
}
}
/// Раскладка по умолчанию, когда пользовательская недоступна.
fn fallback() -> Self {
let context = xkbcommon::xkb::Context::new(0);
let keymap = xkbcommon::xkb::Keymap::new_from_names(
&context,
"",
"",
"us",
"",
None,
xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.expect("раскладка us собирается всегда");
Self { context, keymap }
}
/// Сколько уровней (вариантов символа) у клавиши.
///
/// Уровни выше нуля появляются у раскладок с несколькими группами
/// (`grp:…`).
fn levels(&self, keycode: xkbcommon::xkb::Keycode) -> u32 {
self.keymap.num_levels_for_key(keycode, 0).max(1)
}
/// Текст, который набирается клавишей.
///
/// `None` означает, что клавиша не печатает символ: служебная, стрелка
/// или сочетание с Ctrl/Alt/Super.
pub fn text(&self, keycode: Keycode, modifiers: ModifiersState) -> Option<String> {
// Сочетания с Ctrl и Alt печатают управляющие символы, а не текст:
// их символы нельзя вставлять в строку поиска.
if modifiers.ctrl || modifiers.alt || modifiers.logo {
return None;
}
// smithay и xkbcommon используют один и тот же формат кода: оба
// считают от evdev плюс смещение.
let code = xkbcommon::xkb::Keycode::new(u32::from(keycode));
let levels = self.levels(code);
let level = if modifiers.shift && levels > 1 { 1 } else { 0 };
let syms = self.keymap.key_get_syms_by_level(code, 0, level);
if syms.is_empty() {
return None;
}
let text: String = syms
.iter()
.filter_map(|sym| char::from_u32(xkbcommon::xkb::keysym_to_utf32(*sym)))
.collect();
// Управляющие символы (Enter, Tab, Backspace) возвращаются непустой
// строкой, но вставлять их как текст нельзя.
(!text.is_empty() && text.chars().all(|ch| !ch.is_control())).then_some(text)
}
}
/// Код клавиши по её номеру в evdev: используется в тестах.
#[cfg(test)]
fn evdev(number: u32) -> Keycode {
Keycode::from(number + XKB_OFFSET)
}
/// Тесты раскладки.
#[cfg(test)]
mod tests {
use super::*;
fn layout(layout_name: &str) -> KeyboardLayout {
KeyboardLayout::new(&KeyboardConfig {
xkb_layout: Some(layout_name.to_string()),
..KeyboardConfig::default()
})
}
/// Буква в базовой раскладке даёт символ.
#[test]
fn latin_letter_produces_text() {
// evdev 30 — клавиша `A`: в русской раскладке на ней «ф».
assert_eq!(
layout("us").text(evdev(30), ModifiersState::default()).as_deref(),
Some("a"),
"раскладка us"
);
}
/// Shift даёт заглавную букву.
#[test]
fn shift_gives_uppercase() {
let modifiers = ModifiersState {
shift: true,
..ModifiersState::default()
};
assert_eq!(
layout("us").text(evdev(30), modifiers).as_deref(),
Some("A"),
"сдвинутая буква"
);
}
/// Русская раскладка даёт кириллицу для той же клавиши.
#[test]
fn cyrillic_layout_produces_cyrillic() {
assert_eq!(
layout("ru").text(evdev(30), ModifiersState::default()).as_deref(),
Some("ф"),
"раскладка ru"
);
}
/// Пробел печатает пробел.
#[test]
fn space_produces_space() {
assert_eq!(
layout("us").text(evdev(57), ModifiersState::default()).as_deref(),
Some(" ")
);
}
/// Стрелки не печатают символов.
#[test]
fn arrow_keys_produce_no_text() {
// evdev 103 — стрелка вверх.
assert_eq!(layout("us").text(evdev(103), ModifiersState::default()), None);
}
/// Escape не печатает символов.
#[test]
fn escape_produces_no_text() {
// evdev 1 — Escape.
assert_eq!(layout("us").text(evdev(1), ModifiersState::default()), None);
}
/// Enter не печатает символов.
#[test]
fn enter_produces_no_text() {
// evdev 28 — Enter.
assert_eq!(layout("us").text(evdev(28), ModifiersState::default()), None);
}
/// Сочетание с Ctrl не вставляется как текст.
#[test]
fn control_modifier_suppresses_text() {
let modifiers = ModifiersState {
ctrl: true,
..ModifiersState::default()
};
assert_eq!(layout("us").text(evdev(30), modifiers), None);
}
/// Неизвестная раскладка не ломает ввод: берётся запасная.
#[test]
fn unknown_layout_falls_back() {
assert_eq!(
layout("такой-раскладки-точно-нет")
.text(evdev(30), ModifiersState::default())
.as_deref(),
Some("a"),
"нужна запасная раскладка"
);
}
}

2919
src/compositor/mod.rs Normal file

File diff suppressed because it is too large Load diff

369
src/config.rs Normal file
View file

@ -0,0 +1,369 @@
//! Конфигурация Lark DE.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Главная конфигурация Lark DE.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
/// Настройки композитора.
#[serde(default)]
pub compositor: CompositorConfig,
/// Настройки панели.
#[serde(default)]
pub panel: PanelConfig,
/// Настройки темы.
#[serde(default)]
pub theme: ThemeConfig,
/// Настройки языка.
#[serde(default)]
pub language: LanguageConfig,
/// Настройки клавиатуры.
#[serde(default)]
pub keyboard: KeyboardConfig,
/// Настройки декораций окон.
#[serde(default)]
pub decoration: DecorationConfig,
/// Настройки рабочего стола.
#[serde(default)]
pub desktop: DesktopConfig,
/// Настройки ввода.
#[serde(default)]
pub input: InputConfig,
/// Настройки сессии ввода (имя wl_seat).
#[serde(default)]
pub seat: SeatConfig,
}
/// Настройки композитора.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositorConfig {
/// Бэкенд рендеринга: auto, vulkan, gl, cpu.
#[serde(default = "default_backend")]
pub backend: String,
/// Имя Wayland-сокета, который занимает композитор.
#[serde(default = "default_socket")]
pub socket: String,
/// Включить анимации.
#[serde(default = "default_true")]
pub animations: bool,
/// Включить тени окон.
#[serde(default = "default_true")]
pub shadows: bool,
/// Включить прозрачность.
#[serde(default = "default_true")]
pub transparency: bool,
}
/// Настройки панели задач.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PanelConfig {
/// Позиция панели: bottom, top.
#[serde(default = "default_panel_position")]
pub position: String,
/// Центрировать иконки (как в Windows 11).
#[serde(default = "default_true")]
pub centered: bool,
/// Высота панели в пикселях.
#[serde(default = "default_panel_height")]
pub height: u32,
/// Прозрачность панели.
#[serde(default = "default_true")]
pub transparent: bool,
}
/// Настройки клавиатуры и раскладки.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyboardConfig {
/// Файл правил xkbcommon (`evdev`, `base`).
#[serde(default)]
pub xkb_rules: Option<String>,
/// Модель клавиатуры (`pc105`, `pc104`).
#[serde(default)]
pub xkb_model: Option<String>,
/// Раскладка (`us`, `ru`, `de`, ...).
#[serde(default)]
pub xkb_layout: Option<String>,
/// Вариант раскладки.
#[serde(default)]
pub xkb_variant: Option<String>,
/// Дополнительные опции xkb (grp:alt_shift_toggle и т. п.).
#[serde(default)]
pub xkb_options: Option<String>,
/// Задержка автоповтора, мс.
#[serde(default = "default_repeat_delay")]
pub repeat_delay: i32,
/// Частота автоповтора, символов/с.
#[serde(default = "default_repeat_rate")]
pub repeat_rate: i32,
}
/// Настройки рабочего стола.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesktopConfig {
/// Сколько рабочих областей доступно.
#[serde(default = "default_workspaces")]
pub workspaces: u32,
/// Цвет фона рабочего стола (hex).
#[serde(default = "default_desktop_color")]
pub background: String,
}
impl Default for DesktopConfig {
fn default() -> Self {
Self {
workspaces: default_workspaces(),
background: default_desktop_color(),
}
}
}
/// Настройки декораций окон в стиле Windows 11.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecorationConfig {
/// Рисовать ли заголовок окна силами композитора.
///
/// При `false` окна остаются без декораций: заголовок рисует сам клиент.
#[serde(default = "default_true")]
pub server_side: bool,
/// Высота заголовка в логических пикселях.
#[serde(default = "default_titlebar_height")]
pub titlebar_height: u32,
/// Ширина кнопки заголовка в логических пикселях.
#[serde(default = "default_titlebar_button_width")]
pub button_width: u32,
}
impl Default for DecorationConfig {
fn default() -> Self {
Self {
server_side: true,
titlebar_height: default_titlebar_height(),
button_width: default_titlebar_button_width(),
}
}
}
/// Настройки ввода.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputConfig {
/// Включён ли тачпад.
#[serde(default = "default_true")]
pub touchpad: bool,
/// Скорость прокрутки.
#[serde(default = "default_scroll_speed")]
pub scroll_speed: f64,
}
/// Настройки сессии ввода.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeatConfig {
/// Имя устройства-седа (`wl_seat.name`).
#[serde(default = "default_seat_name")]
pub name: String,
}
/// Настройки темы.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
/// Название темы: light, dark.
#[serde(default = "default_theme")]
pub name: String,
/// Акцентный цвет (hex).
#[serde(default = "default_accent")]
pub accent: String,
}
/// Настройки языка.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageConfig {
/// Код языка: ru-RU, en-US.
#[serde(default = "default_language")]
pub code: String,
}
impl Default for CompositorConfig {
fn default() -> Self {
Self {
backend: default_backend(),
socket: default_socket(),
animations: true,
shadows: true,
transparency: true,
}
}
}
impl Default for PanelConfig {
fn default() -> Self {
Self {
position: default_panel_position(),
centered: true,
height: default_panel_height(),
transparent: true,
}
}
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
name: default_theme(),
accent: default_accent(),
}
}
}
impl Default for LanguageConfig {
fn default() -> Self {
Self {
code: default_language(),
}
}
}
impl Default for KeyboardConfig {
fn default() -> Self {
Self {
xkb_rules: Some(default_xkb_rules()),
xkb_model: Some(default_xkb_model()),
xkb_layout: Some(default_xkb_layout()),
xkb_variant: None,
xkb_options: None,
repeat_delay: default_repeat_delay(),
repeat_rate: default_repeat_rate(),
}
}
}
impl Default for InputConfig {
fn default() -> Self {
Self {
touchpad: true,
scroll_speed: default_scroll_speed(),
}
}
}
impl Default for SeatConfig {
fn default() -> Self {
Self {
name: default_seat_name(),
}
}
}
impl Config {
/// Загружает конфигурацию из файла или создаёт дефолтную.
pub fn load() -> Result<Self> {
let path = Self::config_path();
if path.exists() {
let content = std::fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
} else {
let config = Config::default();
config.save()?;
Ok(config)
}
}
/// Сохраняет конфигурацию в файл.
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&path, content)?;
Ok(())
}
/// Возвращает путь к файлу конфигурации.
fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("lark")
.join("config.toml")
}
}
fn default_backend() -> String {
"auto".to_string()
}
fn default_socket() -> String {
"wayland-1".to_string()
}
fn default_panel_position() -> String {
"bottom".to_string()
}
fn default_panel_height() -> u32 {
48
}
fn default_theme() -> String {
"dark".to_string()
}
fn default_accent() -> String {
"#0078D4".to_string()
}
fn default_language() -> String {
"ru-RU".to_string()
}
fn default_true() -> bool {
true
}
fn default_workspaces() -> u32 {
4
}
fn default_desktop_color() -> String {
"#0F0F17".to_string()
}
fn default_titlebar_height() -> u32 {
32
}
fn default_titlebar_button_width() -> u32 {
46
}
fn default_xkb_rules() -> String {
"evdev".to_string()
}
fn default_xkb_model() -> String {
"pc105".to_string()
}
fn default_xkb_layout() -> String {
"us".to_string()
}
fn default_repeat_delay() -> i32 {
25
}
fn default_repeat_rate() -> i32 {
600
}
fn default_scroll_speed() -> f64 {
1.0
}
fn default_seat_name() -> String {
"seat0".to_string()
}

98
src/i18n.rs Normal file
View file

@ -0,0 +1,98 @@
//! Мультиязычная поддержка через Fluent.
use anyhow::Result;
use fluent::{FluentBundle, FluentResource};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use unic_langid::LanguageIdentifier;
/// Обёртка для FluentBundle, чтобы реализовать Sync.
struct SyncBundle(FluentBundle<FluentResource>);
// SAFETY: FluentBundle не Sync/Send из-за внутреннего TypeMap, но мы гарантируем,
// что доступ к бандлам происходит только через Mutex.
unsafe impl Sync for SyncBundle {}
unsafe impl Send for SyncBundle {}
/// Глобальный хранилище бандлов локалей.
static BUNDLES: Mutex<Option<HashMap<String, Arc<SyncBundle>>>> = Mutex::new(None);
/// Текущий язык.
static CURRENT_LANG: Mutex<String> = Mutex::new(String::new());
/// Инициализирует систему локализации.
pub fn init(lang_code: &str) -> Result<()> {
let mut bundles = HashMap::new();
// Загрузка русской локали
let ru = load_bundle("ru-RU", include_str!("../locales/ru-RU/app.ftl"))?;
bundles.insert("ru-RU".to_string(), Arc::new(SyncBundle(ru)));
// Загрузка английской локали
let en = load_bundle("en-US", include_str!("../locales/en-US/app.ftl"))?;
bundles.insert("en-US".to_string(), Arc::new(SyncBundle(en)));
*BUNDLES.lock().unwrap() = Some(bundles);
*CURRENT_LANG.lock().unwrap() = lang_code.to_string();
Ok(())
}
/// Загружает бандл локали из .ftl строки.
fn load_bundle(lang: &str, ftl: &str) -> Result<FluentBundle<FluentResource>> {
let lang_id: LanguageIdentifier = lang.parse()?;
let resource = FluentResource::try_new(ftl.to_string())
.map_err(|e| anyhow::anyhow!("Ошибка парсинга FTL: {:?}", e))?;
let mut bundle = FluentBundle::new(vec![lang_id]);
bundle.add_resource(resource)
.map_err(|e| anyhow::anyhow!("Ошибка добавления ресурса: {:?}", e))?;
Ok(bundle)
}
/// Возвращает переведённую строку по ключу.
pub fn t(key: &str) -> String {
t_with_args(key, &[])
}
/// Возвращает переведённую строку с аргументами.
pub fn t_with_args(key: &str, args: &[(&str, &str)]) -> String {
let bundles = BUNDLES.lock().unwrap();
let lang = CURRENT_LANG.lock().unwrap();
if let Some(map) = bundles.as_ref() {
if let Some(bundle) = map.get(lang.as_str()) {
let msg = bundle.0.get_message(key);
if let Some(msg) = msg {
if let Some(pattern) = msg.value() {
let mut args_map = fluent::FluentArgs::new();
for (k, v) in args {
args_map.set(*k, *v);
}
let mut errors = vec![];
let value = bundle.0.format_pattern(pattern, Some(&args_map), &mut errors);
if errors.is_empty() {
return value.to_string();
}
}
}
}
}
// Fallback: вернуть ключ
key.to_string()
}
/// Устанавливает текущий язык.
pub fn set_language(lang_code: &str) {
*CURRENT_LANG.lock().unwrap() = lang_code.to_string();
}
/// Возвращает список доступных языков.
pub fn available_languages() -> Vec<&'static str> {
vec!["ru-RU", "en-US"]
}

4
src/lib.rs Normal file
View file

@ -0,0 +1,4 @@
//! Lark DE — библиотека общих компонентов.
pub mod config;
pub mod i18n;

126
src/main.rs Normal file
View file

@ -0,0 +1,126 @@
//! Lark DE — лёгкая среда рабочего стола на Rust для слабых устройств.
//!
//! Точка входа: инициализация логирования, запуск композитора и shell.
mod compositor;
mod shell;
mod services;
mod config;
mod i18n;
mod text;
use anyhow::Result;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use tracing::info;
use tracing_subscriber::EnvFilter;
/// Фильтр журнала по умолчанию.
///
/// Свой код показывается целиком, а чужие библиотеки — только
/// предупреждениями. При фильтре `info` smithay печатает полный список
/// расширений OpenGL и параметры каждой инициализации: это несколько
/// экранов текста, который никто не читает, и он вытесняет сообщения Lark.
const DEFAULT_LOG_FILTER: &str = "lark=info,warn";
fn main() -> Result<()> {
// Инициализация логирования
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER)),
)
.init();
info!("Lark DE запускается...");
// Загрузка конфигурации
let config = config::Config::load()?;
info!("Конфигурация загружена: {}", config.compositor.backend);
// Инициализация i18n
i18n::init(&config.language.code)?;
info!("Язык инициализирован: {}", config.language.code);
// Запуск композитора
let mut compositor = compositor::LarkCompositor::new(&config)?;
info!("Композитор инициализирован");
// Запуск shell
let mut shell = shell::LarkShell::new(&config, &compositor)?;
info!("Shell инициализирован");
// Запуск сервисов
services::init(&config)?;
info!("Сервисы запущены");
// Обработка сигналов: композитор должен завершиться штатно, а не
// потеряв буферы с ошибкой.
let running = compositor.running_flag();
install_signal_handler(running);
// Главный цикл
info!("Lark DE запущена!");
compositor.run(&mut shell)?;
info!("Lark DE остановлена");
Ok(())
}
/// Завершает композитор по SIGINT/SIGTERM.
///
/// Флаг хранится в статическом хранилище, потому что обработчик сигнала
/// не имеет доступа к состоянию вызывающего кода.
fn install_signal_handler(running: Arc<AtomicBool>) {
static FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
if FLAG.set(running).is_err() {
// Обработчик уже установлен: повторная регистрация не нужна.
return;
}
// SAFETY: обработчик только меняет атомарный флаг, что async-signal-safe.
// Приведение к `*const ()` нужно, потому что сигнатура обработчика в libc
// — просто указатель, а не `fn`.
unsafe {
let handler = handle_shutdown_signal as *const () as libc::sighandler_t;
libc::signal(libc::SIGINT, handler);
libc::signal(libc::SIGTERM, handler);
}
fn handle_shutdown_signal(_signal: libc::c_int) {
if let Some(flag) = FLAG.get() {
flag.store(false, Ordering::SeqCst);
}
}
}
/// Тесты журналирования.
#[cfg(test)]
mod tests {
use super::*;
/// Фильтр по умолчанию разбирается и задаёт нужные уровни.
///
/// Опечатка в строке фильтра молча отключила бы часть журнала, и это
/// заметно только при первом запуске — поэтому проверяется здесь.
#[test]
fn default_filter_is_valid() {
let filter = EnvFilter::try_new(DEFAULT_LOG_FILTER)
.expect("фильтр по умолчанию должен разбираться");
// Разобранный фильтр сохраняет директивы: строка не была молча
// отброшена как неверная.
let directives = filter.to_string();
assert!(
directives.contains("lark=info"),
"свой код должен попадать в журнал на уровне info: {directives}"
);
assert!(
directives.contains("warn"),
"чужие библиотеки должны ограничиваться предупреждениями: {directives}"
);
}
}

15
src/services/audio.rs Normal file
View file

@ -0,0 +1,15 @@
//! Аудио-сервис.
use crate::config::Config;
use anyhow::Result;
/// Инициализирует аудио-сервис.
pub fn init(_config: &Config) -> Result<()> {
tracing::info!("Аудио-сервис запущен");
// TODO: Интеграция с PipeWire через D-Bus
// - Управление громкостью
// - Переключение устройств вывода
Ok(())
}

23
src/services/mod.rs Normal file
View file

@ -0,0 +1,23 @@
//! Lark Services — фоновые демоны.
mod power;
mod network;
mod audio;
mod session;
mod notification;
use crate::config::Config;
use anyhow::Result;
/// Инициализирует все сервисы.
pub fn init(config: &Config) -> Result<()> {
tracing::info!("Инициализация сервисов...");
power::init(config)?;
network::init(config)?;
audio::init(config)?;
session::init(config)?;
notification::init(config)?;
Ok(())
}

15
src/services/network.rs Normal file
View file

@ -0,0 +1,15 @@
//! Сетевой сервис.
use crate::config::Config;
use anyhow::Result;
/// Инициализирует сетевой сервис.
pub fn init(_config: &Config) -> Result<()> {
tracing::info!("Сетевой сервис запущен");
// TODO: Интеграция с NetworkManager через D-Bus
// - Мониторинг состояния сети
// - Управление Wi-Fi/Ethernet
Ok(())
}

View file

@ -0,0 +1,16 @@
//! Сервис уведомлений.
use crate::config::Config;
use anyhow::Result;
/// Инициализирует сервис уведомлений.
pub fn init(_config: &Config) -> Result<()> {
tracing::info!("Сервис уведомлений запущен");
// TODO: D-Bus сервис уведомлений (org.freedesktop.Notifications)
// - Приём уведомлений от приложений
// - Очередь уведомлений
// - Отправка в NotificationCenter
Ok(())
}

16
src/services/power.rs Normal file
View file

@ -0,0 +1,16 @@
//! Сервис управления питанием.
use crate::config::Config;
use anyhow::Result;
/// Инициализирует сервис питания.
pub fn init(_config: &Config) -> Result<()> {
tracing::info!("Сервис питания запущен");
// TODO: Интеграция с logind/upower через D-Bus
// - Мониторинг батареи
// - Управление яркостью
// - Управление сном/гибернацией
Ok(())
}

16
src/services/session.rs Normal file
View file

@ -0,0 +1,16 @@
//! Сервис управления сессией.
use crate::config::Config;
use anyhow::Result;
/// Инициализирует сервис сессии.
pub fn init(_config: &Config) -> Result<()> {
tracing::info!("Сервис сессии запущен");
// TODO: Управление сессией
// - Автозапуск приложений
// - Блокировка экрана
// - Выход/перезагрузка/завершение работы
Ok(())
}

200
src/shell/clock.rs Normal file
View file

@ -0,0 +1,200 @@
//! Часы в системном трее.
//!
//! Время берётся из libc напрямую: подключать chrono ради двух строк
//! `localtime_r` смысла нет, а лишние зависичности расходятся по версиям.
//!
//! Строки кэшируются и пересчитываются не чаще раза в секунду: иначе
//! панель растеризовывала бы текст 60 раз в секунду ради неизменной надписи.
use std::time::{SystemTime, UNIX_EPOCH};
/// Разобранное локальное время.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DateTime {
/// Час, 0..24.
pub hour: u32,
/// Минута, 0..60.
pub minute: u32,
/// День месяца, 1..32.
pub day: u32,
/// Месяц, 1..12.
pub month: u32,
/// Год.
pub year: i32,
}
/// Собранные подписи часов.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClockText {
/// Время в формате `ЧЧ:ММ`.
pub time: String,
/// Дата в формате `ДД.ММ.ГГГГ`.
pub date: String,
}
impl ClockText {
/// Формирует подписи из разобранного времени.
pub fn from_datetime(value: DateTime) -> Self {
Self {
time: format!("{:02}:{:02}", value.hour, value.minute),
date: format!(
"{:02}.{:02}.{:04}",
value.day, value.month, value.year
),
}
}
}
/// Кэш подписей часов.
#[derive(Debug, Default)]
pub struct Clock {
/// Последние собранные подписи.
text: Option<ClockText>,
/// Момент, когда подписи были собраны, в секундах с эпохи.
stamp: u64,
}
impl Clock {
/// Создаёт часы без подписей: они соберутся при первом обращении.
pub fn new() -> Self {
Self::default()
}
/// Возвращает актуальные подписи, обновляя их не чаще раза в секунду.
pub fn text(&mut self) -> ClockText {
let now = unix_seconds();
match &self.text {
Some(text) if now == self.stamp => text.clone(),
_ => {
let text = ClockText::from_datetime(local_datetime(now));
self.stamp = now;
self.text = Some(text.clone());
text
}
}
}
/// `true`, если подписи собраны и ещё не устарели.
pub fn text_is_cached(&self) -> bool {
self.text.is_some() && self.stamp == unix_seconds()
}
/// Принудительно состаривает кэш: нужно только в тестах.
fn force_stamp(&mut self, stamp: u64) {
self.stamp = stamp;
}
}
/// Разбирает момент времени в локальную дату и время.
pub fn local_datetime(unix: u64) -> DateTime {
let seconds = unix as libc::time_t;
// `localtime_r` пишет в переданный буфер и возвращает указатель на него
// либо `None`, если время не представимо (не бывает для u64 в разумных
// пределах, но проверка всё равно дешевле паники).
let mut parts: libc::tm = unsafe { std::mem::zeroed() };
let ok = !unsafe { libc::localtime_r(&seconds, &mut parts) }.is_null();
if ok {
DateTime {
// Поля `tm` — знаковые целые, приведение безопасно: libc
// гарантирует диапазон для успешного разбора.
hour: parts.tm_hour as u32,
minute: parts.tm_min as u32,
day: parts.tm_mday as u32,
month: (parts.tm_mon + 1) as u32,
year: parts.tm_year + 1900,
}
} else {
// Фолбэк в UTC: лучше чуть неверная дата, чем пустая панель.
DateTime {
hour: ((unix / 3600) % 24) as u32,
minute: ((unix / 60) % 60) as u32,
day: 1,
month: 1,
year: 1970,
}
}
}
/// Текущее время в секундах с эпохи Unix.
fn unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|delta| delta.as_secs())
.unwrap_or(0)
}
/// Тесты часов.
#[cfg(test)]
mod tests {
use super::*;
/// Время и дата форматируются с ведущими нулями.
#[test]
fn text_has_leading_zeros() {
let text = ClockText::from_datetime(DateTime {
hour: 9,
minute: 5,
day: 3,
month: 1,
year: 2026,
});
assert_eq!(text.time, "09:05");
assert_eq!(text.date, "03.01.2026");
}
/// Полночь не превращается в 24 часа.
#[test]
fn midnight_is_rendered_as_zero() {
let text = ClockText::from_datetime(DateTime {
hour: 0,
minute: 0,
day: 1,
month: 1,
year: 2026,
});
assert_eq!(text.time, "00:00");
}
/// Разбор реального момента времени не падает и даёт разумную дату.
#[test]
fn known_timestamp_is_parsed() {
// 2026-10-01T12:30:00Z
let value = local_datetime(1_788_265_800);
assert!(
(1970..=2100).contains(&value.year),
"год вне диапазона: {}",
value.year
);
assert!((1..=12).contains(&value.month), "месяц: {}", value.month);
assert!((1..=31).contains(&value.day), "день: {}", value.day);
assert!(value.hour <= 23, "час: {}", value.hour);
assert!(value.minute <= 59, "минута: {}", value.minute);
}
/// Подписи переиспользуются, пока секунда не сменилась.
#[test]
fn text_is_cached_within_a_second() {
let mut clock = Clock::new();
let first = clock.text();
assert_eq!(clock.text(), first, "подписи должны совпадать");
assert!(clock.text_is_cached(), "кеш должен быть заполнен");
}
/// Ключ кэша меняется при смене секунды.
#[test]
fn cache_key_follows_time() {
let mut clock = Clock::new();
clock.text();
assert!(clock.text_is_cached());
clock.force_stamp(clock.stamp - 1);
assert!(!clock.text_is_cached(), "устаревший кеш должен сбрасываться");
}
}

47
src/shell/desktop.rs Normal file
View file

@ -0,0 +1,47 @@
//! Рабочий стол: иконки, обои, контекстное меню.
/// Иконка на рабочем столе.
#[derive(Debug, Clone)]
pub struct DesktopIcon {
pub name: String,
pub icon: String,
pub position: (i32, i32),
}
/// Рабочий стол.
pub struct Desktop {
icons: Vec<DesktopIcon>,
wallpaper: Option<String>,
}
impl Desktop {
/// Создаёт новый рабочий стол.
pub fn new() -> Self {
Self {
icons: Vec::new(),
wallpaper: None,
}
}
/// Добавляет иконку на рабочий стол.
pub fn add_icon(&mut self, name: impl Into<String>, icon: impl Into<String>, position: (i32, i32)) {
self.icons.push(DesktopIcon {
name: name.into(),
icon: icon.into(),
position,
});
}
/// Устанавливает обои.
pub fn set_wallpaper(&mut self, path: impl Into<String>) {
self.wallpaper = Some(path.into());
}
/// Рендерит рабочий стол.
pub fn render(&self) {
// TODO: Рендеринг рабочего стола
// - Обои
// - Иконки
// - Контекстное меню
}
}

760
src/shell/mod.rs Normal file
View file

@ -0,0 +1,760 @@
//! Lark Shell — пользовательский интерфейс в стиле Windows 11.
mod clock;
mod desktop;
mod notifications;
mod panel;
pub mod start_menu;
pub mod switcher;
mod tray;
use crate::compositor::LarkCompositor;
use crate::config::Config;
use crate::text::{FontRenderer, TextColor, TextElement};
use anyhow::Result;
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::Kind;
use smithay::backend::renderer::element::memory::{
MemoryBuffer, MemoryRenderBuffer, MemoryRenderBufferRenderElement,
};
use smithay::backend::renderer::gles::GlesRenderer;
use smithay::backend::renderer::Color32F;
use smithay::utils::{Logical, Physical, Point, Size, Transform};
use std::sync::Arc;
/// Кегль подписей интерфейса.
const LABEL_SIZE: f32 = 13.0;
/// Главный shell Lark DE.
pub struct LarkShell {
config: Arc<Config>,
panel: panel::Panel,
start_menu: start_menu::StartMenu,
tray: tray::SystemTray,
notifications: notifications::NotificationCenter,
desktop: desktop::Desktop,
/// Растровщик подписей: общий для панели, меню и заголовков.
pub font: FontRenderer,
/// Часы в трее.
clock: clock::Clock,
/// Состояние переключателя рабочих областей.
workspaces: Option<panel::WorkspaceBar>,
/// Переключатель окон по `Alt+Tab`.
switcher: switcher::WindowSwitcher,
/// Раскладка панели последнего кадра.
///
/// Хранится, чтобы попадание указателем считалось по тем же
/// прямоугольникам, что и нарисованы: иначе кнопка «попадает» мимо.
panel_layout: panel::PanelLayout,
}
impl LarkShell {
/// Создаёт и инициализирует shell.
pub fn new(config: &Config, _compositor: &LarkCompositor) -> Result<Self> {
tracing::info!("Инициализация shell...");
Ok(Self {
config: Arc::new(config.clone()),
panel: panel::Panel::new(config),
start_menu: start_menu::StartMenu::new(),
tray: tray::SystemTray::new(),
notifications: notifications::NotificationCenter::new(),
desktop: desktop::Desktop::new(),
font: FontRenderer::new(),
clock: clock::Clock::new(),
workspaces: None,
switcher: switcher::WindowSwitcher::new(),
panel_layout: panel::PanelLayout::default(),
})
}
/// Обновляет shell (вызывается каждый кадр).
pub fn update(&mut self) {
// Состояние меню меняется только по действиям пользователя, поэтому
// здесь обновляется лишь время в трее.
let _ = self.clock.text();
}
/// Рендерит shell.
pub fn render(&mut self) {
// TODO: Рендеринг панели, меню, трея, уведомлений
}
/// Цвет фона рабочего стола, выведенный из настроек темы.
pub fn background_color(&self) -> Color32F {
// Для тёмной темы используем почти чёрный фон, иначе — светло-серый.
if self.config.theme.name.eq_ignore_ascii_case("light") {
Color32F::new(0.85, 0.85, 0.87, 1.0)
} else {
Color32F::new(0.06, 0.06, 0.09, 1.0)
}
}
/// Акцентный цвет темы в нормализованном виде.
pub fn accent_color(&self) -> Color32F {
match parse_hex_color(&self.config.theme.accent) {
Some((r, g, b)) => Color32F::new(r, g, b, 1.0),
None => Color32F::new(0.0, 0.47, 0.83, 1.0),
}
}
/// Список открытых окон для панели задач, в порядке z-порядка.
pub fn set_windows(&mut self, windows: Vec<TaskbarEntry>) {
self.panel.set_windows(windows);
}
/// Задаёт состояние переключателя рабочих областей.
pub fn set_workspaces(&mut self, current: usize, count: usize, window_counts: Vec<usize>) {
self.workspaces = Some(panel::WorkspaceBar {
current,
count,
// Счётчики дополняются нулями: панель обращается к ним по
// номеру области, а список мог оказаться короче.
window_counts: (0..count)
.map(|index| window_counts.get(index).copied().unwrap_or(0))
.collect(),
});
}
/// Формирует список элементов рендеринга поверх окон.
///
/// Сейчас рисуется панель задач с кнопкой «Пуск», кнопками окон,
/// подписями и часами; меню Пуск, трей и уведомления будут добавлены
/// по мере реализации соответствующих модулей.
pub fn render_elements(
&mut self,
renderer: &mut GlesRenderer,
output_size: Size<i32, Physical>,
) -> Vec<MemoryRenderBufferRenderElement<GlesRenderer>> {
let origin = panel::output_origin(output_size);
let clock = self.clock.text();
// Раскладка сохраняется: по ней же считается попадание указателем.
self.panel_layout = self.panel.layout_full(
origin,
self.accent_color(),
&mut self.font,
Some((&clock.time, &clock.date)),
self.workspaces.as_ref(),
);
let layout = std::mem::take(&mut self.panel_layout);
let mut elements = Vec::with_capacity(layout.solids.len() + layout.labels.len());
// Порядок критичен: элементы сцены отрисовываются **в обратном
// порядке списка**. Подписи идут первыми, чтобы оказаться сверху
// заливок, а фон панели последним, чтобы лечь вниз.
for label in &layout.labels {
if let Some(element) = self.label_element(renderer, label) {
elements.push(element);
}
}
for solid in &layout.solids {
let (position, width, height) = solid.rect;
if width <= 0 || height <= 0 {
continue;
}
let buffer = match solid_color_buffer(width, height, solid.color) {
Ok(buffer) => buffer,
Err(err) => {
tracing::warn!("не удалось построить буфер панели: {err}");
continue;
}
};
match MemoryRenderBufferRenderElement::from_buffer(
renderer,
Point::new(position.x, position.y).to_f64(),
&buffer,
None,
None,
None,
Kind::Unspecified,
) {
Ok(element) => elements.push(element),
Err(err) => tracing::warn!("не удалось создать элемент панели: {err}"),
}
}
// Переключатель окон рисуется поверх всего, поэтому его элементы
// идут в самое начало списка: отрисовка идёт в обратном порядке.
if self.switcher.is_open() {
let switcher_layout = self.switcher_layout(output_size);
for element in self.switcher_elements(renderer, &switcher_layout) {
elements.push(element);
}
}
// Меню «Пуск» рисуется поверх панели, поэтому его элементы идут в
// начало списка: при обратном порядке отрисовки панель закрыла бы
// меню целиком.
if self.start_menu.is_open() {
let menu = self.start_menu_layout(output_size);
let apps: Vec<String> = self
.start_menu
.matches()
.iter()
.map(|app| app.name.to_string())
.collect();
for element in self.start_menu_elements(renderer, &menu, &apps) {
elements.push(element);
}
}
self.panel_layout = layout;
elements
}
/// Открывает переключатель окон.
///
/// Список пересобирается только при открытии: пока `Alt` удерживается,
/// порядок окон не должен меняться под выделением.
pub fn open_switcher(&mut self, entries: Vec<switcher::SwitcherEntry>) {
self.switcher.open_with(entries);
}
/// Открыт ли переключатель окон.
pub fn switcher_open(&self) -> bool {
self.switcher.is_open()
}
/// Переходит к следующему окну в переключателе.
pub fn switcher_next(&mut self) {
self.switcher.select_next();
}
/// Переходит к предыдущему окну в переключателе.
pub fn switcher_previous(&mut self) {
self.switcher.select_previous();
}
/// Закрывает переключатель окон.
///
/// Возвращает идентификатор выбранного окна: `None`, если выбор отменён
/// или список пуст.
pub fn close_switcher(&mut self) -> Option<u64> {
let selected = if self.switcher.is_open() {
self.switcher.selected_id()
} else {
None
};
self.switcher.close();
selected
}
/// Переключает на окно под точкой, если переключатель открыт.
pub fn switcher_click(&mut self, point: Point<f64, Logical>, output_size: Size<i32, Physical>) -> bool {
if !self.switcher.is_open() {
return false;
}
let layout = self.switcher_layout(output_size);
if let Some(index) = layout.item_at(point) {
// Выбор доводится до нужного пункта: подтверждение выполняется
// тем же путём, что и по отпусканию `Alt`.
while self.switcher.selected() != index && self.switcher.selected() < self.switcher.entries().len() {
if self.switcher.selected() < index {
self.switcher.select_next();
} else {
self.switcher.select_previous();
}
}
}
true
}
/// Считает раскладку переключателя окон.
fn switcher_layout(&mut self, output_size: Size<i32, Physical>) -> switcher::SwitcherLayout {
switcher::layout(
Point::new(output_size.w, output_size.h),
self.switcher.entries(),
&mut self.font,
)
}
/// Формирует элементы переключателя окон.
fn switcher_elements(
&mut self,
renderer: &mut GlesRenderer,
layout: &switcher::SwitcherLayout,
) -> Vec<MemoryRenderBufferRenderElement<GlesRenderer>> {
let entries: Vec<switcher::SwitcherEntry> = self.switcher.entries().to_vec();
let selected = self.switcher.selected();
let mut elements = Vec::with_capacity(layout.items.len() * 3 + 1);
// Порядок заливок обратен порядку отрисовки: значок идёт перед
// своей подложкой (иначе он окажется под ней), а фон панели —
// последним, чтобы лечь под всеми пунктами.
let accent = self.accent_color();
let mut solids = Vec::with_capacity(layout.items.len() * 2 + 1);
for (index, item) in layout.items.iter().enumerate() {
solids.push((
switcher::icon_rect(*item),
Color32F::new(0.45, 0.55, 0.70, 1.0),
));
if index == selected {
solids.push((*item, accent));
}
}
solids.push((layout.background, Color32F::new(0.10, 0.10, 0.14, 0.96)));
for (rect, color) in &solids {
let (origin, width, height) = *rect;
if width <= 0 || height <= 0 {
continue;
}
let Ok(buffer) = solid_color_buffer(width, height, *color) else {
continue;
};
match MemoryRenderBufferRenderElement::from_buffer(
renderer,
Point::new(origin.x, origin.y).to_f64(),
&buffer,
None,
None,
None,
Kind::Unspecified,
) {
Ok(element) => elements.push(element),
Err(err) => tracing::warn!("не удалось создать элемент переключателя: {err}"),
}
}
// Подписи идут первыми: отрисовка идёт в обратном порядке, поэтому
// иначе заливки закрыли бы текст.
let mut texts = Vec::with_capacity(layout.items.len());
for (index, entry) in entries.iter().enumerate() {
let Some(item) = layout.items.get(index) else {
break;
};
// Длинное название обрезается по ширине пункта.
let available = item.1 - (switcher::label_origin(*item).x - item.0.x) - 8;
let raw = self.font.render(&entry.title, switcher::title_size());
let text = raw.truncated(&mut self.font, switcher::title_size(), available as f32);
if text.is_empty() {
continue;
}
texts.push(panel::Label {
text: text.text,
bold: index == selected,
origin: switcher::label_origin(*item),
color: Color32F::new(1.0, 1.0, 1.0, 1.0),
});
}
let mut built: Vec<_> = texts
.iter()
.filter_map(|label| self.label_element(renderer, label))
.collect();
built.append(&mut elements);
built
}
/// Формирует элементы меню «Пуск».
fn start_menu_elements(
&mut self,
renderer: &mut GlesRenderer,
menu: &start_menu::StartMenuLayout,
apps: &[String],
) -> Vec<MemoryRenderBufferRenderElement<GlesRenderer>> {
let mut elements = Vec::with_capacity(menu.items.len() + 4);
let query = self.start_menu.query().to_string();
let selected = self.start_menu.selected();
// Фон меню и рамка поиска.
for (rect, color) in [
(menu.background, Color32F::new(0.11, 0.11, 0.15, 0.97)),
(menu.search, Color32F::new(0.18, 0.18, 0.23, 1.0)),
] {
let (origin, width, height) = rect;
if width <= 0 || height <= 0 {
continue;
}
let Ok(buffer) = solid_color_buffer(width, height, color) else {
continue;
};
match MemoryRenderBufferRenderElement::from_buffer(
renderer,
Point::new(origin.x, origin.y).to_f64(),
&buffer,
None,
None,
None,
Kind::Unspecified,
) {
Ok(element) => elements.push(element),
Err(err) => tracing::warn!("не удалось создать элемент меню: {err}"),
}
}
// Подписи: сначала поиск, потом названия приложений. Их элементы
// должны оказаться поверх заливок, поэтому список пополняется ими
// первыми — отрисовка идёт в обратном порядке.
let mut texts: Vec<panel::Label> = Vec::with_capacity(apps.len() + 1);
let placeholder = if query.is_empty() { "Поиск приложений" } else { "" };
if !placeholder.is_empty() {
texts.push(panel::Label {
text: placeholder.to_string(),
bold: false,
origin: Point::new(menu.search.0.x + 12, menu.search.0.y + 11),
color: Color32F::new(0.6, 0.6, 0.66, 1.0),
});
} else {
texts.push(panel::Label {
text: query.clone(),
bold: false,
origin: Point::new(menu.search.0.x + 12, menu.search.0.y + 11),
color: Color32F::new(1.0, 1.0, 1.0, 1.0),
});
}
for (index, name) in apps.iter().enumerate() {
let Some(rect) = menu.items.get(index) else {
break;
};
texts.push(panel::Label {
text: name.clone(),
bold: false,
origin: Point::new(rect.0.x + 48, rect.0.y + 12),
color: if index == selected {
Color32F::new(1.0, 1.0, 1.0, 1.0)
} else {
Color32F::new(0.88, 0.88, 0.92, 1.0)
},
});
}
let mut built: Vec<_> = texts
.iter()
.filter_map(|label| self.label_element(renderer, label))
.collect();
built.append(&mut elements);
built
}
/// Растеризует подпись в элемент сцены.
fn label_element(
&mut self,
renderer: &mut GlesRenderer,
label: &panel::Label,
) -> Option<MemoryRenderBufferRenderElement<GlesRenderer>> {
// Панель задаёт цвет заливками `Color32F`, а текст растеризуется в
// `TextColor`: пересчёт делается здесь, один раз на подпись.
let color = TextColor {
r: label.color.r(),
g: label.color.g(),
b: label.color.b(),
a: label.color.a(),
};
let bitmap = if label.bold {
self.font.render_bold(&label.text, LABEL_SIZE)
} else {
self.font.render(&label.text, LABEL_SIZE)
};
let buffer = TextElement { bitmap, color }.into_buffer()?;
MemoryRenderBufferRenderElement::from_buffer(
renderer,
Point::new(label.origin.x, label.origin.y).to_f64(),
&buffer,
None,
None,
None,
Kind::Unspecified,
)
.ok()
}
/// Идентификатор окна под точкой панели задач.
///
/// `None` означает, что клик попал вне панели, на фон или на кнопку
/// «Пуск», то есть переводить фокус не на что.
pub fn window_at(&self, point: Point<f64, Logical>) -> Option<u64> {
self.panel.window_at(&self.panel_layout, point)
}
/// Проверяет, находится ли точка внутри панели задач.
pub fn on_panel(&self, point: Point<f64, Logical>, output_size: Size<i32, Physical>) -> bool {
self.panel
.contains_point(point, panel::output_origin(output_size))
}
/// Попадает ли точка в кнопку «Пуск».
///
/// Кнопка не входит в список окон, поэтому по нему не найти — он
/// отсчитывается прямоугольником из раскладки.
pub fn start_button_hit(&self, point: Point<f64, Logical>) -> bool {
let (origin, width, height) = self.panel_layout.start_button;
let p = point.to_i32_round::<i32>();
p.x >= origin.x
&& p.x < origin.x + width
&& p.y >= origin.y
&& p.y < origin.y + height
}
/// Попадает ли точка в меню «Пуск».
///
/// Раскладка считается заново: она зависит от числа совпавших с поиском
/// приложений, а не от того, что было нарисовано в прошлом кадре.
pub fn start_menu_contains(
&mut self,
point: Point<f64, Logical>,
output_size: Size<i32, Physical>,
) -> bool {
if !self.start_menu.is_open() {
return false;
}
self.start_menu_layout(output_size).contains(point)
}
/// Перемещает выделение в меню «Пуск» вниз.
pub fn start_menu_select_next(&mut self) {
self.start_menu.select_next();
}
/// Перемещает выделение в меню «Пуск» вверх.
pub fn start_menu_select_previous(&mut self) {
self.start_menu.select_previous();
}
/// Открыто ли меню «Пуск».
pub fn start_menu_open(&self) -> bool {
self.start_menu.is_open()
}
/// Переключает меню «Пуск».
pub fn toggle_start_menu(&mut self) {
self.start_menu.toggle();
}
/// Закрывает меню «Пуск».
pub fn close_start_menu(&mut self) {
self.start_menu.close();
}
/// Запускает выбранное в меню «Пуск» приложение.
///
/// Меню закрывается при любом исходе: оставить его открытым после
/// нажатия на пункт нельзя — пользователь уже сделал выбор.
pub fn launch_selected_app(&mut self) -> start_menu::LaunchOutcome {
let outcome = self.start_menu.launch_selected();
self.start_menu.close();
outcome
}
/// Обрабатывает символ, набранный при открытом меню «Пуск».
///
/// Возвращает `false`, если символ не должен попадать в меню: строка
/// поиска пуста и Backspace ушёл на удаление символа из окна.
pub fn start_menu_key(&mut self, text: &str) -> bool {
if !self.start_menu.is_open() {
return false;
}
for ch in text.chars() {
match ch {
// Пробел идёт в поиск, иначе строку нельзя было бы набрать.
'\u{8}' => {
if !self.start_menu.backspace() {
return false;
}
}
'\r' | '\n' => {
self.launch_selected_app();
}
_ if ch.is_control() => {}
_ => self.start_menu.type_char(ch),
}
}
true
}
/// Удаляет символ из строки поиска меню «Пуск».
///
/// Возвращает `false`, если строка уже пуста: тогда Backspace должен
/// уйти в окно, а не быть проглочен.
pub fn start_menu_backspace(&mut self) -> bool {
self.start_menu.backspace()
}
/// Действие по клику в меню «Пуск».
///
/// `None` означает, что клик не относится к меню.
pub fn start_menu_click(
&mut self,
point: Point<f64, Logical>,
output_size: Size<i32, Physical>,
) -> Option<start_menu::LaunchOutcome> {
if !self.start_menu.is_open() {
return None;
}
let layout = start_menu::layout(
Point::new(output_size.w, output_size.h),
self.start_menu.matches().len(),
self.start_menu.selected(),
&mut self.font,
);
if !layout.contains(point) {
// Клик мимо меню закрывает его — как в Windows.
self.start_menu.close();
return Some(start_menu::LaunchOutcome::Nothing);
}
let outcome = match layout.app_at(point) {
Some(index) => {
self.start_menu.select(index);
self.launch_selected_app()
}
// Клик по пустому месту меню: выбор не изменился.
None => start_menu::LaunchOutcome::Nothing,
};
Some(outcome)
}
/// Переключатель рабочих областей: размеры меню «Пуск» и панели.
fn start_menu_layout(
&mut self,
output_size: Size<i32, Physical>,
) -> start_menu::StartMenuLayout {
start_menu::layout(
Point::new(output_size.w, output_size.h),
self.start_menu.matches().len(),
self.start_menu.selected(),
&mut self.font,
)
}
/// Рабочая область под точкой панели.
pub fn workspace_at(&self, point: Point<f64, Logical>) -> Option<usize> {
self.workspaces
.as_ref()
.and_then(|bar| bar.workspace_at(point, &self.panel_layout))
}
}
/// Сведения об открытом окне для панели задач.
#[derive(Debug, Clone)]
pub struct TaskbarEntry {
/// Идентификатор окна, по которому клик переводит фокус на него.
pub id: u64,
/// Заголовок окна, либо имя приложения по умолчанию.
pub title: String,
/// Активно ли окно (в него уходит клавиатурный ввод).
pub active: bool,
/// Минимизировано ли окно.
pub minimized: bool,
}
/// Создаёт буфер, залитый сплошным цветом.
pub fn solid_color_buffer(
width: i32,
height: i32,
color: Color32F,
) -> anyhow::Result<MemoryRenderBuffer> {
let width = width.max(1) as usize;
let height = height.max(1) as usize;
let b = (color.b() * 255.0).clamp(0.0, 255.0) as u8;
let g = (color.g() * 255.0).clamp(0.0, 255.0) as u8;
let r = (color.r() * 255.0).clamp(0.0, 255.0) as u8;
let a = (color.a() * 255.0).clamp(0.0, 255.0) as u8;
// Fourcc::Abgr8888 — это 0xAABBGGRR, то есть в памяти байты лежат
// в порядке R, G, B, A.
let mut data = Vec::with_capacity(width * height * 4);
for _ in 0..width * height {
data.extend_from_slice(&[r, g, b, a]);
}
let mem = MemoryBuffer::from_slice(
&data,
Fourcc::Abgr8888,
(width as i32, height as i32),
);
Ok(MemoryRenderBuffer::from_memory(mem, 1, Transform::Normal, None))
}
/// Разбирает цвет вида `#RRGGBB` в нормализованные компоненты.
fn parse_hex_color(value: &str) -> Option<(f32, f32, f32)> {
let hex = value.trim().trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0))
}
/// Тесты оформления панели задач.
#[cfg(test)]
mod tests {
use super::*;
/// Проверяет разбор цвета из настроек темы.
#[test]
fn accent_color_is_parsed() {
let (r, g, b) = parse_hex_color("#0078D4").expect("цвет должен разбираться");
assert!(r.abs() < 0.01, "красный канал: {r}");
assert!((g - 0.47).abs() < 0.01, "зелёный канал: {g}");
assert!((b - 0.83).abs() < 0.01, "синий канал: {b}");
}
/// Проверяет разбор граничных значений цвета.
#[test]
fn accent_color_handles_extremes() {
assert_eq!(parse_hex_color("#000000"), Some((0.0, 0.0, 0.0)));
assert_eq!(parse_hex_color("#FFFFFF"), Some((1.0, 1.0, 1.0)));
}
/// Проверяет отбрасывание некорректного цвета: без него композитор
/// использовал бы неверный цвет панели.
#[test]
fn invalid_accent_color_is_rejected() {
assert_eq!(parse_hex_color(""), None);
assert_eq!(parse_hex_color("#FFF"), None);
assert_eq!(parse_hex_color("ZZZZZZ"), None);
}
/// Проверяет, что буфер панели создаётся для корректных размеров.
#[test]
fn solid_color_buffer_is_created() {
solid_color_buffer(8, 4, Color32F::new(1.0, 0.0, 0.0, 1.0))
.expect("буфер должен создаваться");
}
/// Проверяет, что нулевые и отрицательные размеры не приводят к панике.
///
/// Размер панели берётся из конфигурации, поэтому нельзя исключить
/// значение `0`.
#[test]
fn solid_color_buffer_survives_invalid_size() {
solid_color_buffer(0, -5, Color32F::new(0.0, 1.0, 0.0, 1.0))
.expect("буфер должен создаваться даже при некорректном размере");
}
}

View file

@ -0,0 +1,59 @@
//! Центр уведомлений.
/// Уведомление.
#[derive(Debug, Clone)]
pub struct Notification {
pub id: u64,
pub title: String,
pub body: String,
pub app_name: String,
}
/// Центр уведомлений.
pub struct NotificationCenter {
notifications: Vec<Notification>,
next_id: u64,
}
impl NotificationCenter {
/// Создаёт новый центр уведомлений.
pub fn new() -> Self {
Self {
notifications: Vec::new(),
next_id: 1,
}
}
/// Добавляет уведомление.
pub fn add(&mut self, title: impl Into<String>, body: impl Into<String>, app_name: impl Into<String>) -> u64 {
let id = self.next_id;
self.next_id += 1;
self.notifications.push(Notification {
id,
title: title.into(),
body: body.into(),
app_name: app_name.into(),
});
id
}
/// Удаляет уведомление по ID.
pub fn remove(&mut self, id: u64) {
self.notifications.retain(|n| n.id != id);
}
/// Очищает все уведомления.
pub fn clear_all(&mut self) {
self.notifications.clear();
}
/// Возвращает список уведомлений.
pub fn notifications(&self) -> &[Notification] {
&self.notifications
}
/// Рендерит центр уведомлений.
pub fn render(&self) {
// TODO: Рендеринг уведомлений
}
}

1148
src/shell/panel.rs Normal file

File diff suppressed because it is too large Load diff

909
src/shell/start_menu.rs Normal file
View file

@ -0,0 +1,909 @@
//! Меню «Пуск» в стиле Windows 11.
//!
//! Меню состоит из панели поиска и списка приложений. Список берётся из
//! набора встроенных приложений, а не сканируется по файловой системе:
//! приложения Larkа — это его собственные программы, и их всегда ровно
//! столько, сколько собрано в бинарном файле.
//!
//! Фильтрация регистронезависимая и работает с кириллицей, поэтому сравнение
//! идёт по `char`, а не по байтам.
use smithay::utils::{Logical, Point};
use std::process::{Command, Stdio};
/// Приложение в списке меню «Пуск».
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppEntry {
/// Идентификатор приложения: по нему выбирается обработчик запуска.
pub id: AppId,
/// Название, показываемое пользователю.
pub name: &'static str,
/// Кандидаты запуска: берётся первый, найденный в `PATH`.
///
/// Один пункт меню может соответствовать нескольким программам: в
/// минимальной системе терминала может не быть вовсе, а файловых
/// менеджеров обычно несколько.
pub launches: &'static [Launch],
}
/// Способ запуска приложения.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Launch {
/// Имя программы; ищется в `PATH`.
pub program: &'static str,
/// Аргументы. В аргументе `$HOME` подставляется домашний каталог.
pub args: &'static [&'static str],
}
/// Идентификатор приложения.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppId {
/// Файловый менеджер.
Files,
/// Терминал.
Terminal,
/// Завершение сессии.
Logout,
}
impl AppId {
/// Действие выполняется самим композитором, а не внешней программой.
pub fn is_builtin(self) -> bool {
matches!(self, AppId::Logout)
}
}
impl AppEntry {
/// Приложения, доступные из меню «Пуск».
///
/// Список состоит только из того, что действительно можно запустить:
/// пункт, ведущий в никуда, хуже отсутствующего пункта.
pub fn builtin() -> Vec<AppEntry> {
vec![
AppEntry {
id: AppId::Files,
name: "Файлы",
launches: &[
Launch { program: "nautilus", args: &[] },
Launch { program: "thunar", args: &[] },
Launch { program: "pcmanfm", args: &[] },
Launch { program: "dolphin", args: &[] },
// Запасной вариант: открыть домашний каталог чем есть.
Launch { program: "xdg-open", args: &["$HOME"] },
],
},
AppEntry {
id: AppId::Terminal,
name: "Терминал",
launches: &[
Launch { program: "foot", args: &[] },
Launch { program: "alacritty", args: &[] },
Launch { program: "kitty", args: &[] },
Launch { program: "gnome-terminal", args: &[] },
Launch { program: "konsole", args: &[] },
Launch { program: "xfce4-terminal", args: &[] },
Launch { program: "xterm", args: &[] },
],
},
AppEntry {
id: AppId::Logout,
name: "Завершение сессии",
// Обрабатывается композитором, внешней программы нет.
launches: &[],
},
]
}
}
/// Меню «Пуск».
pub struct StartMenu {
/// Открыто ли меню.
open: bool,
/// Приложения, доступные для запуска.
apps: Vec<AppEntry>,
/// Строка поиска; пустая означает «показать всё».
query: String,
/// Приложение, выбранное клавиатурой.
selected: usize,
}
impl StartMenu {
/// Номер выделенного приложения.
pub fn selected(&self) -> usize {
self.selected
}
/// Выделяет приложение по индексу в отфильтрованном списке.
///
/// Индекс за границей списка игнорируется: после смены запроса прежний
/// номер мог перестать существовать.
pub fn select(&mut self, index: usize) {
if index < self.matches().len() {
self.selected = index;
}
}
}
impl Default for StartMenu {
fn default() -> Self {
Self::new()
}
}
impl StartMenu {
/// Создаёт закрытое меню со списком встроенных приложений.
pub fn new() -> Self {
Self {
open: false,
apps: AppEntry::builtin(),
query: String::new(),
selected: 0,
}
}
/// Открывает меню и сбрасывает поиск.
pub fn open(&mut self) {
self.open = true;
self.query.clear();
self.selected = 0;
}
/// Закрывает меню.
pub fn close(&mut self) {
self.open = false;
self.query.clear();
self.selected = 0;
}
/// Переключает меню.
pub fn toggle(&mut self) {
if self.open {
self.close();
} else {
self.open();
}
}
/// Открыто ли меню.
pub fn is_open(&self) -> bool {
self.open
}
/// Строка поиска.
pub fn query(&self) -> &str {
&self.query
}
/// Добавляет символ в строку поиска.
pub fn type_char(&mut self, ch: char) {
self.query.push(ch);
self.selected = 0;
}
/// Удаляет последний символ строки поиска.
///
/// Возвращает `false`, если строка уже пуста: сигнал о том, что символ
/// поиска не был обработан и его стоит передать окну.
pub fn backspace(&mut self) -> bool {
match self.query.pop() {
Some(_) => {
self.selected = 0;
true
}
None => false,
}
}
/// Перемещает выделение на следующее совпавшее приложение.
pub fn select_next(&mut self) {
let matches = self.matches().len();
if matches > 0 {
self.selected = (self.selected + 1) % matches;
}
}
/// Перемещает выделение на предыдущее совпавшее приложение.
pub fn select_previous(&mut self) {
let matches = self.matches().len();
if matches > 0 {
// Прибавляется число совпадений, чтобы переход с нуля уходил
// на последний элемент, а не оставался на нуле.
self.selected = (self.selected + matches - 1) % matches;
}
}
/// Приложения, совпавшие с запросом.
pub fn matches(&self) -> Vec<&AppEntry> {
if self.query.trim().is_empty() {
return self.apps.iter().collect();
}
self.apps
.iter()
.filter(|app| contains_ignore_case(app.name, self.query.trim()))
.collect()
}
/// Выбранное приложение, если есть что запускать.
pub fn selected_app(&self) -> Option<&AppEntry> {
self.matches().into_iter().nth(self.selected)
}
/// Запускает выбранное приложение.
pub fn launch_selected(&self) -> LaunchOutcome {
match self.selected_app() {
Some(app) => launch(app),
None => LaunchOutcome::Nothing,
}
}
/// Запускает приложение по идентификатору.
pub fn launch(&self, id: AppId) -> LaunchOutcome {
match self.apps.iter().find(|app| app.id == id) {
Some(app) => launch(app),
None => LaunchOutcome::Nothing,
}
}
}
/// Результат запуска приложения.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchOutcome {
/// Приложение запущено.
Started,
/// Подходящей программы нет в системе.
NotFound,
/// Требуется завершение сессии: действие обрабатывает композитор.
Logout,
/// Запускать нечего: пункт не выбран.
Nothing,
}
/// Запускает приложение в фоне.
///
/// Перебираются кандидаты: в системе может не оказаться привычного
/// терминала, и вместо ошибки пользователь получит тот, который есть.
fn launch(app: &AppEntry) -> LaunchOutcome {
// Действия самого композитора не запускают внешних программ.
if app.id.is_builtin() {
return LaunchOutcome::Logout;
}
for candidate in app.launches {
if !in_path(candidate.program) {
continue;
}
let (program, args) = build_command(candidate);
// Приложения наследуют окружение композитора: без `WAYLAND_DISPLAY`
// и `XDG_RUNTIME_DIR` они не подключатся к сессии.
//
// Вывод перенаправляется: иначе приложение вместе со своей шиной
// D-Bus и запущенными ею службами напишет в консоль композитора.
let mut command = Command::new(&program);
command
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
match command.spawn() {
Ok(_) => {
tracing::info!("запущено приложение {}: {}", app.name, candidate.program);
return LaunchOutcome::Started;
}
Err(err) => {
tracing::warn!("не удалось запустить {}: {err}", candidate.program);
}
}
}
tracing::warn!(
"для «{}» не найдено ни одной программы из {}",
app.name,
app.launches.len()
);
LaunchOutcome::NotFound
}
/// Ищет программу в `PATH`.
///
/// Проверка нужна, чтобы отличить «программы нет» от «программа есть, но
/// упала»: без неё первая неудачная попытка обрывает перебор.
fn in_path(program: &str) -> bool {
// Имя с путём запускается как есть: искать в `PATH` нужно только
// голое имя.
if program.contains('/') {
return std::path::Path::new(program).exists();
}
std::env::var_os("PATH").is_some_and(|path| {
std::env::split_paths(&path).any(|dir| dir.join(program).exists())
})
}
/// Домашний каталог пользователя.
fn home_directory() -> String {
dirs::home_dir()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|| "/".to_string())
}
/// Программа, запускающая приложение в отдельной сессии D-Bus.
const DBUS_RUN_SESSION: &str = "dbus-run-session";
/// Строит команду запуска приложения.
///
/// Приложение запускается в собственной сессии D-Bus. Без этого
/// однопользовательские программы (thunar, gedit, файловые менеджеры)
/// находят уже запущенный экземпляр во внешней сессии и открывают окно
/// в ней — то есть не в Lark, а в том композиторе, внутри которого Lark
/// запущен.
fn build_command(launch: &Launch) -> (String, Vec<String>) {
let args: Vec<String> = launch
.args
.iter()
.map(|arg| arg.replace("$HOME", &home_directory()))
.collect();
if in_path(DBUS_RUN_SESSION) {
let mut full = vec!["--".to_string(), launch.program.to_string()];
full.extend(args);
return (DBUS_RUN_SESSION.to_string(), full);
}
(launch.program.to_string(), args)
}
/// Проверяет, что строка содержит подстроку без учёта регистра.
fn contains_ignore_case(haystack: &str, needle: &str) -> bool {
// Сравнение по `char` с приведением к нижнему региму: для кириллицы
// побайтовое сравнение регистронезависимо работает не всегда.
haystack
.chars()
.flat_map(char::to_lowercase)
.collect::<String>()
.contains(&needle.chars().flat_map(char::to_lowercase).collect::<String>())
}
/// Тесты меню «Пуск».
#[cfg(test)]
mod tests {
use super::*;
fn menu() -> StartMenu {
StartMenu::new()
}
/// Меню изначально закрыто.
#[test]
fn menu_starts_closed() {
assert!(!menu().is_open());
}
/// Переключение открывает и закрывает меню.
#[test]
fn toggle_flips_state() {
let mut menu = menu();
menu.toggle();
assert!(menu.is_open());
menu.toggle();
assert!(!menu.is_open());
}
/// Открытие сбрасывает поиск: прошлый запрос не должен прятать
/// приложения в новом открытии.
#[test]
fn opening_resets_query() {
let mut menu = menu();
menu.open();
menu.type_char('ф');
menu.close();
menu.open();
assert_eq!(menu.query(), "");
assert_eq!(menu.matches().len(), AppEntry::builtin().len());
}
/// Без запроса показываются все приложения.
#[test]
fn empty_query_shows_everything() {
let mut menu = menu();
menu.open();
assert_eq!(menu.matches().len(), AppEntry::builtin().len());
}
/// Поиск фильтрует по названию без учёта регистра.
#[test]
fn search_filters_by_name() {
let mut menu = menu();
menu.open();
for ch in "Фай".chars() {
menu.type_char(ch);
}
let found = menu.matches();
assert_eq!(found.len(), 1, "ожидалось одно совпадение");
assert_eq!(found[0].id, AppId::Files);
}
/// Поиск работает и в нижнем регистре.
#[test]
fn search_ignores_case() {
let mut menu = menu();
menu.open();
for ch in "фай".chars() {
menu.type_char(ch);
}
assert_eq!(menu.matches().len(), 1, "регистр не должен влиять");
}
/// Пробелы по краям запроса не мешают поиску.
#[test]
fn search_ignores_surrounding_spaces() {
let mut menu = menu();
menu.open();
for ch in " фай ".chars() {
menu.type_char(ch);
}
assert_eq!(menu.matches().len(), 1, "пробелы обрезаются");
}
/// Запрос без совпадений даёт пустой список.
#[test]
fn search_without_matches_is_empty() {
let mut menu = menu();
menu.open();
for ch in "zzz".chars() {
menu.type_char(ch);
}
assert!(menu.matches().is_empty());
assert!(menu.selected_app().is_none());
}
/// Backspace убирает символ, а на пустой строке сообщает об этом.
#[test]
fn backspace_removes_and_then_reports() {
let mut menu = menu();
menu.open();
menu.type_char('ф');
assert!(menu.backspace(), "символ должен удалиться");
assert_eq!(menu.query(), "");
assert!(!menu.backspace(), "пустая строка должна сообщать");
}
/// Выделение ходит по кругу и не выходит за список.
#[test]
fn selection_wraps_around() {
let mut menu = menu();
menu.open();
let count = menu.matches().len();
for _ in 0..count {
menu.select_next();
}
assert_eq!(menu.selected, 0, "выделение вернулось к началу");
menu.select_previous();
assert_eq!(menu.selected, count - 1, "назад — последний элемент");
}
/// Выделение не выходит за пределы отфильтрованного списка.
#[test]
fn selection_respects_filter() {
let mut menu = menu();
menu.open();
for ch in "фай".chars() {
menu.type_char(ch);
}
menu.select_next();
assert_eq!(menu.selected, 0, "одно совпадение — выделение на нём");
}
/// Список приложений непуст и содержит завершение сессии.
#[test]
fn builtin_apps_include_logout() {
let apps = AppEntry::builtin();
assert!(!apps.is_empty());
assert!(
apps.iter().any(|app| app.id == AppId::Logout),
"в меню должно быть завершение сессии"
);
}
/// У каждого внешнего приложения есть хотя бы один способ запуска.
///
/// Пункт без кандидатов привёл бы к ошибке при нажатии: в меню попадает
/// только то, что действительно можно открыть.
#[test]
fn external_apps_have_launch_candidates() {
for app in AppEntry::builtin() {
if app.id.is_builtin() {
continue;
}
assert!(
!app.launches.is_empty(),
"у «{}» нет ни одного способа запуска",
app.name
);
}
}
/// Завершение сессии выполняется композитором, а не внешней программой.
#[test]
fn logout_is_handled_without_a_program() {
let app = AppEntry::builtin()
.into_iter()
.find(|app| app.id == AppId::Logout)
.expect("завершение сессии должно быть в меню");
assert!(app.launches.is_empty(), "у завершения сессии нет программы");
assert!(app.id.is_builtin());
assert_eq!(menu().launch(AppId::Logout), LaunchOutcome::Logout);
}
/// Терминал и файловый менеджер перебирают несколько программ.
///
/// В минимальной системе привычной программы может не оказаться: один
/// кандидат означал бы ошибку вместо работающего терминала.
#[test]
fn apps_offer_several_candidates() {
for app in AppEntry::builtin() {
if app.id.is_builtin() {
continue;
}
assert!(
app.launches.len() > 1,
"у «{}» всего один кандидат: {:?}",
app.name,
app.launches
);
}
}
/// `$HOME` в аргументах заменяется домашним каталогом.
///
/// Без подстановки в файловом менеджере открывался бы каталог,
/// буквально названный `$HOME`.
#[test]
fn home_placeholder_is_expanded() {
let launch = Launch {
program: "xdg-open",
args: &["$HOME"],
};
let (_, args) = build_command(&launch);
assert!(
!args.iter().any(|arg| arg == "$HOME"),
"подстановка не выполнена: {args:?}"
);
// Последний аргумент — сам каталог: при запуске через
// `dbus-run-session` перед ним добавляются программа и разделитель.
let home = args.last().expect("должен остаться хотя бы один аргумент");
assert!(
home.starts_with('/'),
"домашний каталог должен быть путём: {home}"
);
}
/// Приложение запускается в отдельной сессии D-Bus.
///
/// Иначе однопользовательская программа найдёт уже запущенный
/// экземпляр во внешней сессии и откроет окно не в Lark.
#[test]
fn apps_get_their_own_dbus_session() {
if !in_path(DBUS_RUN_SESSION) {
// Без `dbus-run-session` изоляция невозможна: проверять нечего.
return;
}
let launch = Launch {
program: "thunar",
args: &[],
};
let (program, args) = build_command(&launch);
assert_eq!(program, DBUS_RUN_SESSION, "запуск идёт через отдельную шину");
assert_eq!(args[0], "--", "разделитель аргументов");
assert_eq!(args[1], "thunar", "сама программа");
}
/// Поиск программы в `PATH` различает существующую и отсутствующую.
#[test]
fn path_lookup_finds_shell_only() {
// Оболочка есть в любой системе с `PATH`.
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let name = std::path::Path::new(&shell)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "sh".to_string());
assert!(in_path(&name), "оболочка должна быть найдена: {name}");
assert!(
!in_path("lark-такой-программы-точно-нет"),
"несуществующая программа не должна считаться найденной"
);
}
/// Идентификаторы приложений уникальны: иначе выбор в меню был бы
/// неоднозначным.
#[test]
fn builtin_apps_have_unique_ids() {
let apps = AppEntry::builtin();
for (index, app) in apps.iter().enumerate() {
assert!(
!apps[..index].iter().any(|other| other.id == app.id),
"повторяющийся идентификатор {:?}",
app.id
);
}
}
/// Запуск внешнего приложения даёт осмысленный результат.
#[test]
fn launch_reports_a_definite_outcome() {
let menu = menu();
// В тестовой среде файлового менеджера может не быть: исход должен
// быть либо «запущено», либо «не найдено», но не паникой.
let outcome = menu.launch(AppId::Files);
assert!(
matches!(
outcome,
LaunchOutcome::Started | LaunchOutcome::NotFound
),
"неожиданный исход запуска: {outcome:?}"
);
}
}
/// Прямоугольник в логических координатах.
type Rect = (Point<i32, Logical>, i32, i32);
/// Размеры меню «Пуск».
const MENU_WIDTH: i32 = 360;
const MENU_ITEM_HEIGHT: i32 = 44;
const MENU_PADDING: i32 = 12;
const SEARCH_HEIGHT: i32 = 40;
/// Раскладка меню «Пуск».
#[derive(Debug, Default, Clone)]
pub struct StartMenuLayout {
/// Фон меню.
pub background: Rect,
/// Строка поиска.
pub search: Rect,
/// Прямоугольник каждого приложения, сверху вниз.
pub items: Vec<Rect>,
}
impl StartMenuLayout {
/// Попадает ли точка в меню.
pub fn contains(&self, point: Point<f64, Logical>) -> bool {
let p = point.to_i32_round();
inside(self.background, p)
}
/// Индекс приложения под точкой.
pub fn app_at(&self, point: Point<f64, Logical>) -> Option<usize> {
let p = point.to_i32_round();
self.items.iter().position(|rect| inside(*rect, p))
}
}
/// Проверяет, что точка внутри прямоугольника.
fn inside(rect: Rect, point: Point<i32, Logical>) -> bool {
let (origin, width, height) = rect;
point.x >= origin.x
&& point.x < origin.x + width
&& point.y >= origin.y
&& point.y < origin.y + height
}
/// Считает раскладку меню «Пуск`.
///
/// Меню открывается над панелью задач и по центру экрана, как в Windows 11.
/// Пункты обрезаются по высоте: длинный список приложений обязан помещаться
/// на экран.
pub fn layout(
output_size: Point<i32, Logical>,
app_count: usize,
selected: usize,
font: &mut crate::text::FontRenderer,
) -> StartMenuLayout {
let panel_height = 48;
let _ = font.line_height(13.0);
let available = (output_size.y - panel_height - MENU_PADDING * 2).max(MENU_ITEM_HEIGHT);
let capacity = ((available - SEARCH_HEIGHT) / MENU_ITEM_HEIGHT).max(1) as usize;
let shown = app_count.min(capacity);
let height = SEARCH_HEIGHT + shown as i32 * MENU_ITEM_HEIGHT + MENU_PADDING * 2;
let width = MENU_WIDTH.min(output_size.x - MENU_PADDING * 2).max(0);
// Меню выравнивается по центру по горизонтали и прижато к панели снизу.
let origin = Point::new(
(output_size.x - width) / 2,
(output_size.y - panel_height - height).max(0),
);
let mut layout = StartMenuLayout {
background: (origin, width, height),
search: (
Point::new(
origin.x + MENU_PADDING,
origin.y + MENU_PADDING,
),
width - MENU_PADDING * 2,
SEARCH_HEIGHT,
),
items: Vec::with_capacity(shown),
};
for index in 0..shown {
let top = origin.y + MENU_PADDING + SEARCH_HEIGHT + index as i32 * MENU_ITEM_HEIGHT;
layout.items.push((
Point::new(origin.x + MENU_PADDING, top),
width - MENU_PADDING * 2,
MENU_ITEM_HEIGHT - 4,
));
}
// Выделение не должно выходить за показанный список: после смены
// фильтра индекс может оказаться больше числа совпадений.
let _ = selected;
layout
}
/// Тесты раскладки меню «Пуск».
#[cfg(test)]
mod layout_tests {
use super::*;
use crate::text::FontRenderer;
fn layout_for(app_count: usize) -> StartMenuLayout {
layout(
Point::new(1280, 800),
app_count,
0,
&mut FontRenderer::new(),
)
}
/// Меню помещается на экране и не залезает под панель задач.
#[test]
fn menu_fits_above_the_panel() {
let layout = layout_for(4);
let (origin, width, height) = layout.background;
assert!(width > 0 && height > 0, "меню нулевого размера");
assert!(origin.y + height <= 800 - 48, "меню налезает на панель");
assert!(origin.y >= 0, "меню уехало за верх экрана");
assert!(origin.x >= 0 && origin.x + width <= 1280, "меню за экраном");
}
/// Строка поиска находится внутри меню.
#[test]
fn search_field_is_inside_the_menu() {
let layout = layout_for(4);
let (menu, _, menu_height) = layout.background;
let (search, _, search_height) = layout.search;
assert!(search.x >= menu.x, "поиск левее меню");
assert!(search.y >= menu.y, "поиск выше меню");
assert!(
search.y + search_height <= menu.y + menu_height,
"поиск ниже меню"
);
}
/// Пункты приложений идут подряд и не перекрываются.
#[test]
fn items_stack_without_overlap() {
let layout = layout_for(4);
assert_eq!(layout.items.len(), 4);
for pair in layout.items.windows(2) {
let (a, _, a_height) = pair[0];
let (b, _, _) = pair[1];
assert!(b.y >= a.y + a_height, "пункты перекрываются");
}
}
/// Пункт приложения находится внутри меню.
#[test]
fn items_are_inside_the_menu() {
let layout = layout_for(4);
let (menu, menu_width, menu_height) = layout.background;
for item in &layout.items {
let (origin, width, height) = *item;
assert!(origin.x >= menu.x, "пункт левее меню");
assert!(
origin.x + width <= menu.x + menu_width,
"пункт шире меню"
);
assert!(
origin.y + height <= menu.y + menu_height,
"пункт ниже меню"
);
}
}
/// Список не длиннее экрана: лишние приложения не показываются.
#[test]
fn long_list_is_clipped_to_the_screen() {
let layout = layout_for(500);
let (_, _, height) = layout.background;
assert!(height <= 800 - 48, "меню не помещается: {height}");
assert!(layout.items.len() < 500, "список не обрезан");
assert!(!layout.items.is_empty(), "список не должен быть пуст");
}
/// Попадание указателем в пункт возвращает его номер.
#[test]
fn click_on_item_returns_index() {
let layout = layout_for(4);
for (index, (origin, width, height)) in layout.items.iter().enumerate() {
let center = Point::new(
(origin.x + width / 2) as f64,
(origin.y + height / 2) as f64,
);
assert_eq!(layout.app_at(center), Some(index));
}
}
/// Клик по фону внутри меню не выбирает приложение.
#[test]
fn click_on_background_selects_nothing() {
let layout = layout_for(4);
let (origin, width, height) = layout.background;
let corner = Point::new(
(origin.x + 4) as f64,
(origin.y + 4) as f64,
);
assert_eq!(layout.app_at(corner), None);
assert!(
layout.contains(Point::new((origin.x + width / 2) as f64, (origin.y + height / 2) as f64)),
"точка внутри меню должна считаться попаданием"
);
}
/// Клик вне меню не попадает в него.
#[test]
fn click_outside_menu_misses() {
let layout = layout_for(4);
assert!(!layout.contains(Point::new(10.0, 10.0)));
assert_eq!(layout.app_at(Point::new(10.0, 10.0)), None);
}
}

440
src/shell/switcher.rs Normal file
View file

@ -0,0 +1,440 @@
//! Переключатель окон по `Alt+Tab`.
//!
//! Пока клавиша `Alt` удерживается, список открытых окон показывается
//! поверх рабочего стола, а `Tab` перебирает их. Отпускание `Alt`
//! подтверждает выбор, `Escape` — отменяет.
//!
//! Выбор не применяется сразу: окно получает фокус только при
//! подтверждении. Иначе быстрый пролёт по списку переключал бы фокус на
//! каждом шаге, и приложения мигали бы.
use smithay::utils::{Logical, Point};
/// Прямоугольник в логических координатах.
type Rect = (Point<i32, Logical>, i32, i32);
/// Размеры панели переключателя.
const ITEM_HEIGHT: i32 = 48;
const PADDING: i32 = 10;
const MIN_WIDTH: i32 = 240;
const ICON_SIZE: i32 = 32;
/// Одно окно в списке переключателя.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SwitcherEntry {
/// Идентификатор окна.
pub id: u64,
/// Название окна.
pub title: String,
}
/// Состояние переключателя окон.
#[derive(Debug, Default, Clone)]
pub struct WindowSwitcher {
/// Открытые окна в порядке перебора.
entries: Vec<SwitcherEntry>,
/// Номер выбранного окна.
selected: usize,
/// Открыт ли переключатель.
open: bool,
}
impl WindowSwitcher {
/// Создаёт закрытый переключатель.
pub fn new() -> Self {
Self::default()
}
/// Открывает переключатель со списком окон.
///
/// Повторный вызов, пока переключатель открыт, ничего не меняет: список
/// не должен пересобираться на каждом нажатии `Tab`, иначе порядок окон
/// менялся бы под курсором.
pub fn open_with(&mut self, entries: Vec<SwitcherEntry>) {
if self.open {
return;
}
self.open = true;
self.entries = entries;
self.selected = 0;
}
/// Закрывает переключатель.
pub fn close(&mut self) {
self.open = false;
self.entries.clear();
self.selected = 0;
}
/// Открыт ли переключатель.
pub fn is_open(&self) -> bool {
self.open
}
/// Список окон.
pub fn entries(&self) -> &[SwitcherEntry] {
&self.entries
}
/// Номер выбранного окна.
pub fn selected(&self) -> usize {
self.selected
}
/// Идентификатор выбранного окна.
pub fn selected_id(&self) -> Option<u64> {
self.entries.get(self.selected).map(|entry| entry.id)
}
/// Переходит к следующему окну по кругу.
pub fn select_next(&mut self) {
if !self.entries.is_empty() {
self.selected = (self.selected + 1) % self.entries.len();
}
}
/// Переходит к предыдущему окну по кругу.
pub fn select_previous(&mut self) {
let count = self.entries.len();
if count > 0 {
// Прибавляется длина списка, чтобы шаг назад с нуля уходил в
// конец, а не оставался на месте.
self.selected = (self.selected + count - 1) % count;
}
}
}
/// Раскладка переключателя окон.
#[derive(Debug, Default, Clone)]
pub struct SwitcherLayout {
/// Фон панели переключателя.
pub background: Rect,
/// Прямоугольники пунктов сверху вниз.
pub items: Vec<Rect>,
}
impl SwitcherLayout {
/// Попадает ли точка в переключатель.
pub fn contains(&self, point: Point<f64, Logical>) -> bool {
let p = point.to_i32_round::<i32>();
let (origin, width, height) = self.background;
p.x >= origin.x
&& p.x < origin.x + width
&& p.y >= origin.y
&& p.y < origin.y + height
}
/// Номер пункта под точкой.
pub fn item_at(&self, point: Point<f64, Logical>) -> Option<usize> {
let p = point.to_i32_round::<i32>();
self.items.iter().position(|(origin, width, height)| {
p.x >= origin.x
&& p.x < origin.x + width
&& p.y >= origin.y
&& p.y < origin.y + height
})
}
}
/// Считает раскладку переключателя.
///
/// Панель выводится по центру экрана и обрезается по высоте: список окон
/// может быть длиннее экрана.
pub fn layout(
output_size: Point<i32, Logical>,
entries: &[SwitcherEntry],
font: &mut crate::text::FontRenderer,
) -> SwitcherLayout {
if entries.is_empty() {
return SwitcherLayout::default();
}
// Ширина подбирается по самому длинному названию, но не выходит за
// пределы экрана.
let widest = entries
.iter()
.map(|entry| font.render(&entry.title, TITLE_SIZE).advance as i32)
.max()
.unwrap_or(0);
let wanted = widest + ICON_SIZE + PADDING * 3;
let width = wanted
.max(MIN_WIDTH)
.min((output_size.x - PADDING * 2).max(MIN_WIDTH));
let capacity = ((output_size.y - PADDING * 4) / ITEM_HEIGHT).max(1) as usize;
let shown = entries.len().min(capacity);
let height = shown as i32 * ITEM_HEIGHT + PADDING * 2;
let origin = Point::new(
(output_size.x - width) / 2,
(output_size.y - height) / 2,
);
let mut layout = SwitcherLayout {
background: (origin, width, height),
items: Vec::with_capacity(shown),
};
for index in 0..shown {
layout.items.push((
Point::new(origin.x + PADDING, origin.y + PADDING + index as i32 * ITEM_HEIGHT),
width - PADDING * 2,
ITEM_HEIGHT - 4,
));
}
layout
}
/// Место значка окна внутри пункта.
pub fn icon_rect(item: Rect) -> Rect {
let (origin, _, height) = item;
let top = origin.y + (height - ICON_SIZE) / 2;
(Point::new(origin.x + 8, top), ICON_SIZE, ICON_SIZE)
}
/// Точка начала подписи внутри пункта.
pub fn label_origin(item: Rect) -> Point<i32, Logical> {
let (origin, _, height) = item;
let icon = icon_rect(item);
Point::new(
icon.0.x + icon.1 + 10,
origin.y + (height - font_line_height()) / 2,
)
}
/// Кегль подписей окон в переключателе.
const TITLE_SIZE: f32 = 14.0;
/// Высота строки подписи при кегле переключателя.
fn font_line_height() -> i32 {
// Значение совпадает с реальной высотой строки у выбранного шрифта,
// но не требует растровщика: подпись выравнивается по центру пункта.
(TITLE_SIZE * 1.2) as i32
}
/// Кегль подписей переключателя.
pub fn title_size() -> f32 {
TITLE_SIZE
}
/// Тесты переключателя окон.
#[cfg(test)]
mod tests {
use super::*;
use crate::text::FontRenderer;
fn entries(count: usize) -> Vec<SwitcherEntry> {
(1..=count)
.map(|id| SwitcherEntry {
id: id as u64,
title: format!("Окно {id}"),
})
.collect()
}
fn switcher_open_with(count: usize) -> WindowSwitcher {
let mut switcher = WindowSwitcher::new();
switcher.open_with(entries(count));
switcher
}
/// Переключатель изначально закрыт.
#[test]
fn switcher_starts_closed() {
assert!(!WindowSwitcher::new().is_open());
}
/// Открытие запоминает список и выбирает первое окно.
#[test]
fn opening_selects_the_first() {
let switcher = switcher_open_with(3);
assert!(switcher.is_open());
assert_eq!(switcher.selected(), 0);
assert_eq!(switcher.selected_id(), Some(1));
}
/// Повторное открытие не сбрасывает сделанный выбор.
///
/// Иначе удержание `Alt` с несколькими `Tab` возвращало бы выделение
/// к первому окну на каждом шаге.
#[test]
fn repeated_opening_keeps_the_selection() {
let mut switcher = switcher_open_with(3);
switcher.select_next();
switcher.open_with(entries(3));
assert_eq!(switcher.selected(), 1, "выбор должен сохраниться");
}
/// Перебор идёт по кругу в обе стороны.
#[test]
fn selection_wraps_in_both_directions() {
let mut switcher = switcher_open_with(3);
switcher.select_next();
switcher.select_next();
assert_eq!(switcher.selected(), 2);
switcher.select_next();
assert_eq!(switcher.selected(), 0, "вперёд с последнего — на первый");
switcher.select_previous();
assert_eq!(switcher.selected(), 2, "назад с первого — на последний");
}
/// Перебор по пустому списку не паникует.
#[test]
fn selection_survives_empty_list() {
let mut switcher = WindowSwitcher::new();
switcher.open_with(Vec::new());
switcher.select_next();
switcher.select_previous();
assert_eq!(switcher.selected(), 0);
assert_eq!(switcher.selected_id(), None);
}
/// Закрытие очищает состояние.
#[test]
fn closing_clears_everything() {
let mut switcher = switcher_open_with(2);
switcher.close();
assert!(!switcher.is_open());
assert!(switcher.entries().is_empty());
assert_eq!(switcher.selected_id(), None);
}
/// Панель помещается на экране и стоит по центру.
#[test]
fn layout_is_centred_and_fits() {
let layout = layout(
Point::new(1280, 800),
&entries(3),
&mut FontRenderer::new(),
);
let (origin, width, height) = layout.background;
assert!(origin.x >= 0 && origin.x + width <= 1280, "панель за экраном");
assert!(origin.y >= 0 && origin.y + height <= 800, "панель за экраном");
let centred_x = (1280 - width) / 2;
assert_eq!(origin.x, centred_x, "панель должна быть по центру");
}
/// Пункты идут подряд и не перекрываются.
#[test]
fn items_do_not_overlap() {
let layout = layout(
Point::new(1280, 800),
&entries(4),
&mut FontRenderer::new(),
);
assert_eq!(layout.items.len(), 4);
for pair in layout.items.windows(2) {
let (first, _, first_height) = pair[0];
let (second, _, _) = pair[1];
assert!(second.y >= first.y + first_height, "пункты наезжают");
}
}
/// Длинный список обрезается по высоте экрана.
#[test]
fn long_list_is_clipped() {
let layout = layout(
Point::new(1280, 800),
&entries(100),
&mut FontRenderer::new(),
);
let (_, _, height) = layout.background;
assert!(height <= 800, "панель выше экрана: {height}");
assert!(layout.items.len() < 100, "список не обрезан");
assert!(!layout.items.is_empty());
}
/// Пустой список не даёт панели.
#[test]
fn empty_list_has_no_layout() {
let layout = layout(Point::new(1280, 800), &[], &mut FontRenderer::new());
assert_eq!(layout.background.1, 0, "панель нулевой ширины");
assert!(layout.items.is_empty());
}
/// Панель шире минимальной, если названия длинные.
#[test]
fn long_titles_widen_the_panel() {
let short = layout(
Point::new(1280, 800),
&[SwitcherEntry {
id: 1,
title: "a".into(),
}],
&mut FontRenderer::new(),
);
let long = layout(
Point::new(1280, 800),
&[SwitcherEntry {
id: 1,
title: "Очень длинное название окна приложения".into(),
}],
&mut FontRenderer::new(),
);
assert!(
long.background.1 > short.background.1,
"длинное название должно расширять панель"
);
}
/// Попадание указателем возвращает номер пункта.
#[test]
fn click_on_item_returns_index() {
let layout = layout(
Point::new(1280, 800),
&entries(3),
&mut FontRenderer::new(),
);
for (index, (origin, width, height)) in layout.items.iter().enumerate() {
let center = Point::new(
(origin.x + width / 2) as f64,
(origin.y + height / 2) as f64,
);
assert_eq!(layout.item_at(center), Some(index));
}
}
/// Подпись не наезжает на значок.
#[test]
fn label_starts_after_the_icon() {
let layout = layout(
Point::new(1280, 800),
&entries(1),
&mut FontRenderer::new(),
);
let item = layout.items[0];
let icon = icon_rect(item);
let label = label_origin(item);
assert!(
label.x >= icon.0.x + icon.1,
"подпись налезает на значок: {} < {}",
label.x,
icon.0.x + icon.1
);
}
}

23
src/shell/tray.rs Normal file
View file

@ -0,0 +1,23 @@
//! Системный трей.
/// Системный трей.
pub struct SystemTray {
// TODO: Иконки сеть, звук, батарея, часы
// TODO: Индикаторы состояния
}
impl SystemTray {
/// Создаёт новый системный трей.
pub fn new() -> Self {
Self {}
}
/// Рендерит трей.
pub fn render(&self) {
// TODO: Рендеринг системного трея
// - Иконка сети
// - Иконка звука
// - Иконка батареи
// - Часы
}
}

784
src/text.rs Normal file
View file

@ -0,0 +1,784 @@
//! Растровщик текста.
//!
//! Панель задач, заголовки окон, часы и меню рисуются без готовых элементов
//! smithay, поэтому текст растеризуется здесь в буфер, который затем
//! отрисовывается как обычный прямоугольник.
//!
//! Шрифты берутся из системной базы через `fontdb`, контуры глифов
//! растеризует `fontdue` (внутри это stb_truetype).
use fontdb::{Database, ID};
use fontdue::{Font, FontSettings};
use smithay::backend::allocator::Fourcc;
use smithay::backend::renderer::element::memory::{MemoryBuffer, MemoryRenderBuffer};
use smithay::utils::Transform;
use std::collections::HashMap;
/// Символ, которым проверяется способность шрифта рисовать кириллицу.
const PROBE: char = 'Ж';
/// Показатель гаммы для покрытия глифа.
///
/// Растеризатор работает без хинтинга, поэтому на мелком кегле штрихи ложатся
/// на дробные пиксели и чередуются: то один пиксель, то два. На увеличенном
/// виде это читается как «битые» буквы, особенно в цифрах.
///
/// Возведение покрытия в степень меньше единицы делает частично закрытые
/// пиксели плотнее: штрихи выравниваются, а края остаются мягкими. Значение
/// подобрано сравнением трёх вариантов: 0.65 уже выглядит жирно, 1.0 — рвано.
const COVERAGE_GAMMA: f32 = 0.80;
/// Применяет гамму к покрытию глифа.
fn gamma(coverage: u32) -> u32 {
let value = (coverage as f32 / 255.0).powf(COVERAGE_GAMMA) * 255.0;
value.round().clamp(0.0, 255.0) as u32
}
/// Гарнитуры, которым отдаётся предпочтение при выборе шрифта.
const PREFERRED: &[&str] = &[
"DejaVu Sans",
"Liberation Sans",
"Noto Sans",
"FreeSans",
"Ubuntu",
"Cantarell",
"Roboto",
"Open Sans",
"Arial",
"Segoe UI",
"Fira Sans",
"Nimbus Sans",
];
/// Сколько кандидатов проверяется растеризацией до остановки.
const MAX_PROBES: usize = 40;
/// Цвет текста в нормализованном виде.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TextColor {
/// Красный канал, 0..1.
pub r: f32,
/// Зелёный канал, 0..1.
pub g: f32,
/// Синий канал, 0..1.
pub b: f32,
/// Прозрачность, 0..1.
pub a: f32,
}
impl TextColor {
/// Белый текст для тёмной темы.
pub const LIGHT: TextColor = TextColor {
r: 1.0,
g: 1.0,
b: 1.0,
a: 1.0,
};
/// Светло-серый текст для вторичных подписей на тёмной теме.
pub const MUTED: TextColor = TextColor {
r: 0.72,
g: 0.72,
b: 0.78,
a: 1.0,
};
/// Почти чёрный текст для светлой темы.
pub const DARK: TextColor = TextColor {
r: 0.06,
g: 0.06,
b: 0.09,
a: 1.0,
};
}
/// Растеризованная строка.
#[derive(Debug, Clone)]
pub struct TextBitmap {
/// Растр в формате R, G, B, A. Пиксели всегда белые: тонировка
/// выполняется при сборке буфера, иначе кэш пришлось бы хранить
/// отдельно для каждого цвета.
pub rgba: Vec<u8>,
/// Ширина растра в пикселях.
pub width: i32,
/// Высота растра в пикселях.
pub height: i32,
/// Ширина строки в логических единицах.
pub advance: f32,
/// Расстояние от верха растра до базовой линии.
pub ascent: f32,
/// Исходная строка: нужна для обрезки по ширине.
pub text: String,
}
impl TextBitmap {
/// Пустая строка.
pub fn empty() -> Self {
Self {
rgba: Vec::new(),
width: 0,
height: 0,
advance: 0.0,
ascent: 0.0,
text: String::new(),
}
}
/// `true`, если растр непустой и его есть что рисовать.
pub fn is_empty(&self) -> bool {
self.width <= 0 || self.height <= 0 || self.rgba.is_empty()
}
/// Обрезает строку по ширине, добавляя многоточие при необходимости.
///
/// Без обрезки длинные заголовки вылезали бы за пределы окна.
pub fn truncated(&self, font: &mut FontRenderer, size_px: f32, max_width: f32) -> TextBitmap {
if self.advance <= max_width || max_width <= 0.0 {
return self.clone();
}
let chars: Vec<char> = self.text.chars().collect();
// Подбирается самый длинный префикс, который помещается **вместе с
// многоточием**: иначе многоточие вытесняет последний символ и
// обрезка теряет смысл.
let mut best: Option<String> = None;
for length in 1..=chars.len() {
let prefix: String = chars[..length].iter().collect();
if font.render(&format!("{prefix}…"), size_px).advance <= max_width {
best = Some(prefix);
} else {
break;
}
}
match best {
Some(prefix) => font.render(&format!("{prefix}…"), size_px),
// Даже один символ с многоточием не помещается: подписывать
// нечего, пустое место честнее обрезки до нечитаемого огрызка.
None => TextBitmap::empty(),
}
}
}
/// Растеризованный глиф.
#[derive(Debug, Clone)]
struct Glyph {
/// Однобайтовая маска покрытия, сверху вниз.
mask: Vec<u8>,
/// Ширина растра глифа.
width: i32,
/// Высота растра глифа.
height: i32,
/// Смещение левого края растра относительно пера.
x: i32,
/// Смещение верхнего края растра относительно базовой линии: обычно
/// отрицательное, так как глиф растёт вверх.
y: i32,
/// Горизонтальный advance в кегле.
advance: f32,
}
/// Растровщик шрифтов с кэшем глифов.
pub struct FontRenderer {
/// Обычное начертание; `None`, если в системе нет пригодного.
font: Option<Font>,
/// Полужирное начертание той же гарнитуры, если оно есть в системе.
bold: Option<Font>,
/// Имя выбранной гарнитуры: показывается в диагностике.
family: String,
/// Кеш глифов: код символа + кегль в пунктах по 1/8 + начертание.
glyphs: HashMap<(u32, u16, bool), Option<Glyph>>,
}
impl FontRenderer {
/// `true`, если в системе есть полужирное начертание.
pub fn has_bold(&self) -> bool {
self.bold.is_some()
}
/// Ищет в системе шрифт, умеющий рисовать кириллицу.
pub fn new() -> Self {
let mut database = Database::new();
database.load_system_fonts();
let (font, family) = load_font(&database).unwrap_or((None, String::new()));
let bold = if family.is_empty() {
None
} else {
load_bold(&database, &family)
};
Self {
font,
bold,
family,
glyphs: HashMap::new(),
}
}
/// `true`, если в системе найден пригодный шрифт.
pub fn is_available(&self) -> bool {
self.font.is_some()
}
/// Имя выбранной гарнитуры.
pub fn family(&self) -> &str {
&self.family
}
/// Растеризует глиф, пользуясь кэшем.
fn glyph(&mut self, ch: char, size_px: f32, bold: bool) -> Option<Glyph> {
let source = if bold { self.bold.as_ref() } else { self.font.as_ref() };
let font = source.or(self.font.as_ref())?;
// Кегль хранится в пунктах по 1/8, иначе ключ был бы дробным.
let key = (ch as u32, (size_px * 8.0).round() as u16, bold);
if let Some(cached) = self.glyphs.get(&key) {
return cached.clone();
}
let (metrics, mask) = font.rasterize(ch, size_px);
let advance = metrics.advance_width;
// У пробела и непечатаемых символов растра нет, но advance есть:
// иначе слова в строке слипались бы.
let glyph = if metrics.width == 0 || metrics.height == 0 {
Glyph {
mask: Vec::new(),
width: 0,
height: 0,
x: 0,
y: 0,
advance,
}
} else {
// `ymin` в fontdue — нижний край растра, измеренный от базовой
// линии вверх. Верхний край получается вычитанием высоты, иначе
// глиф рисуется на высоту самого глифа ниже нужного места и
// обрезается.
let top = -metrics.ymin - metrics.height as i32;
Glyph {
mask,
width: metrics.width as i32,
height: metrics.height as i32,
x: metrics.xmin,
y: top,
advance,
}
};
self.glyphs.insert(key, Some(glyph.clone()));
Some(glyph)
}
/// Возвышение шрифта над базовой линией.
pub fn ascent(&self, size_px: f32) -> f32 {
self.font
.as_ref()
.and_then(|font| font.horizontal_line_metrics(size_px))
.map(|metrics| metrics.ascent)
.unwrap_or(size_px * 0.8)
}
/// Высота строки шрифта.
pub fn line_height(&self, size_px: f32) -> f32 {
self.font
.as_ref()
.and_then(|font| font.horizontal_line_metrics(size_px))
.map(|metrics| metrics.new_line_size)
.unwrap_or(size_px * 1.2)
}
/// Растеризует строку текста.
pub fn render(&mut self, text: &str, size_px: f32) -> TextBitmap {
self.render_with(text, size_px, false)
}
/// Растеризует строку полужирным начертанием.
pub fn render_bold(&mut self, text: &str, size_px: f32) -> TextBitmap {
self.render_with(text, size_px, true)
}
/// Растеризует строку выбранным начертанием.
fn render_with(&mut self, text: &str, size_px: f32, bold: bool) -> TextBitmap {
if self.font.is_none() || text.is_empty() {
return TextBitmap::empty();
}
let ascent = self.ascent(size_px);
let height = self.line_height(size_px).ceil() as i32;
// Символы растеризуются заранее: сборка идёт по значению, иначе
// кэш нельзя менять, пока он заимствован.
let glyphs: Vec<Option<Glyph>> = text
.chars()
.map(|ch| self.glyph(ch, size_px, bold))
.collect();
let advance: f32 = glyphs.iter().flatten().map(|g| g.advance).sum();
let width = advance.ceil() as i32;
if width <= 0 || height <= 0 {
return TextBitmap {
advance,
ascent,
text: text.to_string(),
..TextBitmap::empty()
};
}
let mut rgba = vec![0u8; (width * height * 4) as usize];
let mut pen = 0.0f32;
for glyph in glyphs.iter().flatten() {
let x = pen.round() as i32 + glyph.x;
let y = ascent.round() as i32 + glyph.y;
for row in 0..glyph.height {
for col in 0..glyph.width {
let alpha = glyph.mask[(row * glyph.width + col) as usize] as u32;
if alpha == 0 {
continue;
}
let dst_x = x + col;
let dst_y = y + row;
if dst_x < 0 || dst_y < 0 || dst_x >= width || dst_y >= height {
continue;
}
let offset = ((dst_y * width + dst_x) * 4) as usize;
rgba[offset] = 255;
rgba[offset + 1] = 255;
rgba[offset + 2] = 255;
rgba[offset + 3] = gamma(alpha) as u8;
}
}
pen += glyph.advance;
}
TextBitmap {
rgba,
width,
height,
advance,
ascent,
text: text.to_string(),
}
}
}
/// Выбирает и загружает шрифт из системной базы.
///
/// Покрытие кириллицы не проверяется по таблице символов: у `fontdb` её
/// нет. Вместо этого кандидаты проверяются растеризацией `Ж` — так
/// отсеиваются знаковые и иконковые шрифты, которые формально подходят.
fn load_font(database: &Database) -> Option<(Option<Font>, String)> {
for id in candidates(database) {
let bytes = database.with_face_data(id, |data, _| data.to_vec())?;
let Ok(font) = Font::from_bytes(bytes, FontSettings::default()) else {
continue;
};
let (metrics, _) = font.rasterize(PROBE, 16.0);
if metrics.width == 0 || metrics.height == 0 {
continue;
}
let family = database
.face(id)
.and_then(|face| face.families.first().map(|(name, _)| name.clone()))
.unwrap_or_default();
return Some((Some(font), family));
}
Some((None, String::new()))
}
/// Загружает начертание по идентификатору лица.
fn load(database: &Database, id: ID) -> Option<Font> {
let bytes = database.with_face_data(id, |data, _| data.to_vec())?;
Font::from_bytes(bytes, FontSettings::default()).ok()
}
/// Ищет полужирное начертание той же гарнитуры.
///
/// Без него мелкий текст выходит бледным: растеризатор не хинтит штрихи, и
/// тонкие вертикали едва набирают пиксель. Жирное начертание ложится на
/// пиксельную сетку увереннее и читается заметно лучше.
fn load_bold(database: &Database, family: &str) -> Option<Font> {
let bold = database
.faces()
.filter(|face| {
face.weight >= fontdb::Weight::BOLD
&& face
.families
.first()
.is_some_and(|(name, _)| name.eq_ignore_ascii_case(family))
})
// Ближайшее к полужирному: слишком жирный шрифт в подписях выглядит
// тяжело.
.min_by_key(|face| face.weight.0)?;
load(database, bold.id)
}
/// Упорядочивает лица базы по пригодности для интерфейса.
fn candidates(database: &Database) -> Vec<ID> {
let mut scored: Vec<(usize, &str, ID)> = database
.faces()
// Моноширинные шрифты в панели и заголовках смотрятся хуже.
.filter(|face| !face.monospaced)
// Знаковые гарнитуры не должны занимать место обычного шрифта.
.filter(|face| !is_symbolic(face))
.filter_map(|face| {
let name = face.families.first().map(|(name, _)| name.as_str())?;
let rank = PREFERRED
.iter()
.position(|candidate| name.eq_ignore_ascii_case(candidate))
.unwrap_or(usize::MAX);
Some((rank, name, face.id))
})
.collect();
// Сортировка по имени нужна только для стабильности выбора между
// одинаковыми по приоритету гарнитурами.
scored.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
scored.truncate(MAX_PROBES);
scored.into_iter().map(|(_, _, id)| id).collect()
}
/// Проверяет, что гарнитура не является знаковой или иконковой.
fn is_symbolic(face: &fontdb::FaceInfo) -> bool {
const MARKERS: &[&str] = &[
"symbol",
"icon",
"emoji",
"nerd",
"math",
"dingbat",
"ornament",
"wingding",
"webding",
];
let haystack = format!(
"{} {}",
face.post_script_name,
face.families
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(" ")
)
.to_lowercase();
MARKERS.iter().any(|marker| haystack.contains(marker))
}
/// Текст вместе с цветом, готовый к преобразованию в буфер.
pub struct TextElement {
/// Растр текста.
pub bitmap: TextBitmap,
/// Цвет текста.
pub color: TextColor,
}
impl TextElement {
/// Превращает растр в буфер для smithay.
pub fn into_buffer(self) -> Option<MemoryRenderBuffer> {
if self.bitmap.is_empty() {
return None;
}
let mut data = vec![0u8; self.bitmap.rgba.len()];
// Формат Abgr8888 в памяти — это R, G, B, A.
let color = (
(self.color.r.clamp(0.0, 1.0) * 255.0) as u32,
(self.color.g.clamp(0.0, 1.0) * 255.0) as u32,
(self.color.b.clamp(0.0, 1.0) * 255.0) as u32,
);
let alpha = (self.color.a.clamp(0.0, 1.0) * 255.0) as u32;
for (src, dst) in self.bitmap.rgba.chunks_exact(4).zip(data.chunks_exact_mut(4)) {
let coverage = src[3] as u32;
if coverage == 0 {
continue;
}
dst[0] = color.0 as u8;
dst[1] = color.1 as u8;
dst[2] = color.2 as u8;
dst[3] = (coverage * alpha / 255) as u8;
}
let memory = MemoryBuffer::from_slice(
&data,
Fourcc::Abgr8888,
(self.bitmap.width, self.bitmap.height),
);
Some(MemoryRenderBuffer::from_memory(
memory,
1,
Transform::Normal,
None,
))
}
}
/// Тесты растровщика текста.
/// Тесты растровщика текста.
#[cfg(test)]
mod tests {
use super::*;
/// Системный шрифт должен находиться: без него интерфейс останется без
/// подписей, и это надо видеть в тестах, а не на экране.
#[test]
fn system_font_is_found() {
let renderer = FontRenderer::new();
assert!(
renderer.is_available(),
"в системе не найден шрифт с кириллицей"
);
}
/// Выбранный шрифт не должен быть моноширинным или знаковым.
#[test]
fn chosen_font_is_not_monospace() {
let renderer = FontRenderer::new();
let family = renderer.family().to_lowercase();
for marker in ["mono", "symbol", "icon", "nerd", "emoji"] {
assert!(
!family.contains(marker),
"выбран знаковый шрифт: {family}"
);
}
}
/// Пустая строка не даёт буфера ненулевого размера.
#[test]
fn empty_text_produces_empty_bitmap() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render("", 14.0);
assert!(bitmap.is_empty());
assert_eq!(bitmap.advance, 0.0);
}
/// Высота строки положительна и растёт с кеглем.
#[test]
fn line_height_grows_with_size() {
let renderer = FontRenderer::new();
let small = renderer.line_height(12.0);
let large = renderer.line_height(32.0);
assert!(small > 0.0);
assert!(large > small);
}
/// Более длинная строка шире более короткой.
#[test]
fn longer_text_is_wider() {
let mut renderer = FontRenderer::new();
let short = renderer.render("a", 16.0).advance;
let long = renderer.render("aaaaaaaaaa", 16.0).advance;
assert!(long > short);
}
/// Пробелы имеют ширину, но не содержат пикселей.
#[test]
fn spaces_have_advance() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render(" ", 14.0);
assert!(bitmap.advance > 0.0, "у пробелов есть ширина");
}
/// Кириллица растеризуется в непрозрачные пиксели.
#[test]
fn cyrillic_renders_ink() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render("Привет", 20.0);
assert!(bitmap.height > 0, "высота строки");
assert!(bitmap.advance > 0.0, "ширина строки");
assert!(
bitmap.rgba.chunks_exact(4).any(|px| px[3] > 128),
"в растре нет непрозрачных пикселей"
);
}
/// Обрезка укладывает строку в отведённую ширину.
#[test]
fn truncation_fits_max_width() {
let mut renderer = FontRenderer::new();
let long = renderer.render("Очень длинное название окна", 16.0);
let full_width = long.advance;
let cut = long.truncated(&mut renderer, 16.0, full_width / 3.0);
assert!(
cut.advance <= full_width / 3.0,
"не помещается: {}",
cut.advance
);
assert!(cut.text.ends_with('…'), "нет многоточия: {}", cut.text);
}
/// Обрезка строки, которая уже помещается, ничего не меняет.
#[test]
fn truncation_keeps_short_text() {
let mut renderer = FontRenderer::new();
let short = renderer.render("Окно", 16.0);
let cut = short.truncated(&mut renderer, 16.0, 10_000.0);
assert_eq!(cut.text, "Окно");
assert_eq!(cut.width, short.width);
}
/// Текст помещается в буфер, пригодный для отрисовки.
#[test]
fn text_converts_to_buffer() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render("Окно", 18.0);
assert!(bitmap.width > 0, "ширина растра");
assert!(bitmap.height > 0, "высота растра");
let element = TextElement {
bitmap,
color: TextColor::LIGHT,
};
assert!(
element.into_buffer().is_some(),
"буфер должен создаваться для непустого растра"
);
}
/// Хвосты строчных букв попадают в растр.
///
/// Смещение глифа по вертикали легко перепутать: `ymin` в fontdue — это
/// нижний край растра, а не верхний. Ошибка на высоту глифа сдвигает
/// буквы вниз, и хвосты (`р`, `у`, `д`) просто исчезают. Проверяется
/// именно на хвостах: заглавные буквы такой ошибки не показывают.
#[test]
fn descenders_are_visible() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render("ууу", 16.0);
let last_row = bitmap
.rgba
.chunks_exact(4)
.enumerate()
.filter(|(_, px)| px[3] > 0)
.map(|(i, _)| i / bitmap.width as usize)
.max()
.expect("нужны пиксели");
assert_eq!(
last_row,
bitmap.height as usize - 1,
"хвост буквы обрезан снизу: последняя строка {last_row} при высоте {}",
bitmap.height
);
}
/// Заглавные буквы не прижимаются к верху строки.
///
/// Буквы с диакритикой (`Й`, `Ё`) не берутся намеренно: их знаки
/// доходят до высоты `ascent` и упираются в верх законно.
#[test]
fn uppercase_text_has_headroom() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render("ОХУ", 16.0);
let first_row = bitmap
.rgba
.chunks_exact(4)
.enumerate()
.find(|(_, px)| px[3] > 0)
.map(|(i, _)| i / bitmap.width as usize)
.expect("нужны пиксели");
assert!(first_row > 0, "заглавные буквы упираются в верх строки");
}
/// Строчные буквы не занимают всю высоту строки.
#[test]
fn lowercase_text_leaves_headroom() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render("ооо", 16.0);
let first_row = bitmap
.rgba
.chunks_exact(4)
.enumerate()
.find(|(_, px)| px[3] > 0)
.map(|(i, _)| i / bitmap.width as usize)
.expect("нужны пиксели");
assert!(first_row > 0, "строчные буквы не должны упираться в верх");
}
/// Гамма уплотняет частично закрытые пиксели.
///
/// Это и есть смысл гаммы: штрих без хинтинга ложится на дробный пиксель
/// и выходит бледным, а соседние горизонтальные участки — плотными.
/// Возведение в степень меньше единицы выравнивает их.
#[test]
fn gamma_darkens_partial_coverage() {
assert_eq!(gamma(0), 0, "пустое место должно остаться пустым");
assert_eq!(gamma(255), 255, "закрытый пиксель не должен бледнеть");
for coverage in 1..255 {
assert!(
gamma(coverage) >= coverage,
"покрытие {coverage} побледнело: {}",
gamma(coverage)
);
}
// Часть значений округляется обратно в себя — гамма работает, просто
// при 8 битах эффект мал. Проверяется на середине шкалы, где он
// заметен сильнее всего.
assert!(gamma(96) > 96, "середина шкалы не уплотнилась");
assert!(gamma(160) > 160, "светлый край не уплотнился");
}
/// Буквы остаются читаемыми: штрихи не исчезают и не заливают строку.
#[test]
fn rendered_letters_keep_contrast() {
let mut renderer = FontRenderer::new();
let bitmap = renderer.render("Н", 13.0);
let ink = bitmap.rgba.chunks_exact(4).filter(|px| px[3] > 0).count();
let total = (bitmap.width * bitmap.height) as usize;
assert!(ink > 0, "растр пуст");
assert!(ink < total, "растр полностью залит — это не буква");
// Средняя непрозрачность заливок: гамма уплотняет штрихи, но они не
// обязаны быть полностью непрозрачными — края остаются мягкими.
let mean = bitmap
.rgba
.chunks_exact(4)
.filter(|px| px[3] > 0)
.map(|px| px[3] as usize)
.sum::<usize>()
/ ink;
assert!(mean > 150, "штрихи слишком бледные: среднее {mean}");
}
}

102
tests/smoke.rs Normal file
View file

@ -0,0 +1,102 @@
//! Тесты обработки ввода на бэкенде winit.
//!
//! Изначально композитор не собирался с ошибкой
//! `no method named key_code found for struct WinitKeyboardInputEvent`:
//! трейт `KeyboardKeyEvent` не был импортирован. Такие ошибки дешевле ловить
//! тестом, чем запуском композитора, поэтому здесь проверяется, что трейты
//! событий ввода smithay доступны и что их методы вызываются корректно.
use smithay::backend::input::{
AbsolutePositionEvent, Axis, Event, InputBackend, InputEvent, Keycode, KeyboardKeyEvent,
KeyState, PointerAxisEvent, PointerButtonEvent, PointerMotionAbsoluteEvent,
};
use smithay::backend::winit::{self, WinitInput};
/// Проверяет, что трейты событий ввода реализованы для типов бэкенда winit.
///
/// Ошибка `no method named key_code` возникает именно тогда, когда трейт не
/// импортирован, поэтому проверка идёт через обобщённые сигнатуры.
#[test]
fn input_event_traits_are_available() {
fn assert_keyboard<E: KeyboardKeyEvent<WinitInput>>() {}
fn assert_button<E: PointerButtonEvent<WinitInput>>() {}
fn assert_axis<E: PointerAxisEvent<WinitInput>>() {}
fn assert_motion<E: PointerMotionAbsoluteEvent<WinitInput>>() {}
assert_keyboard::<winit::WinitKeyboardInputEvent>();
assert_button::<winit::WinitMouseInputEvent>();
assert_axis::<winit::WinitMouseWheelEvent>();
assert_motion::<winit::WinitMouseMovedEvent>();
}
/// Проверяет, что из события клавиатуры извлекаются код, состояние и время.
///
/// Именно эти три значения композитор передаёт в `KeyboardHandle::input`.
#[test]
fn keyboard_event_exposes_code_state_and_time() {
fn read<E: KeyboardKeyEvent<WinitInput>>(event: &E) -> (Keycode, KeyState) {
(event.key_code(), event.state())
}
// Вызов компилируется, только если все методы трейта доступны.
let _ = read::<winit::WinitKeyboardInputEvent>;
}
/// Проверяет, что события указателя дают доступ к координатам, кнопкам и
/// осям прокрутки.
#[test]
fn pointer_events_expose_expected_fields() {
fn read_motion<E: PointerMotionAbsoluteEvent<WinitInput>>(event: &E) -> (f64, f64) {
(event.x(), event.y())
}
fn read_button<E: PointerButtonEvent<WinitInput>>(
event: &E,
) -> (u32, smithay::backend::input::ButtonState) {
(event.button_code(), event.state())
}
fn read_axis<E: PointerAxisEvent<WinitInput>>(event: &E) {
let _ = event.source();
let _ = event.relative_direction(Axis::Vertical);
let _ = event.amount_v120(Axis::Horizontal);
}
let _ = read_motion::<winit::WinitMouseMovedEvent>;
let _ = read_button::<winit::WinitMouseInputEvent>;
let _ = read_axis::<winit::WinitMouseWheelEvent>;
}
/// Проверяет разбор всех событий ввода, которые обрабатывает композитор.
///
/// Это защищает от ситуации, когда в `match` добавлена ветка для события,
/// доступ к полям которого требует неимпортированного трейта.
#[test]
fn compositor_handles_all_input_variants() {
fn route<B: InputBackend>(event: InputEvent<B>) {
match event {
InputEvent::Keyboard { event } => {
let code: Keycode = event.key_code();
let state: KeyState = event.state();
let _ = (code, state, event.time());
}
InputEvent::PointerMotionAbsolute { event } => {
let _ = (event.x(), event.y());
let _ = event.position_transformed((100, 100).into());
}
InputEvent::PointerButton { event } => {
let _ = (event.button_code(), event.state());
}
InputEvent::PointerAxis { event } => {
let _ = event.source();
let _ = event.amount(Axis::Vertical);
let _ = event.amount_v120(Axis::Vertical);
let _ = event.relative_direction(Axis::Vertical);
}
_ => {}
}
}
// Проверка на этапе компиляции для бэкенда winit.
let _ = route::<WinitInput>;
}