diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index f121dba..04de30f 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -38,8 +38,6 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он в `logos/` уже лежат отобранные PNG (chrome, firefox, opera, yandex, brave) - [x] `.gitignore`: `tools/out/`, `*.log`, `coldstart.txt` -Коммит: `chore: repo hygiene` - ## Этап B — утилиты в `tools/` Все скрипты — PowerShell, без сторонних зависимостей. @@ -55,8 +53,6 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он `Stop-Process`. Решает проблему «GUI-процесс блокирует терминал агента» - [ ] Опционально: `slint-lsp` / VS Code extension — ей для редактора, агент не зависит -Коммит: `feat(tools): visual dev loop for ai agents` - Проверка цикла: `render-refs` → `run-and-shot` → оба PNG существуют, агент их прочитал. ## Этап C — возвращение к no-frame окну (цель дизайна) @@ -78,19 +74,22 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он Требует ручной проверки: перетаскивание мышью, Esc, центрирование на активном мониторе. -Коммит: `feat(ui): frameless transparent window with drag` - ## Этап D — список браузеров (MVP-ядро) -- [ ] Раскомментировать/восстановить ListView + BrowserItem в main.slint (было закомментировано) -- [ ] Логотипы: PNG из `logos/` вместо emoji (поле icon → путь к png) -- [ ] Мок-данные 4 браузеров в VecModel (уже были в старом src/main.rs) -- [ ] Выбор кликом + подсветка selected; hover-стили -- [ ] Кнопка «Открыть» → callback → Rust: `std::process::Command(path).args(flags).arg(url)` -- [ ] URL из argv: `std::env::args().nth(1)` -- [ ] Закрытие окна после запуска +- [x] ListView + BrowserItem восстановлены в main.slint (список растягивается, карточки 84px) +- [x] Логотипы: PNG из `logos/`; `BrowserConfig.icon` теперь `image` — грузится в Rust + через `Image::load_from_path` (@image-url не умеет рантайм-пути; serde-derive + из types.slint убран — вернём в этапе E строкой в TOML) +- [x] Мок-данные 4 браузеров; selected-index = первый is_default +- [x] Выбор кликом + ring-подсветка + hover; галочка — SVG (`icons/check.svg`), + т.к. глифа U+2713 в шрифтах нет; добавлен токен `Theme.surface-item` +- [x] Чекбоксы «Запомнить выбор…»/«Всегда спрашивать» — кастомный CheckRow по макету +- [x] «Открыть» → `Command::new(path).args(flags).arg(url)`; при успехе окно скрывается + (loop завершается), при ошибке — сообщение в stderr +- [x] URL из argv (`std::env::args().nth(1)`) -Коммит: `feat: browser list mvp` +Примечание: мок-пути (firefox.exe, chrome.exe, zen.exe) вне PATH — запуск вернёт +ошибку в stderr до этапа E (автодетект/конфиг). Клик-выбор проверяется визуально. ## Этап E — конфиг и настройки @@ -100,8 +99,6 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он - [ ] Автодетект установленных браузеров (реестр Windows) как fallback при отсутствии конфига - [ ] Чтение флагов из .lnk ярлыков (крейс `lnk`) — по желанию -Коммит: `feat: config + settings page` - ## Этап F — релиз - [ ] `[profile.release]`: lto, codegen-units=1, strip diff --git a/icons/check.svg b/icons/check.svg new file mode 100644 index 0000000..6f9875f --- /dev/null +++ b/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/main.rs b/src/main.rs index d5ffd92..20f3c90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,43 +1,55 @@ use std::rc::Rc; use i_slint_backend_winit::WinitWindowAccessor; +use slint::Model; use crate::winit::center_window; 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 browser_model = Rc::new(slint::VecModel::::from(vec![ + let browsers = vec![ BrowserConfig { - icon: "🦊".into(), + icon: load_icon("logos/firefox_48x48.png"), name: "Work browser".into(), path: "firefox.exe".into(), flags: "--profile work".into(), is_default: true, }, BrowserConfig { - icon: "🔥".into(), + icon: load_icon("logos/chrome_48x48.png"), name: "Ku6epXBOCTuK".into(), path: "chrome.exe".into(), flags: "--profile personal".into(), is_default: false, }, BrowserConfig { - icon: "🌐".into(), + icon: load_icon("logos/opera_48x48.png"), name: "Chrome".into(), path: "chrome.exe".into(), flags: "".into(), is_default: false, }, BrowserConfig { - icon: "🧘".into(), + 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::::from(browsers)); let main_window = MainWindow::new().unwrap(); @@ -45,30 +57,60 @@ fn init() -> State { main_window.global::().set_is_dark(false); } - main_window.set_current_url("ku6epxboctuk.github.io".into()); + 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_browser_selected({ + let main_window = main_window.clone_strong(); + let browser_model = browser_model.clone(); move |browser: BrowserConfig| { - println!("Selected browser: {:?} {:?}", browser.path, browser.flags); + 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); + println!( + "Selected [{}]: {:?} {:?}", + index, browser.path, browser.flags + ); } }); - main_window.on_toggle_remember(move |checked| { - println!("Remember choice: {}", checked); - }); + 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(); - main_window.on_toggle_always_ask(move |checked| { - println!("Always ask: {}", checked); - }); + println!( + "Launching [{}]: {:?} {:?} {}", + index, browser.path, browser.flags, url + ); - main_window.on_settings_clicked(move || { - println!("Settings clicked"); - }); + let mut cmd = std::process::Command::new(browser.path.to_string()); + if !browser.flags.is_empty() { + cmd.args(browser.flags.split_whitespace()); + } - main_window.on_open_clicked(move || { - println!("Open clicked"); + match cmd.arg(url).spawn() { + Ok(_) => { + let _ = main_window.window().hide(); + } + Err(e) => eprintln!("launch failed: {e}"), + } + } }); main_window.on_request_drag({ diff --git a/ui/browser_item.slint b/ui/browser_item.slint index 668da24..2fa357e 100644 --- a/ui/browser_item.slint +++ b/ui/browser_item.slint @@ -1,98 +1,112 @@ -import { - HorizontalBox, - VerticalBox, -} from "std-widgets.slint"; -import { BrowserConfig } from "./types.slint"; +import { Theme } from "./theme.slint"; -export component BrowserItem { - in property browser; +export component BrowserItem inherits Rectangle { + in property browser-icon; + in property browser-name; + in property is-default; in property selected; + callback clicked; - height: 88px; - width: 100%; + height: 84px; + background: transparent; - Rectangle { - width: 100%; - height: 100%; - border-radius: 16px; - background: selected ? #1e293b : #0f172a; + panel := Rectangle { + y: 4px; + height: 76px; + border-radius: 12px; + background: touch.has-hover ? Theme.surface-hover + : root.selected ? Theme.surface-elevated + : Theme.surface-item; + border-width: root.selected ? 2px : 1px; + border-color: root.selected ? #3b82f680 : transparent; - HorizontalBox { - padding: 16px; + touch := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + } + + HorizontalLayout { + padding: 14px; spacing: 16px; Rectangle { width: 48px; - height: 48px; + vertical-stretch: 0; border-radius: 12px; - background: #1e293b; + background: Theme.surface-elevated; - Text { - width: 100%; - height: 100%; - horizontal-alignment: center; - vertical-alignment: center; - text: browser.icon; - font-size: 24px; + Image { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 30px; + height: 30px; + source: root.browser-icon; } } - VerticalBox { + VerticalLayout { alignment: center; - spacing: 4px; - Text { - text: browser.name; - color: #f8fafc; - font-size: 16px; - font-weight: 500; - } - - if (browser.is-default): Rectangle { - height: 22px; - border-radius: 4px; - background: #3b82f6; + HorizontalLayout { + spacing: 8px; + alignment: start; Text { - text: "По умолчанию"; - color: #60a5fa; - font-size: 12px; - } - } - } - - VerticalBox { - alignment: center; - width: 24px; - height: 24px; - - if (selected): Rectangle { - width: 24px; - height: 24px; - border-radius: 12px; - background: #3b82f6; - - Text { - width: 100%; - height: 100%; - horizontal-alignment: center; + text: root.browser-name; + color: Theme.text-main; + font-size: 16px; + font-weight: 500; vertical-alignment: center; - text: "✓"; - color: #ffffff; - font-size: 14px; + } + + if root.is-default: Rectangle { + height: 22px; + border-radius: 11px; + background: #3b82f620; + + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + + Text { + text: "По умолчанию"; + color: #60a5fa; + font-size: 12px; + vertical-alignment: center; + } + } } } + } - if (!selected): Text { + Rectangle { + width: 24px; + border-radius: 12px; + background: root.selected ? #3b82f6 : Theme.surface-elevated; + + Image { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 12px; + height: 12px; + source: @image-url("../icons/check.svg"); + colorize: #ffffff; + visible: root.selected; + } + + Text { width: 100%; height: 100%; horizontal-alignment: center; vertical-alignment: center; - text: ">"; - color: #64748b; - font-size: 18px; + text: "›"; + color: Theme.text-muted; + font-size: 14px; + visible: !root.selected; } } } } -} \ No newline at end of file +} diff --git a/ui/main.slint b/ui/main.slint index e4a9ad4..ed7f5cf 100644 --- a/ui/main.slint +++ b/ui/main.slint @@ -7,6 +7,53 @@ import { Theme } from "theme.slint"; export { Theme } +component CheckRow inherits Rectangle { + in property label; + in-out property checked; + callback toggled(); + + height: 28px; + background: transparent; + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { + root.checked = !root.checked; + root.toggled(); + } + } + + HorizontalLayout { + spacing: 12px; + padding-left: 4px; + + Rectangle { + width: 20px; + border-radius: 6px; + background: root.checked ? #3b82f6 : transparent; + border-width: 2px; + border-color: root.checked ? #3b82f6 : Theme.border-main; + + Image { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + width: 12px; + height: 12px; + source: @image-url("../icons/check.svg"); + colorize: #ffffff; + visible: root.checked; + } + } + + Text { + text: root.label; + color: Theme.text-muted; + font-size: 14px; + vertical-alignment: center; + } + } +} + export component MainWindow inherits Window { title: "bropicker"; in property <[BrowserConfig]> browser-model: []; @@ -65,37 +112,44 @@ export component MainWindow inherits Window { request-drag => root.request-drag(); } - // ListView { - // max-height: 400px; + ListView { + vertical-stretch: 1; - // for browser[i] in root.browser-model: BrowserItem { - // browser: browser; - // selected: i == root.selected-index; - // } - // } - - // spacing: 12px; - // padding-left: 4px; - - // CheckBox { - // text: "Запомнить выбор для " + root.current-url; - // checked <=> root.remember-choice; - // toggled => { - // root.toggle-remember(root.remember-choice); - // } - // } - - // CheckBox { - // text: "Всегда спрашивать"; - // checked <=> root.always-ask; - // toggled => { - // root.toggle-always-ask(root.always-ask); - // } - // } - // } + for browser[i] in root.browser-model: BrowserItem { + browser-icon: browser.icon; + browser-name: browser.name; + is-default: browser.is-default; + selected: i == root.selected-index; + clicked => { + root.browser-selected(browser); + } + } + } Rectangle { - vertical-stretch: 1; + height: 12px; + } + + VerticalLayout { + spacing: 8px; + padding-left: 4px; + padding-bottom: 8px; + + CheckRow { + label: "Запомнить выбор для " + root.current-url; + checked <=> root.remember-choice; + toggled => { + root.toggle-remember(root.remember-choice); + } + } + + CheckRow { + label: "Всегда спрашивать"; + checked <=> root.always-ask; + toggled => { + root.toggle-always-ask(root.always-ask); + } + } } Footer { diff --git a/ui/theme.slint b/ui/theme.slint index 7e9404e..6260807 100644 --- a/ui/theme.slint +++ b/ui/theme.slint @@ -5,6 +5,7 @@ export global Theme { out property surface-main: is-dark ? #0f172a : #ffffff; // out property surface-control: is-dark ? #1e293b4d : #ffffff; + out property surface-item: is-dark ? #1e293b4d : #f8fafc; out property surface-elevated: is-dark ? #1e293b80 : #f8fafc; out property surface-hover: is-dark ? #1e293b : #ffffff; diff --git a/ui/types.slint b/ui/types.slint index cecb4f2..46ce35f 100644 --- a/ui/types.slint +++ b/ui/types.slint @@ -1,8 +1,7 @@ -@rust-attr(derive(serde::Serialize, serde::Deserialize)) export struct BrowserConfig { - icon: string, + icon: image, name: string, path: string, flags: string, is-default: bool, -} \ No newline at end of file +}