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