Add window layout: stacked, grid, main+strip, Save and Apply.
Layout page in the control panel places captured clients like the source project's wizard: per-slot x/y/w/h, pin, generate, capture.
This commit is contained in:
parent
32731fe59b
commit
50b2aebee6
@ -105,6 +105,15 @@ maps:
|
|||||||
- bind: ctm_off
|
- bind: ctm_off
|
||||||
target: others
|
target: others
|
||||||
|
|
||||||
|
# Window layout (pixels). Generate from the Layout page, then Save and Apply.
|
||||||
|
layout:
|
||||||
|
preset: main_strip
|
||||||
|
one_row: true
|
||||||
|
main_at_bottom: false
|
||||||
|
auto_apply: false
|
||||||
|
monitor: ""
|
||||||
|
slots: []
|
||||||
|
|
||||||
# Viewer sits on the current primary. Hover + pass_through sends keys/clicks to source_slot only.
|
# Viewer sits on the current primary. Hover + pass_through sends keys/clicks to source_slot only.
|
||||||
video_fx:
|
video_fx:
|
||||||
- name: alt_party
|
- name: alt_party
|
||||||
|
|||||||
@ -262,6 +262,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
video_fx: vec![],
|
video_fx: vec![],
|
||||||
|
layout: Default::default(),
|
||||||
};
|
};
|
||||||
Engine::new(profile).unwrap()
|
Engine::new(profile).unwrap()
|
||||||
}
|
}
|
||||||
@ -318,6 +319,7 @@ mod tests {
|
|||||||
release_steps: vec![],
|
release_steps: vec![],
|
||||||
}],
|
}],
|
||||||
video_fx: vec![],
|
video_fx: vec![],
|
||||||
|
layout: Default::default(),
|
||||||
};
|
};
|
||||||
let e = Engine::new(profile).unwrap();
|
let e = Engine::new(profile).unwrap();
|
||||||
assert!(e.should_intercept("e"));
|
assert!(e.should_intercept("e"));
|
||||||
|
|||||||
142
src/gui.rs
142
src/gui.rs
@ -3,7 +3,8 @@
|
|||||||
use crate::hotkey::Hotkey;
|
use crate::hotkey::Hotkey;
|
||||||
use crate::macros::print_macros;
|
use crate::macros::print_macros;
|
||||||
use crate::profile::{
|
use crate::profile::{
|
||||||
default_config_path, Group, Map, Mode, NormRect, Profile, Repeater, Step, VideoFx, WindowMatch,
|
default_config_path, Group, LayoutPreset, Map, Mode, NormRect, Profile, Repeater, Step,
|
||||||
|
VideoFx, WindowMatch,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use eframe::egui::{self, Color32};
|
use eframe::egui::{self, Color32};
|
||||||
@ -14,6 +15,7 @@ use std::process::{Child, Command, Stdio};
|
|||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum Page {
|
enum Page {
|
||||||
Session,
|
Session,
|
||||||
|
Layout,
|
||||||
Maps,
|
Maps,
|
||||||
Video,
|
Video,
|
||||||
Macros,
|
Macros,
|
||||||
@ -78,6 +80,7 @@ fn default_profile() -> Profile {
|
|||||||
groups: BTreeMap::<String, Group>::new(),
|
groups: BTreeMap::<String, Group>::new(),
|
||||||
maps: vec![],
|
maps: vec![],
|
||||||
video_fx: vec![],
|
video_fx: vec![],
|
||||||
|
layout: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,6 +289,16 @@ impl eframe::App for App {
|
|||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
ui.menu_button("Layout", |ui| {
|
||||||
|
if ui.button("Window layout…").clicked() {
|
||||||
|
self.page = Page::Layout;
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
if ui.button("Apply layout now").clicked() {
|
||||||
|
self.apply_layout();
|
||||||
|
ui.close_menu();
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.menu_button("Video", |ui| {
|
ui.menu_button("Video", |ui| {
|
||||||
if ui.button("Add crop").clicked() {
|
if ui.button("Add crop").clicked() {
|
||||||
self.profile.video_fx.push(VideoFx {
|
self.profile.video_fx.push(VideoFx {
|
||||||
@ -340,6 +353,7 @@ impl eframe::App for App {
|
|||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.heading("enBoxer");
|
ui.heading("enBoxer");
|
||||||
ui.selectable_value(&mut self.page, Page::Session, "Session");
|
ui.selectable_value(&mut self.page, Page::Session, "Session");
|
||||||
|
ui.selectable_value(&mut self.page, Page::Layout, "Layout");
|
||||||
ui.selectable_value(&mut self.page, Page::Maps, "Maps");
|
ui.selectable_value(&mut self.page, Page::Maps, "Maps");
|
||||||
ui.selectable_value(&mut self.page, Page::Video, "Video FX");
|
ui.selectable_value(&mut self.page, Page::Video, "Video FX");
|
||||||
ui.selectable_value(&mut self.page, Page::Macros, "Game macros");
|
ui.selectable_value(&mut self.page, Page::Macros, "Game macros");
|
||||||
@ -357,6 +371,7 @@ impl eframe::App for App {
|
|||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| match self.page {
|
egui::CentralPanel::default().show(ctx, |ui| match self.page {
|
||||||
Page::Session => self.page_session(ui),
|
Page::Session => self.page_session(ui),
|
||||||
|
Page::Layout => self.page_layout(ui),
|
||||||
Page::Maps => self.page_maps(ui),
|
Page::Maps => self.page_maps(ui),
|
||||||
Page::Video => self.page_video(ui),
|
Page::Video => self.page_video(ui),
|
||||||
Page::Macros => self.page_macros(ui),
|
Page::Macros => self.page_macros(ui),
|
||||||
@ -603,6 +618,131 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_layout(&mut self) {
|
||||||
|
self.save();
|
||||||
|
match Command::new(Self::exe())
|
||||||
|
.args(["layout-apply", "-c"])
|
||||||
|
.arg(&self.path)
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(o) => {
|
||||||
|
self.status = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
||||||
|
if !o.status.success() {
|
||||||
|
self.error = Some(String::from_utf8_lossy(&o.stderr).into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.error = Some(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_layout_preview(&mut self) {
|
||||||
|
let out = Command::new("hyprctl").args(["-j", "monitors"]).output();
|
||||||
|
let Ok(out) = out else {
|
||||||
|
self.error = Some("hyprctl monitors failed".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mons: Vec<crate::layout::Monitor> =
|
||||||
|
serde_json::from_slice(&out.stdout).unwrap_or_default();
|
||||||
|
self.profile.layout.slots =
|
||||||
|
crate::layout::generate(&self.profile.layout, self.profile.slots, &mons);
|
||||||
|
self.status = format!("generated {} tiles", self.profile.layout.slots.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_layout(&mut self) {
|
||||||
|
let out = Command::new("hyprctl").args(["-j", "clients"]).output();
|
||||||
|
let Ok(out) = out else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(clients) = serde_json::from_slice::<Vec<crate::hypr::Client>>(&out.stdout) else {
|
||||||
|
self.error = Some("parse clients".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let windows = crate::layout::select_windows(&self.profile, clients);
|
||||||
|
self.profile.layout.slots = crate::layout::capture_from(&windows);
|
||||||
|
self.status = format!("captured {} window positions", windows.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page_layout(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.heading("Window layout");
|
||||||
|
ui.label("Place each game client: stacked, equal grid, or one large main plus a strip of minions. Save and Apply moves captured windows.");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Windows");
|
||||||
|
ui.add(egui::DragValue::new(&mut self.profile.slots).range(1..=16));
|
||||||
|
ui.label("Monitor");
|
||||||
|
ui.text_edit_singleline(&mut self.profile.layout.monitor);
|
||||||
|
ui.label("(empty = largest)");
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.profile.layout.preset,
|
||||||
|
LayoutPreset::Stacked,
|
||||||
|
"All stacked",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.profile.layout.preset,
|
||||||
|
LayoutPreset::Grid,
|
||||||
|
"Same size grid",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.profile.layout.preset,
|
||||||
|
LayoutPreset::MainStrip,
|
||||||
|
"Main + strip",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.profile.layout.one_row,
|
||||||
|
"One row/col for small windows",
|
||||||
|
);
|
||||||
|
ui.checkbox(&mut self.profile.layout.main_at_bottom, "Main at bottom");
|
||||||
|
ui.checkbox(&mut self.profile.layout.pin, "Stay on top");
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.profile.layout.auto_apply,
|
||||||
|
"Auto-apply when captured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Generate").clicked() {
|
||||||
|
self.generate_layout_preview();
|
||||||
|
}
|
||||||
|
if ui.button("Capture current positions").clicked() {
|
||||||
|
self.capture_layout();
|
||||||
|
}
|
||||||
|
if ui
|
||||||
|
.add(egui::Button::new("Save and Apply").fill(Color32::from_rgb(180, 120, 40)))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.generate_layout_preview();
|
||||||
|
self.apply_layout();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.label("Per-window x y w h (pixels). Slot 1 is the main tile.");
|
||||||
|
while self.profile.layout.slots.len() < self.profile.slots as usize {
|
||||||
|
self.profile.layout.slots.push(crate::profile::LayoutSlot {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 800,
|
||||||
|
h: 600,
|
||||||
|
pin: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.profile
|
||||||
|
.layout
|
||||||
|
.slots
|
||||||
|
.truncate(self.profile.slots as usize);
|
||||||
|
for (i, s) in self.profile.layout.slots.iter_mut().enumerate() {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(format!("#{}", i + 1));
|
||||||
|
ui.add(egui::DragValue::new(&mut s.x).prefix("x "));
|
||||||
|
ui.add(egui::DragValue::new(&mut s.y).prefix("y "));
|
||||||
|
ui.add(egui::DragValue::new(&mut s.w).prefix("w "));
|
||||||
|
ui.add(egui::DragValue::new(&mut s.h).prefix("h "));
|
||||||
|
ui.checkbox(&mut s.pin, "pin");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn page_macros(&mut self, ui: &mut egui::Ui) {
|
fn page_macros(&mut self, ui: &mut egui::Ui) {
|
||||||
ui.heading("In-game macros");
|
ui.heading("In-game macros");
|
||||||
ui.label("Bind these on every account. enBoxer only sends keystrokes.");
|
ui.label("Bind these on every account. enBoxer only sends keystrokes.");
|
||||||
|
|||||||
294
src/layout.rs
Normal file
294
src/layout.rs
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
//! Place captured game windows: stacked, equal grid, or one big + a strip of minions.
|
||||||
|
|
||||||
|
use crate::hypr::{self, Client};
|
||||||
|
use crate::profile::{Layout, LayoutPreset, LayoutSlot, Profile};
|
||||||
|
use anyhow::Result;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Monitor {
|
||||||
|
pub name: String,
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn monitors() -> Result<Vec<Monitor>> {
|
||||||
|
let raw = hypr::hyprctl(["-j", "monitors"]).await?;
|
||||||
|
Ok(serde_json::from_str(&raw)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_monitor<'a>(mons: &'a [Monitor], name: &str) -> &'a Monitor {
|
||||||
|
if !name.is_empty() {
|
||||||
|
if let Some(m) = mons.iter().find(|m| m.name == name) {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mons.iter()
|
||||||
|
.max_by_key(|m| m.width as i64 * m.height as i64)
|
||||||
|
.unwrap_or(&mons[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_for_n(n: u32) -> (u32, u32) {
|
||||||
|
let n = n.max(1);
|
||||||
|
let mut y = (n as f64).sqrt().floor() as u32;
|
||||||
|
y = y.max(1);
|
||||||
|
let mut x = (n as f64).sqrt().ceil() as u32;
|
||||||
|
x = x.max(1);
|
||||||
|
while x * y < n {
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(layout: &Layout, n: u32, mons: &[Monitor]) -> Vec<LayoutSlot> {
|
||||||
|
if mons.is_empty() || n == 0 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let m = pick_monitor(mons, &layout.monitor);
|
||||||
|
match layout.preset {
|
||||||
|
LayoutPreset::Stacked => {
|
||||||
|
let (w, h) = constrain(m.width, m.height);
|
||||||
|
(0..n)
|
||||||
|
.map(|_| LayoutSlot {
|
||||||
|
x: m.x,
|
||||||
|
y: m.y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
pin: layout.pin,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
LayoutPreset::Grid => grid(m, n, layout.pin),
|
||||||
|
LayoutPreset::MainStrip => main_strip(m, n, layout),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constrain(w: i32, h: i32) -> (i32, i32) {
|
||||||
|
(w.max(1), h.max(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grid(m: &Monitor, n: u32, pin: bool) -> Vec<LayoutSlot> {
|
||||||
|
let (cols, rows) = split_for_n(n);
|
||||||
|
let ww = m.width / cols as i32;
|
||||||
|
let wh = m.height / rows as i32;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for y in 0..rows {
|
||||||
|
for x in 0..cols {
|
||||||
|
if out.len() as u32 >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(LayoutSlot {
|
||||||
|
x: m.x + x as i32 * ww,
|
||||||
|
y: m.y + y as i32 * wh,
|
||||||
|
w: ww,
|
||||||
|
h: wh,
|
||||||
|
pin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main_strip(m: &Monitor, n: u32, layout: &Layout) -> Vec<LayoutSlot> {
|
||||||
|
if n <= 1 {
|
||||||
|
return grid(m, n, layout.pin);
|
||||||
|
}
|
||||||
|
let small_n = n - 1;
|
||||||
|
let (bw, bh, strip_h, strip_w, horizontal) = if layout.one_row {
|
||||||
|
let c = small_n as i32;
|
||||||
|
// wide monitor: strip along the bottom (or top)
|
||||||
|
let bh = (m.height as f64 * c as f64 / (c + 1) as f64).round() as i32;
|
||||||
|
(m.width, bh, m.height - bh, m.width, true)
|
||||||
|
} else {
|
||||||
|
let c = (n as i32 - 2).max(2);
|
||||||
|
let bw = (m.width as f64 * c as f64 / (c + 1) as f64).round() as i32;
|
||||||
|
let bh = (m.height as f64 * c as f64 / (c + 1) as f64).round() as i32;
|
||||||
|
(bw, bh, m.height - bh, m.width - bw, false)
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let main_y = if layout.main_at_bottom {
|
||||||
|
m.y + (m.height - bh)
|
||||||
|
} else {
|
||||||
|
m.y
|
||||||
|
};
|
||||||
|
out.push(LayoutSlot {
|
||||||
|
x: m.x,
|
||||||
|
y: main_y,
|
||||||
|
w: bw,
|
||||||
|
h: bh,
|
||||||
|
pin: true,
|
||||||
|
});
|
||||||
|
let sw = if horizontal {
|
||||||
|
m.width / small_n.max(1) as i32
|
||||||
|
} else {
|
||||||
|
strip_w.max(1)
|
||||||
|
};
|
||||||
|
let sh = if horizontal {
|
||||||
|
strip_h.max(1)
|
||||||
|
} else {
|
||||||
|
bh / small_n.max(1) as i32
|
||||||
|
};
|
||||||
|
let strip_y = if layout.main_at_bottom { m.y } else { m.y + bh };
|
||||||
|
for i in 0..small_n {
|
||||||
|
let (x, y) = if horizontal {
|
||||||
|
(m.x + i as i32 * sw, strip_y)
|
||||||
|
} else {
|
||||||
|
(m.x + bw, m.y + i as i32 * sh)
|
||||||
|
};
|
||||||
|
out.push(LayoutSlot {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: sw.max(1),
|
||||||
|
h: sh.max(1),
|
||||||
|
pin: layout.pin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_windows(profile: &Profile, clients: Vec<Client>) -> Vec<(u32, Client)> {
|
||||||
|
let class_re = profile
|
||||||
|
.window_match
|
||||||
|
.class
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| Regex::new(p).ok());
|
||||||
|
let title_re = profile
|
||||||
|
.window_match
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| Regex::new(p).ok());
|
||||||
|
if class_re.is_none() && title_re.is_none() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let mut matched: Vec<Client> = clients
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| {
|
||||||
|
if c.class == "enboxer-vfx" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let class_ok = class_re
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.is_match(&c.class))
|
||||||
|
.unwrap_or(true);
|
||||||
|
let title_ok = title_re
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.is_match(&c.title))
|
||||||
|
.unwrap_or(true);
|
||||||
|
class_ok && title_ok && c.mapped && !c.hidden
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
|
||||||
|
matched
|
||||||
|
.into_iter()
|
||||||
|
.take(profile.slots as usize)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| ((i as u32) + 1, c))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply_for_profile(profile: &mut Profile) -> Result<usize> {
|
||||||
|
let mons = monitors().await?;
|
||||||
|
if profile.layout.slots.len() as u32 != profile.slots {
|
||||||
|
profile.layout.slots = generate(&profile.layout, profile.slots, &mons);
|
||||||
|
}
|
||||||
|
let clients = hypr::clients().await?;
|
||||||
|
let windows = select_windows(profile, clients);
|
||||||
|
apply(&profile.layout.slots, &windows).await?;
|
||||||
|
Ok(windows.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn apply(slots: &[LayoutSlot], windows: &[(u32, Client)]) -> Result<()> {
|
||||||
|
for (i, win) in windows {
|
||||||
|
let Some(geom) = slots.get((*i as usize).saturating_sub(1)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let sel = win.address_selector();
|
||||||
|
hypr::dispatch_lua(&format!(
|
||||||
|
"hl.dsp.window.float({{ window = {sel:?}, action = \"on\" }})"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
hypr::move_resize_window(&sel, geom.x, geom.y, geom.w, geom.h).await?;
|
||||||
|
let pin = if geom.pin { "on" } else { "off" };
|
||||||
|
hypr::dispatch_lua(&format!(
|
||||||
|
"hl.dsp.window.pin({{ window = {sel:?}, action = {pin:?} }})"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture_from(windows: &[(u32, Client)]) -> Vec<LayoutSlot> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (_, c) in windows {
|
||||||
|
out.push(LayoutSlot {
|
||||||
|
x: c.at[0],
|
||||||
|
y: c.at[1],
|
||||||
|
w: c.size[0],
|
||||||
|
h: c.size[1],
|
||||||
|
pin: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap geometry of slot `n` with slot 1 (the main tile).
|
||||||
|
pub fn swap_main(slots: &mut [LayoutSlot], n: usize) {
|
||||||
|
if n == 0 || n >= slots.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slots.swap(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mon() -> Monitor {
|
||||||
|
Monitor {
|
||||||
|
name: "DP-1".into(),
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stacked_same_rect() {
|
||||||
|
let l = Layout {
|
||||||
|
preset: LayoutPreset::Stacked,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let s = generate(&l, 3, &[mon()]);
|
||||||
|
assert_eq!(s.len(), 3);
|
||||||
|
assert!(s.iter().all(|w| w.w == 1920 && w.h == 1080 && w.x == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grid_five() {
|
||||||
|
let l = Layout {
|
||||||
|
preset: LayoutPreset::Grid,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let s = generate(&l, 5, &[mon()]);
|
||||||
|
assert_eq!(s.len(), 5);
|
||||||
|
assert!(s.iter().all(|w| w.w > 0 && w.h > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn main_strip_has_big_first() {
|
||||||
|
let l = Layout {
|
||||||
|
preset: LayoutPreset::MainStrip,
|
||||||
|
one_row: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let s = generate(&l, 4, &[mon()]);
|
||||||
|
assert_eq!(s.len(), 4);
|
||||||
|
assert!(s[0].w * s[0].h > s[1].w * s[1].h);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ pub mod engine;
|
|||||||
pub mod gui;
|
pub mod gui;
|
||||||
pub mod hotkey;
|
pub mod hotkey;
|
||||||
pub mod hypr;
|
pub mod hypr;
|
||||||
|
pub mod layout;
|
||||||
pub mod macros;
|
pub mod macros;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|||||||
15
src/main.rs
15
src/main.rs
@ -53,6 +53,11 @@ enum Cmd {
|
|||||||
},
|
},
|
||||||
/// Control panel (default if you run `enboxer` with no command)
|
/// Control panel (default if you run `enboxer` with no command)
|
||||||
Gui,
|
Gui,
|
||||||
|
/// Generate and apply the window layout to captured game clients
|
||||||
|
LayoutApply {
|
||||||
|
#[arg(short, long)]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
},
|
||||||
/// Called by Hyprland binds; not for humans
|
/// Called by Hyprland binds; not for humans
|
||||||
Ipc {
|
Ipc {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@ -71,6 +76,16 @@ async fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
match cli.cmd.unwrap_or(Cmd::Gui) {
|
match cli.cmd.unwrap_or(Cmd::Gui) {
|
||||||
Cmd::Gui => enboxer::gui::run(),
|
Cmd::Gui => enboxer::gui::run(),
|
||||||
|
Cmd::LayoutApply { config } => {
|
||||||
|
let path = config.unwrap_or_else(profile::default_config_path);
|
||||||
|
let mut profile = Profile::load(&path)?;
|
||||||
|
let n = enboxer::layout::apply_for_profile(&mut profile).await?;
|
||||||
|
if let Ok(text) = serde_yaml::to_string(&profile) {
|
||||||
|
let _ = std::fs::write(&path, text);
|
||||||
|
}
|
||||||
|
println!("laid out {n} windows");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Cmd::Run { config } => {
|
Cmd::Run { config } => {
|
||||||
let path = config.unwrap_or_else(profile::default_config_path);
|
let path = config.unwrap_or_else(profile::default_config_path);
|
||||||
let profile = Profile::load(&path)?;
|
let profile = Profile::load(&path)?;
|
||||||
|
|||||||
@ -34,6 +34,8 @@ pub struct Profile {
|
|||||||
pub maps: Vec<Map>,
|
pub maps: Vec<Map>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub video_fx: Vec<VideoFx>,
|
pub video_fx: Vec<VideoFx>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layout: Layout,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_client() -> String {
|
fn default_client() -> String {
|
||||||
@ -200,6 +202,61 @@ pub struct VideoFx {
|
|||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
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,
|
||||||
|
/// 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,
|
||||||
|
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 {
|
fn default_fps() -> u32 {
|
||||||
12
|
12
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user