use super::*;
use crate::compositor::clusters::state::ClusterState;
use crate::compositor::monitor::state::MonitorState;
use crate::window::active_window_frame_pad_px;
use halley_core::cluster::ClusterId;
use halley_core::cluster_layout::{ClusterWorkspaceLayoutKind, layout_cluster_workspace};
use halley_core::tiling::Rect;
pub(super) struct ClusterReadController<'a> {
pub(super) field: &'a Field,
pub(super) cluster_state: &'a ClusterState,
pub(super) monitor_state: &'a MonitorState,
pub(super) tuning: &'a RuntimeTuning,
}
pub(super) struct EnterClusterWorkspacePlan {
pub(super) cid: ClusterId,
pub(super) core_id: NodeId,
pub(super) core_pos: Vec2,
pub(super) current_viewport: halley_core::viewport::Viewport,
pub(super) hidden_ids: Vec<NodeId>,
}
pub(super) struct ExitClusterWorkspacePlan {
pub(super) cid: ClusterId,
pub(super) core_id: Option<NodeId>,
pub(super) core_pos: Option<Vec2>,
pub(super) hidden_ids: Vec<NodeId>,
}
pub(super) struct ClusterTilePlacement {
pub(super) node_id: NodeId,
pub(super) rect: halley_core::tiling::Rect,
}
pub(super) struct ClusterLayoutPlan {
pub(super) kind: ClusterWorkspaceLayoutKind,
pub(super) tiles: Vec<ClusterTilePlacement>,
pub(super) overflow_members: Vec<NodeId>,
}
impl<'a> ClusterReadController<'a> {
const OVERFLOW_STRIP_PAD_PX: f32 = 18.0;
const OVERFLOW_STRIP_W_PX: f32 = 56.0;
const OVERFLOW_SCROLLBAR_GUTTER_PX: f32 = 12.0;
const OVERFLOW_ICON_PAD_PX: f32 = 8.0;
const OVERFLOW_ICON_SIZE_PX: f32 = 40.0;
const OVERFLOW_ICON_GAP_PX: f32 = 8.0;
const OVERFLOW_VISIBLE_SLOTS: usize = 15;
pub(super) fn cluster_bloom_for_monitor(&self, monitor: &str) -> Option<ClusterId> {
self.cluster_state.cluster_bloom_open.get(monitor).copied()
}
pub(super) fn preferred_monitor_for_cluster(
&self,
cid: ClusterId,
preferred: Option<&str>,
) -> Option<String> {
preferred
.map(str::to_string)
.or_else(|| {
self.cluster_state
.active_cluster_workspaces
.iter()
.find_map(|(monitor, active_cid)| (*active_cid == cid).then(|| monitor.clone()))
})
.or_else(|| {
self.cluster_state
.cluster_bloom_open
.iter()
.find_map(|(monitor, open_cid)| (*open_cid == cid).then(|| monitor.clone()))
})
.or_else(|| {
self.field
.cluster(cid)
.and_then(|cluster| cluster.core)
.and_then(|core_id| self.monitor_state.node_monitor.get(&core_id).cloned())
})
.or_else(|| {
self.field.cluster(cid).and_then(|cluster| {
cluster
.members()
.iter()
.find_map(|member| self.monitor_state.node_monitor.get(member).cloned())
})
})
.or_else(|| Some(self.monitor_state.current_monitor.clone()))
}
pub(super) fn workspace_viewport_for_monitor(
&self,
monitor: &str,
) -> Option<halley_core::viewport::Viewport> {
self.monitor_state
.monitors
.get(monitor)
.map(|space| space.usable_viewport)
}
fn cluster_layout_kind(&self) -> ClusterWorkspaceLayoutKind {
self.tuning.cluster_layout_kind()
}
fn cluster_layout_bounds(
&self,
viewport: halley_core::viewport::Viewport,
) -> (halley_core::tiling::Rect, f32) {
let (outer_gap, inner_gap) = compensated_cluster_gaps(
self.tuning.tile_gaps_outer_px.max(0.0),
self.tuning.tile_gaps_inner_px.max(0.0),
active_window_frame_pad_px(self.tuning) as f32,
);
let outer_gap = outer_gap.max(0.0);
let viewport_left = viewport.center.x - viewport.size.x * 0.5;
let viewport_top = viewport.center.y - viewport.size.y * 0.5;
(
halley_core::tiling::Rect {
x: viewport_left + outer_gap,
y: viewport_top + outer_gap,
w: (viewport.size.x - outer_gap * 2.0).max(0.0),
h: (viewport.size.y - outer_gap * 2.0).max(0.0),
},
inner_gap.max(0.0),
)
}
fn cluster_layout_plan_for_members(
&self,
viewport: halley_core::viewport::Viewport,
members: &[NodeId],
) -> ClusterLayoutPlan {
let kind = self.cluster_layout_kind();
let (bounds, inner_gap) = self.cluster_layout_bounds(viewport);
let result = layout_cluster_workspace(
kind,
bounds,
inner_gap,
active_window_frame_pad_px(self.tuning) as f32,
members,
self.tuning.active_cluster_visible_limit(),
);
let overflow_members = if matches!(kind, ClusterWorkspaceLayoutKind::Tiling) {
result.queue_members.clone()
} else {
Vec::new()
};
let tiles = result
.placements
.into_iter()
.map(|placement| ClusterTilePlacement {
node_id: placement.node_id,
rect: placement.rect,
})
.collect::<Vec<_>>();
ClusterLayoutPlan {
kind,
tiles,
overflow_members,
}
}
pub(super) fn cluster_spawn_rect_for_new_member(
&self,
monitor: &str,
cid: ClusterId,
) -> Option<halley_core::tiling::Rect> {
let cluster = self.field.cluster(cid)?;
let viewport = self.workspace_viewport_for_monitor(monitor)?;
let mut preview_members = cluster.members().to_vec();
let visible_limit = halley_core::cluster_layout::cluster_visible_limit(
self.cluster_layout_kind(),
self.tuning.active_cluster_visible_limit(),
);
if visible_limit == usize::MAX || preview_members.len() < visible_limit {
if matches!(
self.cluster_layout_kind(),
ClusterWorkspaceLayoutKind::Stacking
) {
preview_members.insert(0, NodeId::new(u64::MAX));
} else {
preview_members.push(NodeId::new(u64::MAX));
}
}
self.cluster_layout_plan_for_members(viewport, &preview_members)
.tiles
.into_iter()
.last()
.map(|tile| tile.rect)
}
pub(super) fn overflow_strip_rect_for_monitor(
&self,
monitor: &str,
overflow_len: usize,
) -> Option<halley_core::tiling::Rect> {
if overflow_len == 0 {
return None;
}
let space = self.monitor_state.monitors.get(monitor)?;
let strip_w = if overflow_len > Self::OVERFLOW_VISIBLE_SLOTS {
Self::OVERFLOW_STRIP_W_PX + Self::OVERFLOW_SCROLLBAR_GUTTER_PX
} else {
Self::OVERFLOW_STRIP_W_PX
};
let visible_slots = overflow_len.min(Self::OVERFLOW_VISIBLE_SLOTS) as f32;
let height = Self::OVERFLOW_ICON_PAD_PX * 2.0
+ visible_slots * Self::OVERFLOW_ICON_SIZE_PX
+ (visible_slots - 1.0).max(0.0) * Self::OVERFLOW_ICON_GAP_PX;
Some(halley_core::tiling::Rect {
x: (space.width as f32 - strip_w - Self::OVERFLOW_STRIP_PAD_PX).max(0.0),
y: ((space.height as f32 - height) * 0.5).max(Self::OVERFLOW_STRIP_PAD_PX),
w: strip_w,
h: height,
})
}
pub(super) fn overflow_strip_slot_rect_for_monitor(
&self,
monitor: &str,
overflow_len: usize,
slot_index: usize,
) -> Option<Rect> {
let strip = self.overflow_strip_rect_for_monitor(monitor, overflow_len)?;
let scrollbar_extra = if overflow_len > Self::OVERFLOW_VISIBLE_SLOTS {
Self::OVERFLOW_SCROLLBAR_GUTTER_PX
} else {
0.0
};
let icon_x = strip.x + (strip.w - Self::OVERFLOW_ICON_SIZE_PX - scrollbar_extra) * 0.5;
Some(Rect {
x: icon_x,
y: strip.y
+ Self::OVERFLOW_ICON_PAD_PX
+ slot_index as f32 * (Self::OVERFLOW_ICON_SIZE_PX + Self::OVERFLOW_ICON_GAP_PX),
w: Self::OVERFLOW_ICON_SIZE_PX,
h: Self::OVERFLOW_ICON_SIZE_PX,
})
}
pub(super) fn plan_enter_cluster_workspace(
&self,
core_id: NodeId,
monitor: &str,
) -> Option<EnterClusterWorkspacePlan> {
let cid = self.field.cluster_id_for_core_public(core_id)?;
let cluster = self.field.cluster(cid)?;
let members = cluster.members().iter().copied().collect::<HashSet<_>>();
let core_pos = self.field.node(core_id)?.pos;
let current_viewport = self.workspace_viewport_for_monitor(monitor)?;
let mut hidden_ids = Vec::new();
for &id in self.field.nodes().keys() {
if members.contains(&id) || id == core_id {
continue;
}
if self
.monitor_state
.node_monitor
.get(&id)
.is_some_and(|node_monitor| node_monitor != monitor)
{
continue;
}
let already_detached = self
.field
.node(id)
.is_some_and(|n| n.visibility.has(Visibility::DETACHED));
if !already_detached {
hidden_ids.push(id);
}
}
Some(EnterClusterWorkspacePlan {
cid,
core_id,
core_pos,
current_viewport,
hidden_ids,
})
}
pub(super) fn plan_exit_cluster_workspace(
&self,
monitor: &str,
) -> Option<ExitClusterWorkspacePlan> {
let cid = self
.cluster_state
.active_cluster_workspaces
.get(monitor)
.copied()?;
let hidden_ids = self
.cluster_state
.workspace_hidden_nodes
.get(monitor)
.cloned()
.unwrap_or_default();
let core_id = self.field.cluster(cid).and_then(|c| c.core);
let core_pos = core_id.and_then(|id| self.field.node(id).map(|node| node.pos));
Some(ExitClusterWorkspacePlan {
cid,
core_id,
core_pos,
hidden_ids,
})
}
pub(super) fn plan_active_cluster_layout(&self, monitor: &str) -> Option<ClusterLayoutPlan> {
let cid = self
.cluster_state
.active_cluster_workspaces
.get(monitor)
.copied()?;
let cluster = self.field.cluster(cid)?;
let viewport = self.workspace_viewport_for_monitor(monitor)?;
Some(self.cluster_layout_plan_for_members(viewport, cluster.members()))
}
pub(super) fn stack_layout_rects_for_members(
&self,
monitor: &str,
members: &[NodeId],
) -> Option<std::collections::HashMap<NodeId, Rect>> {
let viewport = self.workspace_viewport_for_monitor(monitor)?;
Some(
self.cluster_layout_plan_for_members(viewport, members)
.tiles
.into_iter()
.map(|tile| (tile.node_id, tile.rect))
.collect(),
)
}
}
#[cfg(test)]
fn direct_cluster_layout_rects(
viewport: halley_core::viewport::Viewport,
member_count: usize,
outer_gap: f32,
inner_gap: f32,
master_width_frac: f32,
) -> Vec<halley_core::tiling::Rect> {
if member_count == 0 {
return Vec::new();
}
let outer_gap = outer_gap.max(0.0);
let inner_gap = inner_gap.max(0.0);
let viewport_left = viewport.center.x - viewport.size.x * 0.5;
let viewport_top = viewport.center.y - viewport.size.y * 0.5;
let content_x = viewport_left + outer_gap;
let content_y = viewport_top + outer_gap;
let content_w = (viewport.size.x - outer_gap * 2.0).max(0.0);
let content_h = (viewport.size.y - outer_gap * 2.0).max(0.0);
if member_count == 1 {
return vec![halley_core::tiling::Rect {
x: content_x,
y: content_y,
w: content_w,
h: content_h,
}];
}
let split_w = (content_w - inner_gap).max(0.0);
let master_w = (split_w * master_width_frac.clamp(0.0, 1.0)).clamp(0.0, split_w);
let stack_w = (split_w - master_w).max(0.0);
let stack_x = content_x + master_w + inner_gap;
let stack_count = member_count - 1;
let mut rects = Vec::with_capacity(member_count);
rects.push(halley_core::tiling::Rect {
x: content_x,
y: content_y,
w: master_w,
h: content_h,
});
if stack_count == 1 {
rects.push(halley_core::tiling::Rect {
x: stack_x,
y: content_y,
w: stack_w,
h: content_h,
});
return rects;
}
let total_stack_gap = inner_gap * (stack_count.saturating_sub(1) as f32);
let stack_window_h = ((content_h - total_stack_gap).max(0.0)) / stack_count as f32;
let mut next_y = content_y;
let content_bottom = content_y + content_h;
for index in 0..stack_count {
let remaining = stack_count - index;
let h = if remaining == 1 {
(content_bottom - next_y).max(0.0)
} else {
stack_window_h.max(0.0)
};
rects.push(halley_core::tiling::Rect {
x: stack_x,
y: next_y,
w: stack_w,
h,
});
next_y += h + inner_gap;
}
rects
}
fn compensated_cluster_gaps(outer_gap: f32, inner_gap: f32, border_px: f32) -> (f32, f32) {
let border_px = border_px.max(0.0);
(
outer_gap.max(0.0) + border_px,
inner_gap.max(0.0) + border_px * 2.0,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_viewport() -> halley_core::viewport::Viewport {
halley_core::viewport::Viewport::new(
Vec2 { x: 400.0, y: 300.0 },
Vec2 { x: 800.0, y: 600.0 },
)
}
fn assert_close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() <= 0.01,
"expected {expected}, got {actual}"
);
}
#[test]
fn compensated_cluster_gaps_reserve_outward_border_space() {
let (outer, inner) = compensated_cluster_gaps(10.0, 10.0, 3.0);
assert_close(outer, 13.0);
assert_close(inner, 16.0);
}
#[test]
fn two_window_layout_uses_full_height_and_exact_horizontal_gap() {
let rects = direct_cluster_layout_rects(test_viewport(), 2, 0.0, 10.0, 0.6);
assert_eq!(rects.len(), 2);
let master = rects[0];
let stack = rects[1];
assert_close(master.y, 0.0);
assert_close(master.h, 600.0);
assert_close(stack.y, 0.0);
assert_close(stack.h, 600.0);
assert_close(stack.x - master.right(), 10.0);
}
#[test]
fn three_window_layout_keeps_exact_vertical_stack_gap() {
let rects = direct_cluster_layout_rects(test_viewport(), 3, 10.0, 10.0, 0.6);
assert_eq!(rects.len(), 3);
let master = rects[0];
let upper = rects[1];
let lower = rects[2];
assert_close(master.x, 10.0);
assert_close(master.y, 10.0);
assert_close(master.bottom(), 590.0);
assert_close(upper.x - master.right(), 10.0);
assert_close(upper.y, 10.0);
assert_close(lower.y - upper.bottom(), 10.0);
assert_close(lower.bottom(), 590.0);
}
}