Started some decoupling of UI and logic
This commit is contained in:
parent
7bd95a8179
commit
2e2676acd5
105
plan.md
Normal file
105
plan.md
Normal 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
83
src/launcher/mod.rs
Normal 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
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
pub mod drun;
|
||||
pub mod launcher;
|
||||
pub mod plugins;
|
||||
pub mod ui;
|
||||
pub mod util;
|
||||
|
23
src/main.rs
23
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();
|
||||
|
102
src/ui/controller.rs
Normal file
102
src/ui/controller.rs
Normal 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
425
src/ui/gtk/mod.rs
Normal 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
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
488
src/ui/mod.rs
488
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<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
40
src/ui/traits.rs
Normal 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,
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user