use gio::prelude::*; use gtk::gdk::Texture; use gtk::gdk_pixbuf::Pixbuf; use gtk::prelude::*; use gtk::{ Application, ApplicationWindow, Box as GtkBox, Entry, EventControllerKey, IconTheme, Image, Label, ListBox, Orientation, ScrolledWindow, SearchEntry, }; use gtk4_layer_shell as layerShell; use layerShell::LayerShell; use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; use waycast::{LauncherListItem, LauncherPlugin, drun, plugins}; struct AppModel { window: ApplicationWindow, list_box: ListBox, entries: Vec>, plugins: Vec>, } struct ListItem { text: String, icon: String, } impl ListItem { fn new(text: String, icon: String) -> Self { Self { text, icon } } fn create_widget(&self) -> GtkBox { let container = GtkBox::new(Orientation::Horizontal, 10); let display = gtk::gdk::Display::default().unwrap(); let icon_theme = gtk::IconTheme::for_display(&display); let icon_size = 48; let image: gtk::Image; if let Some(icon_path) = find_icon_file(&self.icon, "48", &icon_theme) { image = match Pixbuf::from_file_at_scale(icon_path, icon_size, icon_size, true) { Ok(pb) => { let tex = Texture::for_pixbuf(&pb); gtk::Image::from_paintable(Some(&tex)) } Err(e) => { eprintln!("err: {}", e); Image::from_icon_name("application-x-executable") } } } else { let default = find_icon_file("vscode", "48", &icon_theme).unwrap(); image = gtk::Image::from_file(default); } image.set_pixel_size(icon_size); // image.set_icon_name(Some("application-x-executable")); // Safe fallback let label = Label::new(Some(&self.text)); label.set_xalign(0.0); container.append(&image); container.append(&label); container } } impl AppModel { fn new(app: &Application) -> Rc> { let window = ApplicationWindow::builder() .application(app) .title("Waycast") .default_width(800) .default_height(500) .resizable(false) .build(); let main_box = GtkBox::new(Orientation::Vertical, 0); let search_input = SearchEntry::new(); search_input.set_placeholder_text(Some("Search...")); let scrolled_window = ScrolledWindow::new(); scrolled_window.set_min_content_height(300); let list_box = ListBox::new(); list_box.set_vexpand(true); list_box.set_can_focus(true); list_box.set_activate_on_single_click(false); scrolled_window.set_child(Some(&list_box)); main_box.append(&search_input); main_box.append(&scrolled_window); window.set_child(Some(&main_box)); // Set up layer shell so the launcher can float window.init_layer_shell(); let edges = [ layerShell::Edge::Top, layerShell::Edge::Bottom, layerShell::Edge::Left, layerShell::Edge::Right, ]; for edge in edges { window.set_anchor(edge, false); } window.set_keyboard_mode(layerShell::KeyboardMode::OnDemand); window.set_layer(layerShell::Layer::Top); let entries: Vec> = Vec::new(); let plugins: Vec> = vec![Box::from(plugins::drun::DrunPlugin {})]; let model: Rc> = Rc::new(RefCell::new(AppModel { window, list_box: list_box.clone(), entries, plugins, })); // Populate the list model.borrow_mut().populate_list(); // Set initial focus to search input so user can start typing immediately search_input.grab_focus(); // Set up SearchEntry to capture keys from the window for proper navigation search_input.set_key_capture_widget(Some(&model.borrow().window)); // Connect search input signal let model_clone = model.clone(); search_input.connect_changed(move |entry| { let query = entry.text().to_string(); println!("query: {query}"); model_clone.borrow_mut().filter_list(&query); }); // Connect search navigation signals let list_box_clone_for_next = list_box.clone(); let list_box_clone_for_prev = list_box.clone(); search_input.connect_next_match(move |_| { if let Some(selected_row) = list_box_clone_for_next.selected_row() { let index = selected_row.index(); if let Some(next_row) = list_box_clone_for_next.row_at_index(index + 1) { list_box_clone_for_next.select_row(Some(&next_row)); } } else if let Some(first_row) = list_box_clone_for_next.row_at_index(0) { list_box_clone_for_next.select_row(Some(&first_row)); } }); search_input.connect_previous_match(move |_| { if let Some(selected_row) = list_box_clone_for_prev.selected_row() { let index = selected_row.index(); if index > 0 { if let Some(prev_row) = list_box_clone_for_prev.row_at_index(index - 1) { list_box_clone_for_prev.select_row(Some(&prev_row)); } } } }); // Add ESC key handler to close window let key_controller = EventControllerKey::new(); let window_clone = model.borrow().window.clone(); key_controller.connect_key_pressed(move |_controller, keyval, _keycode, _state| { if keyval == gtk::gdk::Key::Escape { window_clone.close(); gtk::glib::Propagation::Stop } else { gtk::glib::Propagation::Proceed } }); model.borrow().window.add_controller(key_controller); // Connect row activation signal to launch app and close launcher let model_clone_2 = model.clone(); list_box.connect_row_activated(move |_, row| { let index = row.index() as usize; let model_ref = model_clone_2.borrow(); if let Some(entry) = model_ref.entries.get(index) { println!("Launching app: {}", entry.title()); match entry.execute() { Ok(_) => { println!("App launched successfully, closing launcher"); // Close the launcher window after successful launch model_ref.window.close(); } Err(e) => { eprintln!("Failed to launch app: {:?}", e); } } } }); model } fn clear_list_ui(&self) { while let Some(child) = self.list_box.first_child() { self.list_box.remove(&child); } } fn render_list(&self) { self.clear_list_ui(); for entry in &self.entries { let list_item = ListItem::new(entry.title(), entry.icon()); let widget = list_item.create_widget(); self.list_box.append(&widget); } // Always select the first item if available if let Some(first_row) = self.list_box.row_at_index(0) { self.list_box.select_row(Some(&first_row)); } } fn populate_list(&mut self) { self.entries.clear(); for plugin in &self.plugins { for entry in plugin.default_list() { self.entries.push(entry); } } self.render_list(); } fn filter_list(&mut self, query: &str) { self.entries.clear(); for plugin in &self.plugins { for entry in plugin.filter(query) { self.entries.push(entry); } } self.render_list(); } fn show(&self) { self.window.present(); } } fn find_icon_file( icon_name: &str, size: &str, icon_theme: &IconTheme, ) -> Option { let pixmap_paths: Vec = icon_theme .search_path() .into_iter() .filter(|p| p.to_string_lossy().contains("pixmap")) .collect(); let search_paths: Vec = icon_theme .search_path() .into_iter() .filter(|p| p.to_string_lossy().contains("icons")) .collect(); let sizes = [size, "scalable"]; let categories = ["apps", "applications", "mimetypes"]; let extensions = ["svg", "png", "xpm"]; // Build the search paths let mut search_in: Vec = Vec::new(); // Do all the theme directories first and high color second for base in &search_paths { for size in sizes { for cat in &categories { let path = base .join(icon_theme.theme_name()) .join(if !(size == "scalable".to_string()) { format!("{}x{}", size, size) } else { size.to_string() }) .join(cat); if path.exists() { search_in.push(path); } } } } for base in &search_paths { for size in sizes { for cat in &categories { let path = base .join("hicolor") .join(if !(size == "scalable".to_string()) { format!("{}x{}", size, size) } else { size.to_string() }) .join(cat); if path.exists() { search_in.push(path); } } } } // Last resort, search pixmaps directly (no subdirectories) for base in &pixmap_paths { if !base.exists() { continue; } for ext in &extensions { let direct_icon = base.join(format!("{}.{}", icon_name, ext)); if direct_icon.exists() { return Some(direct_icon); } } } for s in &search_in { for ext in &extensions { let icon_path = s.join(format!("{}.{}", icon_name, ext)); if icon_path.exists() { return Some(icon_path); } } } None } fn main() { let app = Application::builder() .application_id("dev.thegrind.waycast") .build(); app.connect_activate(|app| { let model = AppModel::new(app); model.borrow().show(); }); app.run(); // gtk::init().expect("Failed to init GTK"); // let display = gtk::gdk::Display::default().unwrap(); // let icon_theme = gtk::IconTheme::for_display(&display); // println!("Current icon theme: {:?}", icon_theme.theme_name()); // for p in icon_theme.search_path() { // println!("{}", p.to_string_lossy()); // } }