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
+}