Started some decoupling of UI and logic

This commit is contained in:
Javier Feliz 2025-09-04 20:50:16 -04:00
parent 7bd95a8179
commit 2e2676acd5
9 changed files with 776 additions and 510 deletions

105
plan.md Normal file
View File

@ -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<Arc<dyn LauncherPlugin>>,
plugins_show_always: Vec<Arc<dyn LauncherPlugin>>,
plugins_by_prefix: HashMap<String, Arc<dyn LauncherPlugin>>,
current_results: Vec<Box<dyn LauncherListItem>>,
}
impl WaycastLauncher {
pub fn new() -> LauncherBuilder { ... }
pub fn add_plugin(plugin: Box<dyn LauncherPlugin>) -> Self { ... }
pub fn init_plugins(&self) { ... }
pub fn get_default_results(&mut self) -> &Vec<Box<dyn LauncherListItem>> { ... }
pub fn search(&mut self, query: &str) -> &Vec<Box<dyn LauncherListItem>> { ... }
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<dyn LauncherListItem>]);
}
pub struct LauncherUIController {
launcher: WaycastLauncher,
ui: Box<dyn LauncherUI>,
}
```
### 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

83
src/launcher/mod.rs Normal file
View File

@ -0,0 +1,83 @@
use crate::{LauncherListItem, LauncherPlugin};
use std::collections::HashMap;
use std::sync::Arc;
pub struct WaycastLauncher {
plugins: Vec<Arc<dyn LauncherPlugin>>,
plugins_show_always: Vec<Arc<dyn LauncherPlugin>>,
plugins_by_prefix: HashMap<String, Arc<dyn LauncherPlugin>>,
current_results: Vec<Box<dyn LauncherListItem>>,
}
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<dyn LauncherPlugin>) -> Self {
let p: Arc<dyn LauncherPlugin> = 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<Box<dyn LauncherListItem>> {
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<Box<dyn LauncherListItem>> {
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<Box<dyn LauncherListItem>> {
&self.current_results
}
}

View File

@ -1,4 +1,5 @@
pub mod drun;
pub mod launcher;
pub mod plugins;
pub mod ui;
pub mod util;

View File

@ -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();

102
src/ui/controller.rs Normal file
View File

@ -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<RefCell<WaycastLauncher>>,
ui: GtkLauncherUI,
event_receiver: Receiver<UIEvent>,
}
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
}
}

425
src/ui/gtk/mod.rs Normal file
View File

@ -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<String>,
pub description: RefCell<Option<String>>,
pub icon: RefCell<String>,
pub index: RefCell<usize>, // 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<imp::LauncherItemObject>);
}
impl LauncherItemObject {
pub fn new(title: String, description: Option<String>, 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<Sender<UIEvent>>,
current_results: Vec<Box<dyn LauncherListItem>>, // 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::<LauncherItemObject>();
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::<GtkBox>().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::<LauncherItemObject>() {
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<UIEvent>) {
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::<LauncherItemObject>() {
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<dyn LauncherListItem>]) {
// 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<dyn LauncherListItem>
})
.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<String>,
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<String> {
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<std::path::PathBuf> {
let pixmap_paths: Vec<PathBuf> = icon_theme
.search_path()
.into_iter()
.filter(|p| p.to_string_lossy().contains("pixmap"))
.collect();
let search_paths: Vec<PathBuf> = 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<PathBuf> = 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
}

View File

@ -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<Box<dyn LauncherPlugin>>,
}
impl WaycastLauncherBuilder {
pub fn add_plugin<T: LauncherPlugin + 'static>(mut self, plugin: T) -> Self {
self.plugins.push(Box::new(plugin));
self
}
pub fn initialize(self, app: &Application) -> Rc<RefCell<WaycastLauncher>> {
WaycastLauncher::create_with_plugins(app, self.plugins)
}
}

View File

@ -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<String>,
pub description: RefCell<Option<String>>,
pub icon: RefCell<String>,
pub index: RefCell<usize>, // 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<imp::LauncherItemObject>);
}
impl LauncherItemObject {
pub fn new(title: String, description: Option<String>, 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<Box<dyn LauncherListItem>>,
// All plugins
pub plugins: Vec<Arc<dyn LauncherPlugin>>,
// Plugins with by_prefix_only()->false
pub plugins_show_always: Vec<Arc<dyn LauncherPlugin>>,
// Prefix hash map
pub plugins_by_prefix: HashMap<String, Arc<dyn LauncherPlugin>>,
}
impl WaycastLauncher {
pub fn new() -> WaycastLauncherBuilder {
WaycastLauncherBuilder {
plugins: Vec::new(),
}
}
}
impl WaycastLauncher {
fn create_with_plugins(
app: &Application,
init_plugins: Vec<Box<dyn LauncherPlugin>>,
) -> Rc<RefCell<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::<LauncherItemObject>();
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::<GtkBox>().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::<LauncherItemObject>() {
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<Arc<dyn LauncherPlugin>> = 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<Arc<dyn LauncherPlugin>> = Vec::new();
for p in &plugins {
if !p.by_prefix_only() {
plugins_show_always.push(Arc::clone(p));
}
}
let mut plugins_by_prefix: HashMap<String, Arc<dyn LauncherPlugin>> = 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<Box<dyn LauncherListItem>> = Vec::new();
let model: Rc<RefCell<WaycastLauncher>> = 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::<LauncherItemObject>() {
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::<LauncherItemObject>() {
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<std::path::PathBuf> {
let pixmap_paths: Vec<PathBuf> = icon_theme
.search_path()
.into_iter()
.filter(|p| p.to_string_lossy().contains("pixmap"))
.collect();
let search_paths: Vec<PathBuf> = 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<PathBuf> = 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;

40
src/ui/traits.rs Normal file
View File

@ -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<dyn LauncherListItem>]);
/// 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,
}