shepherd-launcher/crates/shepherd-launcher-ui/src/tile.rs
Albert Armea 9da95a27b3 Add "steam"-specific type
This implementation allows each platform to choose how to launch Steam (on Linux, we use the snap as the examples suggested before), and keeps Steam alive after an activity exits so that save sync, game updates, etc. can continue to run.

Change written by Codex 5.2 on medium:

Consider this GitHub issue https://github.com/aarmea/shepherd-launcher/issues/4. On Linux, an activity that uses the "steam" type should launch Steam via the snap as shown in the example configuration in this repository.

Go ahead and implement the feature. I'm expecting one of the tricky bits to be killing the activity while keeping Steam alive, as we can no longer just kill the Steam snap cgroup.
2026-02-07 16:22:55 -05:00

154 lines
5 KiB
Rust

//! Individual tile widget for the launcher grid
use gtk4::glib;
use gtk4::prelude::*;
use gtk4::subclass::prelude::*;
use shepherd_api::EntryView;
use std::cell::RefCell;
mod imp {
use super::*;
#[derive(Default)]
pub struct LauncherTile {
pub entry: RefCell<Option<EntryView>>,
pub icon: gtk4::Image,
pub label: gtk4::Label,
}
#[glib::object_subclass]
impl ObjectSubclass for LauncherTile {
const NAME: &'static str = "ShepherdLauncherTile";
type Type = super::LauncherTile;
type ParentType = gtk4::Button;
}
impl ObjectImpl for LauncherTile {
fn constructed(&self) {
self.parent_constructed();
let obj = self.obj();
// Create layout
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
content.set_halign(gtk4::Align::Center);
content.set_valign(gtk4::Align::Center);
// Icon
self.icon.set_pixel_size(96);
self.icon.set_icon_name(Some("application-x-executable"));
content.append(&self.icon);
// Label
self.label.set_wrap(true);
self.label.set_wrap_mode(gtk4::pango::WrapMode::Word);
self.label.set_justify(gtk4::Justification::Center);
self.label.set_max_width_chars(12);
self.label.add_css_class("tile-label");
content.append(&self.label);
obj.set_child(Some(&content));
obj.add_css_class("launcher-tile");
obj.add_css_class("flat");
obj.set_size_request(160, 160);
}
}
impl WidgetImpl for LauncherTile {}
impl ButtonImpl for LauncherTile {}
}
glib::wrapper! {
pub struct LauncherTile(ObjectSubclass<imp::LauncherTile>)
@extends gtk4::Button, gtk4::Widget,
@implements gtk4::Accessible, gtk4::Actionable, gtk4::Buildable, gtk4::ConstraintTarget;
}
impl LauncherTile {
pub fn new() -> Self {
glib::Object::builder().build()
}
pub fn set_entry(&self, entry: EntryView) {
let imp = self.imp();
// Set label
imp.label.set_text(&entry.label);
// Determine fallback icon based on entry kind
let fallback_icon = match entry.kind_tag {
shepherd_api::EntryKindTag::Process => "application-x-executable",
shepherd_api::EntryKindTag::Snap => "application-x-executable",
shepherd_api::EntryKindTag::Steam => "application-x-executable",
shepherd_api::EntryKindTag::Flatpak => "application-x-executable",
shepherd_api::EntryKindTag::Vm => "computer",
shepherd_api::EntryKindTag::Media => "video-x-generic",
shepherd_api::EntryKindTag::Custom => "applications-other",
};
// Set icon, first trying to load as an image file, then as an icon name
if let Some(ref icon_ref) = entry.icon_ref {
let mut loaded = false;
// First, try to load as an image file (JPG, PNG, etc.)
// Expand ~ to home directory if present
let expanded_path = if icon_ref.starts_with("~/") {
if let Some(home) = dirs::home_dir() {
icon_ref.replacen("~", &home.to_string_lossy(), 1)
} else {
icon_ref.clone()
}
} else {
icon_ref.clone()
};
let path = std::path::Path::new(&expanded_path);
if path.exists() && path.is_file() {
// Try to load as an image file
imp.icon.set_from_file(Some(path));
loaded = true;
}
// If not loaded as a file, try as an icon name from the theme
if !loaded {
let icon_theme = gtk4::IconTheme::for_display(&self.display());
if icon_theme.has_icon(icon_ref) {
imp.icon.set_icon_name(Some(icon_ref));
} else {
imp.icon.set_icon_name(Some(fallback_icon));
}
}
} else {
imp.icon.set_icon_name(Some(fallback_icon));
}
// Entry is available if enabled and has no blocking reasons
let available = entry.enabled && entry.reasons.is_empty();
self.set_sensitive(available);
// Add tooltip with reason if not available
if !available && !entry.reasons.is_empty() {
// Format the first reason for tooltip
let reason_text = format!("{:?}", entry.reasons[0]);
self.set_tooltip_text(Some(&reason_text));
} else {
self.set_tooltip_text(None);
}
*imp.entry.borrow_mut() = Some(entry);
}
pub fn entry(&self) -> Option<EntryView> {
self.imp().entry.borrow().clone()
}
pub fn entry_id(&self) -> Option<shepherd_util::EntryId> {
self.imp().entry.borrow().as_ref().map(|e| e.entry_id.clone())
}
}
impl Default for LauncherTile {
fn default() -> Self {
Self::new()
}
}