initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+15
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: >k::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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user