Compare commits

...
12 Commits
46 changed files with 1664 additions and 235 deletions
Generated
+44 -5
View File
@@ -611,7 +611,11 @@ dependencies = [
"serde_json",
"slint",
"slint-build",
"toml 1.1.4+spec-1.1.0",
"windows 0.62.2",
"winit",
"winreg",
"winresource",
]
[[package]]
@@ -4129,7 +4133,7 @@ dependencies = [
"regex",
"serde_json",
"tar",
"toml",
"toml 0.9.12+spec-1.1.0",
]
[[package]]
@@ -4662,6 +4666,21 @@ dependencies = [
"winnow 0.7.15",
]
[[package]]
name = "toml"
version = "1.1.4+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.2",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -4695,18 +4714,18 @@ dependencies = [
[[package]]
name = "toml_parser"
version = "1.1.2+spec-1.1.0"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
dependencies = [
"winnow 1.0.2",
]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
@@ -5757,6 +5776,26 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"windows-sys 0.61.2",
]
[[package]]
name = "winresource"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
dependencies = [
"toml 1.1.4+spec-1.1.0",
"version_check",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+9
View File
@@ -9,7 +9,16 @@ i-slint-backend-winit = "1.16.1"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
slint = { version = "1.16.1", features = ["renderer-skia", "serde"] }
toml = "1.1.4"
windows = { version = "0.62.2", features = ["Win32_UI_WindowsAndMessaging", "Win32_Foundation"] }
winit = "0.30"
winreg = "0.56.0"
[build-dependencies]
slint-build = "1.16.1"
winresource = "0.1.31"
[profile.release]
lto = true
codegen-units = 1
strip = true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ku6epXBOCTuK
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+70 -23
View File
@@ -1,37 +1,84 @@
## 🚀 Rust Browser Picker (Working Title)
<div align="center">
Легкий и мгновенный переключатель браузеров на Rust + Slint. Замена тяжеловесным аналогам, которые долго загружаются.
<img src="assets/icon.png" width="96" alt="bropicker">
## 🎯 Цель
# bropicker
Создать утилиту, которая перехватывает открытие ссылок и предлагает выбрать браузер в красивом UI. Главный приоритет — скорость запуска (Cold Start < 100ms).
Лёгкий переключатель браузеров: кликаешь ссылку — bropicker спрашивает, каким браузером её открыть.
## 🛠 Технологический стек
**Rust + Slint, нативный рендер без WebView, cold start ~110 ms.**
- Язык: [Rust](https://www.rust-lang.org/)
- GUI: [Slint](https://slint.dev/) (Native-speed, Declarative UI)
- Запуск процессов: std::process::Command
<a href="https://slint.dev"><img src="https://raw.githubusercontent.com/slint-ui/slint/master/logo/MadeWithSlint-logo-whitebg.png" alt="Made with Slint" height="28"></a>
## 📊 Текущий статус
<img src="docs/img/screenshot-dark.png" width="244" alt="Тёмная тема">&nbsp;<img src="docs/img/screenshot-light.png" width="244" alt="Светлая тема">
- Frameless-окно с прозрачным фоном: карточка парит над рабочим столом со скруглёнными углами
- Тёмная и светлая темы (переключение кнопкой, env `BP_THEME=light`)
- Перетаскивание окна за хедер, Esc — закрыть, центрирование при старте
- Дизайн-макеты: [refs/dark.html](refs/dark.html) / [refs/light.html](refs/light.html)
</div>
## 🔧 Разработка
## Возможности
План работ и цикл визуальной разработки с ИИ-агентом — [docs/dev-workflow.md](docs/dev-workflow.md).
- Frameless-окно с прозрачностью, перетаскивание за хедер, Esc — закрыть
- Тёмная и светлая темы
- Автодетект установленных браузеров из реестра (Firefox, Chrome, Edge, Zen, Opera, Brave, Яндекс…)
- Запоминание выбора по домену: повторные ссылки открываются сразу, без вопроса
- Галочка «Всегда спрашивать» — пикер вызывается даже для запомненных доменов
- Несколько профилей одного браузера как отдельные записи (флаги + эмодзи-иконка)
- Конфиг в TOML, открывается кнопкой шестерёнки
## Сборка
```powershell
cargo run # запуск
tools/render-refs.ps1 # PNG-референсы из макетов
tools/run-and-shot.ps1 # собрать, снять скриншот окна
tools/run-and-shot.ps1 -Light # то же в светлой теме
cargo build --release
```
## 🗺 Дорожная карта
## Установка обработчиком ссылок
1. Список браузеров с логотипами, запуск выбранного браузера с URL (MVP)
2. Регистрация в системе как браузер по умолчанию
3. config.toml + страница настроек
```powershell
tools\register.ps1 # скопирует сборку в %LOCALAPPDATA%\bropicker и откроет настройки Windows
```
В открывшемся окне выбери **bropicker** для HTTP и HTTPS. Откат: `tools\register.ps1 -Uninstall`.
## Конфигурация
`%APPDATA%\bropicker\config.toml` (шестерёнка в пикере открывает его в редакторе).
Пример с профилями и эмодзи: [docs/config.example.toml](docs/config.example.toml).
```toml
[[browsers]]
name = "Chrome — Work"
path = 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
flags = '--profile-directory="Default"'
emoji = "💼"
[remembered]
"github.com" = "Chrome — Work"
[settings]
remember_choice = true
always_ask = true
```
## Разработка
```powershell
just check # fmt + clippy -D warnings + тесты
just screenshots # перегенерация скриншотов README (тёмная + светлая)
just icons # перегенерация иконок из assets/icon.svg
just release # release-сборка
just register # установка/обновление обработчика ссылок
```
То же напрямую: `tools\check.ps1`, `tools\screenshots.ps1`, `tools\register.ps1`.
Планы и процесс: [docs/dev-workflow.md](docs/dev-workflow.md).
## Лицензия
Код bropicker — [MIT](LICENSE).
Интерфейс построен на [Slint](https://slint.dev), который используется по
[Slint Royalty-free License 2.0](licenses/Slint-Royalty-free-2.0.md)
Текст лицензии также входит
в состав установки (`licenses/`).
Логотипы браузеров — [alrrr/browser-logos](https://github.com/alrrr/browser-logos), MIT
([LICENSE](logos/LICENSE-browser-logos.txt)).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b82f6"/>
<stop offset="1" stop-color="#06b6d4"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="58" fill="url(#bg)"/>
<g stroke="#ffffff" stroke-width="13" fill="none" stroke-linecap="round">
<circle cx="128" cy="122" r="62"/>
<ellipse cx="128" cy="122" rx="27" ry="62"/>
<line x1="66" y1="122" x2="190" y2="122"/>
</g>
<circle cx="128" cy="212" r="11" fill="#ffffff"/>
<circle cx="88" cy="212" r="11" fill="#ffffff" opacity="0.45"/>
<circle cx="168" cy="212" r="11" fill="#ffffff" opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 750 B

+6 -3
View File
@@ -1,6 +1,9 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: MIT
fn main() {
slint_build::compile("ui/main.slint").unwrap();
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut res = winresource::WindowsResource::new();
res.set_icon("assets/app.ico");
res.compile().unwrap();
}
}
+85
View File
@@ -0,0 +1,85 @@
# Ревью кода bropicker (2026-08-26) — ВЫПОЛНЕНО
> Скоуп: src/main.rs (401 строка), src/config.rs (~210), src/winit.rs (45),
> ui/*.slint. Плюс вывод clippy.
>
> Итог рефакторинга: main.rs 401→32 строки (чистый скелет), появился launcher.rs
> и app.rs (`struct App`: new/wire×4 группы/run), 12 юнит-тестов,
> tools/check.ps1 (fmt+clippy -D warnings+test) зелёный, дублирование запуска
> устранено, индекс из UI вместо поиска по имени, все P0-P2 закрыты.
> UI проверен скриншотом — идентичен. Cold start release 110-133 ms.
## P0 — мусор и артефакты правок (чинить сразу)
- [ ] `main.rs:141-142`**дубль** `let url = main_window.get_current_url()..`
(первый не используется; поймано clippy). Артефакт моих правок этапа D/E
- [ ] `main.rs:229-231``on_settings_clicked` зарегистрирован **дважды**:
мёртвый первый вариант с `println!`. Второй вызов перезаписывает, но мусор
путает
- [ ] Остатки `println!` (строки 230, 268, 281, 304): в release никуда не пишут,
в debug сорят. Либо `config::log`, либо удалить
- [ ] Обратная конвертация `BrowserConfig → BrowserEntry` теряет emoji/icon
(`icon: String::new()` в двух местах). На запуск не влияет, но это бомба:
любая будущая фича, читающая поля из этой конвертации, получит пустышку
## P1 — дублирование и SRP
- [ ] **Дубль логики запуска**: `on_launch_browser` и `on_open_clicked`
совпадают на ~90% (лог + launch + remember + hide). Extract:
`fn launch_and_remember(&Config, &MainWindow, entry, url)`
- [ ] **main.rs — файл-бог** (401 строка): бутстрап + клей UI-моделей + запуск
браузеров + парсер флагов + иконки + remembered-логика. Разрезать:
- `launcher.rs` — split_flags, launch, launch_and_remember
- `config.rs` оставить: TOML + реестр + пути (или вынести `detect.rs`)
- `main.rs` — тонкий: env, fast-path, show/run (~80 строк)
- [ ] Ручные конвертации моделей ↔ конфиг дважды → `impl From<&BrowserConfig>
for BrowserEntry` (+ обратный From)
- [ ] Поиск индекса по имени (`find(|b| b.name == browser.name)`): ломается при
одинаковых именах (два профиля «Chrome»). Правильно: передавать индекс из
UI — в slint цикл уже знает `i`: `clicked => root.launch-browser(i)`
- [ ] `struct State`: поля browser_model/remembered_model/config после init не
читаются (Rc живут в замыканиях, модели — в свойствах окон). Оставить
только окна, или убрать структуру вовсе
## P2 — rust-way (clippy + идиоматика)
- [ ] clippy: `winit.rs:9` match → if let; `load_icon` — collapse nested if let
(`cargo clippy --fix` закрывает все три)
- [ ] Нет юнит-тестов. Чистые функции напрашиваются:
- `split_flags`: кавычки с пробелами, пустая строка, двойные пробелы
- `domain_of`: схема/без схемы/www/порт/путь/userinfo
- `extract_exe`: кавычки, аргументы, относительный путь
- `is_known_browser`: регистр, «Яндекс», подстрока в пути-подобном имени
- [ ] В воркфлоу нет `cargo fmt`/`clippy` — добавить `tools/check.ps1`
(fmt --check + clippy -D warnings + test) и гонять перед коммитом
- [ ] Магические строки → константы: URL-плейсхолдер `"ku6epxboctuk.github.io"`,
префикс лога `"[bp]"`, имена env (`BP_THEME/BP_CONFIG/BP_VIEW`)
- [ ] `Settings::default` ручной — заменить на `#[derive(Default)]` c
`#[default = true]`-эквивалентом (serde default-функции уже есть)
## P3 — косметика и Slint
- [ ] `ui/main.slint:1` — неиспользуемые импорты `Button, CheckBox` (CheckRow их
заменил)
- [ ] Хардкод акцента `#3b82f6` в 4 местах slint → токен `Theme.accent`
- [ ] `theme.slint`: неиспользуемые тени shadow-primary-x/y/blur,
text-control-muted — проверить и вычистить
- [ ] `settings.slint`: строки браузеров и доменов почти одинаковые — общий
компонент-строка (низкий приоритет, панелей всего две)
- [ ] Лог без ротации: при желании — усечение до N КБ при старте
## Что трогать НЕ нужно
- `unwrap()` при создании окон в main — норм для startup-фейлов приложения
- `Rc<RefCell<Config>>` — осознанный выбор для однопоточного Slint (не тащить
Mutex ради галочки)
- Частота `config::save` на каждый чекбокс — частоты ничтожны
## Порядок исполнения (одна сессия)
1. [x] P0 целиком + прогон clippy --fix (P2 первая строка)
2. [x] P1: launcher.rs + launch_and_remember + From-конвертации + индекс из UI
3. [x] Юнит-тесты чистых функций + tools/check.ps1
4. [x] P3 косметика: импорты, Theme.accent/danger токены
Каждый пункт — зелёный `tools\check.ps1` перед коммитом.
+45
View File
@@ -0,0 +1,45 @@
# bropicker config
# Расположение: %APPDATA%\bropicker\config.toml
# Открывается из пикера кнопкой шестерёнки (subl/notepad)
# [[browsers]] — один блок на запись пикера.
# path — полный путь к exe (или имя из PATH)
# flags — аргументы запуска; пробелы внутри значения — в кавычках
# icon — необязательно: путь к png/svg, иначе подбирается по имени
# emoji — необязательно: эмодзи вместо иконки (удобно различать профили);
# имеет приоритет над icon
[[browsers]]
name = "Firefox"
path = 'C:\Program Files\Mozilla Firefox\firefox.exe'
flags = ""
# Профили Chrome: имена каталогов профилей смотри на chrome://version
# (Profile Path: ...\User Data\Default → "Default",
# ...\User Data\Profile 1 → "Profile 1")
[[browsers]]
name = "Chrome — Work"
path = 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
flags = '--profile-directory="Default"'
emoji = "💼"
[[browsers]]
name = "Chrome — Personal"
path = 'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe'
flags = '--profile-directory="Profile 1"'
emoji = "🏠"
[[browsers]]
name = "Firefox — Work"
path = 'C:\Program Files\Mozilla Firefox\firefox.exe'
flags = '-P "Work"'
emoji = "🦊"
# Запомненные домены: домен = имя записи из [[browsers]]
[remembered]
"github.com" = "Chrome — Work"
[settings]
remember_choice = true
always_ask = true
+107 -9
View File
@@ -101,13 +101,89 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
## Этап E — конфиг и настройки
- [ ] `config.toml` рядом с exe / `%APPDATA%\bropicker` (крейс `toml`), serde-структура BrowserConfig
- [ ] Логика запуска с учётом галочек: remember-choice → записать выбор для домена;
always-ask → показывать пикер несмотря на запомненное
- [ ] Страница настроек: TabWidget + LineEdit/SpinBox/ComboBox/CheckBox std-widgets;
вход по кнопке cog из футера (колбэк settings-clicked уже есть)
- [ ] Автодетект установленных браузеров (реестр Windows) как fallback при отсутствии конфига
### E1 — конфиг, автодетект, логика запуска — ГОТОВО
- [x] `src/config.rs`: `%APPDATA%\bropicker\config.toml` (крейсы `toml` + `winreg`);
env `BP_CONFIG` для тестов; секции `[[browsers]]`, `[remembered]` (домен→имя),
`[settings]` (remember_choice, always_ask)
- [x] Автодетект из реестра `HKLM/HKCU\SOFTWARE\Clients\StartMenuInternet`:
имя + exe из shell\open\command, дедуп по пути, проверка существования файла;
результат автосохраняется. Проверено: найдены Firefox, Zen, Chrome, Edge
- [x] Иконки по имени (firefox/chrome/opera/brave/yandex → `logos/*.png`,
остальное → globe.svg). Нет логотипов: Edge, Zen
- [x] Логика запуска: домен запомнен + always_ask=off → мгновенный запуск без окна
(протестировано); иначе пикер. Клик/«Открыть» + remember_choice → запись
домена в конфиг; переключение галочек персистится
- [x] URL-бар привязан к свойству (был захардкожен), метка чекбокса — домен
### E2 — страница настроек — ГОТОВО (отдельное окно)
- [x] Настройки = **отдельное окно** `SettingsWindow` (760×560, обычная рамка,
resizable; в маленьком пикере редактировать неудобно). То же событийное
кольцо; Theme-глобал красит оба окна; модели шарятся (ModelRc один на оба)
- [x] cog в пикере → показать окно (центрирование через invoke_from_event_loop —
winit-окно реализуется лениво, как и у пикера)
- [x] Две панели: браузеры (иконка/имя/путь/корзина) + запомненные домены
(domain → browser/корзина); кнопка «Автодетект» (мерж по пути)
- [x] Esc в настройках = закрыть окно; X работает по умолчанию
- [x] env `BP_VIEW=settings` — запуск сразу с окном настроек (без пикера);
нюанс: `run()` авто-показывает своё окно, поэтому в этом режиме цикл
крутится на settings_window
- [ ] Редактирование браузера (имя/путь/флаги через LineEdit) — ОТЛОЖЕНО (решено:
cog в пикере открывает `config.toml` в редакторе — `subl`, fallback `notepad`;
если файла нет — создаётся с автодетектом). Окно SettingsWindow осталось
в коде (недоступно из UI), dev-доступ: env `BP_VIEW=settings`
- [ ] Чтение флагов из .lnk ярлыков (крейс `lnk`) — по желанию
- [ ] Добавить логотипы Edge/Zen в logos/ (пересоздать набор из alrrr/browser-logos)
### E3 — доведение до ежедневного использования — ГОТОВО
- [x] cog → открыть конфиг в редакторе (subl → notepad)
- [x] Замер cold start: Instant в main → печать при старте event loop
- [x] `tools/register.ps1` (+ `-Uninstall`): **ставит пакет в
`%LOCALAPPDATA%\bropicker\` (exe + logos + icons)** и регистрирует
ProgId/Capabilities/RegisteredApplications в HKCU на путь из LOCALAPPDATA
(не target\ — переживает cargo clean и перенос репо), открывает
ms-settings:defaultapps
- [x] Иконки резолвятся от папки exe (иначе при запуске обработчиком ссылок
cwd = system32 и иконки бы отвалились); проверено запуском из
LOCALAPPDATA с cwd=System32
- [x] Release-профиль: lto + codegen-units=1 + strip
- [x] Release без консоли: `windows_subsystem="windows"` только для не-debug
(в debug консоль остаётся); весь вывод → `%APPDATA%\bropicker\bropicker.log`
(config::log с unix-timestamp)
- [x] Парсер флагов понимает кавычки (`split_flags`) — профили браузеров:
`flags = '--profile-directory="Profile 1"'` (Chrome), `-P "Work"` (Firefox);
пример: docs/config.example.toml
- [x] Эмодзи-иконка для записи: поле `emoji` в конфиге (приоритет над icon) —
различение профилей одного браузера; фолбэк-глобус colorize по теме
(был чёрным на тёмной), цветные PNG логотипов не трогаются
(`icon-mask` только для генерик-глобуса)
- [x] Cold start release: 131 ms (после добавления файлового лога)
## Этап F — релиз
- [x] `[profile.release]`: lto, codegen-units=1, strip
- [x] Замер cold start (release, до старта event loop): **183 ms холодный /
120-122 ms тёплый**; из LOCALAPPDATA первый запуск 153 ms.
Цель <100 ms чуть не достигнута; варианты оптимизации, если захочется:
renderer-femtovg вместо skia (быстрее init GPU), отложенная загрузка
конфига. Пока считаем приемлемым
- [x] Иконка exe: `assets/icon.svg` (градиент + глобус + точки «выбора»)
`magick``assets/icon.png` + `assets/app.ico` (16-256) → `winresource`
в build.rs; проверено ExtractAssociatedIcon
- [x] Лицензия MIT (`LICENSE`); Slint — по Royalty-free License 2.0 (маршрут (b):
бейдж Made-with-Slint в README, текст лицензии в `licenses/` и в
составе установки `%LOCALAPPDATA%\bropicker\licenses\`).
ВАЖНО: маршрут (b) требует публичной страницы с бейджем; если репо
станет приватным — добавить виджет AboutSlint в окно настроек
- [x] Скриншоты в README: `docs/img/screenshot-{dark,light}.png`;
перегенерация — `just screenshots` (tools/screenshots.ps1 → shot.ps1 -Card:
DPI-aware обрезка до карточки, клип на 2px внутрь срезает AA-бахрому
скруглений, углы прозрачные — чужих пикселей нет)
- [ ] Ручной сценарий приёмки: register → клик ссылки в другом приложении →
пикер → выбор → вкладка открылась; повторный клик того же домена при
always_ask=off → сразу браузер
## Этап F — релиз
@@ -119,11 +195,33 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
атрибуция AboutSlint) — зафиксировать в README и LICENSE
- [ ] Скриншоты в README из tools/out
## Решение по архитектуре запуска (исследование 2026-08-25)
ОС вызывает пикер так: `CreateProcess("bropicker.exe", "https://...")` — новый процесс
на каждый клик, постоянной связи с вызывающим приложением нет. Два режима:
- **Новый инстанс (выбрано для MVP):** нулевая сложность; если cold start < 100 ms,
разница с резидентом невидима
- **Резидент + трей (этап G, по показаниям):** spawn-заглушка + IPC + показ готового
окна (~5-10ms), но: постоянная память ~30-80MB, named mutex, IPC (named pipe /
WM_COPYDATA), крейс `tray-icon` + скрытое winit-окно (в Slint трея нет), автозапуск
Критерий старта этапа G: замер этапа F показал cold start > 100 ms, либо нужен
доступ к настройкам/функциям из трея. Single-instance guard (named mutex + фокус
существующего окна) можно добавить отдельно, без полного резидента — если быстрые
клики по нескольким ссылкам начнут плодить окна.
## Этап G — резидент + трей (отложен до результатов этапа F)
- [ ] Решение по итогам замера cold start (см. блок выше)
- [ ] Single-instance guard: named mutex, форвард URL в существующий инстанс (named pipe)
- [ ] Иконка в трее (`tray-icon`), меню: Открыть настройки / Выход
- [ ] Автозапуск (HKCU Run или Startup-ярлык)
## Заметки для агента
- GUI-приложение запускать только через `tools/run-and-shot.ps1`, никогда не блокирующе
- После каждого изменения UI: скриншот + сравнение с `tools/out/ref-{dark,light}.png`
- Тёмная/светлая тема переключается кнопкой sun/moon (Theme.toggle уже работает);
для скриншота светлой темы кликать нельзя — добавить env-флаг `BP_THEME=light`,
который в main() выставит `Theme.is-dark = false` перед показом
- Тёмная/светлая тема: переключение кнопкой sun/moon (Theme.toggle); для скриншота
светлой темы — `tools/run-and-shot.ps1 -Light` (env `BP_THEME=light`)
- Cargo.toml правится только через cargo CLI; манифесты руками — только человеком
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>

After

Width:  |  Height:  |  Size: 215 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>

After

Width:  |  Height:  |  Size: 294 B

+26
View File
@@ -0,0 +1,26 @@
default:
@just --list
# fmt + clippy -D warnings + тесты
check:
powershell -ExecutionPolicy Bypass -File tools/check.ps1
# release-сборка
release:
cargo build --release
# перегенерация скриншотов для README (тёмная + светлая, только карточка)
screenshots:
powershell -ExecutionPolicy Bypass -File tools/screenshots.ps1
# перегенерация иконок из assets/icon.svg (icon.png, app.ico, PNG-лестница)
icons:
powershell -ExecutionPolicy Bypass -File tools/icons.ps1
# установка/обновление обработчика ссылок (+ открытие настроек Windows)
register:
powershell -ExecutionPolicy Bypass -File tools/register.ps1
# снять регистрацию и удалить %LOCALAPPDATA%\bropicker
unregister:
powershell -ExecutionPolicy Bypass -File tools/register.ps1 -Uninstall
+43
View File
@@ -0,0 +1,43 @@
# Slint Royalty-free Desktop, Mobile, and Web Applications License
Version 2.0
## Preamble
Slint is a toolkit that can be used to build user interfaces for applications. Slint (hereafter referred to as **Software**) is made available under different licenses by SixtyFPS GmbH incorporated at Oranienburger Str. 44, 16540 Hohen Neuendorf, Germany (**SixtyFPS**). The **Slint Royalty-free Desktop, Mobile, and Web Applications License** is suitable for those who develop desktop, mobile, or web applications and do not want to use open source components under copyleft licenses.
## 1. Grant of Rights
SixtyFPS hereby grants You a world-wide, royalty-free, non-exclusive license to use, reproduce, make available, modify, display, perform, distribute the Software as part of a Desktop, Mobile, or Web Application.
A **Desktop Application** is a computer program that is designed to run on a general-purpose computer (PC or notebook), typically installed and executed locally on the computer's operating system.
A **Mobile Application** is a computer program that is designed to run on a general-purpose mobile computer (mobile phone or tablet), typically installed and executed locally on the computer's operating system.
A **Web Application** is a computer program that is designed to run in the sandbox environment provided by a web browser.
Desktop Application, Mobile Application, and Web Application are hereafter referred to as **Application**.
## 2. License Conditions - Attribution
You may distribute the Software as part of an Application, modified or unmodified, provided that You do either of the following:
(a) Display the [`AboutSlint`](https://docs.slint.dev/latest/docs/slint/reference/std-widgets/misc/aboutslint/) widget in an "About" screen or dialog that is accessible from the top level menu of the Application. In the absence of such a screen or dialog, display the widget in the "Splash Screen" of the Application.
(b) Display the [Slint attribution badge](https://github.com/slint-ui/slint/tree/master/logo/MadeWithSlint-logo-whitebg.png) on a public webpage, preferably where the binaries of your Application can be downloaded from, in such a way that it can be easily found by any visitor to that page.
## 3. Limitations
The License does not permit to distribute or make the Software publicly available alone and without integration into an Application. For this purpose you may use the Software under the GNU General Public License, version 3.
The License does not permit the use of the Software within Embedded Systems. An **Embedded System** is a computer system designed to perform a specific task within a larger mechanical or electrical system.
The License does not permit the distribution of Application that exposes the APIs, in part or in total, of the Software.
You may not remove or alter any license notices (including copyright notices, disclaimers of warranty, or limitations of liability) contained within the source code form of the Software.
## 4. Warranty and Liability
SixtyFPS is only liable for conflicting rights of third parties if SixtyFPS was aware of these rights without informing you. Unless required by applicable law or agreed to in writing, SixtyFPS provides the Software on an "as is" basis, without warranties or conditions of any kind, either express or implied, including, without limitation, any warranties or conditions of merchantability, or fitness for a particular purpose.
Unless required by law, SixtyFPS won't be liable for any direct, indirect, incidental, or consequential damages arising in any way out of the use of the Software.
+273
View File
@@ -0,0 +1,273 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Instant;
use i_slint_backend_winit::WinitWindowAccessor;
use slint::{ComponentHandle, Model};
use crate::config::{self, BrowserEntry, Config};
use crate::launcher;
use crate::mapping::to_ui_config;
use crate::winit;
use crate::{BrowserConfig, MainWindow, RememberedEntry, SettingsWindow, Theme};
const DEFAULT_URL: &str = "ku6epxboctuk.github.io";
const ENV_THEME: &str = "BP_THEME";
const ENV_VIEW: &str = "BP_VIEW";
const VIEW_SETTINGS: &str = "settings";
pub struct App {
main_window: MainWindow,
settings_window: SettingsWindow,
browser_model: Rc<slint::VecModel<BrowserConfig>>,
remembered_model: Rc<slint::VecModel<RememberedEntry>>,
config: Rc<RefCell<Config>>,
}
impl App {
pub fn new(cfg: Config, url: Option<String>) -> Self {
let config = Rc::new(RefCell::new(cfg));
let browser_model = Rc::new(slint::VecModel::<BrowserConfig>::from(vec![]));
let remembered_model = Rc::new(slint::VecModel::<RememberedEntry>::from(vec![]));
let main_window = MainWindow::new().unwrap();
let settings_window = SettingsWindow::new().unwrap();
if std::env::var(ENV_THEME).as_deref() == Ok("light") {
main_window.global::<Theme>().set_is_dark(false);
}
Self::refresh_models(&browser_model, &remembered_model, &config.borrow());
settings_window.set_browsers(browser_model.clone().into());
settings_window.set_remembered(remembered_model.clone().into());
main_window.set_browser_model(browser_model.clone().into());
main_window.set_current_url(
url.clone()
.unwrap_or_else(|| DEFAULT_URL.to_string())
.into(),
);
if let Some(u) = &url {
main_window.set_current_domain(config::domain_of(u).into());
}
main_window.set_remember_choice(config.borrow().settings.remember_choice);
main_window.set_always_ask(config.borrow().settings.always_ask);
Self {
main_window,
settings_window,
browser_model,
remembered_model,
config,
}
}
pub fn wire(&self) {
self.wire_launch();
self.wire_config_editor();
self.wire_settings_window();
self.wire_window_chrome();
}
fn wire_launch(&self) {
self.main_window.on_launch_browser({
let main_window = self.main_window.clone_strong();
let browser_model = self.browser_model.clone();
let config = self.config.clone();
move |index: i32| {
let index = index.max(0) as usize;
let Some(ui_entry) = browser_model.row_data(index) else {
return;
};
main_window.set_selected_index(index as i32);
let entry = BrowserEntry::from(&ui_entry);
let url = main_window.get_current_url().to_string();
let remember = main_window.get_remember_choice();
if launcher::launch_and_remember(&config, &entry, &url, remember) {
let _ = main_window.window().hide();
}
}
});
self.main_window.on_open_clicked({
let main_window = self.main_window.clone_strong();
let browser_model = self.browser_model.clone();
let config = self.config.clone();
move || {
let index = main_window.get_selected_index().max(0) as usize;
let Some(ui_entry) = browser_model.row_data(index) else {
return;
};
let entry = BrowserEntry::from(&ui_entry);
let url = main_window.get_current_url().to_string();
let remember = main_window.get_remember_choice();
if launcher::launch_and_remember(&config, &entry, &url, remember) {
let _ = main_window.window().hide();
}
}
});
self.main_window.on_toggle_remember({
let config = self.config.clone();
move |checked| {
config.borrow_mut().settings.remember_choice = checked;
config::save(&config.borrow());
}
});
self.main_window.on_toggle_always_ask({
let config = self.config.clone();
move |checked| {
config.borrow_mut().settings.always_ask = checked;
config::save(&config.borrow());
}
});
}
fn wire_config_editor(&self) {
self.main_window.on_settings_clicked(move || {
let path = config::config_path();
if !path.exists() {
let mut c = config::load();
config::ensure_browsers(&mut c);
}
let editor = std::process::Command::new("subl")
.arg(&path)
.spawn()
.or_else(|_| std::process::Command::new("notepad").arg(&path).spawn());
if let Err(e) = editor {
config::log(&format!("cannot open editor: {e}"));
}
});
}
fn wire_settings_window(&self) {
self.settings_window.on_remove_browser({
let browser_model = self.browser_model.clone();
let remembered_model = self.remembered_model.clone();
let config = self.config.clone();
move |name| {
let name = name.to_string();
config.borrow_mut().browsers.retain(|b| b.name != name);
config::save(&config.borrow());
Self::refresh_models(&browser_model, &remembered_model, &config.borrow());
}
});
self.settings_window.on_remove_remembered({
let browser_model = self.browser_model.clone();
let remembered_model = self.remembered_model.clone();
let config = self.config.clone();
move |domain| {
let domain = domain.to_string();
config.borrow_mut().remembered.remove(&domain);
config::save(&config.borrow());
Self::refresh_models(&browser_model, &remembered_model, &config.borrow());
}
});
self.settings_window.on_redetect({
let browser_model = self.browser_model.clone();
let remembered_model = self.remembered_model.clone();
let config = self.config.clone();
move || {
let detected = config::detect_browsers();
let mut cfg_ref = config.borrow_mut();
for entry in detected {
if !cfg_ref
.browsers
.iter()
.any(|b| b.path.eq_ignore_ascii_case(&entry.path))
{
cfg_ref.browsers.push(entry);
}
}
config::save(&cfg_ref);
drop(cfg_ref);
Self::refresh_models(&browser_model, &remembered_model, &config.borrow());
}
});
self.settings_window.on_esc_pressed({
let settings_window = self.settings_window.clone_strong();
let main_window = self.main_window.clone_strong();
move || {
let _ = settings_window.window().hide();
main_window.invoke_restore_focus();
}
});
self.settings_window.window().on_close_requested({
let main_window = self.main_window.clone_strong();
move || {
main_window.invoke_restore_focus();
slint::CloseRequestResponse::HideWindow
}
});
}
fn wire_window_chrome(&self) {
self.main_window.on_request_drag({
let main_window = self.main_window.clone_strong();
move || {
main_window.window().with_winit_window(|winit_window| {
winit_window.drag_window().ok();
});
}
});
self.main_window.on_esc_pressed({
let main_window = self.main_window.clone_strong();
move || {
let _ = main_window.window().hide();
}
});
}
fn refresh_models(
browser_model: &slint::VecModel<BrowserConfig>,
remembered_model: &slint::VecModel<RememberedEntry>,
cfg: &Config,
) {
let rows: Vec<BrowserConfig> = cfg.browsers.iter().map(to_ui_config).collect();
browser_model.set_vec(rows);
let rows: Vec<RememberedEntry> = cfg
.remembered
.iter()
.map(|(d, b)| RememberedEntry {
domain: d.clone().into(),
browser: b.clone().into(),
})
.collect();
remembered_model.set_vec(rows);
}
pub fn run(self, start: Instant) {
let settings_only = std::env::var(ENV_VIEW).as_deref() == Ok(VIEW_SETTINGS);
if settings_only {
self.settings_window.show().unwrap();
let weak = self.settings_window.as_weak();
let _ = slint::invoke_from_event_loop(move || {
if let Some(sw) = weak.upgrade() {
winit::center_window(sw.window());
sw.invoke_restore_focus();
}
config::log(&format!("cold start -> event loop: {:?}", start.elapsed()));
});
self.settings_window.run().unwrap();
} else {
self.main_window.show().unwrap();
let weak = self.main_window.as_weak();
let _ = slint::invoke_from_event_loop(move || {
if let Some(mw) = weak.upgrade() {
winit::center_window(mw.window());
mw.invoke_restore_focus();
}
config::log(&format!("cold start -> event loop: {:?}", start.elapsed()));
});
self.main_window.run().unwrap();
}
}
}
+258
View File
@@ -0,0 +1,258 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub fn log(msg: &str) {
use std::io::Write;
let Ok(appdata) = std::env::var("APPDATA") else {
return;
};
let dir = Path::new(&appdata).join("bropicker");
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let path = dir.join("bropicker.log");
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(f, "[{ts}] [bp] {msg}");
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct BrowserEntry {
pub name: String,
pub path: String,
#[serde(default)]
pub flags: String,
#[serde(default)]
pub icon: String,
#[serde(default)]
pub emoji: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Settings {
#[serde(default = "default_true")]
pub remember_choice: bool,
#[serde(default = "default_true")]
pub always_ask: bool,
}
fn default_true() -> bool {
true
}
impl Default for Settings {
fn default() -> Self {
Self {
remember_choice: true,
always_ask: true,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Config {
#[serde(default)]
pub browsers: Vec<BrowserEntry>,
#[serde(default)]
pub remembered: HashMap<String, String>,
#[serde(default)]
pub settings: Settings,
}
pub fn config_path() -> PathBuf {
if let Ok(p) = std::env::var("BP_CONFIG") {
return PathBuf::from(p);
}
std::env::var("APPDATA")
.map(|d| PathBuf::from(d).join("bropicker").join("config.toml"))
.unwrap_or_else(|_| PathBuf::from("config.toml"))
}
pub fn load() -> Config {
let path = config_path();
match std::fs::read_to_string(&path) {
Ok(s) => toml::from_str(&s).unwrap_or_else(|e| {
crate::config::log(&format!("[bp] config parse error ({path:?}): {e}"));
Config::default()
}),
Err(_) => Config::default(),
}
}
pub fn save(cfg: &Config) {
let path = config_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match toml::to_string_pretty(cfg) {
Ok(s) => {
if let Err(e) = std::fs::write(&path, s) {
log(&format!("[bp] config save failed ({path:?}): {e}"));
}
}
Err(e) => log(&format!("[bp] config serialize failed: {e}")),
}
}
pub fn ensure_browsers(cfg: &mut Config) {
if cfg.browsers.is_empty() {
cfg.browsers = detect_browsers();
log(&format!("autodetected {} browsers", cfg.browsers.len()));
if !cfg.browsers.is_empty() {
save(cfg);
}
}
}
pub fn domain_of(url: &str) -> String {
let rest = url.split("://").nth(1).unwrap_or(url);
let host = rest.split(['/', '?', '#']).next().unwrap_or("");
let host = host.split('@').next().unwrap_or(host);
let host = host.split(':').next().unwrap_or(host);
host.trim_start_matches("www.").to_lowercase()
}
fn extract_exe(cmd: &str) -> Option<String> {
let t = cmd.trim();
if t.starts_with('"') {
t.split('"').nth(1).map(String::from)
} else {
t.split_whitespace().next().map(String::from)
}
}
pub fn detect_browsers() -> Vec<BrowserEntry> {
use winreg::RegKey;
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
let mut out: Vec<BrowserEntry> = Vec::new();
for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] {
let Ok(clients) = RegKey::predef(hive).open_subkey("SOFTWARE\\Clients\\StartMenuInternet")
else {
continue;
};
for key_name in clients.enum_keys().flatten() {
let Ok(client) = clients.open_subkey(&key_name) else {
continue;
};
let friendly: String = client.get_value("").unwrap_or_else(|_| key_name.clone());
let command: String = client
.open_subkey("shell\\open\\command")
.and_then(|k| k.get_value(""))
.unwrap_or_default();
let Some(path) = extract_exe(&command) else {
continue;
};
if !Path::new(&path).exists() {
continue;
}
if out
.iter()
.any(|b: &BrowserEntry| b.path.eq_ignore_ascii_case(&path))
{
continue;
}
out.push(BrowserEntry {
name: friendly,
path,
flags: String::new(),
icon: String::new(),
emoji: String::new(),
});
}
}
out
}
pub fn icon_for(name: &str) -> &'static str {
if is_known_browser(name) {
KNOWN_ICONS
.iter()
.find(|(kw, _)| name.to_lowercase().contains(kw))
.map(|(_, path)| *path)
.unwrap_or("icons/globe.svg")
} else {
"icons/globe.svg"
}
}
const KNOWN_ICONS: &[(&str, &str)] = &[
("firefox", "logos/firefox_48x48.png"),
("chrome", "logos/chrome_48x48.png"),
("opera", "logos/opera_48x48.png"),
("brave", "logos/brave_48x48.png"),
("яндекс", "logos/yandex_48x48.png"),
("yandex", "logos/yandex_48x48.png"),
];
pub fn is_known_browser(name: &str) -> bool {
let n = name.to_lowercase();
KNOWN_ICONS.iter().any(|(kw, _)| n.contains(kw))
}
#[cfg(test)]
mod tests {
use super::{domain_of, extract_exe, is_known_browser};
#[test]
fn domain_basic() {
assert_eq!(domain_of("https://example.com/path?q=1"), "example.com");
assert_eq!(domain_of("http://example.com"), "example.com");
}
#[test]
fn domain_strips_www_and_port() {
assert_eq!(domain_of("https://www.example.com:8443/a"), "example.com");
}
#[test]
fn domain_without_scheme() {
assert_eq!(domain_of("example.com/x"), "example.com");
}
#[test]
fn domain_lowercases() {
assert_eq!(domain_of("https://EXAMPLE.com"), "example.com");
}
#[test]
fn extract_exe_quoted_with_args() {
assert_eq!(
extract_exe(r#""C:\Program Files\app.exe" --flag"#),
Some(r"C:\Program Files\app.exe".to_string())
);
}
#[test]
fn extract_exe_unquoted() {
assert_eq!(
extract_exe(r"C:\tools\app.exe --other"),
Some(r"C:\tools\app.exe".to_string())
);
}
#[test]
fn extract_exe_empty() {
assert_eq!(extract_exe(" "), None);
}
#[test]
fn known_browsers_match_case_insensitive() {
assert!(is_known_browser("Mozilla Firefox"));
assert!(is_known_browser("google chrome"));
assert!(is_known_browser("Яндекс Браузер"));
assert!(!is_known_browser("Zen Browser"));
}
}
+96
View File
@@ -0,0 +1,96 @@
use std::cell::RefCell;
use std::rc::Rc;
use crate::config::{self, BrowserEntry, Config};
pub fn split_flags(flags: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quotes = false;
for c in flags.chars() {
match c {
'"' => in_quotes = !in_quotes,
' ' if !in_quotes => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
_ => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub fn launch(entry: &BrowserEntry, url: &str) -> std::io::Result<()> {
let mut cmd = std::process::Command::new(&entry.path);
if !entry.flags.is_empty() {
cmd.args(split_flags(&entry.flags));
}
cmd.arg(url).spawn().map(|_| ())
}
/// Launch `entry` with `url` and, when `remember` is set, store the
/// domain→browser pair in the config. Returns `false` on spawn failure
/// (the error is logged).
pub fn launch_and_remember(
cfg: &Rc<RefCell<Config>>,
entry: &BrowserEntry,
url: &str,
remember: bool,
) -> bool {
config::log(&format!(
"[bp] launching {:?} {:?} {}",
entry.path, entry.flags, url
));
match launch(entry, url) {
Ok(_) => {
if remember {
let domain = config::domain_of(url);
if !domain.is_empty() {
cfg.borrow_mut()
.remembered
.insert(domain, entry.name.clone());
config::save(&cfg.borrow());
}
}
true
}
Err(e) => {
config::log(&format!("[bp] launch failed: {e}"));
false
}
}
}
#[cfg(test)]
mod tests {
use super::split_flags;
#[test]
fn empty_flags() {
assert!(split_flags("").is_empty());
assert!(split_flags(" ").is_empty());
}
#[test]
fn simple_flags() {
assert_eq!(split_flags("-P Work"), vec!["-P", "Work"]);
}
#[test]
fn quoted_spaces_stay_one_arg() {
assert_eq!(
split_flags(r#"--profile-directory="Profile 1""#),
vec!["--profile-directory=Profile 1"]
);
assert_eq!(split_flags(r#"-P "My Work""#), vec!["-P", "My Work"]);
}
#[test]
fn multiple_spaces_collapse() {
assert_eq!(split_flags(" a b "), vec!["a", "b"]);
}
}
+29 -166
View File
@@ -1,177 +1,40 @@
use std::rc::Rc;
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use i_slint_backend_winit::WinitWindowAccessor;
use slint::Model;
use crate::winit::center_window;
mod app;
mod config;
mod launcher;
mod mapping;
mod winit;
slint::include_modules!();
fn load_icon(rel: &str) -> slint::Image {
slint::Image::load_from_path(std::path::Path::new(rel)).unwrap_or_default()
}
fn init() -> State {
let browsers = vec![
BrowserConfig {
icon: load_icon("logos/firefox_48x48.png"),
name: "Work browser".into(),
path: "firefox.exe".into(),
flags: "--profile work".into(),
is_default: true,
},
BrowserConfig {
icon: load_icon("logos/chrome_48x48.png"),
name: "Ku6epXBOCTuK".into(),
path: "chrome.exe".into(),
flags: "--profile personal".into(),
is_default: false,
},
BrowserConfig {
icon: load_icon("logos/opera_48x48.png"),
name: "Chrome".into(),
path: "chrome.exe".into(),
flags: "".into(),
is_default: false,
},
BrowserConfig {
icon: load_icon("icons/globe.svg"),
name: "Zen Browser".into(),
path: "zen.exe".into(),
flags: "".into(),
is_default: false,
},
];
let default_index = browsers
.iter()
.position(|b| b.is_default)
.unwrap_or_default();
let browser_model = Rc::new(slint::VecModel::<BrowserConfig>::from(browsers));
let main_window = MainWindow::new().unwrap();
if std::env::var("BP_THEME").as_deref() == Ok("light") {
main_window.global::<Theme>().set_is_dark(false);
}
let url = std::env::args()
.nth(1)
.unwrap_or_else(|| "ku6epxboctuk.github.io".into());
main_window.set_current_url(url.into());
main_window.set_selected_index(default_index as i32);
main_window.set_remember_choice(true);
main_window.set_always_ask(true);
main_window.on_launch_browser({
let main_window = main_window.clone_strong();
let browser_model = browser_model.clone();
move |browser: BrowserConfig| {
let index = (0..browser_model.row_count())
.find(|&i| {
browser_model
.row_data(i)
.is_some_and(|b| b.name == browser.name)
})
.unwrap_or(0);
main_window.set_selected_index(index as i32);
let url = main_window.get_current_url().to_string();
println!(
"Launching [{}]: {:?} {:?} {}",
index, browser.path, browser.flags, url
);
let mut cmd = std::process::Command::new(browser.path.to_string());
if !browser.flags.is_empty() {
cmd.args(browser.flags.split_whitespace());
}
match cmd.arg(url).spawn() {
Ok(_) => {
let _ = main_window.window().hide();
}
Err(e) => eprintln!("launch failed: {e}"),
}
}
});
main_window.on_open_clicked({
let main_window = main_window.clone_strong();
let browser_model = browser_model.clone();
move || {
let index = main_window.get_selected_index().max(0) as usize;
let Some(browser) = browser_model.row_data(index) else {
return;
};
let url = main_window.get_current_url().to_string();
println!(
"Launching [{}]: {:?} {:?} {}",
index, browser.path, browser.flags, url
);
let mut cmd = std::process::Command::new(browser.path.to_string());
if !browser.flags.is_empty() {
cmd.args(browser.flags.split_whitespace());
}
match cmd.arg(url).spawn() {
Ok(_) => {
let _ = main_window.window().hide();
}
Err(e) => eprintln!("launch failed: {e}"),
}
}
});
main_window.on_request_drag({
let main_window = main_window.clone_strong();
move || {
main_window.window().with_winit_window(|winit_window| {
winit_window.drag_window().ok();
});
}
});
main_window.on_esc_pressed({
let main_window = main_window.clone_strong();
move || {
let _ = main_window.window().hide();
}
});
main_window.set_browser_model(browser_model.clone().into());
State {
main_window,
browser_model,
}
}
pub struct State {
pub main_window: MainWindow,
pub browser_model: Rc<slint::VecModel<BrowserConfig>>,
}
pub fn main() {
// TODO: how to enable skia? not panics
// let _selector = BackendSelector::new()
// .backend_name("renderer_skia".into())
// .select()
// .unwrap();
let start = std::time::Instant::now();
let state = init();
let main_window = state.main_window.clone_strong();
main_window.show().unwrap();
let mut cfg = config::load();
config::ensure_browsers(&mut cfg);
let weak = main_window.as_weak();
let _ = slint::invoke_from_event_loop(move || {
if let Some(mw) = weak.upgrade() {
center_window(mw.window());
let url = std::env::args().nth(1);
if let Some(url) = &url {
let domain = config::domain_of(url);
if let Some(name) = cfg.remembered.get(&domain).cloned() {
if cfg.settings.always_ask {
config::log(&format!(
"'{domain}' remembered -> {name}, but always_ask is on"
));
} else if let Some(entry) = cfg.browsers.iter().find(|b| b.name == name) {
config::log(&format!("'{domain}' remembered -> launching {name}"));
let _ = launcher::launch(entry, url);
return;
}
}
}
});
main_window.run().unwrap();
let app = app::App::new(cfg, url);
app.wire();
app.run(start);
}
+43
View File
@@ -0,0 +1,43 @@
use crate::BrowserConfig;
use crate::config::{self, BrowserEntry};
impl From<&BrowserConfig> for BrowserEntry {
fn from(b: &BrowserConfig) -> Self {
Self {
name: b.name.to_string(),
path: b.path.to_string(),
flags: b.flags.to_string(),
icon: String::new(),
emoji: b.emoji.to_string(),
}
}
}
pub fn load_icon(rel: &str) -> slint::Image {
let from_exe = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(rel)));
let path = from_exe
.filter(|p| p.exists())
.unwrap_or_else(|| std::path::PathBuf::from(rel));
slint::Image::load_from_path(&path).unwrap_or_default()
}
pub fn to_ui_config(entry: &BrowserEntry) -> BrowserConfig {
let icon_rel = if entry.icon.is_empty() {
config::icon_for(&entry.name)
} else {
&entry.icon
};
BrowserConfig {
icon: load_icon(icon_rel),
emoji: entry.emoji.clone().into(),
icon_mask: entry.icon.is_empty()
&& entry.emoji.is_empty()
&& !config::is_known_browser(&entry.name),
name: entry.name.clone().into(),
path: entry.path.clone().into(),
flags: entry.flags.clone().into(),
is_default: false,
}
}
+28 -4
View File
@@ -2,14 +2,38 @@ use i_slint_backend_winit::WinitWindowAccessor;
use i_slint_backend_winit::winit::dpi::PhysicalPosition;
use i_slint_backend_winit::winit::monitor::MonitorHandle;
use i_slint_backend_winit::winit::window::Window;
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
fn get_cursor_monitor(window: &Window) -> Option<MonitorHandle> {
let mut point = POINT { x: 0, y: 0 };
let ok = unsafe { GetCursorPos(&mut point) };
if ok.is_err() {
return None;
}
for monitor in window.available_monitors() {
let pos = monitor.position();
let size = monitor.size();
if point.x >= pos.x
&& point.x < pos.x + size.width as i32
&& point.y >= pos.y
&& point.y < pos.y + size.height as i32
{
return Some(monitor);
}
}
None
}
pub fn center_window(window: &slint::Window) {
if window.has_winit_window() {
window.with_winit_window(|window: &Window| {
match window.current_monitor() {
Some(monitor) => set_centered(window, &monitor),
None => (),
};
let monitor = get_cursor_monitor(window)
.or_else(|| window.current_monitor());
if let Some(monitor) = monitor {
set_centered(window, &monitor);
}
None as Option<()>
});
+12
View File
@@ -0,0 +1,12 @@
$ErrorActionPreference = "Stop"
cargo fmt
if ($LASTEXITCODE -ne 0) { exit 1 }
cargo clippy --all-targets -- -D warnings
if ($LASTEXITCODE -ne 0) { exit 1 }
cargo test
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host "check OK"
+12
View File
@@ -0,0 +1,12 @@
$ErrorActionPreference = "Stop"
$svg = "assets/icon.svg"
magick -background none $svg -resize 256x256 assets/icon.png
magick -background none $svg -define icon:auto-resize=256,128,64,48,32,24,16 assets/app.ico
foreach ($size in @(16, 24, 32, 44, 48, 50, 64, 88, 128, 150, 256, 512)) {
magick -background none $svg -resize "${size}x${size}" ("assets/icon-{0}.png" -f $size)
}
Write-Host "icons regenerated: icon.png, app.ico, icon-{16..512}.png"
+57
View File
@@ -0,0 +1,57 @@
param(
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$installDir = Join-Path $env:LOCALAPPDATA "bropicker"
$classes = "HKCU:\Software\Classes\bropicker"
$caps = "HKCU:\Software\bropicker-Capabilities"
$regApps = "HKCU:\Software\RegisteredApplications"
if ($Uninstall) {
Remove-Item -Recurse -Force $classes -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $caps -ErrorAction SilentlyContinue
Remove-ItemProperty -Path $regApps -Name "bropicker" -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $installDir -ErrorAction SilentlyContinue
Write-Host "bropicker unregistered and removed from $installDir"
exit 0
}
$src = Join-Path $PSScriptRoot "..\target\release\bropicker.exe"
if (-not (Test-Path $src)) {
Write-Error "target\release\bropicker.exe not found. Build first: cargo build --release"
exit 1
}
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
Copy-Item $src (Join-Path $installDir "bropicker.exe") -Force
Copy-Item (Join-Path $PSScriptRoot "..\logos") (Join-Path $installDir "logos") -Recurse -Force
Copy-Item (Join-Path $PSScriptRoot "..\icons") (Join-Path $installDir "icons") -Recurse -Force
$licensesDir = Join-Path $installDir "licenses"
New-Item -ItemType Directory -Force -Path $licensesDir | Out-Null
Copy-Item (Join-Path $PSScriptRoot "..\licenses\Slint-Royalty-free-2.0.md") $licensesDir -Force
Copy-Item (Join-Path $PSScriptRoot "..\logos\LICENSE-browser-logos.txt") $licensesDir -Force
$exe = Join-Path $installDir "bropicker.exe"
Write-Host "Installed to: $exe"
New-Item -Path "$classes\shell\open\command" -Force | Out-Null
Set-ItemProperty -Path $classes -Name "(default)" -Value "bropicker"
Set-ItemProperty -Path $classes -Name "URL Protocol" -Value ""
Set-ItemProperty -Path "$classes\shell\open\command" -Name "(default)" -Value "`"$exe`" `"%1`""
New-Item -Path $caps -Force | Out-Null
Set-ItemProperty -Path $caps -Name "ApplicationName" -Value "bropicker"
Set-ItemProperty -Path $caps -Name "ApplicationDescription" -Value "Lightweight link picker: choose which browser opens the URL"
New-Item -Path "$caps\URLAssociations" -Force | Out-Null
Set-ItemProperty -Path "$caps\URLAssociations" -Name "https" -Value "bropicker"
Set-ItemProperty -Path "$caps\URLAssociations" -Name "http" -Value "bropicker"
New-Item -Path $regApps -Force | Out-Null
Set-ItemProperty -Path $regApps -Name "bropicker" -Value "Software\bropicker-Capabilities"
Write-Host "Registered. Now select bropicker for HTTP/HTTPS in the opened Settings window."
Start-Process "ms-settings:defaultapps"
+23
View File
@@ -0,0 +1,23 @@
$ErrorActionPreference = "Stop"
cargo build
if ($LASTEXITCODE -ne 0) { exit 1 }
New-Item -ItemType Directory -Force -Path docs\img | Out-Null
$exe = Join-Path $PWD "target\debug\bropicker.exe"
$proc = Start-Process -FilePath $exe -WorkingDirectory $PWD -PassThru
Start-Sleep -Milliseconds 1800
powershell -ExecutionPolicy Bypass -File "$PSScriptRoot\shot.ps1" -ProcId $proc.Id -Out docs\img\screenshot-dark.png -Card
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
if ($LASTEXITCODE -ne 0) { exit 1 }
$env:BP_THEME = "light"
$proc = Start-Process -FilePath $exe -WorkingDirectory $PWD -PassThru
Start-Sleep -Milliseconds 1800
powershell -ExecutionPolicy Bypass -File "$PSScriptRoot\shot.ps1" -ProcId $proc.Id -Out docs\img\screenshot-light.png -Card
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Remove-Item Env:\BP_THEME -ErrorAction SilentlyContinue
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host "screenshots updated: docs\img\screenshot-{dark,light}.png"
+44 -3
View File
@@ -1,7 +1,8 @@
param(
[string]$ProcName = "bropicker",
[int]$ProcId = 0,
[string]$Out = "tools/out/app.png"
[string]$Out = "tools/out/app.png",
[switch]$Card
)
$ErrorActionPreference = "Stop"
@@ -18,6 +19,7 @@ public class BpWin {
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll")] public static extern uint GetDpiForWindow(IntPtr hWnd);
[StructLayout(LayoutKind.Sequential)]
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
}
@@ -64,11 +66,50 @@ if ($w -le 0 -or $h -le 0) {
}
Add-Type -AssemblyName System.Drawing
$bmp = New-Object System.Drawing.Bitmap($w, $h)
$g = [System.Drawing.Graphics]::FromImage($bmp)
$full = New-Object System.Drawing.Bitmap($w, $h)
$g = [System.Drawing.Graphics]::FromImage($full)
$g.CopyFromScreen($rect.Left, $rect.Top, 0, 0, (New-Object System.Drawing.Size($w, $h)))
$g.Dispose()
if ($Card) {
$dpi = [BpWin]::GetDpiForWindow($found)
$scale = if ($dpi -gt 0) { $dpi / 96.0 } else { 1.0 }
$inset = [int][Math]::Round(20 * $scale)
$radius = [int][Math]::Round(16 * $scale)
$clipInset = 2
$cw = $w - 2 * $inset - 2 * $clipInset
$ch = $h - 2 * $inset - 2 * $clipInset
$srcX = $inset + $clipInset
$srcY = $inset + $clipInset
$radius = [Math]::Max(1, $radius - $clipInset)
$bmp = New-Object System.Drawing.Bitmap($cw, $ch)
$g2 = [System.Drawing.Graphics]::FromImage($bmp)
$g2.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g2.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$cardPath = New-Object System.Drawing.Drawing2D.GraphicsPath
$cardPath.AddArc(0, 0, $radius, $radius, 180, 90)
$cardPath.AddArc($cw - $radius, 0, $radius, $radius, 270, 90)
$cardPath.AddArc($cw - $radius, $ch - $radius, $radius, $radius, 0, 90)
$cardPath.AddArc(0, $ch - $radius, $radius, $radius, 90, 90)
$cardPath.CloseFigure()
$g2.SetClip($cardPath)
$srcRect = New-Object System.Drawing.Rectangle($srcX, $srcY, $cw, $ch)
$dstRect = New-Object System.Drawing.Rectangle(0, 0, $cw, $ch)
$g2.DrawImage($full, $dstRect, $srcRect, [System.Drawing.GraphicsUnit]::Pixel)
$g2.Dispose()
$cardPath.Dispose()
$full.Dispose()
$w = $cw
$h = $ch
} else {
$bmp = $full
}
$outDir = Split-Path -Parent (Join-Path $PWD $Out)
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
$outAbs = [System.IO.Path]::GetFullPath((Join-Path $PWD $Out))
+27 -6
View File
@@ -1,11 +1,14 @@
import { Theme } from "./theme.slint";
export component BrowserItem inherits Rectangle {
in property <int> index;
in property <image> browser-icon;
in property <string> emoji;
in property <bool> icon-mask;
in property <string> browser-name;
in property <bool> is-default;
in property <bool> selected;
callback clicked;
callback clicked(int);
height: 84px;
background: transparent;
@@ -18,12 +21,12 @@ export component BrowserItem inherits Rectangle {
: root.selected ? Theme.surface-elevated
: Theme.surface-item;
border-width: root.selected ? 2px : 1px;
border-color: root.selected ? #3b82f680 : transparent;
border-color: root.selected ? Theme.accent-ring : transparent;
touch := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
root.clicked(root.index);
}
}
@@ -37,7 +40,25 @@ export component BrowserItem inherits Rectangle {
border-radius: 12px;
background: Theme.surface-elevated;
Image {
if root.emoji != "": Text {
width: 100%;
height: 100%;
text: root.emoji;
font-size: 26px;
horizontal-alignment: center;
vertical-alignment: center;
}
if root.emoji == "" && root.icon-mask: Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 30px;
height: 30px;
source: root.browser-icon;
colorize: Theme.text-muted;
}
if root.emoji == "" && !root.icon-mask: Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 30px;
@@ -64,7 +85,7 @@ export component BrowserItem inherits Rectangle {
if root.is-default: Rectangle {
height: 22px;
border-radius: 11px;
background: #3b82f620;
background: Theme.accent-soft;
HorizontalLayout {
padding-left: 10px;
@@ -89,7 +110,7 @@ export component BrowserItem inherits Rectangle {
width: 16px;
height: 16px;
source: @image-url("../icons/check.svg");
colorize: #3b82f6;
colorize: Theme.accent;
visible: root.selected;
}
+1 -1
View File
@@ -90,7 +90,7 @@ export component Header inherits Rectangle {
padding-top: 2px;
Text {
text: "ku6epxboctuk.github.io";
text: root.url;
color: Theme.text-control;
font-size: 14px;
font-family: "Consolas";
+21 -10
View File
@@ -1,4 +1,4 @@
import { Button, CheckBox, ListView } from "std-widgets.slint";
import { ListView } from "std-widgets.slint";
import { BrowserItem } from "./browser_item.slint";
import { Header } from "./header.slint";
import { Footer } from "./footer.slint";
@@ -6,6 +6,8 @@ import { BrowserConfig } from "./types.slint";
import { Theme } from "theme.slint";
export { Theme }
export { SettingsWindow } from "./settings.slint";
export { RememberedEntry } from "./types.slint";
component CheckRow inherits Rectangle {
in property <string> label;
@@ -30,9 +32,9 @@ component CheckRow inherits Rectangle {
Rectangle {
width: 20px;
border-radius: 6px;
background: root.checked ? #3b82f6 : transparent;
background: root.checked ? Theme.accent : transparent;
border-width: 2px;
border-color: root.checked ? #3b82f6 : Theme.border-main;
border-color: root.checked ? Theme.accent : Theme.border-main;
Image {
x: (parent.width - self.width) / 2;
@@ -58,11 +60,12 @@ export component MainWindow inherits Window {
title: "bropicker";
in property <[BrowserConfig]> browser-model: [];
in property <string> current-url: "";
in property <string> current-domain: "";
in-out property <bool> remember-choice: false;
in-out property <bool> always-ask: true;
in property <int> selected-index: 0;
callback launch-browser(BrowserConfig);
callback launch-browser(int);
callback toggle-remember(bool);
callback toggle-always-ask(bool);
callback settings-clicked();
@@ -75,9 +78,10 @@ export component MainWindow inherits Window {
width: 488px;
height: 800px;
default-font-family: "Segoe UI";
FocusScope {
init => { self.focus(); }
scope := FocusScope {
init => {
self.focus();
}
key-pressed(event) => {
if (event.text == Key.Escape) {
root.esc-pressed();
@@ -88,6 +92,10 @@ export component MainWindow inherits Window {
}
}
callback restore-focus();
restore-focus => {
scope.focus();
}
VerticalLayout {
alignment: start;
width: 488px;
@@ -120,12 +128,15 @@ export component MainWindow inherits Window {
vertical-stretch: 1;
for browser[i] in root.browser-model: BrowserItem {
index: i;
browser-icon: browser.icon;
emoji: browser.emoji;
icon-mask: browser.icon-mask;
browser-name: browser.name;
is-default: browser.is-default;
selected: i == root.selected-index;
clicked => {
root.launch-browser(browser);
clicked(idx) => {
root.launch-browser(idx);
}
}
}
@@ -141,7 +152,7 @@ export component MainWindow inherits Window {
padding-bottom: 8px;
CheckRow {
label: "Запомнить выбор для " + root.current-url;
label: "Запомнить выбор для " + root.current-domain;
checked <=> root.remember-choice;
toggled => {
root.toggle-remember(root.remember-choice);
+237
View File
@@ -0,0 +1,237 @@
import { ListView } from "std-widgets.slint";
import { Theme } from "./theme.slint";
import { BrowserConfig, RememberedEntry } from "./types.slint";
export component SettingsWindow inherits Window {
title: "bropicker — настройки";
width: 760px;
height: 560px;
background: Theme.surface-main;
default-font-family: "Segoe UI";
in property <[BrowserConfig]> browsers;
in property <[RememberedEntry]> remembered;
callback remove-browser(string);
callback remove-remembered(string);
callback redetect();
callback esc-pressed();
scope := FocusScope {
init => {
self.focus();
}
key-pressed(event) => {
if (event.text == Key.Escape) {
root.esc-pressed();
accept
} else {
reject
}
}
}
callback restore-focus();
restore-focus => {
scope.focus();
}
VerticalLayout {
padding: 20px;
spacing: 14px;
Text {
text: "Настройки";
color: Theme.text-main;
font-size: 20px;
font-weight: 600;
}
HorizontalLayout {
spacing: 14px;
VerticalLayout {
spacing: 8px;
HorizontalLayout {
spacing: 8px;
Text {
text: "Браузеры";
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
Rectangle {}
Rectangle {
width: 110px;
height: 26px;
border-radius: 8px;
background: redetect-touch.has-hover ? Theme.surface-hover : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-main;
redetect-touch := TouchArea {
mouse-cursor: pointer;
clicked => {
root.redetect();
}
}
Text {
width: 100%;
height: 100%;
text: "Автодетект";
color: redetect-touch.has-hover ? Theme.text-main : Theme.text-control;
font-size: 12px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
Rectangle {
vertical-stretch: 1;
border-radius: 12px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-main;
clip: true;
ListView {
for b in root.browsers: Rectangle {
height: 56px;
background: transparent;
HorizontalLayout {
padding-left: 14px;
padding-right: 10px;
spacing: 12px;
Image {
width: 26px;
y: (parent.height - self.height) / 2;
source: b.icon;
}
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
Text {
text: b.name;
color: Theme.text-main;
font-size: 14px;
}
Text {
text: b.path;
color: Theme.text-hint;
font-size: 11px;
font-family: "Consolas";
overflow: elide;
}
}
Rectangle {
width: 30px;
border-radius: 6px;
background: del-touch.has-hover ? Theme.danger-soft : transparent;
del-touch := TouchArea {
mouse-cursor: pointer;
clicked => {
root.remove-browser(b.name);
}
}
Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 16px;
height: 16px;
source: @image-url("../icons/trash.svg");
colorize: del-touch.has-hover ? Theme.danger : Theme.text-muted;
}
}
}
}
}
}
}
VerticalLayout {
spacing: 8px;
Text {
text: "Запомненные домены";
color: Theme.text-muted;
font-size: 13px;
}
Rectangle {
vertical-stretch: 1;
border-radius: 12px;
background: Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-main;
clip: true;
ListView {
for r in root.remembered: Rectangle {
height: 44px;
background: transparent;
HorizontalLayout {
padding-left: 14px;
padding-right: 10px;
spacing: 12px;
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
Text {
text: r.domain;
color: Theme.text-main;
font-size: 14px;
font-family: "Consolas";
}
Text {
text: "→ " + r.browser;
color: Theme.text-hint;
font-size: 12px;
}
}
Rectangle {
width: 30px;
border-radius: 6px;
background: del2-touch.has-hover ? Theme.danger-soft : transparent;
del2-touch := TouchArea {
mouse-cursor: pointer;
clicked => {
root.remove-remembered(r.domain);
}
}
Image {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: 16px;
height: 16px;
source: @image-url("../icons/trash.svg");
colorize: del2-touch.has-hover ? Theme.danger : Theme.text-muted;
}
}
}
}
}
}
}
}
}
}
+6
View File
@@ -21,6 +21,12 @@ export global Theme {
out property <brush> text-hint: is-dark ? #475569 : #475569;
out property <color> accent: #3b82f6;
out property <color> accent-ring: #3b82f680;
out property <color> accent-soft: #3b82f620;
out property <color> danger: #ef4444;
out property <color> danger-soft: #ef444420;
out property <brush> shadow-color: is-dark ? #3b82f640 : #3b82f640;
out property <length> shadow-primary-x: 0px;
+7
View File
@@ -1,7 +1,14 @@
export struct BrowserConfig {
icon: image,
emoji: string,
icon-mask: bool,
name: string,
path: string,
flags: string,
is-default: bool,
}
export struct RememberedEntry {
domain: string,
browser: string,
}