WIP
This commit is contained in:
commit
b203b6de52
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
85
CLAUDE.md
Normal file
85
CLAUDE.md
Normal 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
1235
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal 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
37
shell.nix
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# shell.nix
|
||||||
|
{
|
||||||
|
pkgs ? import <nixpkgs> { },
|
||||||
|
}:
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
# Tools you’ll 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
118
src/drun/mod.rs
Normal 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
12
src/lib.rs
Normal 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
168
src/main.rs
Normal 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
26
src/util/files.rs
Normal 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
1
src/util/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod files;
|
Loading…
x
Reference in New Issue
Block a user