feat: browser list mvp

This commit is contained in:
2026-08-25 18:36:27 +05:00
parent e336cf7e86
commit 8d3a6dcc79
7 changed files with 244 additions and 134 deletions
+13 -16
View File
@@ -38,8 +38,6 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
в `logos/` уже лежат отобранные PNG (chrome, firefox, opera, yandex, brave) в `logos/` уже лежат отобранные PNG (chrome, firefox, opera, yandex, brave)
- [x] `.gitignore`: `tools/out/`, `*.log`, `coldstart.txt` - [x] `.gitignore`: `tools/out/`, `*.log`, `coldstart.txt`
Коммит: `chore: repo hygiene`
## Этап B — утилиты в `tools/` ## Этап B — утилиты в `tools/`
Все скрипты — PowerShell, без сторонних зависимостей. Все скрипты — PowerShell, без сторонних зависимостей.
@@ -55,8 +53,6 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
`Stop-Process`. Решает проблему «GUI-процесс блокирует терминал агента» `Stop-Process`. Решает проблему «GUI-процесс блокирует терминал агента»
- [ ] Опционально: `slint-lsp` / VS Code extension — ей для редактора, агент не зависит - [ ] Опционально: `slint-lsp` / VS Code extension — ей для редактора, агент не зависит
Коммит: `feat(tools): visual dev loop for ai agents`
Проверка цикла: `render-refs``run-and-shot` → оба PNG существуют, агент их прочитал. Проверка цикла: `render-refs``run-and-shot` → оба PNG существуют, агент их прочитал.
## Этап C — возвращение к no-frame окну (цель дизайна) ## Этап C — возвращение к no-frame окну (цель дизайна)
@@ -78,19 +74,22 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
Требует ручной проверки: перетаскивание мышью, Esc, центрирование на активном мониторе. Требует ручной проверки: перетаскивание мышью, Esc, центрирование на активном мониторе.
Коммит: `feat(ui): frameless transparent window with drag`
## Этап D — список браузеров (MVP-ядро) ## Этап D — список браузеров (MVP-ядро)
- [ ] Раскомментировать/восстановить ListView + BrowserItem в main.slint (было закомментировано) - [x] ListView + BrowserItem восстановлены в main.slint (список растягивается, карточки 84px)
- [ ] Логотипы: PNG из `logos/` вместо emoji (поле icon → путь к png) - [x] Логотипы: PNG из `logos/`; `BrowserConfig.icon` теперь `image` — грузится в Rust
- [ ] Мок-данные 4 браузеров в VecModel (уже были в старом src/main.rs) через `Image::load_from_path` (@image-url не умеет рантайм-пути; serde-derive
- [ ] Выбор кликом + подсветка selected; hover-стили из types.slint убран — вернём в этапе E строкой в TOML)
- [ ] Кнопка «Открыть» → callback → Rust: `std::process::Command(path).args(flags).arg(url)` - [x] Мок-данные 4 браузеров; selected-index = первый is_default
- [ ] URL из argv: `std::env::args().nth(1)` - [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 — конфиг и настройки ## Этап E — конфиг и настройки
@@ -100,8 +99,6 @@ HTML-макеты → PNG: **headless Edge** (`--headless --screenshot`), он
- [ ] Автодетект установленных браузеров (реестр Windows) как fallback при отсутствии конфига - [ ] Автодетект установленных браузеров (реестр Windows) как fallback при отсутствии конфига
- [ ] Чтение флагов из .lnk ярлыков (крейс `lnk`) — по желанию - [ ] Чтение флагов из .lnk ярлыков (крейс `lnk`) — по желанию
Коммит: `feat: config + settings page`
## Этап F — релиз ## Этап F — релиз
- [ ] `[profile.release]`: lto, codegen-units=1, strip - [ ] `[profile.release]`: lto, codegen-units=1, strip
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>

After

Width:  |  Height:  |  Size: 200 B

+61 -19
View File
@@ -1,43 +1,55 @@
use std::rc::Rc; use std::rc::Rc;
use i_slint_backend_winit::WinitWindowAccessor; use i_slint_backend_winit::WinitWindowAccessor;
use slint::Model;
use crate::winit::center_window; use crate::winit::center_window;
mod winit; mod winit;
slint::include_modules!(); 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 { fn init() -> State {
let browser_model = Rc::new(slint::VecModel::<BrowserConfig>::from(vec![ let browsers = vec![
BrowserConfig { BrowserConfig {
icon: "🦊".into(), icon: load_icon("logos/firefox_48x48.png"),
name: "Work browser".into(), name: "Work browser".into(),
path: "firefox.exe".into(), path: "firefox.exe".into(),
flags: "--profile work".into(), flags: "--profile work".into(),
is_default: true, is_default: true,
}, },
BrowserConfig { BrowserConfig {
icon: "🔥".into(), icon: load_icon("logos/chrome_48x48.png"),
name: "Ku6epXBOCTuK".into(), name: "Ku6epXBOCTuK".into(),
path: "chrome.exe".into(), path: "chrome.exe".into(),
flags: "--profile personal".into(), flags: "--profile personal".into(),
is_default: false, is_default: false,
}, },
BrowserConfig { BrowserConfig {
icon: "🌐".into(), icon: load_icon("logos/opera_48x48.png"),
name: "Chrome".into(), name: "Chrome".into(),
path: "chrome.exe".into(), path: "chrome.exe".into(),
flags: "".into(), flags: "".into(),
is_default: false, is_default: false,
}, },
BrowserConfig { BrowserConfig {
icon: "🧘".into(), icon: load_icon("icons/globe.svg"),
name: "Zen Browser".into(), name: "Zen Browser".into(),
path: "zen.exe".into(), path: "zen.exe".into(),
flags: "".into(), flags: "".into(),
is_default: false, is_default: false,
}, },
])); ];
let default_index = browsers
.iter()
.position(|b| b.is_default)
.unwrap_or_default();
let browser_model = Rc::new(slint::VecModel::<BrowserConfig>::from(browsers));
let main_window = MainWindow::new().unwrap(); let main_window = MainWindow::new().unwrap();
@@ -45,30 +57,60 @@ fn init() -> State {
main_window.global::<Theme>().set_is_dark(false); main_window.global::<Theme>().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_remember_choice(true);
main_window.set_always_ask(true); main_window.set_always_ask(true);
main_window.on_browser_selected({ main_window.on_browser_selected({
let main_window = main_window.clone_strong();
let browser_model = browser_model.clone();
move |browser: BrowserConfig| { 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| { main_window.on_open_clicked({
println!("Remember choice: {}", checked); 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!(
println!("Always ask: {}", checked); "Launching [{}]: {:?} {:?} {}",
}); index, browser.path, browser.flags, url
);
main_window.on_settings_clicked(move || { let mut cmd = std::process::Command::new(browser.path.to_string());
println!("Settings clicked"); if !browser.flags.is_empty() {
}); cmd.args(browser.flags.split_whitespace());
}
main_window.on_open_clicked(move || { match cmd.arg(url).spawn() {
println!("Open clicked"); Ok(_) => {
let _ = main_window.window().hide();
}
Err(e) => eprintln!("launch failed: {e}"),
}
}
}); });
main_window.on_request_drag({ main_window.on_request_drag({
+82 -68
View File
@@ -1,98 +1,112 @@
import { import { Theme } from "./theme.slint";
HorizontalBox,
VerticalBox,
} from "std-widgets.slint";
import { BrowserConfig } from "./types.slint";
export component BrowserItem { export component BrowserItem inherits Rectangle {
in property <BrowserConfig> browser; in property <image> browser-icon;
in property <string> browser-name;
in property <bool> is-default;
in property <bool> selected; in property <bool> selected;
callback clicked;
height: 88px; height: 84px;
width: 100%; background: transparent;
Rectangle { panel := Rectangle {
width: 100%; y: 4px;
height: 100%; height: 76px;
border-radius: 16px; border-radius: 12px;
background: selected ? #1e293b : #0f172a; 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 { touch := TouchArea {
padding: 16px; mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
HorizontalLayout {
padding: 14px;
spacing: 16px; spacing: 16px;
Rectangle { Rectangle {
width: 48px; width: 48px;
height: 48px; vertical-stretch: 0;
border-radius: 12px; border-radius: 12px;
background: #1e293b; background: Theme.surface-elevated;
Text { Image {
width: 100%; x: (parent.width - self.width) / 2;
height: 100%; y: (parent.height - self.height) / 2;
horizontal-alignment: center; width: 30px;
vertical-alignment: center; height: 30px;
text: browser.icon; source: root.browser-icon;
font-size: 24px;
} }
} }
VerticalBox { VerticalLayout {
alignment: center; alignment: center;
spacing: 4px;
Text { HorizontalLayout {
text: browser.name; spacing: 8px;
color: #f8fafc; alignment: start;
font-size: 16px;
font-weight: 500;
}
if (browser.is-default): Rectangle {
height: 22px;
border-radius: 4px;
background: #3b82f6;
Text { Text {
text: "По умолчанию"; text: root.browser-name;
color: #60a5fa; color: Theme.text-main;
font-size: 12px; font-size: 16px;
} font-weight: 500;
}
}
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;
vertical-alignment: center; 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%; width: 100%;
height: 100%; height: 100%;
horizontal-alignment: center; horizontal-alignment: center;
vertical-alignment: center; vertical-alignment: center;
text: ">"; text: "";
color: #64748b; color: Theme.text-muted;
font-size: 18px; font-size: 14px;
visible: !root.selected;
} }
} }
} }
} }
} }
+82 -28
View File
@@ -7,6 +7,53 @@ import { Theme } from "theme.slint";
export { Theme } export { Theme }
component CheckRow inherits Rectangle {
in property <string> label;
in-out property <bool> 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 { export component MainWindow inherits Window {
title: "bropicker"; title: "bropicker";
in property <[BrowserConfig]> browser-model: []; in property <[BrowserConfig]> browser-model: [];
@@ -65,37 +112,44 @@ export component MainWindow inherits Window {
request-drag => root.request-drag(); request-drag => root.request-drag();
} }
// ListView { ListView {
// max-height: 400px; vertical-stretch: 1;
// for browser[i] in root.browser-model: BrowserItem { for browser[i] in root.browser-model: BrowserItem {
// browser: browser; browser-icon: browser.icon;
// selected: i == root.selected-index; browser-name: browser.name;
// } is-default: browser.is-default;
// } selected: i == root.selected-index;
clicked => {
// spacing: 12px; root.browser-selected(browser);
// 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);
// }
// }
// }
Rectangle { 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 { Footer {
+1
View File
@@ -5,6 +5,7 @@ export global Theme {
out property <brush> surface-main: is-dark ? #0f172a : #ffffff; out property <brush> surface-main: is-dark ? #0f172a : #ffffff;
// out property <brush> surface-control: is-dark ? #1e293b4d : #ffffff; // out property <brush> surface-control: is-dark ? #1e293b4d : #ffffff;
out property <brush> surface-item: is-dark ? #1e293b4d : #f8fafc;
out property <brush> surface-elevated: is-dark ? #1e293b80 : #f8fafc; out property <brush> surface-elevated: is-dark ? #1e293b80 : #f8fafc;
out property <brush> surface-hover: is-dark ? #1e293b : #ffffff; out property <brush> surface-hover: is-dark ? #1e293b : #ffffff;
+2 -3
View File
@@ -1,8 +1,7 @@
@rust-attr(derive(serde::Serialize, serde::Deserialize))
export struct BrowserConfig { export struct BrowserConfig {
icon: string, icon: image,
name: string, name: string,
path: string, path: string,
flags: string, flags: string,
is-default: bool, is-default: bool,
} }