refactor: fix hardcoded values, remove println, remove doubles

This commit is contained in:
2026-08-26 15:16:09 +05:00
parent 4ff48bad0f
commit 03d4be8444
10 changed files with 513 additions and 415 deletions
+9 -7
View File
@@ -1,10 +1,12 @@
# Ревью кода bropicker (2026-08-26) # Ревью кода bropicker (2026-08-26) — ВЫПОЛНЕНО
> Скоуп: src/main.rs (401 строка), src/config.rs (~210), src/winit.rs (45), > Скоуп: src/main.rs (401 строка), src/config.rs (~210), src/winit.rs (45),
> ui/*.slint. Плюс вывод clippy. > ui/*.slint. Плюс вывод clippy.
> >
> Вердикт: для MVP код здоровый, критических дыр нет. Но есть 2 мусорных > Итог рефакторинга: main.rs 401→340 строк (и стал тонким по смыслу),
> артефакта правок, крупное дублирование и файл-бог. Ниже по приоритетам. > появился launcher.rs, 12 юнит-тестов, tools/check.ps1 (fmt+clippy -D warnings
> +test) зелёный, дублирование запуска устранено, индекс из UI вместо поиска
> по имени, все P0-P2 закрыты. UI проверен скриншотом — идентичен.
## P0 — мусор и артефакты правок (чинить сразу) ## P0 — мусор и артефакты правок (чинить сразу)
@@ -74,9 +76,9 @@
## Порядок исполнения (одна сессия) ## Порядок исполнения (одна сессия)
1. P0 целиком + прогон clippy --fix (P2 первая строка) 1. [x] P0 целиком + прогон clippy --fix (P2 первая строка)
2. P1: launcher.rs + launch_and_remember + From-конвертации + индекс из UI 2. [x] P1: launcher.rs + launch_and_remember + From-конвертации + индекс из UI
3. Юнит-тесты чистых функций + tools/check.ps1 3. [x] Юнит-тесты чистых функций + tools/check.ps1
4. P3 косметика по остаточному принципу 4. [x] P3 косметика: импорты, Theme.accent/danger токены
Каждый пункт — зелёный `tools\check.ps1` перед коммитом. Каждый пункт — зелёный `tools\check.ps1` перед коммитом.
+257 -201
View File
@@ -1,202 +1,258 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub fn log(msg: &str) { pub fn log(msg: &str) {
use std::io::Write; use std::io::Write;
let Ok(appdata) = std::env::var("APPDATA") else { let Ok(appdata) = std::env::var("APPDATA") else {
return; return;
}; };
let dir = Path::new(&appdata).join("bropicker"); let dir = Path::new(&appdata).join("bropicker");
if std::fs::create_dir_all(&dir).is_err() { if std::fs::create_dir_all(&dir).is_err() {
return; return;
} }
let path = dir.join("bropicker.log"); let path = dir.join("bropicker.log");
let ts = std::time::SystemTime::now() let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { if let Ok(mut f) = std::fs::OpenOptions::new()
let _ = writeln!(f, "[{ts}] {msg}"); .create(true)
} .append(true)
} .open(&path)
{
#[derive(Serialize, Deserialize, Clone, Debug)] let _ = writeln!(f, "[{ts}] [bp] {msg}");
pub struct BrowserEntry { }
pub name: String, }
pub path: String,
#[serde(default)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub flags: String, pub struct BrowserEntry {
#[serde(default)] pub name: String,
pub icon: String, pub path: String,
#[serde(default)] #[serde(default)]
pub emoji: String, pub flags: String,
} #[serde(default)]
pub icon: String,
#[derive(Serialize, Deserialize, Clone, Debug)] #[serde(default)]
pub struct Settings { pub emoji: String,
#[serde(default = "default_true")] }
pub remember_choice: bool,
#[serde(default = "default_true")] #[derive(Serialize, Deserialize, Clone, Debug)]
pub always_ask: bool, pub struct Settings {
} #[serde(default = "default_true")]
pub remember_choice: bool,
fn default_true() -> bool { #[serde(default = "default_true")]
true pub always_ask: bool,
} }
impl Default for Settings { fn default_true() -> bool {
fn default() -> Self { true
Self { }
remember_choice: true,
always_ask: 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)] #[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub remembered: HashMap<String, String>, pub struct Config {
#[serde(default)] #[serde(default)]
pub settings: Settings, pub browsers: Vec<BrowserEntry>,
} #[serde(default)]
pub remembered: HashMap<String, String>,
pub fn config_path() -> PathBuf { #[serde(default)]
if let Ok(p) = std::env::var("BP_CONFIG") { pub settings: Settings,
return PathBuf::from(p); }
}
std::env::var("APPDATA") pub fn config_path() -> PathBuf {
.map(|d| PathBuf::from(d).join("bropicker").join("config.toml")) if let Ok(p) = std::env::var("BP_CONFIG") {
.unwrap_or_else(|_| PathBuf::from("config.toml")) return PathBuf::from(p);
} }
std::env::var("APPDATA")
pub fn load() -> Config { .map(|d| PathBuf::from(d).join("bropicker").join("config.toml"))
let path = config_path(); .unwrap_or_else(|_| PathBuf::from("config.toml"))
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}")); pub fn load() -> Config {
Config::default() let path = config_path();
}), match std::fs::read_to_string(&path) {
Err(_) => Config::default(), Ok(s) => toml::from_str(&s).unwrap_or_else(|e| {
} crate::config::log(&format!("[bp] config parse error ({path:?}): {e}"));
} Config::default()
}),
pub fn save(cfg: &Config) { Err(_) => Config::default(),
let path = config_path(); }
if let Some(dir) = path.parent() { }
let _ = std::fs::create_dir_all(dir);
} pub fn save(cfg: &Config) {
match toml::to_string_pretty(cfg) { let path = config_path();
Ok(s) => { if let Some(dir) = path.parent() {
if let Err(e) = std::fs::write(&path, s) { let _ = std::fs::create_dir_all(dir);
log(&format!("[bp] config save failed ({path:?}): {e}")); }
} match toml::to_string_pretty(cfg) {
} Ok(s) => {
Err(e) => log(&format!("[bp] config serialize failed: {e}")), if let Err(e) = std::fs::write(&path, s) {
} log(&format!("[bp] config save failed ({path:?}): {e}"));
} }
}
pub fn ensure_browsers(cfg: &mut Config) { Err(e) => log(&format!("[bp] config serialize failed: {e}")),
if cfg.browsers.is_empty() { }
cfg.browsers = detect_browsers(); }
log(&format!("[bp] autodetected {} browsers", cfg.browsers.len()));
if !cfg.browsers.is_empty() { pub fn ensure_browsers(cfg: &mut Config) {
save(cfg); 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); pub fn domain_of(url: &str) -> String {
host.trim_start_matches("www.").to_lowercase() let rest = url.split("://").nth(1).unwrap_or(url);
} let host = rest.split(['/', '?', '#']).next().unwrap_or("");
let host = host.split('@').next().unwrap_or(host);
fn extract_exe(cmd: &str) -> Option<String> { let host = host.split(':').next().unwrap_or(host);
let t = cmd.trim(); host.trim_start_matches("www.").to_lowercase()
if t.starts_with('"') { }
t.split('"').nth(1).map(String::from)
} else { fn extract_exe(cmd: &str) -> Option<String> {
t.split_whitespace().next().map(String::from) let t = cmd.trim();
} if t.starts_with('"') {
} t.split('"').nth(1).map(String::from)
} else {
pub fn detect_browsers() -> Vec<BrowserEntry> { t.split_whitespace().next().map(String::from)
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; }
use winreg::RegKey; }
let mut out: Vec<BrowserEntry> = Vec::new(); pub fn detect_browsers() -> Vec<BrowserEntry> {
use winreg::RegKey;
for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] { use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE};
let Ok(clients) =
RegKey::predef(hive).open_subkey("SOFTWARE\\Clients\\StartMenuInternet") let mut out: Vec<BrowserEntry> = Vec::new();
else {
continue; for hive in [HKEY_LOCAL_MACHINE, HKEY_CURRENT_USER] {
}; let Ok(clients) = RegKey::predef(hive).open_subkey("SOFTWARE\\Clients\\StartMenuInternet")
for key_name in clients.enum_keys().flatten() { else {
let Ok(client) = clients.open_subkey(&key_name) else { continue;
continue; };
}; for key_name in clients.enum_keys().flatten() {
let friendly: String = client let Ok(client) = clients.open_subkey(&key_name) else {
.get_value("") continue;
.unwrap_or_else(|_| key_name.clone()); };
let command: String = client let friendly: String = client.get_value("").unwrap_or_else(|_| key_name.clone());
.open_subkey("shell\\open\\command") let command: String = client
.and_then(|k| k.get_value("")) .open_subkey("shell\\open\\command")
.unwrap_or_default(); .and_then(|k| k.get_value(""))
let Some(path) = extract_exe(&command) else { .unwrap_or_default();
continue; let Some(path) = extract_exe(&command) else {
}; continue;
if !Path::new(&path).exists() { };
continue; if !Path::new(&path).exists() {
} continue;
if out }
.iter() if out
.any(|b: &BrowserEntry| b.path.eq_ignore_ascii_case(&path)) .iter()
{ .any(|b: &BrowserEntry| b.path.eq_ignore_ascii_case(&path))
continue; {
} continue;
out.push(BrowserEntry { }
name: friendly, out.push(BrowserEntry {
path, name: friendly,
flags: String::new(), path,
icon: String::new(), flags: String::new(),
emoji: String::new(), icon: String::new(),
}); emoji: String::new(),
} });
} }
}
out
} out
}
pub fn icon_for(name: &str) -> &'static str {
if is_known_browser(name) { pub fn icon_for(name: &str) -> &'static str {
KNOWN_ICONS if is_known_browser(name) {
.iter() KNOWN_ICONS
.find(|(kw, _)| name.to_lowercase().contains(kw)) .iter()
.map(|(_, path)| *path) .find(|(kw, _)| name.to_lowercase().contains(kw))
.unwrap_or("icons/globe.svg") .map(|(_, path)| *path)
} else { .unwrap_or("icons/globe.svg")
"icons/globe.svg" } else {
} "icons/globe.svg"
} }
}
const KNOWN_ICONS: &[(&str, &str)] = &[
("firefox", "logos/firefox_48x48.png"), const KNOWN_ICONS: &[(&str, &str)] = &[
("chrome", "logos/chrome_48x48.png"), ("firefox", "logos/firefox_48x48.png"),
("opera", "logos/opera_48x48.png"), ("chrome", "logos/chrome_48x48.png"),
("brave", "logos/brave_48x48.png"), ("opera", "logos/opera_48x48.png"),
("яндекс", "logos/yandex_48x48.png"), ("brave", "logos/brave_48x48.png"),
("yandex", "logos/yandex_48x48.png"), ("яндекс", "logos/yandex_48x48.png"),
]; ("yandex", "logos/yandex_48x48.png"),
];
pub fn is_known_browser(name: &str) -> bool {
let n = name.to_lowercase(); pub fn is_known_browser(name: &str) -> bool {
KNOWN_ICONS.iter().any(|(kw, _)| n.contains(kw)) 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"]);
}
}
+113 -188
View File
@@ -1,4 +1,7 @@
#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")] #![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@@ -7,43 +10,38 @@ use i_slint_backend_winit::WinitWindowAccessor;
use slint::Model; use slint::Model;
mod config; mod config;
mod launcher;
mod winit; mod winit;
use config::{BrowserEntry, Config}; use config::{BrowserEntry, Config};
slint::include_modules!(); slint::include_modules!();
fn split_flags(flags: &str) -> Vec<String> { const DEFAULT_URL: &str = "ku6epxboctuk.github.io";
let mut out = Vec::new(); const ENV_THEME: &str = "BP_THEME";
let mut cur = String::new(); const ENV_VIEW: &str = "BP_VIEW";
let mut in_quotes = false; const VIEW_SETTINGS: &str = "settings";
for c in flags.chars() {
match c { impl From<&BrowserConfig> for BrowserEntry {
'"' => in_quotes = !in_quotes, fn from(b: &BrowserConfig) -> Self {
' ' if !in_quotes => { Self {
if !cur.is_empty() { name: b.name.to_string(),
out.push(std::mem::take(&mut cur)); path: b.path.to_string(),
} flags: b.flags.to_string(),
} icon: String::new(),
_ => cur.push(c), emoji: b.emoji.to_string(),
} }
} }
if !cur.is_empty() {
out.push(cur);
}
out
} }
fn load_icon(rel: &str) -> slint::Image { fn load_icon(rel: &str) -> slint::Image {
if let Ok(exe) = std::env::current_exe() { let from_exe = std::env::current_exe()
if let Some(dir) = exe.parent() { .ok()
let p = dir.join(rel); .and_then(|exe| exe.parent().map(|dir| dir.join(rel)));
if p.exists() { let path = from_exe
return slint::Image::load_from_path(&p).unwrap_or_default(); .filter(|p| p.exists())
} .unwrap_or_else(|| std::path::PathBuf::from(rel));
} slint::Image::load_from_path(&path).unwrap_or_default()
}
slint::Image::load_from_path(std::path::Path::new(rel)).unwrap_or_default()
} }
fn to_ui_config(entry: &BrowserEntry) -> BrowserConfig { fn to_ui_config(entry: &BrowserEntry) -> BrowserConfig {
@@ -65,14 +63,6 @@ fn to_ui_config(entry: &BrowserEntry) -> BrowserConfig {
} }
} }
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(|_| ())
}
fn refresh_models( fn refresh_models(
browser_model: &slint::VecModel<BrowserConfig>, browser_model: &slint::VecModel<BrowserConfig>,
remembered_model: &slint::VecModel<RememberedEntry>, remembered_model: &slint::VecModel<RememberedEntry>,
@@ -91,32 +81,63 @@ fn refresh_models(
remembered_model.set_vec(rows); remembered_model.set_vec(rows);
} }
fn init(cfg: Config, url: Option<String>) -> State { fn launch_index(
let cfg = Rc::new(RefCell::new(cfg)); main_window: &MainWindow,
browser_model: &slint::VecModel<BrowserConfig>,
cfg: &Rc<RefCell<Config>>,
index: usize,
) {
let Some(ui_entry) = browser_model.row_data(index) else {
return;
};
main_window.set_selected_index(index as i32);
let ui_browsers: Vec<BrowserConfig> = cfg let entry = BrowserEntry::from(&ui_entry);
.borrow() let url = main_window.get_current_url().to_string();
.browsers let remember = main_window.get_remember_choice();
.iter()
.map(to_ui_config) if launcher::launch_and_remember(cfg, &entry, &url, remember) {
.collect(); let _ = main_window.window().hide();
let browser_model = Rc::new(slint::VecModel::<BrowserConfig>::from(ui_browsers)); }
}
fn open_config_in_editor() {
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 init(cfg: Config, url: Option<String>) -> (MainWindow, SettingsWindow) {
let cfg = 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 remembered_model = Rc::new(slint::VecModel::<RememberedEntry>::from(vec![]));
let main_window = MainWindow::new().unwrap(); let main_window = MainWindow::new().unwrap();
let settings_window = SettingsWindow::new().unwrap();
if std::env::var("BP_THEME").as_deref() == Ok("light") { if std::env::var(ENV_THEME).as_deref() == Ok("light") {
main_window.global::<Theme>().set_is_dark(false); main_window.global::<Theme>().set_is_dark(false);
} }
refresh_models(&browser_model, &remembered_model, &cfg.borrow()); refresh_models(&browser_model, &remembered_model, &cfg.borrow());
let settings_window = SettingsWindow::new().unwrap();
settings_window.set_browsers(browser_model.clone().into()); settings_window.set_browsers(browser_model.clone().into());
settings_window.set_remembered(remembered_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( main_window.set_current_url(
url.clone().unwrap_or_else(|| "ku6epxboctuk.github.io".into()).into(), url.clone()
.unwrap_or_else(|| DEFAULT_URL.to_string())
.into(),
); );
if let Some(u) = &url { if let Some(u) = &url {
main_window.set_current_domain(config::domain_of(u).into()); main_window.set_current_domain(config::domain_of(u).into());
@@ -128,46 +149,8 @@ fn init(cfg: Config, url: Option<String>) -> State {
let main_window = main_window.clone_strong(); let main_window = main_window.clone_strong();
let browser_model = browser_model.clone(); let browser_model = browser_model.clone();
let cfg = cfg.clone(); let cfg = cfg.clone();
move |browser: BrowserConfig| { move |index: i32| {
let index = (0..browser_model.row_count()) launch_index(&main_window, &browser_model, &cfg, index.max(0) as usize);
.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 entry = browser_model.row_data(index).expect("row exists");
let url = main_window.get_current_url().to_string();
let entry_ref = BrowserEntry {
name: entry.name.to_string(),
path: entry.path.to_string(),
flags: entry.flags.to_string(),
icon: String::new(),
emoji: String::new(),
};
let url = main_window.get_current_url().to_string();
config::log(&format!(
"[bp] launching [{}]: {:?} {:?} {}",
index, entry_ref.path, entry_ref.flags, url
));
match launch(&entry_ref, &url) {
Ok(_) => {
let domain = config::domain_of(&url);
if main_window.get_remember_choice() && !domain.is_empty() {
cfg.borrow_mut()
.remembered
.insert(domain, entry_ref.name.clone());
config::save(&cfg.borrow());
}
let _ = main_window.window().hide();
}
Err(e) => config::log(&format!("[bp] launch failed: {e}")),
}
} }
}); });
@@ -177,36 +160,7 @@ fn init(cfg: Config, url: Option<String>) -> State {
let cfg = cfg.clone(); let cfg = cfg.clone();
move || { move || {
let index = main_window.get_selected_index().max(0) as usize; let index = main_window.get_selected_index().max(0) as usize;
let Some(ui_entry) = browser_model.row_data(index) else { launch_index(&main_window, &browser_model, &cfg, index);
return;
};
let entry = BrowserEntry {
name: ui_entry.name.to_string(),
path: ui_entry.path.to_string(),
flags: ui_entry.flags.to_string(),
icon: String::new(),
emoji: String::new(),
};
let url = main_window.get_current_url().to_string();
config::log(&format!(
"[bp] launching [{}]: {:?} {:?} {}",
index, entry.path, entry.flags, url
));
match launch(&entry, &url) {
Ok(_) => {
let domain = config::domain_of(&url);
if main_window.get_remember_choice() && !domain.is_empty() {
cfg.borrow_mut()
.remembered
.insert(domain, entry.name.clone());
config::save(&cfg.borrow());
}
let _ = main_window.window().hide();
}
Err(e) => config::log(&format!("[bp] launch failed: {e}")),
}
} }
}); });
@@ -227,7 +181,7 @@ fn init(cfg: Config, url: Option<String>) -> State {
}); });
main_window.on_settings_clicked(move || { main_window.on_settings_clicked(move || {
println!("Settings clicked"); open_config_in_editor();
}); });
main_window.on_request_drag({ main_window.on_request_drag({
@@ -239,20 +193,10 @@ fn init(cfg: Config, url: Option<String>) -> State {
} }
}); });
main_window.on_settings_clicked({ main_window.on_esc_pressed({
let main_window = main_window.clone_strong();
move || { move || {
let path = config::config_path(); let _ = main_window.window().hide();
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!("[bp] cannot open editor: {e}"));
}
} }
}); });
@@ -265,7 +209,6 @@ fn init(cfg: Config, url: Option<String>) -> State {
cfg.borrow_mut().browsers.retain(|b| b.name != name); cfg.borrow_mut().browsers.retain(|b| b.name != name);
config::save(&cfg.borrow()); config::save(&cfg.borrow());
refresh_models(&browser_model, &remembered_model, &cfg.borrow()); refresh_models(&browser_model, &remembered_model, &cfg.borrow());
println!("Removed browser: {name}");
} }
}); });
@@ -278,7 +221,6 @@ fn init(cfg: Config, url: Option<String>) -> State {
cfg.borrow_mut().remembered.remove(&domain); cfg.borrow_mut().remembered.remove(&domain);
config::save(&cfg.borrow()); config::save(&cfg.borrow());
refresh_models(&browser_model, &remembered_model, &cfg.borrow()); refresh_models(&browser_model, &remembered_model, &cfg.borrow());
println!("Removed remembered domain: {domain}");
} }
}); });
@@ -301,7 +243,6 @@ fn init(cfg: Config, url: Option<String>) -> State {
config::save(&cfg_ref); config::save(&cfg_ref);
drop(cfg_ref); drop(cfg_ref);
refresh_models(&browser_model, &remembered_model, &cfg.borrow()); refresh_models(&browser_model, &remembered_model, &cfg.borrow());
println!("Re-detected browsers");
} }
}); });
@@ -314,13 +255,6 @@ fn init(cfg: Config, url: Option<String>) -> State {
} }
}); });
main_window.on_esc_pressed({
let main_window = main_window.clone_strong();
move || {
let _ = main_window.window().hide();
}
});
settings_window.window().on_close_requested({ settings_window.window().on_close_requested({
let main_window = main_window.clone_strong(); let main_window = main_window.clone_strong();
move || { move || {
@@ -329,22 +263,39 @@ fn init(cfg: Config, url: Option<String>) -> State {
} }
}); });
main_window.set_browser_model(browser_model.clone().into()); (main_window, settings_window)
State {
main_window,
settings_window,
browser_model,
remembered_model,
config: cfg,
}
} }
pub struct State { fn show_and_run(
pub main_window: MainWindow, main_window: MainWindow,
pub settings_window: SettingsWindow, settings_window: SettingsWindow,
pub browser_model: Rc<slint::VecModel<BrowserConfig>>, start: std::time::Instant,
pub remembered_model: Rc<slint::VecModel<RememberedEntry>>, ) {
pub config: Rc<RefCell<Config>>, let settings_only = std::env::var(ENV_VIEW).as_deref() == Ok(VIEW_SETTINGS);
if settings_only {
settings_window.show().unwrap();
let weak = 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()));
});
settings_window.run().unwrap();
} else {
main_window.show().unwrap();
let weak = 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()));
});
main_window.run().unwrap();
}
} }
pub fn main() { pub fn main() {
@@ -359,43 +310,17 @@ pub fn main() {
let domain = config::domain_of(url); let domain = config::domain_of(url);
if let Some(name) = cfg.remembered.get(&domain).cloned() { if let Some(name) = cfg.remembered.get(&domain).cloned() {
if cfg.settings.always_ask { if cfg.settings.always_ask {
config::log(&format!("[bp] '{domain}' remembered -> {name}, but always_ask is on")); config::log(&format!(
"'{domain}' remembered -> {name}, but always_ask is on"
));
} else if let Some(entry) = cfg.browsers.iter().find(|b| b.name == name) { } else if let Some(entry) = cfg.browsers.iter().find(|b| b.name == name) {
config::log(&format!("[bp] '{domain}' remembered -> launching {name}")); config::log(&format!("'{domain}' remembered -> launching {name}"));
let _ = launch(entry, url); let _ = launcher::launch(entry, url);
return; return;
} }
} }
} }
let state = init(cfg, url); let (main_window, settings_window) = init(cfg, url);
show_and_run(main_window, settings_window, start);
if std::env::var("BP_VIEW").as_deref() == Ok("settings") {
state.settings_window.show().unwrap();
let weak = state.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();
}
});
state.settings_window.run().unwrap();
} else {
let main_window = state.main_window.clone_strong();
main_window.show().unwrap();
let weak = 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!(
"[bp] cold start -> event loop: {:?}",
start.elapsed()
));
});
state.main_window.run().unwrap();
}
} }
+3 -4
View File
@@ -6,10 +6,9 @@ use i_slint_backend_winit::winit::window::Window;
pub fn center_window(window: &slint::Window) { pub fn center_window(window: &slint::Window) {
if window.has_winit_window() { if window.has_winit_window() {
window.with_winit_window(|window: &Window| { window.with_winit_window(|window: &Window| {
match window.current_monitor() { if let Some(monitor) = window.current_monitor() {
Some(monitor) => set_centered(window, &monitor), set_centered(window, &monitor);
None => (), }
};
None as Option<()> 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"
+6 -5
View File
@@ -1,13 +1,14 @@
import { Theme } from "./theme.slint"; import { Theme } from "./theme.slint";
export component BrowserItem inherits Rectangle { export component BrowserItem inherits Rectangle {
in property <int> index;
in property <image> browser-icon; in property <image> browser-icon;
in property <string> emoji; in property <string> emoji;
in property <bool> icon-mask; in property <bool> icon-mask;
in property <string> browser-name; in property <string> browser-name;
in property <bool> is-default; in property <bool> is-default;
in property <bool> selected; in property <bool> selected;
callback clicked; callback clicked(int);
height: 84px; height: 84px;
background: transparent; background: transparent;
@@ -20,12 +21,12 @@ export component BrowserItem inherits Rectangle {
: root.selected ? Theme.surface-elevated : root.selected ? Theme.surface-elevated
: Theme.surface-item; : Theme.surface-item;
border-width: root.selected ? 2px : 1px; border-width: root.selected ? 2px : 1px;
border-color: root.selected ? #3b82f680 : transparent; border-color: root.selected ? Theme.accent-ring : transparent;
touch := TouchArea { touch := TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
clicked => { clicked => {
root.clicked(); root.clicked(root.index);
} }
} }
@@ -84,7 +85,7 @@ export component BrowserItem inherits Rectangle {
if root.is-default: Rectangle { if root.is-default: Rectangle {
height: 22px; height: 22px;
border-radius: 11px; border-radius: 11px;
background: #3b82f620; background: Theme.accent-soft;
HorizontalLayout { HorizontalLayout {
padding-left: 10px; padding-left: 10px;
@@ -109,7 +110,7 @@ export component BrowserItem inherits Rectangle {
width: 16px; width: 16px;
height: 16px; height: 16px;
source: @image-url("../icons/check.svg"); source: @image-url("../icons/check.svg");
colorize: #3b82f6; colorize: Theme.accent;
visible: root.selected; visible: root.selected;
} }
+7 -6
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 { BrowserItem } from "./browser_item.slint";
import { Header } from "./header.slint"; import { Header } from "./header.slint";
import { Footer } from "./footer.slint"; import { Footer } from "./footer.slint";
@@ -32,9 +32,9 @@ component CheckRow inherits Rectangle {
Rectangle { Rectangle {
width: 20px; width: 20px;
border-radius: 6px; border-radius: 6px;
background: root.checked ? #3b82f6 : transparent; background: root.checked ? Theme.accent : transparent;
border-width: 2px; border-width: 2px;
border-color: root.checked ? #3b82f6 : Theme.border-main; border-color: root.checked ? Theme.accent : Theme.border-main;
Image { Image {
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
@@ -65,7 +65,7 @@ export component MainWindow inherits Window {
in-out property <bool> always-ask: true; in-out property <bool> always-ask: true;
in property <int> selected-index: 0; in property <int> selected-index: 0;
callback launch-browser(BrowserConfig); callback launch-browser(int);
callback toggle-remember(bool); callback toggle-remember(bool);
callback toggle-always-ask(bool); callback toggle-always-ask(bool);
callback settings-clicked(); callback settings-clicked();
@@ -128,14 +128,15 @@ export component MainWindow inherits Window {
vertical-stretch: 1; vertical-stretch: 1;
for browser[i] in root.browser-model: BrowserItem { for browser[i] in root.browser-model: BrowserItem {
index: i;
browser-icon: browser.icon; browser-icon: browser.icon;
emoji: browser.emoji; emoji: browser.emoji;
icon-mask: browser.icon-mask; icon-mask: browser.icon-mask;
browser-name: browser.name; browser-name: browser.name;
is-default: browser.is-default; is-default: browser.is-default;
selected: i == root.selected-index; selected: i == root.selected-index;
clicked => { clicked(idx) => {
root.launch-browser(browser); root.launch-browser(idx);
} }
} }
} }
+4 -4
View File
@@ -137,7 +137,7 @@ export component SettingsWindow inherits Window {
Rectangle { Rectangle {
width: 30px; width: 30px;
border-radius: 6px; border-radius: 6px;
background: del-touch.has-hover ? #ef444420 : transparent; background: del-touch.has-hover ? Theme.danger-soft : transparent;
del-touch := TouchArea { del-touch := TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
@@ -152,7 +152,7 @@ export component SettingsWindow inherits Window {
width: 16px; width: 16px;
height: 16px; height: 16px;
source: @image-url("../icons/trash.svg"); source: @image-url("../icons/trash.svg");
colorize: del-touch.has-hover ? #ef4444 : Theme.text-muted; colorize: del-touch.has-hover ? Theme.danger : Theme.text-muted;
} }
} }
} }
@@ -209,7 +209,7 @@ export component SettingsWindow inherits Window {
Rectangle { Rectangle {
width: 30px; width: 30px;
border-radius: 6px; border-radius: 6px;
background: del2-touch.has-hover ? #ef444420 : transparent; background: del2-touch.has-hover ? Theme.danger-soft : transparent;
del2-touch := TouchArea { del2-touch := TouchArea {
mouse-cursor: pointer; mouse-cursor: pointer;
@@ -224,7 +224,7 @@ export component SettingsWindow inherits Window {
width: 16px; width: 16px;
height: 16px; height: 16px;
source: @image-url("../icons/trash.svg"); source: @image-url("../icons/trash.svg");
colorize: del2-touch.has-hover ? #ef4444 : Theme.text-muted; 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 <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 <brush> shadow-color: is-dark ? #3b82f640 : #3b82f640;
out property <length> shadow-primary-x: 0px; out property <length> shadow-primary-x: 0px;