use std::collections::HashSet;
use std::path::PathBuf;
use crate::common::{Rect, WindowId};
use crate::ipc::message::SocketResponse;
use crate::layout::types::{ActualLayout, AppliedLayout};
use crate::loadout::{
ColumnSnapshot, FloatingEntry, LoadoutFile, RectJson, RowSnapshot, ScrollingSnapshot,
WindowRef, WorkspaceSnapshot,
};
use crate::registry::hooks::set_float_hwnds;
use crate::registry::types::{FloatingState, WindowState};
use crate::workspace::{Workspace, WorkspaceId, workspace_y_offset};
use windows::Win32::Foundation::HWND;
use super::types::FlowWM;
#[must_use]
fn rect_json_to_rect(rj: &RectJson) -> Rect {
Rect {
x: rj.x,
y: rj.y,
width: rj.w,
height: rj.h,
}
}
fn resolve_hwnd(live: &mut HashSet<isize>, window: &WindowRef) -> Result<WindowId, String> {
if live.remove(&window.hwnd) {
Ok(WindowId(window.hwnd))
} else {
Err(format!(
"window not currently open: \"{}\" ({}, hwnd {:#x}) — aborting load (no-partial)",
window.title, window.exe, window.hwnd as u64
))
}
}
#[must_use]
fn saved_active_target(workspaces: &[WorkspaceSnapshot]) -> WorkspaceId {
workspaces
.iter()
.find(|ws| ws.active)
.map(|ws| WorkspaceId(ws.workspace_id))
.unwrap_or(WorkspaceId(1))
}
#[must_use]
fn workspace_containing_window(
workspaces: &[Workspace],
window_id: WindowId,
) -> Option<WorkspaceId> {
workspaces.iter().find_map(|ws| {
let in_scrolling = ws
.scrolling
.virtual_layout()
.columns
.iter()
.any(|col| col.rows.iter().any(|row| row.window_id == window_id));
let in_floating = ws.floating.contains(window_id);
if in_scrolling || in_floating {
Some(ws.id)
} else {
None
}
})
}
#[must_use]
fn resolve_seating_target(
workspaces: &[Workspace],
foreground: Option<WindowId>,
saved: &[WorkspaceSnapshot],
) -> WorkspaceId {
foreground
.and_then(|wid| workspace_containing_window(workspaces, wid))
.unwrap_or_else(|| saved_active_target(saved))
}
#[must_use]
fn build_seating_batches(
workspaces: &[Workspace],
target_id: WorkspaceId,
monitor_height: i32,
window_gap: i32,
) -> Vec<(ActualLayout, i32)> {
let mut batches = Vec::new();
for ws in workspaces {
let scroll_actual = ws.scrolling.actual_layout();
let float_actual = ws.floating.to_actual_layout();
if scroll_actual.entries.is_empty() && float_actual.entries.is_empty() {
continue;
}
let y_offset = workspace_y_offset(ws.id, target_id, monitor_height, window_gap);
let mut entries = scroll_actual.entries.clone();
entries.extend(float_actual.entries.iter().cloned());
batches.push((ActualLayout { entries }, y_offset));
}
batches
}
impl FlowWM {
pub(super) fn dispatch_loadout_save(&self, path: Option<PathBuf>) -> SocketResponse {
let resolved =
path.unwrap_or_else(|| self.config_dir.join(&self.config.loadout.default_path));
match self.build_and_write_loadout(&resolved) {
Ok(()) => SocketResponse::Ok,
Err(e) => {
log::warn!("loadout save to {resolved:?} failed: {e}");
SocketResponse::Error {
message: format!("loadout save failed: {e}"),
}
}
}
}
pub(super) fn try_save_loadout_default(&self) -> Result<(), String> {
let default = self.config_dir.join(&self.config.loadout.default_path);
self.build_and_write_loadout(&default)
}
pub(super) fn dispatch_loadout_load(
&mut self,
path: Option<PathBuf>,
) -> SocketResponse {
match self.apply_loadout(path) {
Ok(()) => SocketResponse::Ok,
Err(e) => {
log::warn!("loadout load failed: {e}");
SocketResponse::Error { message: e }
}
}
}
pub fn try_restore_loadout_default(&mut self) {
let default = self.config_dir.join(&self.config.loadout.default_path);
if !default.exists() {
log::debug!("loadout restore: no loadout file at {default:?}, starting fresh");
return;
}
if let Err(e) = self.apply_loadout(Some(default)) {
log::info!("loadout restore skipped: {e}");
}
}
fn apply_loadout(&mut self, path: Option<PathBuf>) -> Result<(), String> {
let resolved =
path.unwrap_or_else(|| self.config_dir.join(&self.config.loadout.default_path));
let raw = std::fs::read_to_string(&resolved)
.map_err(|e| format!("loadout load: failed to read {resolved:?}: {e}"))?;
let file: LoadoutFile = serde_json::from_str(&raw)
.map_err(|e| format!("loadout load: failed to parse {resolved:?}: {e}"))?;
if file.version != LoadoutFile::CURRENT_VERSION {
return Err(format!(
"unsupported file version {} (expected {}) — rejecting {resolved:?}",
file.version,
LoadoutFile::CURRENT_VERSION
));
}
let mut live: HashSet<isize> = HashSet::new();
for win in self.registry.windows() {
if matches!(win.state, WindowState::Ignored(_)) {
continue;
}
live.insert(win.hwnd.0 as isize);
}
struct ResolvedWorkspace {
workspace_id: u32,
columns: Vec<(u32, Vec<WindowId>)>,
focus: Option<WindowId>,
viewport_offset: i32,
floating: Vec<(WindowId, Rect)>,
}
let mut resolved_workspaces: Vec<ResolvedWorkspace> = Vec::new();
for ws_snap in &file.workspaces {
let mut columns = Vec::new();
for col_snap in &ws_snap.scrolling.columns {
let mut rows = Vec::new();
for row_snap in &col_snap.rows {
rows.push(resolve_hwnd(&mut live, &row_snap.window)?);
}
columns.push((col_snap.width_px, rows));
}
let mut floating = Vec::new();
for float_entry in &ws_snap.floating {
let hwnd = resolve_hwnd(&mut live, &float_entry.window)?;
let rect = rect_json_to_rect(&float_entry.rect);
floating.push((hwnd, rect));
}
let focus = ws_snap.scrolling.focus.as_ref().and_then(|focus_ref| {
let focus_hwnd = focus_ref.hwnd;
columns
.iter()
.flat_map(|(_, rows)| rows.iter())
.find(|wid| wid.0 == focus_hwnd)
.copied()
});
resolved_workspaces.push(ResolvedWorkspace {
workspace_id: ws_snap.workspace_id,
columns,
focus,
viewport_offset: ws_snap.scrolling.viewport_offset,
floating,
});
}
let mut leftovers: Vec<WindowId> = Vec::new();
for win in self.registry.windows() {
if !matches!(win.state, WindowState::Tiling(_)) {
continue;
}
let hwnd = win.hwnd.0 as isize;
if live.contains(&hwnd) {
leftovers.push(WindowId(hwnd));
}
}
for rw in resolved_workspaces {
let ws_id = WorkspaceId(rw.workspace_id);
let (monitor_idx, ws_idx) = {
let mut found = None;
for (mi, monitor) in self.monitors.iter().enumerate() {
if let Some(wsi) = monitor.find_workspace_index(ws_id) {
found = Some((mi, wsi));
break;
}
}
match found {
Some(pair) => pair,
None => {
log::warn!(
"loadout load: workspace {} not found on any monitor, skipping",
rw.workspace_id
);
continue;
}
}
};
let (app, floats_to_sync): (AppliedLayout, Vec<(WindowId, Rect)>) = {
let ws = &mut self.monitors[monitor_idx].workspaces_mut()[ws_idx];
let app = ws
.scrolling
.set_layout(rw.columns, rw.focus, rw.viewport_offset);
ws.floating.replace_all(rw.floating);
let floats_to_sync = ws
.floating
.windows()
.iter()
.map(|entry| (entry.window_id, entry.rect))
.collect();
(app, floats_to_sync)
};
self.registry
.update_tiling_slots_from_layout(&app.virtual_layout);
self.registry.update_tiled_rects(&app.actual_layout);
for (wid, rect) in &floats_to_sync {
if let Some(window) = self.registry.get_window_mut(HWND(wid.0 as *mut _)) {
window.state = WindowState::Floating(FloatingState::Active { rect: *rect });
}
}
}
for wid in leftovers {
let applied = self.active_scrolling_mut().add_window(wid);
self.registry
.update_tiling_slots_from_layout(&applied.virtual_layout);
self.registry.update_tiled_rects(&applied.actual_layout);
}
let foreground = crate::registry::win32::get_foreground_window().map(WindowId);
let target_id = resolve_seating_target(
self.active_monitor().workspaces(),
foreground,
&file.workspaces,
);
if self
.active_monitor_mut()
.set_active_workspace(target_id)
.is_none()
{
log::warn!(
"loadout load: seating target workspace {} not found on the active monitor; \
leaving the freshly-tiled layout in place",
target_id.0
);
log::info!("loadout load: applied layout from {resolved:?} (seating skipped)");
return Ok(());
}
let target_floats: Vec<isize> = self
.active_workspace()
.floating
.windows()
.iter()
.map(|entry| entry.window_id.0)
.collect();
set_float_hwnds(&target_floats);
let monitor_height = self.active_monitor().screen_rect().height;
let window_gap = self.active_scrolling().padding().window_gap;
let batches = build_seating_batches(
self.active_monitor().workspaces(),
target_id,
monitor_height,
window_gap,
);
self.animate_workspaces(&batches);
log::info!("loadout load: restored layout from {resolved:?}");
Ok(())
}
fn build_and_write_loadout(&self, path: &PathBuf) -> Result<(), String> {
let workspaces = self.snapshot_workspaces();
let file = LoadoutFile {
version: LoadoutFile::CURRENT_VERSION,
workspaces,
};
let json = serde_json::to_string_pretty(&file)
.map_err(|e| format!("serde serialization failed: {e}"))?;
std::fs::write(path, json).map_err(|e| format!("failed to write {path:?}: {e}"))?;
Ok(())
}
fn snapshot_workspaces(&self) -> Vec<WorkspaceSnapshot> {
let mut snapshots = Vec::new();
for monitor in &self.monitors {
let active_ws_id = monitor.active_workspace_id();
for ws in monitor.workspaces() {
let scrolling = self.snapshot_scrolling(ws);
let floating = self.snapshot_floating(ws);
snapshots.push(WorkspaceSnapshot {
workspace_id: ws.id.0,
active: ws.id == active_ws_id,
scrolling,
floating,
});
}
}
snapshots
}
fn snapshot_scrolling(&self, ws: &crate::workspace::Workspace) -> ScrollingSnapshot {
let vl = ws.scrolling.virtual_layout();
let viewport_offset = vl.viewport_offset;
let focus = ws
.scrolling
.last_focused_window()
.and_then(|wid| self.window_ref_for(wid));
let columns: Vec<ColumnSnapshot> = vl
.columns
.iter()
.map(|col| {
let rows: Vec<RowSnapshot> = col
.rows
.iter()
.filter_map(|row| {
self.window_ref_for(row.window_id).map(|wr| RowSnapshot {
window: wr,
height_px: row.height,
})
})
.collect();
ColumnSnapshot {
width_px: col.width_px as u32,
rows,
}
})
.collect();
ScrollingSnapshot {
viewport_offset,
focus,
columns,
}
}
fn snapshot_floating(&self, ws: &crate::workspace::Workspace) -> Vec<FloatingEntry> {
ws.floating
.windows()
.iter()
.filter_map(|entry| {
self.window_ref_for(entry.window_id)
.map(|wr| FloatingEntry {
window: wr,
rect: RectJson {
x: entry.rect.x,
y: entry.rect.y,
w: entry.rect.width,
h: entry.rect.height,
},
})
})
.collect()
}
fn window_ref_for(&self, wid: WindowId) -> Option<WindowRef> {
let win = self.registry.get_window(HWND(wid.0 as *mut _))?;
Some(WindowRef {
hwnd: wid.0,
exe: win.exe.clone(),
title: win.title.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::types::{MonitorInfo, Padding};
use crate::workspace::{ScrollingSpace, Workspace};
#[test]
fn rect_json_to_rect_correct() {
let rj = RectJson {
x: 10,
y: 20,
w: 300,
h: 400,
};
let r = rect_json_to_rect(&rj);
assert_eq!(
r,
Rect {
x: 10,
y: 20,
width: 300,
height: 400
}
);
}
#[test]
fn rect_json_to_rect_zero() {
let rj = RectJson {
x: 0,
y: 0,
w: 0,
h: 0,
};
let r = rect_json_to_rect(&rj);
assert_eq!(
r,
Rect {
x: 0,
y: 0,
width: 0,
height: 0
}
);
}
#[test]
fn saved_active_target_picks_the_marked_workspace() {
let snaps = vec![
WorkspaceSnapshot {
workspace_id: 1,
active: false,
scrolling: empty_scrolling_snap(),
floating: vec![],
},
WorkspaceSnapshot {
workspace_id: 2,
active: true,
scrolling: empty_scrolling_snap(),
floating: vec![],
},
];
assert_eq!(saved_active_target(&snaps), WorkspaceId(2));
}
#[test]
fn saved_active_target_falls_back_to_workspace_one_when_none_marked() {
let snaps = vec![WorkspaceSnapshot {
workspace_id: 3,
active: false,
scrolling: empty_scrolling_snap(),
floating: vec![],
}];
assert_eq!(saved_active_target(&snaps), WorkspaceId(1));
}
#[test]
fn saved_active_target_falls_back_to_workspace_one_when_empty() {
assert_eq!(saved_active_target(&[]), WorkspaceId(1));
}
#[test]
fn seating_batches_cover_every_nonempty_workspace() {
let workspaces = vec![
make_workspace_with_tile(1, WindowId(100)),
make_empty_workspace(2),
make_workspace_with_tile(3, WindowId(200)),
];
let batches = build_seating_batches(&workspaces, WorkspaceId(1), MONITOR_H, GAP);
assert_eq!(batches.len(), 2, "empty workspace must be skipped");
assert_eq!(batches[0].1, 0, "target workspace parks at offset 0");
assert!(
batches[0]
.0
.entries
.iter()
.any(|e| e.window_id == WindowId(100)),
"target workspace's tile must be in its merged batch"
);
assert_eq!(batches[1].1, Y_UNIT, "ws 3 parks below target at +Y_UNIT");
assert!(
batches[1]
.0
.entries
.iter()
.any(|e| e.window_id == WindowId(200)),
"ws 3's tile must be in its merged batch"
);
}
#[test]
fn seating_batches_offset_signs_around_a_middle_target() {
let workspaces = vec![
make_workspace_with_tile(1, WindowId(100)),
make_workspace_with_tile(2, WindowId(200)),
make_workspace_with_tile(3, WindowId(300)),
];
let batches = build_seating_batches(&workspaces, WorkspaceId(2), MONITOR_H, GAP);
assert_eq!(
batches.iter().map(|(_, off)| *off).collect::<Vec<_>>(),
vec![-Y_UNIT, 0, Y_UNIT],
"offsets must be [-Y_UNIT, 0, +Y_UNIT] around a middle target"
);
}
#[test]
fn seating_batches_include_float_only_workspaces() {
let mut ws = make_empty_workspace(2);
ws.floating.add(
WindowId(400),
Rect {
x: 10,
y: 10,
width: 100,
height: 100,
},
);
let batches = build_seating_batches(&[ws], WorkspaceId(2), MONITOR_H, GAP);
assert_eq!(
batches.len(),
1,
"float-only workspace must produce a batch"
);
assert_eq!(batches[0].1, 0, "target workspace at offset 0");
assert!(
batches[0]
.0
.entries
.iter()
.any(|e| e.window_id == WindowId(400)),
"floating window must be merged into the batch"
);
}
#[test]
fn seating_batches_merge_scrolling_and_float_into_one_entry() {
let mut ws = make_workspace_with_tile(1, WindowId(100));
ws.floating.add(
WindowId(101),
Rect {
x: 20,
y: 20,
width: 80,
height: 80,
},
);
let batches = build_seating_batches(&[ws], WorkspaceId(1), MONITOR_H, GAP);
assert_eq!(batches.len(), 1);
let ids: Vec<isize> = batches[0].0.entries.iter().map(|e| e.window_id.0).collect();
assert!(ids.contains(&100), "tile merged");
assert!(ids.contains(&101), "float merged");
}
#[test]
fn seating_batches_empty_when_all_workspaces_empty() {
let workspaces = vec![make_empty_workspace(1), make_empty_workspace(2)];
let batches = build_seating_batches(&workspaces, WorkspaceId(1), MONITOR_H, GAP);
assert!(batches.is_empty());
}
#[test]
fn workspace_containing_finds_tiled_window() {
let workspaces = vec![
make_empty_workspace(1),
make_workspace_with_tile(2, WindowId(111)),
];
assert_eq!(
workspace_containing_window(&workspaces, WindowId(111)),
Some(WorkspaceId(2))
);
}
#[test]
fn workspace_containing_finds_floating_window() {
let mut ws = make_empty_workspace(3);
ws.floating.add(
WindowId(222),
Rect {
x: 5,
y: 5,
width: 50,
height: 50,
},
);
let workspaces = vec![make_empty_workspace(1), ws];
assert_eq!(
workspace_containing_window(&workspaces, WindowId(222)),
Some(WorkspaceId(3))
);
}
#[test]
fn workspace_containing_returns_none_for_absent_window() {
let workspaces = vec![make_workspace_with_tile(1, WindowId(100))];
assert_eq!(
workspace_containing_window(&workspaces, WindowId(999)),
None
);
}
#[test]
fn workspace_containing_returns_none_when_no_workspaces() {
assert_eq!(workspace_containing_window(&[], WindowId(1)), None);
}
#[test]
fn seating_target_prefers_foreground_workspace() {
let workspaces = vec![
make_workspace_with_tile(1, WindowId(100)),
make_workspace_with_tile(2, WindowId(200)),
];
let saved = vec![WorkspaceSnapshot {
workspace_id: 1,
active: true,
scrolling: empty_scrolling_snap(),
floating: vec![],
}];
assert_eq!(
resolve_seating_target(&workspaces, Some(WindowId(200)), &saved),
WorkspaceId(2)
);
}
#[test]
fn seating_target_falls_back_to_saved_when_foreground_unmanaged() {
let workspaces = vec![
make_workspace_with_tile(1, WindowId(100)),
make_workspace_with_tile(2, WindowId(200)),
];
let saved = vec![WorkspaceSnapshot {
workspace_id: 2,
active: true,
scrolling: empty_scrolling_snap(),
floating: vec![],
}];
assert_eq!(
resolve_seating_target(&workspaces, Some(WindowId(999)), &saved),
WorkspaceId(2)
);
}
#[test]
fn seating_target_falls_back_to_saved_when_no_foreground() {
let workspaces = vec![make_workspace_with_tile(1, WindowId(100))];
let saved = vec![WorkspaceSnapshot {
workspace_id: 1,
active: true,
scrolling: empty_scrolling_snap(),
floating: vec![],
}];
assert_eq!(
resolve_seating_target(&workspaces, None, &saved),
WorkspaceId(1)
);
}
#[test]
fn seating_target_falls_back_to_workspace_one() {
let workspaces = vec![make_workspace_with_tile(3, WindowId(100))];
assert_eq!(
resolve_seating_target(&workspaces, Some(WindowId(999)), &[]),
WorkspaceId(1)
);
}
const MONITOR_H: i32 = 1080;
const GAP: i32 = 4;
const Y_UNIT: i32 = MONITOR_H + GAP;
fn make_scrolling() -> ScrollingSpace {
ScrollingSpace::new(
MonitorInfo {
work_area: Rect {
x: 0,
y: 0,
width: 1920,
height: 1080,
},
},
960,
320,
100,
100,
Padding {
window_gap: GAP,
up: 0,
down: 0,
},
4,
)
}
fn make_workspace_with_tile(id: u32, wid: WindowId) -> Workspace {
let mut scrolling = make_scrolling();
scrolling.add_window(wid);
Workspace::new(WorkspaceId(id), scrolling)
}
fn make_empty_workspace(id: u32) -> Workspace {
Workspace::new(WorkspaceId(id), make_scrolling())
}
fn empty_scrolling_snap() -> ScrollingSnapshot {
ScrollingSnapshot {
viewport_offset: 0,
focus: None,
columns: vec![],
}
}
}