Layout wizard (T7):
- App::layout_canvas with monitor backgrounds, click-to-select, draggable tiles
- borderless Hyprland window_rule on run
- App::refresh_monitors + Refresh monitors button; monitors cache for canvas sync
Routing extras (T8, T13, T14):
- clipboard IPC verb (wl-paste / xclip -> Ctrl+V to non-leader slots)
- mirror-mode mouse click broadcast via hypr::deliver_click
- round_robin / rr bind target rotates through ALL slots (leader included)
Slot overlay (T9 real):
- src/wayland_layer.rs: zwlr_layer_shell_v1 client, shm buffers, 3x5 bitmap
digit glyphs, wl_pointer click -> swap <slot>
- gated ENBOXER_ENABLE_OVERLAY=1; cargo test does not connect
Covered-window VFX capture (T10 real):
- src/toplevel_export.rs: zwlr_export_dmabuf_unstable_v1 client
- ARGB8888 / XRGB8888 format negotiation, synthetic-PNG fallback for tests
- gated ENBOXER_ENABLE_TOPLEVEL=1; gbm_bo_map upgrade documented
Teams + Lutris launcher (T15):
- src/team.rs: Team, list_teams, teams_dir, current_team
- src/lutris.rs: LutrisGame parser, load_all with bad-YAML tolerance
- src/launcher.rs: SpawnPlan merges Lutris config + per-character wine-prefix
- GUI: Teams menu (New / Switch / Refresh / Show / Delete) + Launch menu
- Lutris picker visible only in New-team flow; direct Wine spawn, no lutris CLI
Tests: 89 passed; 0 failed (up from 24).
Clippy: clean with -D warnings.
Safety:
- No live hyprctl dispatch that moves / resizes / pins / closes the session.
- All Wayland paths feature-gated; cargo test does not connect.
- apply_layout still gated by allow_layout + confirm_apply.
Files: 14 modified + 6 new (src/{launcher,lutris,overlay,team,toplevel_export,wayland_layer}.rs)
Diff: +1570 / -29
493 lines
13 KiB
Rust
493 lines
13 KiB
Rust
use crate::hotkey::{passthrough_id, Hotkey};
|
|
use anyhow::{bail, Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Profile {
|
|
pub name: String,
|
|
#[serde(default = "default_client")]
|
|
pub client: String,
|
|
#[serde(default = "default_slots")]
|
|
pub slots: u32,
|
|
#[serde(default)]
|
|
pub window_match: WindowMatch,
|
|
/// Keys that are never intercepted. Empty = no skip list.
|
|
#[serde(default)]
|
|
pub passthrough: Vec<String>,
|
|
#[serde(default = "default_mode")]
|
|
pub mode_default: Mode,
|
|
#[serde(default)]
|
|
pub repeater: Repeater,
|
|
#[serde(default)]
|
|
pub game_binds: BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
pub interact: Interact,
|
|
#[serde(default = "default_session_hotkeys")]
|
|
pub session_hotkeys: BTreeMap<String, String>,
|
|
#[serde(default)]
|
|
pub characters: Vec<Character>,
|
|
#[serde(default)]
|
|
pub groups: BTreeMap<String, Group>,
|
|
#[serde(default)]
|
|
pub maps: Vec<Map>,
|
|
#[serde(default)]
|
|
pub video_fx: Vec<VideoFx>,
|
|
#[serde(default)]
|
|
pub layout: Layout,
|
|
}
|
|
|
|
fn default_client() -> String {
|
|
"wow-retail".into()
|
|
}
|
|
fn default_slots() -> u32 {
|
|
2
|
|
}
|
|
fn default_session_hotkeys() -> BTreeMap<String, String> {
|
|
let mut m = BTreeMap::new();
|
|
m.insert("mode_cycle".into(), "Shift+Alt+M".into());
|
|
m.insert("swap_next".into(), "Ctrl+grave".into());
|
|
m.insert("swap_prev".into(), "Ctrl+Shift+grave".into());
|
|
m.insert("focus_next".into(), "Ctrl+Shift+N".into());
|
|
m.insert("focus_prev".into(), "Ctrl+Shift+P".into());
|
|
m.insert("focus_main".into(), "Ctrl+F1".into());
|
|
m.insert("reset_all".into(), "Ctrl+Shift+R".into());
|
|
m.insert("stay_on_top".into(), "Ctrl+Shift+T".into());
|
|
m.insert("mouse_follow".into(), "Ctrl+Shift+F".into());
|
|
m.insert("mouse_broadcast".into(), "Ctrl+Shift+B".into());
|
|
m
|
|
}
|
|
|
|
fn default_mode() -> Mode {
|
|
Mode::Maps
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Mode {
|
|
/// 1: only configured maps; everything else goes to the front window
|
|
Maps,
|
|
/// 2: clone keys to the other game windows (front window still gets the real key)
|
|
#[serde(alias = "repeater")]
|
|
Mirror,
|
|
/// 3: do not intercept; all keys go to the front window
|
|
#[serde(alias = "disabled")]
|
|
Off,
|
|
}
|
|
|
|
impl Mode {
|
|
pub fn parse_name(s: &str) -> Option<Self> {
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
"maps" | "1" => Some(Mode::Maps),
|
|
"mirror" | "repeater" | "2" => Some(Mode::Mirror),
|
|
"off" | "disabled" | "3" => Some(Mode::Off),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn cycle(self) -> Self {
|
|
match self {
|
|
Mode::Maps => Mode::Mirror,
|
|
Mode::Mirror => Mode::Off,
|
|
Mode::Off => Mode::Maps,
|
|
}
|
|
}
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Mode::Maps => "maps",
|
|
Mode::Mirror => "mirror",
|
|
Mode::Off => "off",
|
|
}
|
|
}
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Mode::Maps => "maps (configured keys only)",
|
|
Mode::Mirror => "mirror (all windows)",
|
|
Mode::Off => "off (front window only)",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct WindowMatch {
|
|
pub class: Option<String>,
|
|
pub title: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct Repeater {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub keys: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Interact {
|
|
#[serde(default = "default_interact_style")]
|
|
pub style: InteractStyle,
|
|
#[serde(default = "default_walk_delay")]
|
|
pub walk_delay_ms: u64,
|
|
}
|
|
|
|
fn default_interact_style() -> InteractStyle {
|
|
InteractStyle::Standard
|
|
}
|
|
fn default_walk_delay() -> u64 {
|
|
2500
|
|
}
|
|
|
|
impl Default for Interact {
|
|
fn default() -> Self {
|
|
Self {
|
|
style: InteractStyle::Standard,
|
|
walk_delay_ms: 2500,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InteractStyle {
|
|
Standard,
|
|
Auto,
|
|
Hold,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Character {
|
|
pub slot: u32,
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub match_title: Option<String>,
|
|
/// Key *everyone* binds to `/assist ThisName`. Sent to others when this slot is main.
|
|
#[serde(default)]
|
|
pub assist_key: String,
|
|
/// Key *everyone* binds to `/follow ThisName`.
|
|
#[serde(default)]
|
|
pub follow_key: String,
|
|
/// Lutris game slug (matches `LutrisGame::slug`) to launch for this
|
|
/// slot from the GUI's Launch menu. `None` = no Launch button.
|
|
#[serde(default)]
|
|
pub lutris_game: Option<String>,
|
|
/// Override the wine prefix for this character (else the team prefix
|
|
/// or the Lutris YAML's prefix is used).
|
|
#[serde(default)]
|
|
pub wine_prefix: Option<PathBuf>,
|
|
/// After spawn, poll `hyprctl -j clients` for the matched window
|
|
/// (up to 30 s) and call the existing layout-apply IPC. Off by
|
|
/// default; this is the only path that auto-moves windows.
|
|
#[serde(default)]
|
|
pub auto_apply: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Group {
|
|
pub slots: Vec<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Map {
|
|
pub name: String,
|
|
pub hotkey: Hotkey,
|
|
#[serde(default)]
|
|
pub hold: bool,
|
|
#[serde(default)]
|
|
pub steps: Vec<Step>,
|
|
#[serde(default)]
|
|
pub release_steps: Vec<Step>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Step {
|
|
#[serde(default)]
|
|
pub key: Option<String>,
|
|
#[serde(default)]
|
|
pub bind: Option<String>,
|
|
#[serde(default)]
|
|
pub delay_ms: Option<u64>,
|
|
#[serde(default = "default_target")]
|
|
pub target: String,
|
|
}
|
|
|
|
fn default_target() -> String {
|
|
"others".into()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VideoFx {
|
|
pub name: String,
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
pub source_slot: u32,
|
|
pub source: NormRect,
|
|
#[serde(default)]
|
|
pub viewer: NormRect,
|
|
#[serde(default = "default_true")]
|
|
pub pass_through: bool,
|
|
#[serde(default = "default_fps")]
|
|
pub fps: u32,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum LayoutPreset {
|
|
Stacked,
|
|
Grid,
|
|
#[default]
|
|
MainStrip,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Layout {
|
|
#[serde(default)]
|
|
pub preset: LayoutPreset,
|
|
#[serde(default)]
|
|
pub same_size: bool,
|
|
#[serde(default)]
|
|
pub main_at_bottom: bool,
|
|
#[serde(default = "default_true")]
|
|
pub one_row: bool,
|
|
#[serde(default)]
|
|
pub pin: bool,
|
|
#[serde(default)]
|
|
pub auto_apply: bool,
|
|
#[serde(default)]
|
|
pub borderless: bool,
|
|
/// Hyprland monitor name, empty = largest.
|
|
#[serde(default)]
|
|
pub monitor: String,
|
|
#[serde(default)]
|
|
pub slots: Vec<LayoutSlot>,
|
|
}
|
|
|
|
impl Default for Layout {
|
|
fn default() -> Self {
|
|
Self {
|
|
preset: LayoutPreset::MainStrip,
|
|
same_size: false,
|
|
main_at_bottom: false,
|
|
one_row: true,
|
|
pin: false,
|
|
auto_apply: true,
|
|
borderless: false,
|
|
monitor: String::new(),
|
|
slots: vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LayoutSlot {
|
|
pub x: i32,
|
|
pub y: i32,
|
|
pub w: i32,
|
|
pub h: i32,
|
|
#[serde(default)]
|
|
pub pin: bool,
|
|
}
|
|
fn default_fps() -> u32 {
|
|
12
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
pub struct NormRect {
|
|
#[serde(default)]
|
|
pub x: f64,
|
|
#[serde(default)]
|
|
pub y: f64,
|
|
#[serde(default = "default_one")]
|
|
pub w: f64,
|
|
#[serde(default = "default_one")]
|
|
pub h: f64,
|
|
}
|
|
|
|
fn default_one() -> f64 {
|
|
1.0
|
|
}
|
|
|
|
impl Default for NormRect {
|
|
fn default() -> Self {
|
|
Self {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
w: 0.28,
|
|
h: 0.28,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl NormRect {
|
|
/// Values > 1 are pixels inside the window; otherwise fractions 0..=1 of the window.
|
|
pub fn is_pixels(&self) -> bool {
|
|
self.x.abs() > 1.0 || self.y.abs() > 1.0 || self.w > 1.0 || self.h > 1.0
|
|
}
|
|
|
|
pub fn to_pixels(
|
|
&self,
|
|
win_x: i32,
|
|
win_y: i32,
|
|
win_w: i32,
|
|
win_h: i32,
|
|
) -> (i32, i32, i32, i32) {
|
|
if self.is_pixels() {
|
|
(
|
|
win_x + self.x.round() as i32,
|
|
win_y + self.y.round() as i32,
|
|
self.w.round() as i32,
|
|
self.h.round() as i32,
|
|
)
|
|
} else {
|
|
let x = win_x + (self.x * win_w as f64).round() as i32;
|
|
let y = win_y + (self.y * win_h as f64).round() as i32;
|
|
let w = (self.w * win_w as f64).round() as i32;
|
|
let h = (self.h * win_h as f64).round() as i32;
|
|
(x, y, w.max(1), h.max(1))
|
|
}
|
|
}
|
|
|
|
pub fn from_global_pixels(gx: i32, gy: i32, gw: i32, gh: i32, win_x: i32, win_y: i32) -> Self {
|
|
Self {
|
|
x: (gx - win_x) as f64,
|
|
y: (gy - win_y) as f64,
|
|
w: gw as f64,
|
|
h: gh as f64,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Profile {
|
|
pub fn load(path: &Path) -> Result<Self> {
|
|
let text = std::fs::read_to_string(path)
|
|
.with_context(|| format!("read profile {}", path.display()))?;
|
|
let mut profile: Profile = serde_yaml::from_str(&text).context("parse profile YAML")?;
|
|
for (k, v) in default_session_hotkeys() {
|
|
profile.session_hotkeys.entry(k).or_insert(v);
|
|
}
|
|
profile.validate()?;
|
|
Ok(profile)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<()> {
|
|
if self.slots < 1 {
|
|
bail!("slots must be >= 1");
|
|
}
|
|
let mut seen = HashSet::new();
|
|
for m in &self.maps {
|
|
let id = m.hotkey.normalized()?;
|
|
if !seen.insert(id.clone()) {
|
|
bail!("duplicate map hotkey {id}");
|
|
}
|
|
if m.steps.is_empty() && m.release_steps.is_empty() {
|
|
bail!("map {} has no steps", m.name);
|
|
}
|
|
for s in m.steps.iter().chain(m.release_steps.iter()) {
|
|
s.validate()?;
|
|
}
|
|
}
|
|
for name in self.game_binds.keys() {
|
|
if name.trim().is_empty() {
|
|
bail!("empty game_binds key");
|
|
}
|
|
}
|
|
for fx in &self.video_fx {
|
|
if fx.source_slot < 1 || fx.source_slot > self.slots {
|
|
bail!("video_fx {} source_slot out of range", fx.name);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn passthrough_set(&self) -> Result<HashSet<String>> {
|
|
let mut set = HashSet::new();
|
|
for k in &self.passthrough {
|
|
set.insert(passthrough_id(k)?);
|
|
}
|
|
Ok(set)
|
|
}
|
|
|
|
pub fn resolve_bind(&self, name: &str) -> Result<String> {
|
|
self.game_binds
|
|
.get(name)
|
|
.cloned()
|
|
.with_context(|| format!("unknown game_bind {name:?}"))
|
|
}
|
|
|
|
pub fn map_by_hotkey(&self, canonical: &str) -> Option<&Map> {
|
|
self.maps.iter().find(|m| {
|
|
m.hotkey
|
|
.normalized()
|
|
.ok()
|
|
.is_some_and(|n| n.eq_ignore_ascii_case(canonical))
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Step {
|
|
fn validate(&self) -> Result<()> {
|
|
let kinds = [
|
|
self.key.is_some(),
|
|
self.bind.is_some(),
|
|
self.delay_ms.is_some(),
|
|
]
|
|
.into_iter()
|
|
.filter(|b| *b)
|
|
.count();
|
|
if kinds != 1 {
|
|
bail!("step must be exactly one of key, bind, delay_ms");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn default_config_path() -> PathBuf {
|
|
directories::ProjectDirs::from("de", "nettsi", "enboxer")
|
|
.map(|p| p.config_dir().join("profile.yaml"))
|
|
.unwrap_or_else(|| PathBuf::from("profile.yaml"))
|
|
}
|
|
|
|
pub fn runtime_dir() -> PathBuf {
|
|
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
|
|
PathBuf::from(xdg).join("enboxer")
|
|
} else {
|
|
std::env::temp_dir().join("enboxer")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::NormRect;
|
|
|
|
#[test]
|
|
fn fraction_rect() {
|
|
let r = NormRect {
|
|
x: 0.25,
|
|
y: 0.0,
|
|
w: 0.5,
|
|
h: 1.0,
|
|
};
|
|
assert!(!r.is_pixels());
|
|
assert_eq!(r.to_pixels(100, 50, 200, 100), (150, 50, 100, 100));
|
|
}
|
|
|
|
#[test]
|
|
fn pixel_rect() {
|
|
let r = NormRect {
|
|
x: 10.0,
|
|
y: 20.0,
|
|
w: 80.0,
|
|
h: 40.0,
|
|
};
|
|
assert!(r.is_pixels());
|
|
assert_eq!(r.to_pixels(100, 50, 200, 100), (110, 70, 80, 40));
|
|
}
|
|
}
|