diff --git a/src/lib.rs b/src/lib.rs index 6fd0bd1..73bec28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod drun; pub mod plugins; +pub mod ui; pub mod util; #[derive(Debug)] pub enum LaunchError { diff --git a/src/main.rs b/src/main.rs index 37b873d..9ded1fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,360 +1,6 @@ -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, -}; -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, 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 = Entry::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(); - - // 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 Enter key activation for search input - let list_box_clone_for_activate = list_box.clone(); - let model_clone_for_activate = model.clone(); - search_input.connect_activate(move |_| { - println!("Search entry activated!"); - if let Some(selected_row) = list_box_clone_for_activate.selected_row() { - let index = selected_row.index() as usize; - let model_ref = model_clone_for_activate.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"); - model_ref.window.close(); - } - Err(e) => { - eprintln!("Failed to launch app: {:?}", e); - } - } - } - } - }); - - // Add key handler for launcher-style navigation - let search_key_controller = EventControllerKey::new(); - let list_box_clone_for_search = list_box.clone(); - let model_clone_for_enter = model.clone(); - search_key_controller.connect_key_pressed(move |_controller, keyval, _keycode, _state| { - match keyval { - gtk::gdk::Key::Down => { - // Move to next item in list - if let Some(selected_row) = list_box_clone_for_search.selected_row() { - let index = selected_row.index(); - if let Some(next_row) = list_box_clone_for_search.row_at_index(index + 1) { - list_box_clone_for_search.select_row(Some(&next_row)); - } - } else if let Some(first_row) = list_box_clone_for_search.row_at_index(0) { - list_box_clone_for_search.select_row(Some(&first_row)); - } - gtk::glib::Propagation::Stop - } - gtk::gdk::Key::Up => { - // Move to previous item in list - if let Some(selected_row) = list_box_clone_for_search.selected_row() { - let index = selected_row.index(); - if index > 0 { - if let Some(prev_row) = list_box_clone_for_search.row_at_index(index - 1) { - list_box_clone_for_search.select_row(Some(&prev_row)); - } - } - } - gtk::glib::Propagation::Stop - } - _ => gtk::glib::Propagation::Proceed - } - }); - search_input.add_controller(search_key_controller); - - // Add simple ESC key handler at window level - let window_key_controller = EventControllerKey::new(); - let window_clone = model.borrow().window.clone(); - window_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(window_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 -} +use gtk::Application; +use waycast::ui::AppModel; fn main() { let app = Application::builder() @@ -367,13 +13,4 @@ fn main() { }); 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()); - // } -} +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..a89a787 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,353 @@ +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, +}; +use gtk4_layer_shell as layerShell; +use layerShell::LayerShell; +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; +use crate::{LauncherListItem, LauncherPlugin, plugins}; + +pub struct AppModel { + pub window: ApplicationWindow, + pub list_box: ListBox, + pub entries: Vec>, + pub plugins: Vec>, +} + +pub struct ListItem { + text: String, + icon: String, +} + +impl ListItem { + pub fn new(text: String, icon: String) -> Self { + Self { text, icon } + } + + pub 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); + + let label = Label::new(Some(&self.text)); + label.set_xalign(0.0); + + container.append(&image); + container.append(&label); + container + } +} + +impl AppModel { + pub 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 = Entry::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(); + + // 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 Enter key activation for search input + let list_box_clone_for_activate = list_box.clone(); + let model_clone_for_activate = model.clone(); + search_input.connect_activate(move |_| { + println!("Search entry activated!"); + if let Some(selected_row) = list_box_clone_for_activate.selected_row() { + let index = selected_row.index() as usize; + let model_ref = model_clone_for_activate.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"); + model_ref.window.close(); + } + Err(e) => { + eprintln!("Failed to launch app: {:?}", e); + } + } + } + } + }); + + // Add key handler for launcher-style navigation + let search_key_controller = EventControllerKey::new(); + let list_box_clone_for_search = list_box.clone(); + search_key_controller.connect_key_pressed(move |_controller, keyval, _keycode, _state| { + match keyval { + gtk::gdk::Key::Down => { + // Move to next item in list + if let Some(selected_row) = list_box_clone_for_search.selected_row() { + let index = selected_row.index(); + if let Some(next_row) = list_box_clone_for_search.row_at_index(index + 1) { + list_box_clone_for_search.select_row(Some(&next_row)); + } + } else if let Some(first_row) = list_box_clone_for_search.row_at_index(0) { + list_box_clone_for_search.select_row(Some(&first_row)); + } + gtk::glib::Propagation::Stop + } + gtk::gdk::Key::Up => { + // Move to previous item in list + if let Some(selected_row) = list_box_clone_for_search.selected_row() { + let index = selected_row.index(); + if index > 0 { + if let Some(prev_row) = list_box_clone_for_search.row_at_index(index - 1) { + list_box_clone_for_search.select_row(Some(&prev_row)); + } + } + } + gtk::glib::Propagation::Stop + } + _ => gtk::glib::Propagation::Proceed + } + }); + search_input.add_controller(search_key_controller); + + // Add simple ESC key handler at window level + let window_key_controller = EventControllerKey::new(); + let window_clone = model.borrow().window.clone(); + window_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(window_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"); + model_ref.window.close(); + } + Err(e) => { + eprintln!("Failed to launch app: {:?}", e); + } + } + } + }); + + model + } + + pub fn clear_list_ui(&self) { + while let Some(child) = self.list_box.first_child() { + self.list_box.remove(&child); + } + } + + pub 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)); + } + } + + pub 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(); + } + + pub 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(); + } + + pub 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 +} \ No newline at end of file