This commit is contained in:
Javier Feliz 2025-09-03 00:20:33 -04:00
commit b203b6de52
11 changed files with 1697 additions and 0 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use nix

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

85
CLAUDE.md Normal file
View File

@ -0,0 +1,85 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Waycast is a GTK4-based application launcher for Wayland compositors, built with Rust using the relm4 framework. It provides a floating launcher interface that displays desktop applications with icons and allows users to search and launch them.
## Build and Development Commands
```bash
# Build the project
cargo build
# Run the application
cargo run
# Build for release
cargo build --release
# Check code formatting
cargo fmt --check
# Format code
cargo fmt
# Run clippy lints
cargo clippy
# Run tests (if any exist)
cargo test
```
## Architecture
### Core Components
- **main.rs**: Contains the main GTK4/relm4 application with two primary components:
- `AppModel`: Main window component with search functionality
- `ListItem`: Factory component for rendering individual launcher items
- **lib.rs**: Defines core traits and types:
- `LauncherListItem` trait: Interface for launchable items
- `LaunchError` enum: Error handling for launch operations
- **drun module** (`src/drun/mod.rs`): Handles desktop application discovery
- `DesktopEntry` struct: Represents a .desktop file
- `all()` function: Scans XDG_DATA_DIRS for desktop applications
- Implements `LauncherListItem` trait for desktop entries
- **util module** (`src/util/`): Utility functions
- `files.rs`: File system operations, particularly `get_files_with_extension()`
### Key Technologies
- **GTK4**: UI framework with gtk4-layer-shell for Wayland layer shell protocol
- **relm4**: Reactive UI framework for GTK4 applications
- **gio**: GLib I/O library for desktop app info and icon handling
### Important Implementation Details
- Uses gtk4-layer-shell to create a floating overlay window on Wayland
- Desktop applications are discovered by parsing .desktop files from XDG_DATA_DIRS
- Icons are handled through GIO's Icon system (ThemedIcon and FileIcon)
- Factory pattern is used for efficiently rendering lists of launcher items
### Lifetime Management
When working with GTK widgets in relm4 view macros, be careful with string references. The view macro context has specific lifetime requirements:
- Avoid returning `&str` from methods called in view macros
- Use `self.field.as_ref().map(|s| s.as_str())` pattern for Option<String> to Option<&str> conversion
- Static strings work fine, but dynamic references may cause stack overflows
### Module Structure
```
src/
├── main.rs # Main application and UI components
├── lib.rs # Core traits and error types
├── drun/
│ └── mod.rs # Desktop application discovery
└── util/
├── mod.rs # Utility module exports
└── files.rs # File system utilities
```

1235
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "waycast"
version = "0.1.0"
edition = "2024"
[dependencies]
gio = "0.21.1"
glib = "0.21.1"
gtk = { version = "0.10.0", package = "gtk4" }
gtk4-layer-shell = "0.6.1"
relm4 = "0.10.0"
relm4-components = "0.10.0"
tracker = "0.2.2"

37
shell.nix Normal file
View File

@ -0,0 +1,37 @@
# shell.nix
{
pkgs ? import <nixpkgs> { },
}:
pkgs.mkShell {
# Tools youll use directly
buildInputs = [
pkgs.pkg-config
# GTK4 stack
pkgs.gtk4
pkgs.glib
pkgs.gdk-pixbuf
pkgs.pango
pkgs.cairo
pkgs.harfbuzz
# Wayland + layer shell (GTK4 variant)
pkgs.wayland
pkgs.gtk4-layer-shell
# Icons (so themed icons resolve; harmless even if you don't use yet)
pkgs.hicolor-icon-theme
pkgs.adwaita-icon-theme
];
shellHook = ''
export GDK_BACKEND=wayland
echo "gtk4: $(pkg-config --modversion gtk4 2>/dev/null || echo missing)"
echo "gtk4-layer-shell: $(pkg-config --modversion gtk4-layer-shell-0 2>/dev/null || echo missing)"
echo "wayland-client: $(pkg-config --modversion wayland-client 2>/dev/null || echo missing)"
echo "XDG_SESSION_TYPE=$XDG_SESSION_TYPE"
echo "WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
echo "GDK_BACKEND=$GDK_BACKEND"
'';
}

118
src/drun/mod.rs Normal file
View File

@ -0,0 +1,118 @@
use crate::util::files;
use crate::{LaunchError, LauncherListItem};
use gio::{AppInfo, DesktopAppInfo, Icon, prelude::*};
use std::env;
use std::path::{Path, PathBuf};
#[derive(Debug)]
struct DesktopEntry {
id: String,
name: String,
generic_name: Option<glib::GString>,
description: Option<glib::GString>,
icon: Option<Icon>,
exec: Option<glib::GString>,
path: PathBuf,
no_display: bool,
is_terminal_app: bool,
}
impl LauncherListItem for DesktopEntry {
fn title(&self) -> String {
return self.name.to_owned();
}
fn description(&self) -> Option<String> {
if let Some(glib_string) = &self.description {
return Some(glib_string.to_string().to_owned());
}
return None;
}
fn execute(&self) -> Result<(), LaunchError> {
if let Some(di) = DesktopAppInfo::from_filename(&self.path) {
let app: AppInfo = di.upcast();
let ctx = gio::AppLaunchContext::new();
if app.launch(&[], Some(&ctx)).ok().is_none() {
return Err(LaunchError::CouldNotLaunch("App failed to launch".into()));
};
return Ok(());
}
return Err(LaunchError::CouldNotLaunch("Invalid .desktop entry".into()));
}
fn icon(&self) -> String {
if let Some(icon) = &self.icon {
if let Ok(ti) = icon.clone().downcast::<gio::ThemedIcon>() {
// ThemedIcon may have multiple names, we take the first
if let Some(name) = ti.names().first() {
return name.to_string();
}
}
if let Ok(fi) = icon.clone().downcast::<gio::FileIcon>() {
if let Some(path) = fi.file().path() {
return path.to_string_lossy().to_string();
}
}
}
return "application-x-executable".into();
}
}
fn get_desktop_files() -> Vec<PathBuf> {
let dir_envs =
env::var("XDG_DATA_DIRS").expect("XDG_DATA_DIRS not set. Please fix your environment");
let dir_string = String::from(dir_envs);
let dirs = dir_string.split(":");
let mut files = Vec::new();
for dir in dirs {
// println!("Data dir: {}", dir);
let apps_path = Path::new(dir).join("applications");
let desktop_files = match files::get_files_with_extension(&apps_path, "desktop") {
Ok(files) => files,
Err(_) => {
// eprintln!("Error reading {dir}: {err}");
continue;
}
};
for f in desktop_files {
files.push(f);
}
}
return files;
}
pub fn all() -> Vec<Box<dyn LauncherListItem>> {
let mut entries: Vec<Box<dyn LauncherListItem>> = Vec::new();
for f in get_desktop_files() {
if let Some(info) = DesktopAppInfo::from_filename(&f) {
if info.is_nodisplay() {
continue;
}
let de = DesktopEntry {
id: info.id().unwrap_or_default().to_string(),
name: info.name().to_string(),
generic_name: info.generic_name(),
description: info.description(),
icon: info.icon(),
exec: info.string("Exec"),
no_display: info.is_nodisplay(),
path: f.clone(),
is_terminal_app: info.boolean("Terminal"),
};
entries.push(Box::new(de));
}
}
entries
}

12
src/lib.rs Normal file
View File

@ -0,0 +1,12 @@
pub mod drun;
pub mod util;
pub enum LaunchError {
CouldNotLaunch(String),
}
pub trait LauncherListItem {
fn title(&self) -> String;
fn description(&self) -> Option<String>;
fn execute(&self) -> Result<(), LaunchError>;
fn icon(&self) -> String;
}

168
src/main.rs Normal file
View File

@ -0,0 +1,168 @@
use gio::{Icon, prelude::*};
use gtk::{
Box as GtkBox, CssProvider, Entry, IconLookupFlags, IconTheme, Image, Label, ListBox, ListView,
NoSelection, Orientation, STYLE_PROVIDER_PRIORITY_APPLICATION, ScrolledWindow,
SignalListItemFactory, StringList, StringObject, Window,
};
use gtk::{Button, prelude::*};
use gtk4_layer_shell as layerShell;
use layerShell::LayerShell;
use relm4::factory::{FactoryComponent, FactorySender, FactoryVecDeque};
use relm4::{Component, ComponentParts, RelmApp, RelmWidgetExt, SimpleComponent, component};
use waycast::{LaunchError, LauncherListItem, drun};
struct AppModel {
list_items: FactoryVecDeque<ListItem>,
}
#[derive(Debug)]
enum AppMsg {
TextEntered(String),
ListItemSelected(String),
None,
}
#[derive(Debug)]
struct ListItem {
text: String,
icon: String,
}
#[relm4::factory]
impl FactoryComponent for ListItem {
type Init = (String, String);
type Input = ();
type Output = ();
type CommandOutput = ();
type ParentWidget = gtk::ListBox;
view! {
#[root]
GtkBox {
set_orientation: Orientation::Horizontal,
set_spacing: 10,
Image {
set_pixel_size: 50,
set_icon_name: Some(self.icon.as_str()),
},
Label {
set_xalign: 0.0,
set_label: &self.text
},
}
}
fn init_model(
(text, icon): Self::Init,
_index: &Self::Index,
_sender: FactorySender<Self>,
) -> Self {
Self { text, icon }
}
}
#[relm4::component]
impl SimpleComponent for AppModel {
type Init = StringList;
type Input = AppMsg;
type Output = ();
view! {
#[name = "launcher_window"]
Window {
set_title: Some("Waycast"),
set_default_width: 800,
set_default_height: 500,
set_resizable: false,
GtkBox {
set_orientation: Orientation::Vertical,
#[name = "search_input"]
Entry {
set_placeholder_text: Some("Search..."),
connect_changed[sender] => move |e| {
sender.input(AppMsg::TextEntered(e.text().to_string()));
}
},
ScrolledWindow {
set_min_content_height: 300,
#[local_ref]
items -> ListBox {
set_vexpand: true,
}
}
}
}
}
fn init(
_list_items: Self::Init,
root: Self::Root,
sender: relm4::ComponentSender<Self>,
) -> relm4::ComponentParts<Self> {
let mut list_items: FactoryVecDeque<ListItem> = FactoryVecDeque::builder()
.launch(ListBox::default())
.forward(sender.input_sender(), |_| AppMsg::None);
{
let mut guard = list_items.guard();
println!("Starting to load desktop entries...");
let entries = drun::all();
println!("Found {} entries", entries.len());
for p in entries {
guard.push_back((p.title(), p.icon()));
}
println!("Finished loading entries");
}
let model = AppModel { list_items };
let items = model.list_items.widget();
let widgets = view_output!();
// Set up layer shell so the launcher can float
// like it's supposed to.
widgets.launcher_window.init_layer_shell();
let edges = [
layerShell::Edge::Top,
layerShell::Edge::Bottom,
layerShell::Edge::Left,
layerShell::Edge::Right,
];
for edge in edges {
widgets.launcher_window.set_anchor(edge, false);
}
widgets
.launcher_window
.set_keyboard_mode(layerShell::KeyboardMode::OnDemand);
widgets.launcher_window.set_layer(layerShell::Layer::Top);
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, _sender: relm4::ComponentSender<Self>) {
match message {
AppMsg::TextEntered(query) => {
println!("query: {query}");
}
_ => unimplemented!(),
}
}
}
macro_rules! yesno {
($var:expr) => {
if $var { "Yes" } else { "No" }
};
}
fn main() {
let app = RelmApp::new("dev.thegrind.waycast");
app.run::<AppModel>(StringList::new(&[]));
// let entries = drun::all();
// for e in &entries {
// println!("---------------------");
// println!("App: {}", e.title());
// println!("Icon: {}", e.icon().unwrap_or("<NONE>".into()));
// }
}

26
src/util/files.rs Normal file
View File

@ -0,0 +1,26 @@
use std::path::{Path, PathBuf};
use std::{fs, io};
pub fn get_files_with_extension<P: AsRef<Path>>(
dir: P,
extension: &str,
) -> io::Result<Vec<PathBuf>> {
let entries = fs::read_dir(dir)?;
let desktop_files: Vec<_> = entries
.filter_map(|res| res.ok())
.map(|f| f.path())
.filter(|path| {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext == extension)
.unwrap_or(false)
})
.collect();
let mut files = Vec::new();
for f in desktop_files {
files.push(f);
}
return Ok(files);
}

1
src/util/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod files;