Первая версия 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:
commit
41fa466410
39 changed files with 13938 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
3534
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal 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
83
README.md
Normal 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
6
clippy.toml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Пороги clippy, согласованные с целями проекта.
|
||||
#
|
||||
# Композитор устроен как цикл событий, поэтому функции с большим числом
|
||||
# аргументов закономерны: состояние, рендерер, выход, трекер повреждений.
|
||||
|
||||
too-many-arguments-threshold = 10
|
||||
146
docs/ARCHITECTURE.md
Normal file
146
docs/ARCHITECTURE.md
Normal 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
295
docs/BUILD.md
Normal 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
94
docs/ROADMAP.md
Normal 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
53
locales/en-US/app.ftl
Normal 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
53
locales/ru-RU/app.ftl
Normal 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
120
scripts/dev-run.sh
Executable 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
27
src/apps/files.rs
Normal 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
5
src/apps/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Lark Apps — собственные приложения.
|
||||
|
||||
pub mod files;
|
||||
pub mod terminal;
|
||||
pub mod settings;
|
||||
13
src/apps/settings.rs
Normal file
13
src/apps/settings.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//! Центр настроек.
|
||||
|
||||
/// Центр настроек.
|
||||
pub struct Settings {
|
||||
// TODO: Разделы настроек
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Создаёт новый центр настроек.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
13
src/apps/terminal.rs
Normal file
13
src/apps/terminal.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//! Эмулятор терминала.
|
||||
|
||||
/// Терминал.
|
||||
pub struct Terminal {
|
||||
// TODO: PTY, буфер, рендеринг текста
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
/// Создаёт новый терминал.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
718
src/compositor/decoration.rs
Normal file
718
src/compositor/decoration.rs
Normal 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
418
src/compositor/input.rs
Normal 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
225
src/compositor/keys.rs
Normal 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
2919
src/compositor/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
369
src/config.rs
Normal file
369
src/config.rs
Normal 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
98
src/i18n.rs
Normal 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
4
src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! Lark DE — библиотека общих компонентов.
|
||||
|
||||
pub mod config;
|
||||
pub mod i18n;
|
||||
126
src/main.rs
Normal file
126
src/main.rs
Normal 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
15
src/services/audio.rs
Normal 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
23
src/services/mod.rs
Normal 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
15
src/services/network.rs
Normal 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(())
|
||||
}
|
||||
16
src/services/notification.rs
Normal file
16
src/services/notification.rs
Normal 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
16
src/services/power.rs
Normal 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
16
src/services/session.rs
Normal 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
200
src/shell/clock.rs
Normal 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
47
src/shell/desktop.rs
Normal 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
760
src/shell/mod.rs
Normal 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("буфер должен создаваться даже при некорректном размере");
|
||||
}
|
||||
}
|
||||
59
src/shell/notifications.rs
Normal file
59
src/shell/notifications.rs
Normal 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
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
909
src/shell/start_menu.rs
Normal 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
440
src/shell/switcher.rs
Normal 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
23
src/shell/tray.rs
Normal 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
784
src/text.rs
Normal 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
102
tests/smoke.rs
Normal 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>;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue