initial commit

This commit is contained in:
2026-07-18 11:21:00 -07:00
commit 17ac4a4b3b
9 changed files with 2456 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target/
+83
View File
@@ -0,0 +1,83 @@
# Repository Guidelines
## Purpose and scope
`hypr-switcher` provides macOS-style application switching and hiding on
Hyprland without compositor patches. Prefer stock Hyprland IPC operations over
changes to the Hyprland fork. A compositor change should only be considered
when the required operation cannot be expressed through the command or event
sockets.
## Project structure
- `src/main.rs`: daemon lifecycle, local command socket, switch sessions, GTK
layer-shell HUD, and keyboard handling.
- `src/ipc.rs`: Hyprland command/event sockets and window operations.
- `src/model.rs`: client data, application grouping, address normalization, and
focus history.
- `src/desktop.rs`: XDG desktop-file metadata and icon lookup.
- `../hyprland.conf`: source configuration integration. The live config may be
deployed from another checkout; verify it before editing or reloading.
Add focused modules under `src/` rather than allowing `main.rs` to absorb
unrelated parsing or model logic.
## Behavioral invariants
- The process must remain a daemon. A one-shot switcher cannot retain a useful
MRU history between invocations.
- Group applications exactly as `Client::app_key` does: current class, then
initial class, then title, then address.
- Freeze normal MRU updates while a HUD session is active. Preview focus events
must not mutate the ordering captured at the start of that session.
- Keep application and same-application window cycle lists stable for the
duration of a cycle. Re-sorting after each preview causes two-window
oscillation instead of traversal.
- A selected application raises all of its windows, with the MRU representative
raised and focused last.
- The HUD owns an exclusive layer-shell keyboard grab. Release and hide it
before final focus, retaining the short delay in `Switcher::finish`; removing
that delay produces raised but unfocused windows.
- Hidden applications live in `ipc::HIDDEN_WORKSPACE`, are appended after
visible applications, and are not unhidden merely by previewing them. Restore
them only when the selection is committed.
- Hidden state must remain discoverable from Hyprland client metadata after a
daemon restart; do not make it depend solely on in-memory state.
## Build and validation
Before handing off changes, run:
```sh
cargo fmt --check
cargo test
cargo clippy --workspace --all-targets -- -D warnings
cargo build --release
```
Install a tested build with:
```sh
cargo install --path . --root ~/.local --force
```
After installation, restart the existing daemon. For configuration changes,
validate and reload the actual active Hyprland config, then inspect live binds
and `hyprctl configerrors`.
For runtime testing, use a disposable window/class rather than moving or
hiding the user's real applications. Confirm layer presence through
`hyprctl layers`, focus through `hyprctl activewindow`, and hidden placement
through `hyprctl clients`.
## Style and tests
Use Rust 2021 idioms and standard `rustfmt`. Keep IPC errors contextual and log
recoverable runtime failures without crashing the daemon. Add unit tests beside
pure parsing, grouping, and ordering logic. UI and compositor behavior require
proportional runtime checks in a live Hyprland session.
Preserve unrelated dirty changes in both configuration checkouts. Use
`readlink -f ~/.config/hypr/hyprland.conf` before assuming the workspace copy is
the active one.
Generated
+1080
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "hypr-switcher"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1"
env_logger = "0.11"
glib = "0.20"
gtk4 = "0.9"
gtk4-layer-shell = "0.4"
log = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+107
View File
@@ -0,0 +1,107 @@
# hypr-switcher
A macOS-style application switcher for Hyprland. It runs as a small GTK
layer-shell daemon, tracks focus through Hyprland's event socket, and exposes
commands for application and same-application window switching.
The application HUD groups windows by Hyprland `class`, orders applications and
their windows by most-recent focus, and resolves names and icons from installed
desktop files. Selecting an application raises all of its windows and focuses
its most-recent window.
This implementation uses only standard Hyprland IPC. It does not depend on the
custom `cycleapp` or `cycleappwindow` dispatchers.
## Behavior
- `Super+Tab` / `Super+Shift+Tab`: show the application HUD and move selection.
- Release `Super` or press `Enter`: accept the selection.
- `Escape`: cancel and restore the original application.
- `Super+grave` / `Super+Shift+grave`: cycle windows in the current application.
- `Super+H`: hide every window in the current application.
Hidden windows are moved silently to `special:hypr-switcher-hidden`. Their
applications remain visible, dimmed, at the end of the HUD. Selecting a hidden
application restores all of its windows to the current workspace and focuses
its most-recent window. The special workspace also lets the daemon rediscover
hidden applications after a restart.
The HUD displays applications from the current workspace plus hidden
applications. Normal application switching does not pull visible windows from
other workspaces.
## Requirements
- Hyprland with its command and event IPC sockets enabled (the default)
- GTK 4
- gtk4-layer-shell
- A working icon theme and desktop files under the standard XDG data paths
- Rust and Cargo to build from source
## Build and install
Run this from the directory containing this README:
```sh
cargo test
cargo clippy --workspace --all-targets -- -D warnings
cargo install --path . --root ~/.local --force
```
The binary is installed as `~/.local/bin/hypr-switcher`. The accompanying
`../hyprland.conf` starts the daemon and defines the bindings:
```ini
$switcher = ~/.local/bin/hypr-switcher
exec-once = $switcher daemon
layerrule = match:namespace ^(hypr-switcher)$, no_anim 1
bind = SUPER, Tab, exec, $switcher show
bind = SUPER SHIFT, Tab, exec, $switcher show-previous
bind = SUPER, grave, exec, $switcher window-next
bind = SUPER SHIFT, grave, exec, $switcher window-previous
bind = SUPER, H, exec, $switcher hide-current
```
After rebuilding, restart the daemon so the running process uses the new
binary. Reload Hyprland after changing bindings.
## Commands
```sh
hypr-switcher daemon
hypr-switcher show
hypr-switcher show-previous
hypr-switcher window-next
hypr-switcher window-previous
hypr-switcher hide-current
hypr-switcher cancel
```
Set `RUST_LOG=hypr_switcher=debug` on the daemon command for diagnostics.
## How it works
- `src/ipc.rs` talks directly to Hyprland's Unix sockets, listens for focus and
window events, raises/focuses windows, and moves hidden apps.
- `src/model.rs` owns address normalization, window metadata, application
grouping, and MRU ordering.
- `src/desktop.rs` resolves application names and icons from XDG desktop files.
- `src/main.rs` owns the daemon command socket, switch sessions, GTK HUD, and
keyboard handling.
The daemon freezes MRU updates while the HUD is previewing applications so its
own focus requests do not reorder the active switch session. Final focus is
sent shortly after the layer-shell HUD closes, allowing its exclusive keyboard
grab to be released first.
## Troubleshooting
- If commands report that the daemon is unavailable, start
`hypr-switcher daemon` or begin a new Hyprland session so `exec-once` runs.
- If the HUD works from the command line but not from `Super+Tab`, inspect live
bindings with `hyprctl binds` and verify which config is active with
`readlink -f ~/.config/hypr/hyprland.conf`.
- If an icon is missing, compare the Hyprland window `class` from
`hyprctl clients` with the desktop file name or its `StartupWMClass` value.
+148
View File
@@ -0,0 +1,148 @@
use std::{
collections::HashMap,
env, fs,
path::{Path, PathBuf},
};
#[derive(Clone, Debug)]
pub struct AppMetadata {
pub name: String,
pub icon: String,
}
#[derive(Default)]
pub struct DesktopRegistry {
by_id: HashMap<String, AppMetadata>,
by_startup_class: HashMap<String, AppMetadata>,
}
impl DesktopRegistry {
pub fn load() -> Self {
let mut registry = Self::default();
for directory in application_directories() {
registry.load_directory(&directory);
}
registry
}
pub fn lookup(&self, app_key: &str) -> AppMetadata {
let key = app_key.to_ascii_lowercase();
self.by_id
.get(&key)
.or_else(|| self.by_startup_class.get(&key))
.cloned()
.unwrap_or_else(|| AppMetadata {
name: humanize(app_key),
icon: app_key.to_string(),
})
}
fn load_directory(&mut self, directory: &Path) {
let Ok(entries) = fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("desktop") {
continue;
}
let Some(record) = parse_desktop_file(&path) else {
continue;
};
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let metadata = AppMetadata {
name: record.name,
icon: record.icon,
};
self.by_id
.entry(stem.to_ascii_lowercase())
.or_insert_with(|| metadata.clone());
if let Some(startup_class) = record.startup_class {
self.by_startup_class
.entry(startup_class.to_ascii_lowercase())
.or_insert(metadata);
}
}
}
}
struct DesktopRecord {
name: String,
icon: String,
startup_class: Option<String>,
}
fn parse_desktop_file(path: &Path) -> Option<DesktopRecord> {
let contents = fs::read_to_string(path).ok()?;
let mut in_desktop_entry = false;
let mut name = None;
let mut icon = None;
let mut startup_class = None;
for line in contents.lines() {
let line = line.trim();
if line.starts_with('[') {
in_desktop_entry = line == "[Desktop Entry]";
continue;
}
if !in_desktop_entry || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
match key {
"Name" if name.is_none() => name = Some(value.to_string()),
"Icon" if icon.is_none() => icon = Some(value.to_string()),
"StartupWMClass" if startup_class.is_none() => startup_class = Some(value.to_string()),
_ => {}
}
}
Some(DesktopRecord {
name: name?,
icon: icon.unwrap_or_else(|| "application-x-executable".into()),
startup_class,
})
}
fn application_directories() -> Vec<PathBuf> {
let mut directories = Vec::new();
if let Ok(data_home) = env::var("XDG_DATA_HOME") {
directories.push(PathBuf::from(data_home).join("applications"));
} else if let Ok(home) = env::var("HOME") {
directories.push(PathBuf::from(home).join(".local/share/applications"));
}
let data_dirs =
env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
directories.extend(
data_dirs
.split(':')
.filter(|directory| !directory.is_empty())
.map(|directory| PathBuf::from(directory).join("applications")),
);
directories
}
fn humanize(app_key: &str) -> String {
app_key
.rsplit(['.', '/'])
.next()
.unwrap_or(app_key)
.replace(['-', '_'], " ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_humanize_reverse_dns_app_id() {
assert_eq!(humanize("org.gnome.Nautilus"), "Nautilus");
assert_eq!(humanize("some-app"), "some app");
}
}
+161
View File
@@ -0,0 +1,161 @@
use std::{
env,
io::{BufRead, BufReader, Read, Write},
os::unix::net::UnixStream,
path::PathBuf,
thread,
};
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::model::{normalize_address, AppGroup, Client, WorkspaceRef};
pub const HIDDEN_WORKSPACE: &str = "special:hypr-switcher-hidden";
#[derive(Clone, Debug)]
pub enum HyprEvent {
ActiveWindow(String),
CloseWindow(String),
Refresh,
}
#[derive(Debug, Deserialize)]
pub struct ActiveWindow {
pub address: String,
pub workspace: WorkspaceRef,
}
pub fn runtime_dir() -> Result<PathBuf> {
let runtime = env::var("XDG_RUNTIME_DIR").context("XDG_RUNTIME_DIR is not set")?;
let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE")
.context("HYPRLAND_INSTANCE_SIGNATURE is not set; is Hyprland running?")?;
Ok(PathBuf::from(runtime).join("hypr").join(signature))
}
pub fn command_socket_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("hypr-switcher.sock"))
}
fn request(command: &str) -> Result<String> {
log::trace!("Hyprland IPC request: {command}");
let mut stream = UnixStream::connect(runtime_dir()?.join(".socket.sock"))
.context("connecting to Hyprland's command socket")?;
stream.write_all(command.as_bytes())?;
stream.shutdown(std::net::Shutdown::Write)?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
Ok(response)
}
pub fn clients() -> Result<Vec<Client>> {
let response = request("j/clients")?;
serde_json::from_str(&response).context("decoding Hyprland clients response")
}
pub fn active_window() -> Result<ActiveWindow> {
let response = request("j/activewindow")?;
serde_json::from_str(&response).context("decoding Hyprland active window response")
}
pub fn active_workspace() -> Result<WorkspaceRef> {
let response = request("j/activeworkspace")?;
serde_json::from_str(&response).context("decoding Hyprland active workspace response")
}
fn dispatch(dispatcher: &str, argument: &str) -> Result<()> {
let response = request(&format!("dispatch {dispatcher} {argument}"))?;
if response.trim() == "ok" {
Ok(())
} else {
anyhow::bail!("Hyprland rejected {dispatcher}: {}", response.trim())
}
}
pub fn activate_app(group: &AppGroup) -> Result<()> {
let representative = group
.representative()
.context("selected application has no windows")?;
// Raise least-recent windows first so the representative remains on top.
for window in group.windows.iter().rev() {
let address = normalize_address(&window.address);
dispatch("alterzorder", &format!("top,address:{address}"))?;
}
let address = normalize_address(&representative.address);
dispatch("focuswindow", &format!("address:{address}"))
}
pub fn activate_window(window: &Client) -> Result<()> {
let address = normalize_address(&window.address);
dispatch("alterzorder", &format!("top,address:{address}"))?;
dispatch("focuswindow", &format!("address:{address}"))
}
pub fn move_app_to_workspace(group: &AppGroup, workspace: &str) -> Result<()> {
for window in &group.windows {
let address = normalize_address(&window.address);
dispatch(
"movetoworkspacesilent",
&format!("{workspace},address:{address}"),
)?;
}
Ok(())
}
pub fn spawn_event_listener(sender: std::sync::mpsc::Sender<HyprEvent>) -> Result<()> {
let event_socket = runtime_dir()?.join(".socket2.sock");
thread::Builder::new()
.name("hypr-events".into())
.spawn(move || loop {
match UnixStream::connect(&event_socket) {
Ok(stream) => {
let reader = BufReader::new(stream);
for line in reader.lines() {
let Ok(line) = line else { break };
if let Some(event) = parse_event(&line) {
if sender.send(event).is_err() {
return;
}
}
}
}
Err(error) => log::warn!("Hyprland event socket unavailable: {error}"),
}
thread::sleep(std::time::Duration::from_millis(500));
})?;
Ok(())
}
fn parse_event(line: &str) -> Option<HyprEvent> {
let (name, data) = line.trim_end().split_once(">>")?;
match name {
"activewindowv2" => Some(HyprEvent::ActiveWindow(normalize_address(data))),
"closewindow" => Some(HyprEvent::CloseWindow(normalize_address(data))),
"openwindow" | "movewindowv2" | "workspacev2" | "windowtitlev2" => Some(HyprEvent::Refresh),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_active_window_event() {
assert!(matches!(
parse_event("activewindowv2>>ABCD\n"),
Some(HyprEvent::ActiveWindow(address)) if address == "0xabcd"
));
}
#[test]
fn test_parse_close_window_event() {
assert!(matches!(
parse_event("closewindow>>0x123\n"),
Some(HyprEvent::CloseWindow(address)) if address == "0x123"
));
}
}
+659
View File
@@ -0,0 +1,659 @@
mod desktop;
mod ipc;
mod model;
use std::{
cell::RefCell,
env, fs,
io::Write,
os::unix::net::{UnixListener, UnixStream},
path::Path,
rc::Rc,
sync::mpsc,
thread,
time::{Duration, Instant},
};
use anyhow::{Context, Result};
use desktop::DesktopRegistry;
use glib::{ControlFlow, Propagation};
use gtk::{gdk, pango};
use gtk4 as gtk;
use gtk4::prelude::*;
use gtk4_layer_shell::{self as layer, LayerShell};
use ipc::HyprEvent;
use model::{normalize_address, AppGroup, FocusHistory};
#[derive(Clone, Copy, Debug)]
enum Direction {
Next,
Previous,
}
#[derive(Clone, Debug)]
enum DaemonMessage {
Show(Direction),
CycleWindow(Direction),
HideCurrent,
Cancel,
Hypr(HyprEvent),
}
struct Session {
groups: Vec<AppGroup>,
selected: usize,
original: usize,
destination_workspace: String,
}
struct RuntimeState {
history: FocusHistory,
session: Option<Session>,
window_cycle: Option<WindowCycleSession>,
ignore_focus_until: Option<Instant>,
}
struct WindowCycleSession {
app_key: String,
addresses: Vec<String>,
index: usize,
updated_at: Instant,
}
struct Switcher {
window: gtk::ApplicationWindow,
row: gtk::Box,
registry: DesktopRegistry,
state: RefCell<RuntimeState>,
}
impl Switcher {
fn new(application: &gtk::Application) -> Result<Rc<Self>> {
let clients = ipc::clients().unwrap_or_default();
let mut history = FocusHistory::default();
history.seed(&clients);
let window = gtk::ApplicationWindow::new(application);
window.set_decorated(false);
window.set_resizable(false);
window.init_layer_shell();
window.set_namespace("hypr-switcher");
window.set_layer(layer::Layer::Overlay);
window.set_keyboard_mode(layer::KeyboardMode::None);
let frame = gtk::Frame::new(None);
frame.add_css_class("switcher-frame");
let row = gtk::Box::new(gtk::Orientation::Horizontal, 10);
row.set_margin_top(14);
row.set_margin_bottom(14);
row.set_margin_start(14);
row.set_margin_end(14);
let scroller = gtk::ScrolledWindow::new();
scroller.set_policy(gtk::PolicyType::Automatic, gtk::PolicyType::Never);
scroller.set_propagate_natural_width(true);
scroller.set_max_content_width(980);
scroller.set_child(Some(&row));
frame.set_child(Some(&scroller));
window.set_child(Some(&frame));
install_css();
Ok(Rc::new(Self {
window,
row,
registry: DesktopRegistry::load(),
state: RefCell::new(RuntimeState {
history,
session: None,
window_cycle: None,
ignore_focus_until: None,
}),
}))
}
fn install_key_controller(self: &Rc<Self>) {
let controller = gtk::EventControllerKey::new();
let switcher = Rc::clone(self);
controller.connect_key_pressed(move |_controller, key, _keycode, modifiers| match key {
gdk::Key::Tab | gdk::Key::ISO_Left_Tab => {
let backwards = key == gdk::Key::ISO_Left_Tab
|| modifiers.contains(gdk::ModifierType::SHIFT_MASK);
switcher.cycle(if backwards {
Direction::Previous
} else {
Direction::Next
});
Propagation::Stop
}
gdk::Key::Escape => {
switcher.finish(false);
Propagation::Stop
}
gdk::Key::Return | gdk::Key::KP_Enter => {
switcher.finish(true);
Propagation::Stop
}
_ => Propagation::Proceed,
});
let switcher = Rc::clone(self);
controller.connect_key_released(move |_controller, key, _keycode, _modifiers| {
if matches!(
key,
gdk::Key::Super_L | gdk::Key::Super_R | gdk::Key::Meta_L | gdk::Key::Meta_R
) {
switcher.finish(true);
}
});
self.window.add_controller(controller);
}
fn handle_message(&self, message: DaemonMessage) {
match message {
DaemonMessage::Show(direction) => self.show(direction),
DaemonMessage::CycleWindow(direction) => self.cycle_window(direction),
DaemonMessage::HideCurrent => self.hide_current_app(),
DaemonMessage::Cancel => self.finish(false),
DaemonMessage::Hypr(event) => self.handle_hypr_event(event),
}
}
fn handle_hypr_event(&self, event: HyprEvent) {
let mut state = self.state.borrow_mut();
match event {
HyprEvent::ActiveWindow(address) => {
let ignoring = state.session.is_some()
|| state
.ignore_focus_until
.is_some_and(|deadline| deadline > Instant::now());
if !ignoring {
state.ignore_focus_until = None;
state.history.touch(&address);
}
}
HyprEvent::CloseWindow(address) => state.history.remove(&address),
HyprEvent::Refresh => {}
}
}
fn show(&self, direction: Direction) {
if self.state.borrow().session.is_some() {
self.cycle(direction);
return;
}
let Ok(clients) = ipc::clients() else {
log::error!("cannot show switcher: failed to read Hyprland clients");
return;
};
let active = ipc::active_window().ok();
let workspace = active
.as_ref()
.map(|window| window.workspace.clone())
.or_else(|| ipc::active_workspace().ok());
let Some(workspace) = workspace else {
log::error!("cannot show switcher: Hyprland has no active workspace");
return;
};
let active_address = active
.as_ref()
.map(|window| normalize_address(&window.address))
.unwrap_or_default();
let mut state = self.state.borrow_mut();
if !active_address.is_empty() {
state.history.touch(&active_address);
}
let mut groups = state.history.groups_for_workspace(&clients, workspace.id);
let visible_count = groups.len();
if let Some(hidden_workspace_id) = clients
.iter()
.find(|client| client.workspace.name == ipc::HIDDEN_WORKSPACE)
.map(|client| client.workspace.id)
{
let mut hidden_groups = state
.history
.groups_for_workspace(&clients, hidden_workspace_id);
hidden_groups.retain(|hidden| !groups.iter().any(|group| group.key == hidden.key));
for group in &mut hidden_groups {
group.hidden = true;
}
groups.extend(hidden_groups);
}
if groups.is_empty() || (groups.len() == 1 && visible_count == 1) {
log::debug!("not showing switcher: no other visible or hidden applications");
return;
}
let original = groups
.iter()
.position(|group| {
group
.windows
.iter()
.any(|window| normalize_address(&window.address) == active_address)
})
.unwrap_or(0);
let selected = match (active_address.is_empty(), direction) {
(true, Direction::Next) => 0,
(true, Direction::Previous) => groups.len() - 1,
(false, Direction::Next) => (original + 1) % groups.len(),
(false, Direction::Previous) => (original + groups.len() - 1) % groups.len(),
};
state.session = Some(Session {
groups,
selected,
original,
destination_workspace: workspace.name,
});
drop(state);
self.render();
self.window
.set_keyboard_mode(layer::KeyboardMode::Exclusive);
self.window.present();
self.activate_selected();
}
fn cycle(&self, direction: Direction) {
let mut state = self.state.borrow_mut();
let Some(session) = state.session.as_mut() else {
return;
};
let length = session.groups.len();
session.selected = match direction {
Direction::Next => (session.selected + 1) % length,
Direction::Previous => (session.selected + length - 1) % length,
};
drop(state);
self.render();
self.activate_selected();
}
fn activate_selected(&self) {
let selected = self
.state
.borrow()
.session
.as_ref()
.map(|session| session.groups[session.selected].clone());
if let Some(group) = selected {
if group.hidden {
return;
}
if let Err(error) = ipc::activate_app(&group) {
log::error!("failed to activate {}: {error:#}", group.key);
}
}
}
fn hide_current_app(&self) {
let Ok(active) = ipc::active_window() else {
return;
};
let Ok(clients) = ipc::clients() else {
return;
};
let active_address = normalize_address(&active.address);
let Some(active_client) = clients
.iter()
.find(|client| normalize_address(&client.address) == active_address)
else {
return;
};
let app_key = active_client.app_key();
let windows = clients
.iter()
.filter(|client| {
client.mapped
&& client.app_key() == app_key
&& client.workspace.name != ipc::HIDDEN_WORKSPACE
})
.cloned()
.collect::<Vec<_>>();
if windows.is_empty() {
return;
}
let group = AppGroup {
key: app_key.clone(),
windows,
hidden: false,
};
let next_app = {
let state = self.state.borrow();
state
.history
.groups_for_workspace(&clients, active.workspace.id)
.into_iter()
.find(|candidate| candidate.key != app_key)
};
if let Err(error) = ipc::move_app_to_workspace(&group, ipc::HIDDEN_WORKSPACE) {
log::error!("failed to hide {app_key}: {error:#}");
return;
}
if let Some(next_app) = next_app {
if let Err(error) = ipc::activate_app(&next_app) {
log::error!("failed to focus after hiding {app_key}: {error:#}");
}
}
}
fn cycle_window(&self, direction: Direction) {
let Ok(active) = ipc::active_window() else {
return;
};
let Ok(clients) = ipc::clients() else {
return;
};
let active_address = normalize_address(&active.address);
let (target_address, app_key) = {
let mut state = self.state.borrow_mut();
state.history.touch(&active_address);
let groups = state
.history
.groups_for_workspace(&clients, active.workspace.id);
let Some(group) = groups.iter().find(|group| {
group
.windows
.iter()
.any(|window| normalize_address(&window.address) == active_address)
}) else {
return;
};
if group.windows.len() < 2 {
return;
}
let fresh_addresses: Vec<String> = group
.windows
.iter()
.map(|window| normalize_address(&window.address))
.collect();
let can_continue = state.window_cycle.as_ref().is_some_and(|cycle| {
cycle.app_key == group.key
&& cycle.updated_at.elapsed() < Duration::from_millis(1200)
&& cycle.addresses.len() == fresh_addresses.len()
&& cycle.addresses.contains(&active_address)
&& cycle.addresses[cycle.index] == active_address
});
let cycle = if can_continue {
state.window_cycle.as_mut().expect("checked above")
} else {
let index = fresh_addresses
.iter()
.position(|address| address == &active_address)
.unwrap_or(0);
state.window_cycle = Some(WindowCycleSession {
app_key: group.key.clone(),
addresses: fresh_addresses,
index,
updated_at: Instant::now(),
});
state.window_cycle.as_mut().expect("just inserted")
};
cycle.index = match direction {
Direction::Next => (cycle.index + 1) % cycle.addresses.len(),
Direction::Previous => {
(cycle.index + cycle.addresses.len() - 1) % cycle.addresses.len()
}
};
cycle.updated_at = Instant::now();
(cycle.addresses[cycle.index].clone(), cycle.app_key.clone())
};
if let Some(target) = clients
.iter()
.find(|client| normalize_address(&client.address) == target_address)
{
if let Err(error) = ipc::activate_window(target) {
log::error!("failed to cycle a window in {app_key}: {error:#}");
}
}
}
fn finish(&self, commit: bool) {
let (target, destination_workspace) = {
let mut state = self.state.borrow_mut();
let Some(session) = state.session.take() else {
return;
};
let target_index = if commit {
session.selected
} else {
session.original
};
let target = session.groups[target_index].clone();
if let Some(window) = target.representative() {
state.history.touch(&window.address);
}
state.ignore_focus_until = Some(Instant::now() + Duration::from_millis(200));
(target, session.destination_workspace)
};
self.window.set_keyboard_mode(layer::KeyboardMode::None);
self.window.hide();
// The layer surface releases its exclusive keyboard focus
// asynchronously. Wait for that round-trip before focusing the
// selected toplevel, otherwise Hyprland raises it without granting
// keyboard focus.
glib::timeout_add_local_once(Duration::from_millis(30), move || {
if target.hidden {
if let Err(error) = ipc::move_app_to_workspace(&target, &destination_workspace) {
log::error!("failed to unhide {}: {error:#}", target.key);
return;
}
}
if let Err(error) = ipc::activate_app(&target) {
log::error!("failed to finalize {}: {error:#}", target.key);
}
});
}
fn render(&self) {
while let Some(child) = self.row.first_child() {
self.row.remove(&child);
}
let state = self.state.borrow();
let Some(session) = state.session.as_ref() else {
return;
};
for (index, group) in session.groups.iter().enumerate() {
let metadata = self.registry.lookup(&group.key);
let item = gtk::Box::new(gtk::Orientation::Vertical, 7);
item.add_css_class("switcher-item");
if group.hidden {
item.add_css_class("hidden");
}
if index == session.selected {
item.add_css_class("selected");
}
let image = if Path::new(&metadata.icon).is_absolute() {
gtk::Image::from_file(&metadata.icon)
} else {
gtk::Image::from_icon_name(&metadata.icon)
};
image.set_pixel_size(64);
image.set_size_request(76, 76);
let title = gtk::Label::new(Some(&metadata.name));
title.add_css_class("switcher-title");
title.set_ellipsize(pango::EllipsizeMode::End);
title.set_max_width_chars(14);
title.set_tooltip_text(Some(&metadata.name));
item.append(&image);
item.append(&title);
self.row.append(&item);
}
}
}
fn install_css() {
let provider = gtk::CssProvider::new();
provider.load_from_data(
"
window { background: transparent; }
.switcher-frame {
background: rgba(31, 31, 34, 0.94);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 18px;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.48);
}
.switcher-item {
min-width: 96px;
padding: 9px 8px 7px 8px;
border: 2px solid transparent;
border-radius: 13px;
}
.switcher-item.selected {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.72);
}
.switcher-item.hidden { opacity: 0.52; }
.switcher-item.hidden.selected { opacity: 0.82; }
.switcher-title {
color: rgba(255, 255, 255, 0.94);
font-size: 12px;
font-weight: 500;
}
",
);
if let Some(display) = gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn run_daemon() -> Result<()> {
let (sender, receiver) = mpsc::channel::<DaemonMessage>();
let receiver = Rc::new(RefCell::new(Some(receiver)));
ipc::spawn_event_listener({
let sender = sender.clone();
let (hypr_sender, hypr_receiver) = mpsc::channel();
thread::spawn(move || {
while let Ok(event) = hypr_receiver.recv() {
if sender.send(DaemonMessage::Hypr(event)).is_err() {
break;
}
}
});
hypr_sender
})?;
spawn_command_server(sender)?;
let application = gtk::Application::builder()
.application_id("net.buzzert.HyprSwitcher")
.build();
application.connect_activate(move |application| {
let Some(receiver) = receiver.borrow_mut().take() else {
return;
};
let switcher = match Switcher::new(application) {
Ok(switcher) => switcher,
Err(error) => {
log::error!("failed to initialize switcher: {error:#}");
application.quit();
return;
}
};
switcher.install_key_controller();
let switcher_for_messages = Rc::clone(&switcher);
glib::timeout_add_local(Duration::from_millis(10), move || {
while let Ok(message) = receiver.try_recv() {
switcher_for_messages.handle_message(message);
}
ControlFlow::Continue
});
// Keep a hidden application window alive for the daemon's lifetime.
switcher.window.set_visible(false);
});
application.run_with_args(&["hypr-switcher"]);
Ok(())
}
fn spawn_command_server(sender: mpsc::Sender<DaemonMessage>) -> Result<()> {
let path = ipc::command_socket_path()?;
if path.exists() {
if UnixStream::connect(&path).is_ok() {
anyhow::bail!("another hypr-switcher daemon is already running");
}
fs::remove_file(&path).context("removing stale switcher socket")?;
}
let listener = UnixListener::bind(&path).context("binding switcher command socket")?;
thread::Builder::new()
.name("switcher-commands".into())
.spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut command = String::new();
if std::io::Read::read_to_string(&mut stream, &mut command).is_err() {
continue;
}
let message = match command.trim() {
"show" => Some(DaemonMessage::Show(Direction::Next)),
"show-previous" => Some(DaemonMessage::Show(Direction::Previous)),
"window-next" => Some(DaemonMessage::CycleWindow(Direction::Next)),
"window-previous" => Some(DaemonMessage::CycleWindow(Direction::Previous)),
"hide-current" => Some(DaemonMessage::HideCurrent),
"cancel" => Some(DaemonMessage::Cancel),
_ => None,
};
if let Some(message) = message {
if sender.send(message).is_err() {
break;
}
}
}
})?;
Ok(())
}
fn send_command(command: &str) -> Result<()> {
let path = ipc::command_socket_path()?;
let mut stream = UnixStream::connect(&path).with_context(|| {
format!(
"connecting to {}; start `hypr-switcher daemon` first",
path.display()
)
})?;
stream.write_all(command.as_bytes())?;
Ok(())
}
fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
match env::args().nth(1).as_deref() {
Some("daemon") => run_daemon(),
Some("show-previous") => send_command("show-previous"),
Some("window-next") => send_command("window-next"),
Some("window-previous") => send_command("window-previous"),
Some("hide-current") => send_command("hide-current"),
Some("cancel") => send_command("cancel"),
Some("show") | None => send_command("show"),
Some(command) => anyhow::bail!("unknown command: {command}"),
}
}
+201
View File
@@ -0,0 +1,201 @@
use std::collections::{HashMap, HashSet, VecDeque};
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct WorkspaceRef {
pub id: i32,
#[serde(default)]
pub name: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Client {
pub address: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub class: String,
#[serde(default, rename = "initialClass")]
pub initial_class: String,
pub workspace: WorkspaceRef,
#[serde(default)]
pub mapped: bool,
#[serde(default)]
pub hidden: bool,
#[serde(default, rename = "focusHistoryID")]
pub focus_history_id: i64,
}
impl Client {
pub fn app_key(&self) -> String {
if !self.class.is_empty() {
self.class.clone()
} else if !self.initial_class.is_empty() {
self.initial_class.clone()
} else if !self.title.is_empty() {
self.title.clone()
} else {
self.address.clone()
}
}
}
#[derive(Clone, Debug)]
pub struct AppGroup {
pub key: String,
pub windows: Vec<Client>,
pub hidden: bool,
}
impl AppGroup {
pub fn representative(&self) -> Option<&Client> {
self.windows.first()
}
}
#[derive(Default, Debug)]
pub struct FocusHistory {
addresses: VecDeque<String>,
}
impl FocusHistory {
pub fn seed(&mut self, clients: &[Client]) {
let mut sorted = clients.to_vec();
sorted.sort_by_key(|client| client.focus_history_id);
for client in sorted.into_iter().rev() {
self.touch(&client.address);
}
}
pub fn touch(&mut self, address: &str) {
let address = normalize_address(address);
if address.is_empty() {
return;
}
self.addresses.retain(|candidate| candidate != &address);
self.addresses.push_front(address);
self.addresses.truncate(256);
}
pub fn remove(&mut self, address: &str) {
let address = normalize_address(address);
self.addresses.retain(|candidate| candidate != &address);
}
pub fn rank(&self, address: &str) -> usize {
let address = normalize_address(address);
self.addresses
.iter()
.position(|candidate| candidate == &address)
.unwrap_or(usize::MAX)
}
pub fn groups_for_workspace(&self, clients: &[Client], workspace_id: i32) -> Vec<AppGroup> {
let mut eligible: Vec<Client> = clients
.iter()
.filter(|client| client.mapped && !client.hidden && client.workspace.id == workspace_id)
.cloned()
.collect();
eligible.sort_by_key(|client| {
let rank = self.rank(&client.address);
if rank == usize::MAX {
(usize::MAX, client.focus_history_id.max(0) as usize)
} else {
(rank, 0)
}
});
let mut by_app: HashMap<String, Vec<Client>> = HashMap::new();
let mut app_order = Vec::new();
let mut seen_apps = HashSet::new();
for client in eligible {
let key = client.app_key();
if seen_apps.insert(key.clone()) {
app_order.push(key.clone());
}
by_app.entry(key).or_default().push(client);
}
app_order
.into_iter()
.filter_map(|key| {
by_app.remove(&key).map(|windows| AppGroup {
key,
windows,
hidden: false,
})
})
.collect()
}
}
pub fn normalize_address(address: &str) -> String {
let address = address.trim();
if address.is_empty() {
String::new()
} else if address.starts_with("0x") {
address.to_ascii_lowercase()
} else {
format!("0x{}", address.to_ascii_lowercase())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn client(address: &str, class: &str, focus_history_id: i64) -> Client {
Client {
address: address.into(),
title: String::new(),
class: class.into(),
initial_class: String::new(),
workspace: WorkspaceRef {
id: 1,
name: "1".into(),
},
mapped: true,
hidden: false,
focus_history_id,
}
}
#[test]
fn test_normalize_address() {
assert_eq!(normalize_address("ABC"), "0xabc");
assert_eq!(normalize_address("0xAbC"), "0xabc");
assert_eq!(normalize_address(""), "");
}
#[test]
fn test_groups_follow_window_mru_and_deduplicate_apps() {
let clients = vec![
client("0x1", "browser", 2),
client("0x2", "editor", 1),
client("0x3", "browser", 0),
];
let mut history = FocusHistory::default();
history.seed(&clients);
let groups = history.groups_for_workspace(&clients, 1);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].key, "browser");
assert_eq!(groups[0].windows[0].address, "0x3");
assert_eq!(groups[1].key, "editor");
}
#[test]
fn test_touch_promotes_an_app_through_its_window() {
let clients = vec![client("0x1", "browser", 0), client("0x2", "editor", 1)];
let mut history = FocusHistory::default();
history.seed(&clients);
history.touch("0x2");
let groups = history.groups_for_workspace(&clients, 1);
assert_eq!(groups[0].key, "editor");
}
}