Compare commits

...
10 Commits
21 changed files with 1347 additions and 206 deletions
Generated
+32 -5
View File
@@ -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"
+7
View File
@@ -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
+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
+99 -9
View File
@@ -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; манифесты руками — только человеком
+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

+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,
}
}
+3 -4
View File
@@ -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<()>
});
+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"
+52
View File
@@ -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
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;
}
+6 -6
View File
@@ -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
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,
}