From 03d4be8444d078b354409aafb5f9f645e6516e86 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Wed, 26 Aug 2026 15:16:09 +0500 Subject: [PATCH] refactor: fix hardcoded values, remove println, remove doubles --- docs/code-review.md | 16 +- src/config.rs | 458 ++++++++++++++++++++++++------------------ src/launcher.rs | 96 +++++++++ src/main.rs | 301 +++++++++++---------------- src/winit.rs | 7 +- tools/check.ps1 | 12 ++ ui/browser_item.slint | 11 +- ui/main.slint | 13 +- ui/settings.slint | 8 +- ui/theme.slint | 6 + 10 files changed, 513 insertions(+), 415 deletions(-) create mode 100644 src/launcher.rs create mode 100644 tools/check.ps1 diff --git a/docs/code-review.md b/docs/code-review.md index 9933f5a..821ce4f 100644 --- a/docs/code-review.md +++ b/docs/code-review.md @@ -1,10 +1,12 @@ -# Ревью кода bropicker (2026-08-26) +# Ревью кода bropicker (2026-08-26) — ВЫПОЛНЕНО > Скоуп: src/main.rs (401 строка), src/config.rs (~210), src/winit.rs (45), > 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 — мусор и артефакты правок (чинить сразу) @@ -74,9 +76,9 @@ ## Порядок исполнения (одна сессия) -1. P0 целиком + прогон clippy --fix (P2 первая строка) -2. P1: launcher.rs + launch_and_remember + From-конвертации + индекс из UI -3. Юнит-тесты чистых функций + tools/check.ps1 -4. P3 косметика по остаточному принципу +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` перед коммитом. diff --git a/src/config.rs b/src/config.rs index 201580e..1a171ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,202 +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}] {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, - #[serde(default)] - pub remembered: HashMap, - #[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!("[bp] 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 { - 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 { - use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; - use winreg::RegKey; - - let mut out: Vec = 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)) +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, + #[serde(default)] + pub remembered: HashMap, + #[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 { + 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 { + use winreg::RegKey; + use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE}; + + let mut out: Vec = 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")); + } } diff --git a/src/launcher.rs b/src/launcher.rs new file mode 100644 index 0000000..6e0cbd0 --- /dev/null +++ b/src/launcher.rs @@ -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 { + 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>, + 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"]); + } +} diff --git a/src/main.rs b/src/main.rs index 57ca807..98d5017 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::rc::Rc; @@ -7,43 +10,38 @@ use i_slint_backend_winit::WinitWindowAccessor; use slint::Model; mod config; +mod launcher; mod winit; use config::{BrowserEntry, Config}; slint::include_modules!(); -fn split_flags(flags: &str) -> Vec { - 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), +const DEFAULT_URL: &str = "ku6epxboctuk.github.io"; +const ENV_THEME: &str = "BP_THEME"; +const ENV_VIEW: &str = "BP_VIEW"; +const VIEW_SETTINGS: &str = "settings"; + +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(), } } - if !cur.is_empty() { - out.push(cur); - } - out } fn load_icon(rel: &str) -> slint::Image { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let p = dir.join(rel); - if p.exists() { - return slint::Image::load_from_path(&p).unwrap_or_default(); - } - } - } - slint::Image::load_from_path(std::path::Path::new(rel)).unwrap_or_default() + 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() } 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( browser_model: &slint::VecModel, remembered_model: &slint::VecModel, @@ -91,32 +81,63 @@ fn refresh_models( remembered_model.set_vec(rows); } -fn init(cfg: Config, url: Option) -> State { - let cfg = Rc::new(RefCell::new(cfg)); +fn launch_index( + main_window: &MainWindow, + browser_model: &slint::VecModel, + cfg: &Rc>, + 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 = cfg - .borrow() - .browsers - .iter() - .map(to_ui_config) - .collect(); - let browser_model = Rc::new(slint::VecModel::::from(ui_browsers)); + 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(cfg, &entry, &url, remember) { + let _ = main_window.window().hide(); + } +} + +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) -> (MainWindow, SettingsWindow) { + let cfg = Rc::new(RefCell::new(cfg)); + let browser_model = Rc::new(slint::VecModel::::from(vec![])); let remembered_model = Rc::new(slint::VecModel::::from(vec![])); 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::().set_is_dark(false); } 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_remembered(remembered_model.clone().into()); + main_window.set_browser_model(browser_model.clone().into()); 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 { main_window.set_current_domain(config::domain_of(u).into()); @@ -128,46 +149,8 @@ fn init(cfg: Config, url: Option) -> State { let main_window = main_window.clone_strong(); let browser_model = browser_model.clone(); let cfg = cfg.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 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}")), - } + move |index: i32| { + launch_index(&main_window, &browser_model, &cfg, index.max(0) as usize); } }); @@ -177,36 +160,7 @@ fn init(cfg: Config, url: Option) -> State { let cfg = cfg.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 { - 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}")), - } + launch_index(&main_window, &browser_model, &cfg, index); } }); @@ -227,7 +181,7 @@ fn init(cfg: Config, url: Option) -> State { }); main_window.on_settings_clicked(move || { - println!("Settings clicked"); + open_config_in_editor(); }); main_window.on_request_drag({ @@ -239,20 +193,10 @@ fn init(cfg: Config, url: Option) -> State { } }); - main_window.on_settings_clicked({ + main_window.on_esc_pressed({ + let main_window = main_window.clone_strong(); 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!("[bp] cannot open editor: {e}")); - } + let _ = main_window.window().hide(); } }); @@ -265,7 +209,6 @@ fn init(cfg: Config, url: Option) -> State { cfg.borrow_mut().browsers.retain(|b| b.name != name); config::save(&cfg.borrow()); refresh_models(&browser_model, &remembered_model, &cfg.borrow()); - println!("Removed browser: {name}"); } }); @@ -278,7 +221,6 @@ fn init(cfg: Config, url: Option) -> State { cfg.borrow_mut().remembered.remove(&domain); config::save(&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) -> State { config::save(&cfg_ref); drop(cfg_ref); refresh_models(&browser_model, &remembered_model, &cfg.borrow()); - println!("Re-detected browsers"); } }); @@ -314,13 +255,6 @@ fn init(cfg: Config, url: Option) -> 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({ let main_window = main_window.clone_strong(); move || { @@ -329,22 +263,39 @@ fn init(cfg: Config, url: Option) -> State { } }); - main_window.set_browser_model(browser_model.clone().into()); - State { - main_window, - settings_window, - browser_model, - remembered_model, - config: cfg, - } + (main_window, settings_window) } -pub struct State { - pub main_window: MainWindow, - pub settings_window: SettingsWindow, - pub browser_model: Rc>, - pub remembered_model: Rc>, - pub config: Rc>, +fn show_and_run( + main_window: MainWindow, + settings_window: SettingsWindow, + start: std::time::Instant, +) { + 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() { @@ -359,43 +310,17 @@ pub fn main() { let domain = config::domain_of(url); if let Some(name) = cfg.remembered.get(&domain).cloned() { 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) { - config::log(&format!("[bp] '{domain}' remembered -> launching {name}")); - let _ = launch(entry, url); + config::log(&format!("'{domain}' remembered -> launching {name}")); + let _ = launcher::launch(entry, url); return; } } } - let state = init(cfg, url); - - 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(); - } + let (main_window, settings_window) = init(cfg, url); + show_and_run(main_window, settings_window, start); } diff --git a/src/winit.rs b/src/winit.rs index 23a27d1..b3672b3 100644 --- a/src/winit.rs +++ b/src/winit.rs @@ -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<()> }); diff --git a/tools/check.ps1 b/tools/check.ps1 new file mode 100644 index 0000000..1aef3aa --- /dev/null +++ b/tools/check.ps1 @@ -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" diff --git a/ui/browser_item.slint b/ui/browser_item.slint index 3341f0e..b22662e 100644 --- a/ui/browser_item.slint +++ b/ui/browser_item.slint @@ -1,13 +1,14 @@ import { Theme } from "./theme.slint"; export component BrowserItem inherits Rectangle { + in property index; in property browser-icon; in property emoji; in property icon-mask; in property browser-name; in property is-default; in property selected; - callback clicked; + callback clicked(int); height: 84px; background: transparent; @@ -20,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); } } @@ -84,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; @@ -109,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; } diff --git a/ui/main.slint b/ui/main.slint index 39e28f5..84a6dd7 100644 --- a/ui/main.slint +++ b/ui/main.slint @@ -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"; @@ -32,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; @@ -65,7 +65,7 @@ export component MainWindow inherits Window { in-out property always-ask: true; in property selected-index: 0; - callback launch-browser(BrowserConfig); + callback launch-browser(int); callback toggle-remember(bool); callback toggle-always-ask(bool); callback settings-clicked(); @@ -128,14 +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); } } } diff --git a/ui/settings.slint b/ui/settings.slint index 3684888..9b9d5b4 100644 --- a/ui/settings.slint +++ b/ui/settings.slint @@ -137,7 +137,7 @@ export component SettingsWindow inherits Window { Rectangle { width: 30px; border-radius: 6px; - background: del-touch.has-hover ? #ef444420 : transparent; + background: del-touch.has-hover ? Theme.danger-soft : transparent; del-touch := TouchArea { mouse-cursor: pointer; @@ -152,7 +152,7 @@ export component SettingsWindow inherits Window { width: 16px; height: 16px; 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 { width: 30px; border-radius: 6px; - background: del2-touch.has-hover ? #ef444420 : transparent; + background: del2-touch.has-hover ? Theme.danger-soft : transparent; del2-touch := TouchArea { mouse-cursor: pointer; @@ -224,7 +224,7 @@ export component SettingsWindow inherits Window { width: 16px; height: 16px; 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; } } } diff --git a/ui/theme.slint b/ui/theme.slint index 6260807..2d5b7e1 100644 --- a/ui/theme.slint +++ b/ui/theme.slint @@ -21,6 +21,12 @@ export global Theme { out property text-hint: is-dark ? #475569 : #475569; + out property accent: #3b82f6; + out property accent-ring: #3b82f680; + out property accent-soft: #3b82f620; + out property danger: #ef4444; + out property danger-soft: #ef444420; + out property shadow-color: is-dark ? #3b82f640 : #3b82f640; out property shadow-primary-x: 0px;