Files
sunstone/crates/sunstone-gtk/src/components/source_picker.rs
T

167 lines
5.6 KiB
Rust

//! Shared relay-source picker: a MenuButton whose popover lists the user's
//! relays / favorites / recents with a type-anything search, Jumble-style.
//! Pure GTK (no relm4 messages) so streams and videos can reuse it cheaply.
use std::cell::RefCell;
use std::collections::HashSet;
use std::rc::Rc;
use std::sync::Arc;
use relm4::gtk;
use relm4::gtk::prelude::*;
use sunstone_core::Core;
fn normalize_relay_url(input: &str) -> Option<String> {
let input = input.trim().trim_end_matches('/');
if input.is_empty() || !input.contains('.') || input.contains(char::is_whitespace) {
return None;
}
let url = if input.contains("://") {
input.to_string()
} else {
format!("wss://{input}")
};
(url.starts_with("wss://") || url.starts_with("ws://")).then_some(url)
}
pub fn relay_label(url: &str) -> String {
url.trim_start_matches("wss://")
.trim_start_matches("ws://")
.trim_end_matches('/')
.to_string()
}
/// `default_label` names the None choice (e.g. "Your relays", "Following").
/// `on_pick(None)` = default source; `on_pick(Some(url))` = a specific relay.
pub fn source_picker<F>(core: Arc<Core>, default_label: &str, on_pick: F) -> gtk::MenuButton
where
F: Fn(Option<String>) + 'static,
{
let on_pick = Rc::new(on_pick);
let entries: Rc<RefCell<Vec<Option<String>>>> = Rc::new(RefCell::new(Vec::new()));
let search = gtk::SearchEntry::builder()
.placeholder_text("Type a relay url…")
.build();
let list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.min_content_height(200)
.max_content_height(320)
.propagate_natural_height(true)
.child(&list)
.build();
let popover_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.width_request(300)
.build();
popover_box.append(&search);
popover_box.append(&scroll);
let popover = gtk::Popover::builder().child(&popover_box).build();
let button = gtk::MenuButton::builder()
.label(default_label)
.popover(&popover)
.build();
let default_label = default_label.to_string();
let rebuild = {
let core = core.clone();
let list = list.clone();
let entries = entries.clone();
let default_label = default_label.clone();
Rc::new(move |query: &str| {
list.remove_all();
let mut slot = entries.borrow_mut();
slot.clear();
let query = query.to_lowercase();
let mut urls: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
{
let user = core.user_relays.lock().unwrap();
urls.extend(user.iter().map(|(u, _)| u.trim_end_matches('/').to_string()));
}
urls.extend(
core.favorite_relays
.lock()
.unwrap()
.iter()
.map(|u| u.trim_end_matches('/').to_string()),
);
{
let settings = core.settings.lock().unwrap();
urls.extend(settings.relays.iter().map(|u| u.trim_end_matches('/').to_string()));
urls.extend(
settings
.recent_relays
.iter()
.map(|u| u.trim_end_matches('/').to_string()),
);
}
urls.retain(|u| seen.insert(u.clone()));
let mut rows: Vec<(String, Option<String>)> = Vec::new();
if query.is_empty() || default_label.to_lowercase().contains(&query) {
rows.push((default_label.clone(), None));
}
for url in urls {
if query.is_empty() || url.to_lowercase().contains(&query) {
rows.push((relay_label(&url), Some(url)));
}
}
if let Some(url) = normalize_relay_url(&query) {
if !seen.contains(url.trim_end_matches('/')) {
rows.push((format!("Go to {}", relay_label(&url)), Some(url)));
}
}
for (label, value) in rows {
let row_label = gtk::Label::builder()
.label(&label)
.xalign(0.0)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.ellipsize(gtk::pango::EllipsizeMode::Middle)
.build();
list.append(&row_label);
slot.push(value);
}
})
};
rebuild("");
{
let rebuild = rebuild.clone();
search.connect_search_changed(move |entry| rebuild(&entry.text()));
}
{
let rebuild = rebuild.clone();
let search = search.clone();
popover.connect_show(move |_| rebuild(&search.text()));
}
{
let button = button.clone();
let popover = popover.clone();
list.connect_row_activated(move |_, row| {
let slot = entries.borrow();
let Some(value) = slot.get(row.index() as usize) else {
return;
};
button.set_label(&match value {
None => default_label.clone(),
Some(url) => relay_label(url),
});
popover.popdown();
on_pick(value.clone());
});
}
button
}