Skip to content

Commit 3b46872

Browse files
committed
✨ feat: feat: template gallery, site detection, and UI polish
- Template gallery: rewrite callback to fire directly in connect_activated instead of connect_destroy (GTK4 close() ≠ immediate destroy). Fix RefCell borrow panic when set_text triggers connect_changed during active borrow. - Site detection (favicon.rs): switch reqwest native-tls → rustls-tls (fix builder error on systems w/o OpenSSL). Auto-prepend https:// when URL has no scheme. Always update name on re-detect (remove is_empty guard). - Icon loading: centralize in load_icon() helper (webapp_row.rs). Use gdk-pixbuf for file-based SVGs at 4× pixel_size. Return icon name (not path) for hicolor dirs so GTK renders at correct size. Fix browser icons in browser_dialog.rs via same pipeline. - Detect button: flat button w/ 24px globe icon (emblem-web-symbolic). - CSS system: add style.rs w/ custom classes (.webapp-icon, .webapp-row, .action-btn). Load provider in connect_startup. - Content wrap: AdwClamp max 900px in main window.
1 parent 109c46f commit 3b46872

10 files changed

Lines changed: 445 additions & 295 deletions

File tree

Cargo.lock

Lines changed: 234 additions & 189 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ env_logger = "0.11"
2727
anyhow = "1"
2828
dirs = "6"
2929
clap = { version = "4", features = ["derive"] }
30-
reqwest = { version = "0.12", default-features = false, features = ["native-tls", "blocking"] }
30+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] }
3131
scraper = "0.22"
3232
zbus = "5"
3333
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

crates/webapps-manager/src/browser_dialog.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ pub fn show(
5454
.build();
5555

5656
// browser icon
57-
let icon = gtk::Image::from_icon_name(&browser.icon_name());
57+
let icon = gtk::Image::new();
5858
icon.set_pixel_size(32);
59+
crate::webapp_row::load_icon(&icon, &browser.icon_name());
5960
row.add_prefix(&icon);
6061

6162
// radio check button

crates/webapps-manager/src/favicon.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,24 @@ pub struct SiteInfo {
1111

1212
/// Fetch title + icons from URL (blocking — call from thread)
1313
pub fn fetch_site_info(url: &str) -> Result<SiteInfo> {
14+
// normalize: prepend https:// if no scheme
15+
let url = if !url.contains("://") {
16+
format!("https://{url}")
17+
} else {
18+
url.to_string()
19+
};
20+
1421
let client = reqwest::blocking::Client::builder()
1522
.user_agent("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0")
1623
.timeout(std::time::Duration::from_secs(10))
1724
.build()?;
1825

19-
let resp = client.get(url).send()?;
26+
let resp = client.get(&url).send()?;
2027
let html_text = resp.text()?;
2128
let doc = Html::parse_document(&html_text);
2229

2330
let title = extract_title(&doc).unwrap_or_default();
24-
let icon_urls = extract_icon_urls(&doc, url);
31+
let icon_urls = extract_icon_urls(&doc, &url);
2532

2633
// download icons to cache
2734
let cache = config::cache_dir().join("favicons");
@@ -37,7 +44,7 @@ pub fn fetch_site_info(url: &str) -> Result<SiteInfo> {
3744

3845
// try /favicon.ico fallback
3946
if icon_paths.is_empty() {
40-
if let Ok(base) = url::Url::parse(url) {
47+
if let Ok(base) = url::Url::parse(&url) {
4148
let favicon_url = format!("{}://{}/favicon.ico", base.scheme(), base.host_str().unwrap_or(""));
4249
if let Ok(path) = download_icon(&client, &favicon_url, &cache, 99) {
4350
icon_paths.push(path);

crates/webapps-manager/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod browser_dialog;
22
mod favicon;
33
mod service;
4+
mod style;
45
mod template_gallery;
56
mod webapp_dialog;
67
mod webapp_row;
@@ -20,6 +21,10 @@ fn main() {
2021
.application_id(config::APP_ID)
2122
.build();
2223

24+
app.connect_startup(|_| {
25+
style::load_css();
26+
});
27+
2328
app.connect_activate(|app| {
2429
window::build(app);
2530
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use gtk4 as gtk;
2+
use gtk4::gdk as gdk4;
3+
4+
const CSS: &str = r#"
5+
/* webapp icon — subtle rounding for modern look */
6+
.webapp-icon {
7+
border-radius: 10px;
8+
}
9+
10+
/* webapp row — refined spacing */
11+
.webapp-row {
12+
padding: 6px 12px;
13+
min-height: 56px;
14+
}
15+
16+
/* category header */
17+
.category-header {
18+
padding-top: 18px;
19+
padding-bottom: 6px;
20+
}
21+
22+
/* action button — circular, subtle */
23+
.action-btn {
24+
border-radius: 50%;
25+
min-width: 36px;
26+
min-height: 36px;
27+
padding: 6px;
28+
}
29+
30+
/* app mode badge */
31+
.app-mode-badge {
32+
font-size: 0.7em;
33+
font-weight: bold;
34+
padding: 2px 8px;
35+
border-radius: 12px;
36+
background: alpha(@accent_bg_color, 0.15);
37+
color: @accent_fg_color;
38+
}
39+
40+
/* empty state refinement */
41+
.empty-state-icon {
42+
opacity: 0.6;
43+
}
44+
45+
/* delete button hover emphasis */
46+
.action-btn.error:hover {
47+
background: alpha(@error_bg_color, 0.15);
48+
}
49+
"#;
50+
51+
pub fn load_css() {
52+
let provider = gtk::CssProvider::new();
53+
provider.load_from_data(CSS);
54+
55+
if let Some(display) = gdk4::Display::default() {
56+
gtk::style_context_add_provider_for_display(
57+
&display,
58+
&provider,
59+
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
60+
);
61+
}
62+
}

crates/webapps-manager/src/template_gallery.rs

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ use libadwaita as adw;
44
use adw::prelude::*;
55
use gettextrs::gettext;
66
use gtk::glib;
7-
use std::cell::RefCell;
87
use std::rc::Rc;
98
use webapps_core::templates::{build_default_registry, TemplateRegistry, WebAppTemplate};
109

11-
/// Show template gallery. Returns selected template_id via callback.
10+
/// Show template gallery. Fires callback immediately on selection.
1211
pub fn show(parent: &impl IsA<gtk::Window>, on_selected: impl Fn(String) + 'static) {
1312
let registry = build_default_registry();
14-
let selected_id: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
13+
let callback: Rc<dyn Fn(String)> = Rc::new(on_selected);
1514

1615
let win = adw::Window::builder()
1716
.title(&gettext("Choose a Template"))
@@ -46,31 +45,21 @@ pub fn show(parent: &impl IsA<gtk::Window>, on_selected: impl Fn(String) + 'stat
4645
content.append(&scroll);
4746

4847
// initial populate
49-
populate_all(&main_box, &registry, &selected_id, &win);
48+
populate_all(&main_box, &registry, &callback, &win);
5049

5150
// search handler
5251
{
5352
let mb = main_box.clone();
5453
let reg = registry;
55-
let sel = selected_id.clone();
54+
let cb = callback.clone();
5655
let w = win.clone();
5756
search_entry.connect_search_changed(move |entry| {
5857
let query = entry.text().to_string();
5958
clear_box(&mb);
6059
if query.is_empty() {
61-
populate_all(&mb, &reg, &sel, &w);
60+
populate_all(&mb, &reg, &cb, &w);
6261
} else {
63-
populate_search(&mb, &reg, &query, &sel, &w);
64-
}
65-
});
66-
}
67-
68-
// on close: fire callback if template was selected
69-
{
70-
let sel = selected_id.clone();
71-
win.connect_destroy(move |_| {
72-
if let Some(id) = sel.borrow_mut().take() {
73-
on_selected(id);
62+
populate_search(&mb, &reg, &query, &cb, &w);
7463
}
7564
});
7665
}
@@ -97,7 +86,7 @@ pub fn show(parent: &impl IsA<gtk::Window>, on_selected: impl Fn(String) + 'stat
9786
fn populate_all(
9887
container: &gtk::Box,
9988
registry: &TemplateRegistry,
100-
selected: &Rc<RefCell<Option<String>>>,
89+
callback: &Rc<dyn Fn(String)>,
10190
win: &adw::Window,
10291
) {
10392
let mut categories = registry.categories();
@@ -107,15 +96,15 @@ fn populate_all(
10796
if templates.is_empty() {
10897
continue;
10998
}
110-
add_category_section(container, cat, &templates, selected, win);
99+
add_category_section(container, cat, &templates, callback, win);
111100
}
112101
}
113102

114103
fn populate_search(
115104
container: &gtk::Box,
116105
registry: &TemplateRegistry,
117106
query: &str,
118-
selected: &Rc<RefCell<Option<String>>>,
107+
callback: &Rc<dyn Fn(String)>,
119108
win: &adw::Window,
120109
) {
121110
let results = registry.search(query);
@@ -126,24 +115,22 @@ fn populate_search(
126115
container.append(&label);
127116
return;
128117
}
129-
add_category_section(container, &gettext("Search Results"), &results, selected, win);
118+
add_category_section(container, &gettext("Search Results"), &results, callback, win);
130119
}
131120

132121
fn add_category_section(
133122
container: &gtk::Box,
134123
category: &str,
135124
templates: &[&WebAppTemplate],
136-
selected: &Rc<RefCell<Option<String>>>,
125+
callback: &Rc<dyn Fn(String)>,
137126
win: &adw::Window,
138127
) {
139-
// category header
140128
let header = gtk::Label::new(Some(category));
141129
header.set_halign(gtk::Align::Start);
142130
header.add_css_class("title-4");
143131
header.set_margin_top(8);
144132
container.append(&header);
145133

146-
// use a ListBox with ActionRows instead of FlowBox for simplicity
147134
let listbox = gtk::ListBox::new();
148135
listbox.add_css_class("boxed-list");
149136
listbox.set_selection_mode(gtk::SelectionMode::None);
@@ -154,15 +141,17 @@ fn add_category_section(
154141
.subtitle(&tpl.url)
155142
.activatable(true)
156143
.build();
157-
let icon = gtk::Image::from_icon_name(&tpl.icon);
144+
let icon = gtk::Image::new();
158145
icon.set_pixel_size(32);
146+
crate::webapp_row::load_icon(&icon, &tpl.icon);
159147
row.add_prefix(&icon);
160148

161-
let sel = selected.clone();
149+
// fire callback immediately, then close gallery
150+
let cb = callback.clone();
162151
let tid = tpl.template_id.clone();
163152
let w = win.clone();
164153
row.connect_activated(move |_| {
165-
*sel.borrow_mut() = Some(tid.clone());
154+
cb(tid.clone());
166155
w.close();
167156
});
168157

crates/webapps-manager/src/webapp_dialog.rs

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,15 @@ pub fn show(
5555

5656
// headerbar
5757
let header = adw::HeaderBar::new();
58-
if is_new {
59-
let tmpl_btn = gtk::Button::from_icon_name("view-grid-symbolic");
60-
tmpl_btn.set_tooltip_text(Some(&gettext("Templates")));
61-
let wc = webapp_cell.clone();
62-
let w = win.clone();
63-
tmpl_btn.connect_clicked(move |_| {
64-
let wc2 = wc.clone();
65-
template_gallery::show(&w, move |template_id| {
66-
let reg = build_default_registry();
67-
if let Some(tpl) = reg.get(&template_id) {
68-
wc2.borrow_mut().apply_template(tpl);
69-
}
70-
// UI sync happens via rebuilding; simplified approach
71-
});
72-
});
73-
header.pack_start(&tmpl_btn);
74-
}
58+
// placeholder for template button — will be wired after widgets exist
59+
let tmpl_btn = if is_new {
60+
let btn = gtk::Button::from_icon_name("view-grid-symbolic");
61+
btn.set_tooltip_text(Some(&gettext("Templates")));
62+
header.pack_start(&btn);
63+
Some(btn)
64+
} else {
65+
None
66+
};
7567
outer.append(&header);
7668

7769
// overlay for loading spinner
@@ -111,9 +103,13 @@ pub fn show(
111103
.title(&gettext("URL"))
112104
.text(&webapp_cell.borrow().app_url)
113105
.build();
114-
let detect_btn = gtk::Button::from_icon_name("emblem-web-symbolic");
106+
let detect_img = gtk::Image::from_icon_name("emblem-web-symbolic");
107+
detect_img.set_pixel_size(24);
108+
let detect_btn = gtk::Button::new();
109+
detect_btn.set_child(Some(&detect_img));
115110
detect_btn.set_tooltip_text(Some(&gettext("Detect name and icon from website")));
116111
detect_btn.set_valign(gtk::Align::Center);
112+
detect_btn.add_css_class("flat");
117113
url_row.add_suffix(&detect_btn);
118114
group.add(&url_row);
119115

@@ -128,15 +124,7 @@ pub fn show(
128124
let icon_row = adw::ActionRow::builder().title(&gettext("Icon")).build();
129125
let icon_preview = gtk::Image::new();
130126
icon_preview.set_pixel_size(32);
131-
{
132-
let icon_path = service::resolve_icon_path(&webapp_cell.borrow().app_icon);
133-
let p = std::path::Path::new(&icon_path);
134-
if p.is_absolute() && p.exists() {
135-
icon_preview.set_from_file(Some(p));
136-
} else {
137-
icon_preview.set_icon_name(Some(&icon_path));
138-
}
139-
}
127+
crate::webapp_row::load_icon(&icon_preview, &webapp_cell.borrow().app_icon);
140128
icon_row.add_prefix(&icon_preview);
141129
let icon_btn = gtk::Button::with_label(&gettext("Select"));
142130
icon_btn.set_valign(gtk::Align::Center);
@@ -238,6 +226,47 @@ pub fn show(
238226

239227
// -- wire up signals --
240228

229+
// Template button → populate URL, name, icon, category after selection
230+
if let Some(ref tb) = tmpl_btn {
231+
let wc = webapp_cell.clone();
232+
let w = win.clone();
233+
let ur = url_row.clone();
234+
let nr = name_row.clone();
235+
let ip = icon_preview.clone();
236+
let cd = cat_dropdown.clone();
237+
tb.connect_clicked(move |_| {
238+
let wc2 = wc.clone();
239+
let ur2 = ur.clone();
240+
let nr2 = nr.clone();
241+
let ip2 = ip.clone();
242+
let cd2 = cd.clone();
243+
template_gallery::show(&w, move |template_id| {
244+
log::info!("Template callback received: {}", &template_id);
245+
let reg = build_default_registry();
246+
if let Some(tpl) = reg.get(&template_id) {
247+
log::info!("Template found: {} url={}", &tpl.name, &tpl.url);
248+
wc2.borrow_mut().apply_template(tpl);
249+
// clone data before dropping borrow — set_text triggers connect_changed
250+
let (url, name, icon, cat) = {
251+
let data = wc2.borrow();
252+
(
253+
data.app_url.clone(),
254+
data.app_name.clone(),
255+
data.app_icon.clone(),
256+
data.main_category().to_string(),
257+
)
258+
};
259+
ur2.set_text(&url);
260+
nr2.set_text(&name);
261+
crate::webapp_row::load_icon(&ip2, &icon);
262+
if let Some(pos) = CATEGORIES.iter().position(|c| *c == cat) {
263+
cd2.set_selected(pos as u32);
264+
}
265+
}
266+
});
267+
});
268+
}
269+
241270
// URL changed
242271
{
243272
let wc = webapp_cell.clone();
@@ -368,7 +397,7 @@ pub fn show(
368397
match rx.try_recv() {
369398
Ok(info) => {
370399
sbr.set_visible(false);
371-
if !info.title.is_empty() && nrr.text().is_empty() {
400+
if !info.title.is_empty() {
372401
nrr.set_text(&info.title);
373402
wcr.borrow_mut().app_name = info.title.clone();
374403
}

0 commit comments

Comments
 (0)