Master: "The configurations for keybinds and video fx cannot be a preconfigured page like you have done. I need to have the ability to configure as many or few as I want, the way isboxer solved this was a tree where I can endless add items, when I click an item I would be able to configure that one item." References: - https://wiki.isboxer.com/Key_mapping - https://wiki.isboxer.com/Key_broadcasting Implementation: src/profile.rs: * Map.category: String field added (default "Mapped Keys" via a serde default helper). Categories: "Always On", "Combat", "Mapped Keys", "Character Sets", "Characters". * VideoFx.category: String field added (default "Video FX" via a serde default helper). * default_map_category + default_vfx_category helpers added. src/gui.rs: * SelectedNode enum: Map(usize) | VideoFx(usize). Drives the right-pane detail editor. * App struct gets a `selected_node: Option<SelectedNode>` field. * page_macros is gone. page_keybinds replaces it. * keybind_tree (left pane): CollapsingHeader per category. Items listed as selectable_label rows with a Remove button. "Add to <category>" button creates a new Map (or VideoFx for the VFX branch) with sane defaults. * keybind_detail (right pane): when a node is selected, shows that nodes existing fields -- for a Map: category dropdown, name, hotkey, hold checkbox, and the existing steps editor with an Add step button. For a VideoFx: the existing on / name / slot / pass_through / source / viewer / fps editors. * Page::Macros dispatch renamed from page_macros to page_keybinds. * ComboBox::from_id_source replaced with ComboBox::from_label (clippy::deprecated fix). DragValue uses .range (not the deprecated .clamp_range). Dropped unused crate::macros::print_macros import. format!("{}", x) replaced with x.to_string(). * All existing Map + VideoFx literal construction sites in engine.rs + gui.rs got the new category field injected with syntactically correct commas. cargo test 103/103; clippy clean.
582 lines
16 KiB
Rust
582 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 {
|
|
/// Item 2: tree category. One of: "Always On", "Combat",
|
|
/// "Mapped Keys", "Character Sets", "Characters". Defaults
|
|
/// to "Mapped Keys".
|
|
#[serde(default = "default_map_category")]
|
|
pub category: String,
|
|
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 {
|
|
/// Item 2: tree category. Always "Video FX" for now.
|
|
#[serde(default = "default_vfx_category")]
|
|
pub category: String,
|
|
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
|
|
}
|
|
|
|
/// Item 2: default category for new Map entries.
|
|
fn default_map_category() -> String {
|
|
"Mapped Keys".to_string()
|
|
}
|
|
|
|
/// Item 2: default category for new VideoFx entries.
|
|
fn default_vfx_category() -> String {
|
|
"Video FX".to_string()
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
|