enBoxer/src/profile.rs
en 8cb96c1f25 Item 1 (Grok round 4): window layout manager rewrite + free-arrange mode.
Master wanted two modes:
- Managed (existing wizard behaviour): auto-arrange grid via the
  layout wizard.
- Free-arrange: launch windows without applying layout. Per-window
  initial resolution. Apply-size button (not position). Daemon does
  NOT keep overwriting resolution if operator manually resized.

Reference: KWin scripts apply-size / apply-position for the live
resize plumbing.

Implementation:

src/profile.rs:
  * LayoutMode enum: Managed (default) | Free. Serde round-trips
    as "managed" / "free".
  * Layout.mode field added (default = Managed via LayoutMode derive).
  * LayoutSlot split into initial_size (Option<(u32,u32)>),
    pos (Option<(i32,i32)>), size_locked (bool). The legacy x/y/w/h
    fields stay for Managed mode (cached geometry).

src/hypr.rs:
  * move_resize_window split into resize_window(w, h) + move_window(x, y).
  * move_resize_window kept as a thin wrapper so the original
    layout-apply IPC path still works.

src/layout.rs:
  * apply(slots, windows) split into apply_size + apply_pos; apply()
    is now the thin wrapper that calls both.
  * All 4 LayoutSlot literals (Stacked preset, Grid helper, main_strip,
    capture_from) gained ..Default::default().

src/session.rs:
  * New IPC verbs: resize-slot N, move-slot N, reset-slot N.
  * resize_slot: applies initial_size (or cached w/h), sets
    size_locked=true so the daemon does not undo future operator
    resizes.
  * move_slot: applies pos (or cached x/y).
  * reset_slot_lock: clears size_locked on slot-N.
  * All three bail with a clear message if the mode is not Free.

src/gui.rs:
  * Mode toggle (Managed / Free) at the top of page_layout.
  * page_layout_managed: existing wizard (presets, drag tiles,
    Save and Apply).
  * page_layout_free: per-window initial W/H picker, Apply-size,
    Apply-position, Reset-size buttons. Per-slot lock indicator.
    Uses DragValue::range (clamp_range is deprecated).
  * App.pending_ipc: Vec<String> queue; the main loop will drain
    it and fire the IPC verbs.

cargo test 103/103 (unchanged from Item 4 + 3 games tests); clippy clean.
2026-09-17 07:22:13 +02:00

564 lines
16 KiB
Rust

use crate::hotkey::Hotkey;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
pub name: String,
#[serde(default = "default_client")]
pub client: String,
#[serde(default = "default_slots")]
pub slots: u32,
#[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, Default,
Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
/// 1: only configured maps; everything else goes to the front window
#[default]
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 Repeater {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub keys: Vec<String>,
}
#[derive(Debug, Clone, Default, 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
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractStyle {
#[default]
Standard,
Auto,
Hold,
}
#[derive(Debug, Clone, Default, 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, Default, Serialize, Deserialize)]
pub struct Group {
pub slots: Vec<u32>,
}
#[derive(Debug, Clone, Default, 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, Default, 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, Default, 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,
}
/// Item 1: layout execution mode.
///
/// Managed: daemon regenerates slot geometry + applies on capture
/// (the existing wizard behaviour).
///
/// Free: daemon only applies the per-window initial size ONCE
/// (when size_locked is false). Once the operator resizes, the
/// daemon does NOT keep overwriting. The GUI exposes
/// Apply-size / Apply-position buttons that fire on demand.
#[derive(
Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum LayoutMode {
#[default]
Managed,
Free,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Layout {
/// Item 1: Managed (default) vs Free-arrange mode.
#[serde(default)]
pub mode: LayoutMode,
#[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>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LayoutSlot {
/// Item 1: cached last-known good geometry, used by Managed
/// mode. Ignored by Free mode.
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
/// Item 1: initial size applied ONCE in Free mode (and never
/// again once size_locked is true). Ignored by Managed mode.
#[serde(default)]
pub initial_size: Option<(u32, u32)>,
/// Item 1: explicit position for Free mode (drag-and-drop
/// target). Ignored by Managed mode.
#[serde(default)]
pub pos: Option<(i32, i32)>,
/// Item 1: when true, Free mode stops applying size to this
/// slot on refresh (operator resized and we should not undo
/// their change). Set by `reset-slot N` IPC verb to allow
/// re-application.
#[serde(default)]
pub size_locked: bool,
#[serde(default)]
pub pin: bool,
}
fn default_fps() -> u32 {
12
}
#[derive(Debug, Clone, Copy, Default, 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 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 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")
}
}
/// `chmod 0o700` the runtime dir so only this user can read/write.
///
/// Any local user with read access to the socket could speak our IPC
/// protocol (including `Command::Type`, which is wide-open text injection
/// into game windows). Permissions are the cheapest defense.
pub fn chmod_runtime_dir() {
chmod_dir(&runtime_dir());
}
/// `chmod 0o700` an arbitrary directory. Split out from
/// [`chmod_runtime_dir`] so tests can exercise it on a tempdir they own
/// without mutating the user's live `XDG_RUNTIME_DIR`.
pub fn chmod_dir(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
path,
std::fs::Permissions::from_mode(0o700),
);
}
}
/// `chmod 0o600` the IPC socket so only this user can connect.
///
/// Belt-and-braces with `chmod_runtime_dir`: the dir alone is not enough
/// if other shared paths leaked earlier.
pub fn chmod_socket<P: AsRef<std::path::Path>>(path: P) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
path.as_ref(),
std::fs::Permissions::from_mode(0o600),
);
}
}
#[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));
}
}
#[cfg(unix)]
#[test]
fn chmod_socket_sets_0o600() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("enboxer-test-chmod");
std::fs::create_dir_all(&dir).unwrap();
let sock = dir.join("test.sock");
std::fs::write(&sock, b"").unwrap();
std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o644)).unwrap();
chmod_socket(&sock);
let m = std::fs::metadata(&sock).unwrap().permissions().mode() & 0o777;
assert_eq!(m, 0o600, "expected 0o600, got {m:o}");
std::fs::remove_file(&sock).ok();
std::fs::remove_dir(&dir).ok();
}
#[cfg(unix)]
#[test]
fn chmod_dir_sets_0o700_on_a_tempdir() {
use std::os::unix::fs::PermissionsExt;
// Use a private tempdir so the test never mutates the user's
// XDG_RUNTIME_DIR (which is what runtime_dir() resolves to).
let unique = format!(
"enboxer-chmod-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let dir = std::env::temp_dir().join(unique);
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
chmod_dir(&dir);
let m = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(m, 0o700, "expected 0o700, got {m:o}");
let _ = std::fs::remove_dir(&dir);
}