Compare commits
10
Commits
08a1739f44
...
ea09f47938
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea09f47938 | ||
|
|
03d4be8444 | ||
|
|
4ff48bad0f | ||
|
|
381fc5c049 | ||
|
|
55e30ecfe6 | ||
|
|
9dfb72f0ea | ||
|
|
9534c6b1e3 | ||
|
|
fc5a7638bc | ||
|
|
c7557c163a | ||
|
|
3f5923ad6d |
Generated
+32
-5
@@ -611,7 +611,9 @@ dependencies = [
|
||||
"serde_json",
|
||||
"slint",
|
||||
"slint-build",
|
||||
"toml 1.1.4+spec-1.1.0",
|
||||
"winit",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4129,7 +4131,7 @@ dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"toml",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4662,6 +4664,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 +4712,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 +5774,16 @@ 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 = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -9,7 +9,14 @@ 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"
|
||||
winit = "0.30"
|
||||
winreg = "0.56.0"
|
||||
|
||||
[build-dependencies]
|
||||
slint-build = "1.16.1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
@@ -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` перед коммитом.
|
||||
@@ -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
|
||||
+99
-9
@@ -101,13 +101,81 @@ 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), отложенная загрузка
|
||||
конфига. Пока считаем приемлемым
|
||||
- [ ] Иконка exe (.ico через build.rs winres)
|
||||
- [ ] Выбрать лицензию проекта: GPLv3 (опенсорс) или Royalty-free Slint (закрытый десктоп,
|
||||
атрибуция AboutSlint) — зафиксировать в README и LICENSE
|
||||
- [ ] Скриншоты в README из tools/out
|
||||
- [ ] Ручной сценарий приёмки: register → клик ссылки в другом приложении →
|
||||
пикер → выбор → вкладка открылась; повторный клик того же домена при
|
||||
always_ask=off → сразу браузер
|
||||
|
||||
## Этап F — релиз
|
||||
|
||||
@@ -119,11 +187,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; манифесты руками — только человеком
|
||||
|
||||
@@ -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 |
@@ -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 |
+273
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -6,10 +6,9 @@ use i_slint_backend_winit::winit::window::Window;
|
||||
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 => (),
|
||||
};
|
||||
if let Some(monitor) = window.current_monitor() {
|
||||
set_centered(window, &monitor);
|
||||
}
|
||||
|
||||
None as Option<()>
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,52 @@
|
||||
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
|
||||
|
||||
$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"
|
||||
+27
-6
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -89,12 +89,12 @@ export component Header inherits Rectangle {
|
||||
VerticalLayout {
|
||||
padding-top: 2px;
|
||||
|
||||
Text {
|
||||
text: "ku6epxboctuk.github.io";
|
||||
color: Theme.text-control;
|
||||
font-size: 14px;
|
||||
font-family: "Consolas";
|
||||
}
|
||||
Text {
|
||||
text: root.url;
|
||||
color: Theme.text-control;
|
||||
font-size: 14px;
|
||||
font-family: "Consolas";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user