diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..2ed219e --- /dev/null +++ b/plan.md @@ -0,0 +1,105 @@ +# Waycast Architecture Refactoring Plan + +## Objective +Separate "Waycast" core logic from UI implementation to enable multiple UI types (GTK, terminal, web, etc.) in the future. + +## Current State Analysis + +Right now `WaycastLauncher` has mixed responsibilities: +- Plugin management and initialization +- Search/filtering logic +- GTK UI creation and event handling +- Widget rendering and selection + +## Proposed Architecture + +### 1. Core Launcher Layer (`src/launcher/`) +```rust +pub struct WaycastLauncher { + plugins: Vec>, + plugins_show_always: Vec>, + plugins_by_prefix: HashMap>, + current_results: Vec>, +} + +impl WaycastLauncher { + pub fn new() -> LauncherBuilder { ... } + pub fn add_plugin(plugin: Box) -> Self { ... } + pub fn init_plugins(&self) { ... } + pub fn get_default_results(&mut self) -> &Vec> { ... } + pub fn search(&mut self, query: &str) -> &Vec> { ... } + pub fn execute_item(&self, index: usize) -> Result<(), LaunchError> { ... } +} +``` + +### 2. UI Abstraction Layer (`src/ui/`) +```rust +pub trait LauncherUI { + fn show(&self); + fn hide(&self); + fn set_results(&mut self, results: &[Box]); +} + +pub struct LauncherUIController { + launcher: WaycastLauncher, + ui: Box, +} +``` + +### 3. GTK Implementation (`src/ui/gtk/`) +```rust +pub struct GtkLauncherUI { + window: ApplicationWindow, + list_view: ListView, + list_store: ListStore, + // ... gtk specific fields +} + +impl LauncherUI for GtkLauncherUI { ... } +``` + +### 4. Future Terminal UI (`src/ui/terminal/`) +```rust +pub struct TerminalLauncherUI { + // crossterm/ratatui components +} + +impl LauncherUI for TerminalLauncherUI { ... } +``` + +## Implementation Plan + +### Phase 1: Extract Core Launcher +1. Create `src/launcher/mod.rs` +2. Move plugin management from `WaycastLauncher` to new `WaycastCore` +3. Move search/filtering logic to core +4. Keep UI-specific code in current location + +### Phase 2: Create UI Abstraction +1. Define `LauncherUI` trait +2. Create `LauncherUIController` to coordinate core + UI +3. Update `main.rs` to use controller pattern + +### Phase 3: Refactor GTK Implementation +1. Move GTK code to `src/ui/gtk/` +2. Implement `LauncherUI` trait for GTK +3. Remove UI logic from core launcher + +### Phase 4: Clean Interface +1. Define clean events/callbacks between core and UI +2. Handle UI -> Core communication (search input, item selection) +3. Handle Core -> UI communication (results updates, state changes) + +## Key Benefits + +- **Plugin logic** stays UI-agnostic +- **Search/filtering** can be unit tested without UI +- **Multiple UIs** can share the same core +- **Clear separation** of data vs presentation +- **Future expansion** for web UI, CLI, etc. + +## Implementation Status +- [x] Phase 1: Extract Core Launcher +- [x] Phase 2: Create UI Abstraction +- [x] Phase 3: Refactor GTK Implementation +- [x] Phase 4: Clean Interface \ No newline at end of file diff --git a/src/launcher/mod.rs b/src/launcher/mod.rs new file mode 100644 index 0000000..1266f32 --- /dev/null +++ b/src/launcher/mod.rs @@ -0,0 +1,83 @@ +use crate::{LauncherListItem, LauncherPlugin}; +use std::collections::HashMap; +use std::sync::Arc; + +pub struct WaycastLauncher { + plugins: Vec>, + plugins_show_always: Vec>, + plugins_by_prefix: HashMap>, + current_results: Vec>, +} + +impl WaycastLauncher { + pub fn new() -> Self { + WaycastLauncher { + plugins: Vec::new(), + plugins_show_always: Vec::new(), + plugins_by_prefix: HashMap::new(), + current_results: Vec::new(), + } + } +} + +impl WaycastLauncher { + pub fn add_plugin(mut self, plugin: Box) -> Self { + let p: Arc = plugin.into(); + if !p.by_prefix_only() { + self.plugins_show_always.push(Arc::clone(&p)); + } + + if let Some(prefix) = p.prefix() { + self.plugins_by_prefix.insert(prefix, Arc::clone(&p)); + } + + self.plugins.push(p); + self + } + + pub fn init(mut self) -> Self { + for p in &self.plugins { + p.init(); + } + + self.plugins.sort_by(|a, b| b.priority().cmp(&a.priority())); + self.plugins_show_always + .sort_by(|a, b| b.priority().cmp(&a.priority())); + + self + } + + pub fn get_default_results(&mut self) -> &Vec> { + self.current_results.clear(); + for plugin in &self.plugins_show_always { + for entry in plugin.default_list() { + self.current_results.push(entry); + } + } + &self.current_results + } + + pub fn search(&mut self, query: &str) -> &Vec> { + self.current_results.clear(); + + for plugin in &self.plugins { + for entry in plugin.filter(query) { + self.current_results.push(entry); + } + } + + &self.current_results + } + + pub fn execute_item(&self, index: usize) -> Result<(), crate::LaunchError> { + if let Some(item) = self.current_results.get(index) { + item.execute() + } else { + Err(crate::LaunchError::CouldNotLaunch("Invalid index".into())) + } + } + + pub fn current_results(&self) -> &Vec> { + &self.current_results + } +} diff --git a/src/lib.rs b/src/lib.rs index 1c44be6..57327c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod drun; +pub mod launcher; pub mod plugins; pub mod ui; pub mod util; diff --git a/src/main.rs b/src/main.rs index 8ab88e5..a1bbcbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,28 @@ use gtk::Application; use gtk::prelude::*; -use std::env; +use waycast::launcher::WaycastLauncher; use waycast::plugins; -use waycast::ui::WaycastLauncher; +use waycast::ui::controller::LauncherController; +use waycast::ui::gtk::GtkLauncherUI; -// TODO: Add an init() function to the launcher plugin spec -// that will get called when loaded. That way plugins like -// file search can index the file system on init instead -// of on the fly fn main() { let app = Application::builder() .application_id("dev.thegrind.waycast") .build(); app.connect_activate(|app| { + // Create the core launcher let launcher = WaycastLauncher::new() - .add_plugin(plugins::drun::DrunPlugin {}) - .add_plugin(plugins::file_search::FileSearchPlugin::new()) - .initialize(app); + .add_plugin(Box::new(plugins::drun::DrunPlugin {})) + .add_plugin(Box::new(plugins::file_search::FileSearchPlugin::new())) + .init(); - launcher.borrow().show(); + // Create the GTK UI + let ui = GtkLauncherUI::new(app); + + // Create and run the controller + let controller = LauncherController::new(launcher, ui); + controller.run(); }); app.run(); diff --git a/src/ui/controller.rs b/src/ui/controller.rs new file mode 100644 index 0000000..b068c88 --- /dev/null +++ b/src/ui/controller.rs @@ -0,0 +1,102 @@ +use super::gtk::GtkLauncherUI; +use super::traits::{LauncherUI, UIEvent}; +use crate::{LaunchError, launcher::WaycastLauncher}; +use gtk::glib; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::mpsc::{self, Receiver}; + +/// Controller that coordinates between the core launcher and UI +pub struct LauncherController { + launcher: Rc>, + ui: GtkLauncherUI, + event_receiver: Receiver, +} + +impl LauncherController { + pub fn new(launcher: WaycastLauncher, mut ui: GtkLauncherUI) -> Self { + let (event_sender, event_receiver) = mpsc::channel(); + + // Set up the event sender in the UI + ui.set_event_sender(event_sender); + + Self { + launcher: Rc::new(RefCell::new(launcher)), + ui, + event_receiver, + } + } + + pub fn initialize(&mut self) { + // Populate with default results + let mut launcher = self.launcher.borrow_mut(); + let results = launcher.get_default_results(); + self.ui.set_results(results); + } + + pub fn show(&self) { + self.ui.show(); + } + + pub fn handle_event(&mut self, event: UIEvent) -> Result<(), LaunchError> { + match event { + UIEvent::SearchChanged(query) => { + let mut launcher = self.launcher.borrow_mut(); + let results = if query.trim().is_empty() { + launcher.get_default_results() + } else { + launcher.search(&query) + }; + self.ui.set_results(results); + } + + UIEvent::ItemActivated(index) => match self.launcher.borrow().execute_item(index) { + Ok(_) => { + self.ui.hide(); + } + Err(e) => { + eprintln!("Failed to launch item: {:?}", e); + return Err(e); + } + }, + + UIEvent::ItemSelected(_index) => { + // Handle selection change if needed + // For now, this is just for keyboard navigation + } + + UIEvent::CloseRequested => { + self.ui.hide(); + } + } + + Ok(()) + } + + pub fn process_events(&mut self) -> Result<(), LaunchError> { + while let Ok(event) = self.event_receiver.try_recv() { + self.handle_event(event)?; + } + Ok(()) + } + + pub fn run(mut self) { + self.initialize(); + self.show(); + + // Set up periodic event processing using glib's idle callback + let controller = Rc::new(RefCell::new(self)); + let controller_clone = controller.clone(); + + glib::idle_add_local(move || { + if let Ok(mut ctrl) = controller_clone.try_borrow_mut() { + if let Err(e) = ctrl.process_events() { + eprintln!("Error processing events: {:?}", e); + } + } + glib::ControlFlow::Continue + }); + + // The GTK main loop will handle the rest + } +} diff --git a/src/ui/gtk/mod.rs b/src/ui/gtk/mod.rs new file mode 100644 index 0000000..655f2fb --- /dev/null +++ b/src/ui/gtk/mod.rs @@ -0,0 +1,425 @@ +use crate::LauncherListItem; +use super::traits::{LauncherUI, UIEvent}; +use gtk::gdk::Texture; +use gtk::gdk_pixbuf::Pixbuf; +use gtk::prelude::*; +use gtk::{ + Application, ApplicationWindow, Box as GtkBox, Entry, EventControllerKey, IconTheme, Image, + Label, ListView, Orientation, ScrolledWindow, SignalListItemFactory, SingleSelection, +}; +use gio::ListStore; +use gtk::subclass::prelude::ObjectSubclassIsExt; +use gtk4_layer_shell as layerShell; +use layerShell::LayerShell; +use std::path::PathBuf; +use std::sync::mpsc::Sender; + +// GObject wrapper to store LauncherListItem in GTK's model system +mod imp { + use gtk::glib; + use gtk::subclass::prelude::*; + use std::cell::RefCell; + + #[derive(Default)] + pub struct LauncherItemObject { + pub title: RefCell, + pub description: RefCell>, + pub icon: RefCell, + pub index: RefCell, // Store index to access original entry + } + + #[glib::object_subclass] + impl ObjectSubclass for LauncherItemObject { + const NAME: &'static str = "WaycastLauncherItemObject"; + type Type = super::LauncherItemObject; + type ParentType = glib::Object; + } + + impl ObjectImpl for LauncherItemObject {} +} + +glib::wrapper! { + pub struct LauncherItemObject(ObjectSubclass); +} + +impl LauncherItemObject { + pub fn new(title: String, description: Option, icon: String, index: usize) -> Self { + let obj: Self = glib::Object::new(); + let imp = obj.imp(); + + // Store the data + *imp.title.borrow_mut() = title; + *imp.description.borrow_mut() = description; + *imp.icon.borrow_mut() = icon; + *imp.index.borrow_mut() = index; + + obj + } + + pub fn title(&self) -> String { + self.imp().title.borrow().clone() + } + + pub fn icon(&self) -> String { + self.imp().icon.borrow().clone() + } + + pub fn index(&self) -> usize { + *self.imp().index.borrow() + } +} + +pub struct GtkLauncherUI { + window: ApplicationWindow, + list_view: ListView, + list_store: ListStore, + selection: SingleSelection, + search_input: Entry, + event_sender: Option>, + current_results: Vec>, // Keep reference for indexing +} + +impl GtkLauncherUI { + pub fn new(app: &Application) -> Self { + 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); + + // Create the list store and selection model + let list_store = ListStore::new::(); + let selection = SingleSelection::new(Some(list_store.clone())); + + // Create factory for rendering list items + let factory = SignalListItemFactory::new(); + + // Setup factory to create widgets + factory.connect_setup(move |_, list_item| { + let container = GtkBox::new(Orientation::Horizontal, 10); + list_item.set_child(Some(&container)); + }); + + // Setup factory to bind data to widgets + factory.connect_bind(move |_, list_item| { + let child = list_item.child().and_downcast::().unwrap(); + + // Clear existing children + while let Some(first_child) = child.first_child() { + child.remove(&first_child); + } + + if let Some(item_obj) = list_item.item().and_downcast::() { + let display = gtk::gdk::Display::default().unwrap(); + let icon_theme = gtk::IconTheme::for_display(&display); + let icon_size = 48; + + // Create icon + let image: gtk::Image; + if let Some(icon_path) = find_icon_file(&item_obj.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 { + if let Some(default) = find_icon_file("vscode", "48", &icon_theme) { + image = gtk::Image::from_file(default); + } else { + image = Image::from_icon_name("application-x-executable"); + } + } + image.set_pixel_size(icon_size); + + // Create label + let label = Label::new(Some(&item_obj.title())); + label.set_xalign(0.0); + + child.append(&image); + child.append(&label); + } + }); + + let list_view = ListView::new(Some(selection.clone()), Some(factory)); + list_view.set_vexpand(true); + list_view.set_can_focus(true); + + scrolled_window.set_child(Some(&list_view)); + 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); + + // Set initial focus to search input so user can start typing immediately + search_input.grab_focus(); + + Self { + window, + list_view, + list_store, + selection, + search_input, + event_sender: None, + current_results: Vec::new(), + } + } + + pub fn set_event_sender(&mut self, sender: Sender) { + self.event_sender = Some(sender.clone()); + + // Connect search input signal + let sender_clone = sender.clone(); + self.search_input.connect_changed(move |entry| { + let query = entry.text().to_string(); + let _ = sender_clone.send(UIEvent::SearchChanged(query)); + }); + + // Connect Enter key activation for search input + let sender_clone = sender.clone(); + let selection_clone = self.selection.clone(); + self.search_input.connect_activate(move |_| { + if let Some(selected_item) = selection_clone.selected_item() { + if let Some(item_obj) = selected_item.downcast_ref::() { + let index = item_obj.index(); + let _ = sender_clone.send(UIEvent::ItemActivated(index)); + } + } + }); + + // Add key handler for launcher-style navigation + let search_key_controller = EventControllerKey::new(); + let selection_clone = self.selection.clone(); + search_key_controller.connect_key_pressed(move |_controller, keyval, _keycode, _state| { + match keyval { + gtk::gdk::Key::Down => { + let current_pos = selection_clone.selected(); + let n_items = selection_clone.model().unwrap().n_items(); + if current_pos < n_items - 1 { + selection_clone.set_selected(current_pos + 1); + } else if n_items > 0 && current_pos == gtk::INVALID_LIST_POSITION { + selection_clone.set_selected(0); + } + gtk::glib::Propagation::Stop + } + gtk::gdk::Key::Up => { + let current_pos = selection_clone.selected(); + if current_pos > 0 { + selection_clone.set_selected(current_pos - 1); + } + gtk::glib::Propagation::Stop + } + _ => gtk::glib::Propagation::Proceed, + } + }); + self.search_input.add_controller(search_key_controller); + + // Add ESC key handler at window level + let window_key_controller = EventControllerKey::new(); + let sender_clone = sender.clone(); + window_key_controller.connect_key_pressed(move |_controller, keyval, _keycode, _state| { + if keyval == gtk::gdk::Key::Escape { + let _ = sender_clone.send(UIEvent::CloseRequested); + gtk::glib::Propagation::Stop + } else { + gtk::glib::Propagation::Proceed + } + }); + self.window.add_controller(window_key_controller); + + // Connect list activation signal + let sender_clone = sender; + self.list_view.connect_activate(move |_, position| { + let _ = sender_clone.send(UIEvent::ItemActivated(position as usize)); + }); + } +} + +impl LauncherUI for GtkLauncherUI { + fn show(&self) { + self.window.present(); + } + + fn hide(&self) { + self.window.close(); + } + + fn set_results(&mut self, results: &[Box]) { + // Clear the list store + self.list_store.remove_all(); + + // Store results for indexing + self.current_results = results.iter() + .map(|item| { + // Create a simple wrapper that implements the trait + // This is a workaround since we can't clone trait objects + Box::new(SimpleListItem { + title: item.title(), + description: item.description(), + icon: item.icon(), + original_ptr: 0, // Not used for execution + }) as Box + }) + .collect(); + + // Add all entries to the store + for (index, entry) in results.iter().enumerate() { + let item_obj = LauncherItemObject::new( + entry.title(), + entry.description(), + entry.icon(), + index + ); + self.list_store.append(&item_obj); + } + + // Select the first item if available + if self.list_store.n_items() > 0 { + self.selection.set_selected(0); + } + } + + fn is_visible(&self) -> bool { + self.window.is_visible() + } +} + +// Helper struct to store launcher item data +struct SimpleListItem { + title: String, + description: Option, + icon: String, + original_ptr: usize, // Not used for execution, just for storage +} + +impl LauncherListItem for SimpleListItem { + fn title(&self) -> String { + self.title.clone() + } + + fn description(&self) -> Option { + self.description.clone() + } + + fn icon(&self) -> String { + self.icon.clone() + } + + fn execute(&self) -> Result<(), crate::LaunchError> { + // This should never be called - the controller handles execution + // using the original items + Err(crate::LaunchError::CouldNotLaunch("Cannot execute from UI wrapper".into())) + } +} + +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 diff --git a/src/ui/launcher_builder.rs b/src/ui/launcher_builder.rs deleted file mode 100644 index 3a36b3e..0000000 --- a/src/ui/launcher_builder.rs +++ /dev/null @@ -1,19 +0,0 @@ -use crate::LauncherPlugin; -use crate::ui::WaycastLauncher; -use gtk::Application; -use std::cell::RefCell; -use std::rc::Rc; -pub struct WaycastLauncherBuilder { - pub plugins: Vec>, -} - -impl WaycastLauncherBuilder { - pub fn add_plugin(mut self, plugin: T) -> Self { - self.plugins.push(Box::new(plugin)); - self - } - - pub fn initialize(self, app: &Application) -> Rc> { - WaycastLauncher::create_with_plugins(app, self.plugins) - } -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d934446..573b037 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,482 +1,8 @@ -use crate::{LauncherListItem, LauncherPlugin}; -use gtk::gdk::Texture; -use gtk::gdk_pixbuf::Pixbuf; -use gtk::prelude::*; -use gtk::{ - Application, ApplicationWindow, Box as GtkBox, Entry, EventControllerKey, IconTheme, Image, - Label, ListView, Orientation, ScrolledWindow, SignalListItemFactory, SingleSelection, -}; -use gio::ListStore; -use gtk::subclass::prelude::ObjectSubclassIsExt; -use gtk4_layer_shell as layerShell; -use layerShell::LayerShell; -use std::cell::RefCell; -use std::collections::HashMap; -use std::path::PathBuf; -use std::rc::Rc; -mod launcher_builder; -use launcher_builder::WaycastLauncherBuilder; -use std::sync::Arc; +pub mod traits; +pub mod controller; +pub mod gtk; -// GObject wrapper to store LauncherListItem in GTK's model system -mod imp { - use gtk::glib; - use gtk::subclass::prelude::*; - use std::cell::RefCell; - - #[derive(Default)] - pub struct LauncherItemObject { - pub title: RefCell, - pub description: RefCell>, - pub icon: RefCell, - pub index: RefCell, // Store index to access original entry - } - - #[glib::object_subclass] - impl ObjectSubclass for LauncherItemObject { - const NAME: &'static str = "WaycastLauncherItemObject"; - type Type = super::LauncherItemObject; - type ParentType = glib::Object; - } - - impl ObjectImpl for LauncherItemObject {} -} - -glib::wrapper! { - pub struct LauncherItemObject(ObjectSubclass); -} - -impl LauncherItemObject { - pub fn new(title: String, description: Option, icon: String, index: usize) -> Self { - let obj: Self = glib::Object::new(); - let imp = obj.imp(); - - // Store the data - *imp.title.borrow_mut() = title; - *imp.description.borrow_mut() = description; - *imp.icon.borrow_mut() = icon; - *imp.index.borrow_mut() = index; - - obj - } - - pub fn title(&self) -> String { - self.imp().title.borrow().clone() - } - - pub fn icon(&self) -> String { - self.imp().icon.borrow().clone() - } - - pub fn index(&self) -> usize { - *self.imp().index.borrow() - } -} - -pub struct WaycastLauncher { - pub window: ApplicationWindow, - pub list_view: ListView, - pub list_store: ListStore, - pub selection: SingleSelection, - pub entries: Vec>, - // All plugins - pub plugins: Vec>, - // Plugins with by_prefix_only()->false - pub plugins_show_always: Vec>, - // Prefix hash map - pub plugins_by_prefix: HashMap>, -} - -impl WaycastLauncher { - pub fn new() -> WaycastLauncherBuilder { - WaycastLauncherBuilder { - plugins: Vec::new(), - } - } -} - - -impl WaycastLauncher { - fn create_with_plugins( - app: &Application, - init_plugins: Vec>, - ) -> 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); - - // Create the list store and selection model - let list_store = ListStore::new::(); - let selection = SingleSelection::new(Some(list_store.clone())); - - // Create factory for rendering list items - let factory = SignalListItemFactory::new(); - - // Setup factory to create widgets - factory.connect_setup(move |_, list_item| { - let container = GtkBox::new(Orientation::Horizontal, 10); - list_item.set_child(Some(&container)); - }); - - // Setup factory to bind data to widgets - factory.connect_bind(move |_, list_item| { - let child = list_item.child().and_downcast::().unwrap(); - - // Clear existing children - while let Some(first_child) = child.first_child() { - child.remove(&first_child); - } - - if let Some(item_obj) = list_item.item().and_downcast::() { - let display = gtk::gdk::Display::default().unwrap(); - let icon_theme = gtk::IconTheme::for_display(&display); - let icon_size = 48; - - // Create icon - let image: gtk::Image; - if let Some(icon_path) = find_icon_file(&item_obj.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 { - if let Some(default) = find_icon_file("vscode", "48", &icon_theme) { - image = gtk::Image::from_file(default); - } else { - image = Image::from_icon_name("application-x-executable"); - } - } - image.set_pixel_size(icon_size); - - // Create label - let label = Label::new(Some(&item_obj.title())); - label.set_xalign(0.0); - - child.append(&image); - child.append(&label); - } - }); - - let list_view = ListView::new(Some(selection.clone()), Some(factory)); - list_view.set_vexpand(true); - list_view.set_can_focus(true); - - scrolled_window.set_child(Some(&list_view)); - 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 mut plugins: Vec> = Vec::new(); - for p in init_plugins { - plugins.push(Arc::from(p)); - } - plugins.sort_by(|a, b| b.priority().cmp(&a.priority())); - // Organize plugins for faster querying - let mut plugins_show_always: Vec> = Vec::new(); - for p in &plugins { - if !p.by_prefix_only() { - plugins_show_always.push(Arc::clone(p)); - } - } - - let mut plugins_by_prefix: HashMap> = HashMap::new(); - for p in &plugins { - if let Some(prefix) = p.prefix() { - plugins_by_prefix.insert(prefix, Arc::clone(p)); - } - } - - // Init the launcher model - let entries: Vec> = Vec::new(); - let model: Rc> = Rc::new(RefCell::new(WaycastLauncher { - window, - list_view: list_view.clone(), - list_store: list_store.clone(), - selection: selection.clone(), - entries, - plugins, - plugins_show_always, - plugins_by_prefix, - })); - - model.borrow().init_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(); - model_clone.borrow_mut().filter_list(&query); - }); - - // Connect Enter key activation for search input - let selection_clone_for_activate = selection.clone(); - let model_clone_for_activate = model.clone(); - search_input.connect_activate(move |_| { - println!("Search entry activated!"); - if let Some(selected_item) = selection_clone_for_activate.selected_item() { - if let Some(item_obj) = selected_item.downcast_ref::() { - let model_ref = model_clone_for_activate.borrow(); - let index = item_obj.index(); - 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 selection_clone_for_search = selection.clone(); - search_key_controller.connect_key_pressed(move |_controller, keyval, _keycode, _state| { - match keyval { - gtk::gdk::Key::Down => { - let current_pos = selection_clone_for_search.selected(); - let n_items = selection_clone_for_search.model().unwrap().n_items(); - if current_pos < n_items - 1 { - selection_clone_for_search.set_selected(current_pos + 1); - } else if n_items > 0 && current_pos == gtk::INVALID_LIST_POSITION { - selection_clone_for_search.set_selected(0); - } - gtk::glib::Propagation::Stop - } - gtk::gdk::Key::Up => { - let current_pos = selection_clone_for_search.selected(); - if current_pos > 0 { - selection_clone_for_search.set_selected(current_pos - 1); - } - 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 list activation signal to launch app and close launcher - let model_clone_2 = model.clone(); - list_view.connect_activate(move |_, position| { - let model_ref = model_clone_2.borrow(); - if let Some(item) = model_ref.list_store.item(position) { - if let Some(item_obj) = item.downcast_ref::() { - let index = item_obj.index(); - 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 - } - - fn init_plugins(&self) { - for plugin in &self.plugins { - plugin.init(); - } - } - - pub fn clear_list_ui(&mut self) { - self.list_store.remove_all(); - } - - pub fn render_list(&mut self) { - // Clear the list store - self.list_store.remove_all(); - - // Add all entries to the store - for (index, entry) in self.entries.iter().enumerate() { - let item_obj = LauncherItemObject::new( - entry.title(), - entry.description(), - entry.icon(), - index - ); - self.list_store.append(&item_obj); - } - - // Select the first item if available - if self.list_store.n_items() > 0 { - self.selection.set_selected(0); - } - } - - - pub fn populate_list(&mut self) { - self.entries.clear(); - for plugin in &self.plugins_show_always { - 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 -} +// Re-export commonly used items +pub use traits::{LauncherUI, UIEvent, ControllerEvent}; +pub use controller::LauncherController; +pub use gtk::GtkLauncherUI; \ No newline at end of file diff --git a/src/ui/traits.rs b/src/ui/traits.rs new file mode 100644 index 0000000..42ccbbb --- /dev/null +++ b/src/ui/traits.rs @@ -0,0 +1,40 @@ +use crate::LauncherListItem; + +/// Trait defining the interface for any launcher UI implementation +pub trait LauncherUI { + /// Show the launcher UI + fn show(&self); + + /// Hide the launcher UI + fn hide(&self); + + /// Update the UI with new search results + fn set_results(&mut self, results: &[Box]); + + /// Check if the UI is currently visible + fn is_visible(&self) -> bool; +} + +/// Events that the UI can send to the controller +#[derive(Debug, Clone)] +pub enum UIEvent { + /// User typed in the search box + SearchChanged(String), + /// User selected an item (by index) + ItemSelected(usize), + /// User activated an item (pressed Enter or clicked) + ItemActivated(usize), + /// User requested to close the launcher (Escape key) + CloseRequested, +} + +/// Events that the controller can send to the UI +#[derive(Debug, Clone)] +pub enum ControllerEvent { + /// Results have been updated + ResultsUpdated, + /// An error occurred + Error(String), + /// Close the UI + Close, +} \ No newline at end of file