Parent directory

Hy3Layout.cpp

52395 bytes
   1#include <cstdint>
   2#include <regex>
   3#include <optional>
   4#include <set>
   5
   6#include <dlfcn.h>
   7#include <hyprland/src/Compositor.hpp>
   8#include <hyprland/src/output/Monitor.hpp>
   9#include <hyprland/src/state/WorkspaceState.hpp>
  10#include <hyprland/src/state/MonitorState.hpp>
  11#include <hyprland/src/desktop/state/GlobalWindowController.hpp>
  12#include <hyprland/src/desktop/state/FocusState.hpp>
  13#include <hyprland/src/config/ConfigManager.hpp>
  14#include <hyprland/src/desktop/DesktopTypes.hpp>
  15#include <hyprland/src/workspace/HLWorkspace.hpp>
  16#include <hyprland/src/desktop/view/window/WindowPresentation.hpp>
  17#include <hyprland/src/desktop/rule/Engine.hpp>
  18#include <hyprland/src/managers/SeatManager.hpp>
  19#include <hyprland/src/managers/fullscreen/FullscreenController.hpp>
  20#include <hyprland/src/managers/input/InputManager.hpp>
  21#include <hyprland/src/pointer/PointerController.hpp>
  22#include <hyprland/src/pointer/PointerManager.hpp>
  23#include <hyprland/src/plugins/PluginAPI.hpp>
  24#include <hyprland/src/plugins/PluginSystem.hpp>
  25#include <hyprland/src/xwayland/XWayland.hpp>
  26#include <hyprland/src/config/shared/workspace/WorkspaceRuleManager.hpp>
  27#include <hyprutils/math/Vector2D.hpp>
  28#include <hyprland/src/config/shared/ConfigErrors.hpp>
  29#include <hyprland/src/config/shared/complex/ComplexDataTypes.hpp>
  30#include <hyprland/src/desktop/state/WindowState.hpp>
  31#include <ranges>
  32
  33#include "log.hpp"
  34#include "Hy3Layout.hpp"
  35#include "Hy3Node.hpp"
  36#include "TabGroup.hpp"
  37#include "globals.hpp"
  38
  39
  40using namespace Desktop::View;
  41
  42static CollapsePolicy nodeCollapsePolicy() {
  43	static const auto node_collapse_policy =
  44	    CConfigValue<Config::INTEGER>("plugin:hy3:node_collapse_policy");
  45
  46	switch (*node_collapse_policy) {
  47	case 0: return CollapsePolicy::SingleNodeGroups;
  48	case 1: return CollapsePolicy::InvalidOnly;
  49	default: return CollapsePolicy::EmptySplits;
  50	}
  51}
  52
  53PHLWORKSPACE workspace_for_action(bool allow_fullscreen) {
  54	auto workspace = Desktop::focusState()->monitor()->m_activeSpecialWorkspace;
  55	if (!valid(workspace)) workspace = Desktop::focusState()->monitor()->m_activeWorkspace;
  56
  57	if (!valid(workspace)) return nullptr;
  58	if (!allow_fullscreen && Fullscreen::controller()->hasFullscreen(workspace)) return nullptr;
  59	if (!hy3InstanceForWorkspace(workspace)) return nullptr;
  60
  61	return workspace;
  62}
  63
  64std::string operationWorkspaceForName(const std::string& workspace) {
  65	typedef std::string (*PHYPRSPLIT_GET_WORKSPACE_FN)(const std::string& workspace);
  66
  67	static auto* hyprsplitTransformer = []() {
  68		for (auto& p: g_pPluginSystem->getAllPlugins()) {
  69			if (p->m_name == "hyprsplit") {
  70				return reinterpret_cast<PHYPRSPLIT_GET_WORKSPACE_FN>(
  71				    dlsym(p->m_handle, "hyprsplitGetWorkspace")
  72				);
  73			}
  74		}
  75
  76		return reinterpret_cast<PHYPRSPLIT_GET_WORKSPACE_FN>(0);
  77	}();
  78
  79	if (hyprsplitTransformer != 0) return hyprsplitTransformer(workspace);
  80	return workspace;
  81}
  82
  83Hy3Node* findTabBarAt(Hy3Node& node, Vector2D pos, Hy3Node** focused_node);
  84
  85Hy3Layout::Hy3Layout() {
  86	g_hy3Instances.insert(this);
  87
  88	m_windowActiveListener = Event::bus()->m_events.window.active.listen(
  89	    [this](PHLWINDOW window, Desktop::eFocusReason) {
  90		    if (!window) {
  91					this->updateGroupBorderColors();
  92			    return;
  93				}
  94
  95		    auto* node = this->getNodeFromWindow(window.get());
  96
  97		    if (!node) {
  98					this->updateGroupBorderColors();
  99					return;
 100				}
 101
 102		    this->onWindowFocusChange(window);
 103	    }
 104	);
 105
 106	m_mouseButtonListener = Event::bus()->m_events.input.mouse.button.listen(
 107	    [this](IPointer::SButtonEvent event, Event::SCallbackInfo& info) {
 108		    if (event.state != 1 || event.button != 272) return;
 109
 110		    auto ptr_surface_resource = g_pSeatManager->m_state.pointerFocus.lock();
 111		    if (!ptr_surface_resource) return;
 112
 113		    auto ptr_surface = CWLSurface::fromResource(ptr_surface_resource);
 114		    if (!ptr_surface) return;
 115
 116		    auto view = ptr_surface->view();
 117		    auto* window = dynamic_cast<Desktop::View::CWindow*>(view.get());
 118		    if (!window || window->isFloating() || Fullscreen::controller()->isFullscreen(window->m_self.lock())) return;
 119
 120		    auto* node = this->getNodeFromWindow(window);
 121		    if (!node) return;
 122
 123		    Hy3Node* focus = nullptr;
 124		    auto mouse_pos = g_pInputManager->getMouseCoordsInternal();
 125		    auto* tab_node = findTabBarAt(*this->root, mouse_pos, &focus);
 126		    if (!tab_node) return;
 127
 128		    while (focus->is_group() && !focus->as_group().group_focused
 129		           && focus->as_group().focused_child != nullptr)
 130			    focus = focus->as_group().focused_child;
 131
 132		    focus->focus(false, Desktop::FOCUS_REASON_CLICK);
 133		    g_pInputManager->simulateMouseMovement();
 134		    this->recalcGeometry();
 135
 136		    info.cancelled = true;
 137	    }
 138	);
 139}
 140
 141Hy3Layout::~Hy3Layout() {
 142	if (this->root) {
 143		for (auto& window: this->root->windows()) {
 144			window.setHidden(false);
 145		}
 146	}
 147	this->root.reset();
 148
 149	g_hy3Instances.erase(this);
 150}
 151
 152PHLWORKSPACE Hy3Layout::workspace() {
 153	auto algo = m_parent.lock();
 154	if (!algo) return nullptr;
 155	auto space = algo->space();
 156	if (!space) return nullptr;
 157	return space->workspace();
 158}
 159
 160PHLMONITORREF Hy3Layout::monitor() {
 161	auto ws = workspace();
 162	if (!ws) return nullptr;
 163	return ws->m_monitor;
 164}
 165
 166// ITiledAlgorithm overrides
 167
 168void Hy3Layout::newTarget(SP<Layout::ITarget> target) {
 169	if (g_suppressInsert) return;
 170	auto window = target->window();
 171	if (!window) return;
 172	hy3_log(
 173	    LOG,
 174	    "newTarget called with window {:x} (monitor: {}, workspace: {})",
 175	    (uintptr_t) window.get(),
 176	    window->monitorID(),
 177	    target->workspace() ? target->workspace()->addressableName() : "null"
 178	);
 179
 180	auto* existing = this->getNodeFromTarget(target);
 181	if (existing != nullptr) {
 182		hy3_log(
 183		    ERR,
 184		    "newTarget called with a target ({:x}) that is already tiled (node: {:x})",
 185		    (uintptr_t) window.get(),
 186		    (uintptr_t) existing
 187		);
 188		return;
 189	}
 190
 191	auto node = Hy3Node::create(target);
 192
 193	this->insertNode(std::move(node));
 194}
 195
 196void Hy3Layout::insertNode(UP<Hy3Node> node_up, std::optional<Vector2D> focalPoint) {
 197	if (node_up->parent != nullptr) {
 198		hy3_log(
 199		    ERR,
 200		    "insertNode called for node {:x} which already has a parent ({:x})",
 201		    (uintptr_t) node_up.get(),
 202		    (uintptr_t) node_up->parent.get()
 203		);
 204		return;
 205	}
 206
 207	auto ws = this->workspace();
 208	if (!valid(ws)) {
 209		hy3_log(
 210		    ERR,
 211		    "insertNode called for node {:x} with invalid workspace {}",
 212		    (uintptr_t) node_up.get(),
 213		    ws ? ws->addressableName() : "null"
 214		);
 215		return;
 216	}
 217
 218	node_up->size_ratio = 1.0;
 219
 220	auto& monitor = ws->m_monitor;
 221
 222	Hy3Node* opening_into;
 223	Hy3Node* opening_after = nullptr;
 224
 225	auto* rootNode = this->getWorkspaceRootGroup(ws.get());
 226
 227	if (rootNode != nullptr) {
 228		if (focalPoint) {
 229			auto window_at_point =
 230					Desktop::viewState()->hitTest().windowAt(*focalPoint, RESERVED_EXTENTS | INPUT_EXTENTS);
 231
 232			if (window_at_point && window_at_point->m_workspace == ws) {
 233				opening_after = this->getNodeFromWindow(window_at_point.get());
 234			}
 235		}
 236
 237		if (!opening_after) opening_after = &rootNode->getFocusedNode();
 238		opening_after = &opening_after->getPlacementActor();
 239
 240		// opening_after->parent cannot be nullptr
 241		if (opening_after == rootNode) {
 242			opening_after->wrap(Hy3GroupLayout::SplitH, GroupEphemeralityOption::Standard);
 243		}
 244
 245		opening_into = opening_after->parent.get();
 246	} else {
 247		static const auto tab_first_window =
 248		    CConfigValue<Config::INTEGER>("plugin:hy3:tab_first_window");
 249
 250		// Use space work area if available, fall back to monitor
 251		CBox wa_box(monitor->m_position, monitor->m_size);
 252		auto algo = m_parent.lock();
 253		if (algo) {
 254			auto space = algo->space();
 255			if (space) wa_box = space->workArea();
 256		}
 257
 258		auto rootUp = makeUnique<Hy3RootNode>(this);
 259		rootUp->self = WP<Hy3Node>(rootUp);
 260		this->root = std::move(rootUp);
 261
 262		UP<Hy3Node> rootGroup;
 263		if (*tab_first_window) {
 264			rootGroup = Hy3Node::create(Hy3GroupLayout::Tabbed);
 265		} else {
 266			auto split_layout =
 267					wa_box.height > wa_box.width ? Hy3GroupLayout::SplitV : Hy3GroupLayout::SplitH;
 268			rootGroup = Hy3Node::create(split_layout);
 269		}
 270
 271		opening_into = rootGroup.get();
 272		this->root->as_group().insertChild(std::move(rootGroup));
 273	}
 274
 275	if (opening_into->is_target()) {
 276		hy3_log(ERR, "opening_into node ({:x}) was not a group node", (uintptr_t) opening_into);
 277		errorNotif();
 278		return;
 279	}
 280
 281	{
 282		// clang-format off
 283		static const auto at_enable = CConfigValue<Config::INTEGER>("plugin:hy3:autotile:enable");
 284		static const auto at_ephemeral = CConfigValue<Config::INTEGER>("plugin:hy3:autotile:ephemeral_groups");
 285		static const auto at_trigger_width = CConfigValue<Config::INTEGER>("plugin:hy3:autotile:trigger_width");
 286		static const auto at_trigger_height = CConfigValue<Config::INTEGER>("plugin:hy3:autotile:trigger_height");
 287		// clang-format on
 288
 289		this->updateAutotileWorkspaces();
 290
 291		auto& target_group = opening_into->as_group();
 292		if (*at_enable && opening_after != nullptr && target_group.children.size() > 1
 293		    && target_group.isSplit()
 294		    && this->shouldAutotileWorkspace(ws.get()))
 295		{
 296			auto is_horizontal = target_group.layout == Hy3GroupLayout::SplitH;
 297			auto trigger = is_horizontal ? *at_trigger_width : *at_trigger_height;
 298			auto target_size = is_horizontal ? opening_into->visualBox.w : opening_into->visualBox.h;
 299			auto size_after_addition = target_size / (target_group.children.size() + 1);
 300
 301			if (trigger >= 0 && (trigger == 0 || size_after_addition < trigger)) {
 302				opening_after->wrap(
 303				    is_horizontal ? Hy3GroupLayout::SplitV : Hy3GroupLayout::SplitH,
 304				    *at_ephemeral ? GroupEphemeralityOption::Ephemeral : GroupEphemeralityOption::Standard
 305				);
 306				opening_into = opening_after->parent.get();
 307			}
 308		}
 309	}
 310
 311	// For mouse drops, determine if we should insert before or after the target node
 312	if (focalPoint && opening_after) {
 313		auto& parentGroup = opening_into->as_group();
 314		bool insert_before = false;
 315
 316		if (parentGroup.layout == Hy3GroupLayout::SplitH) {
 317			insert_before = focalPoint->x < opening_after->visualBox.x + opening_after->visualBox.w * 0.5;
 318		} else if (parentGroup.layout == Hy3GroupLayout::SplitV) {
 319			insert_before = focalPoint->y < opening_after->visualBox.y + opening_after->visualBox.h * 0.5;
 320		}
 321
 322		if (insert_before) {
 323			auto iter = parentGroup.findChild(*opening_after);
 324			if (iter != parentGroup.children.begin()) {
 325				opening_after = std::prev(iter)->get();
 326			} else {
 327				opening_after = nullptr;
 328			}
 329		}
 330	}
 331
 332	auto* node = node_up.get();
 333
 334	{
 335		auto& group = opening_into->as_group();
 336		if (opening_after == nullptr) {
 337			group.insertChild(group.children.begin(), std::move(node_up));
 338		} else {
 339			auto iter = group.findChild(*opening_after);
 340			group.insertChild(std::next(iter), std::move(node_up));
 341		}
 342	}
 343
 344	hy3_log(
 345	    LOG,
 346	    "tiled node {:x} inserted {} node {:x} in node {:x}",
 347	    (uintptr_t) node,
 348	    opening_after ? "after" : "at beginning of",
 349	    (uintptr_t) opening_after,
 350	    (uintptr_t) opening_into
 351	);
 352
 353	node->markFocused();
 354	this->recalcGeometry();
 355	this->updateGroupBorderColors();
 356}
 357
 358void Hy3Layout::movedTarget(SP<Layout::ITarget> target, std::optional<Vector2D> focalPoint) {
 359	if (g_suppressInsert) return;
 360
 361	// Use mouse position as focal point when none provided (e.g. DnD drop)
 362	if (!focalPoint) focalPoint = g_pInputManager->getMouseCoordsInternal();
 363
 364	this->insertNode(Hy3Node::create(target), focalPoint);
 365}
 366
 367void Hy3Layout::removeTarget(SP<Layout::ITarget> target) {
 368	if (g_suppressInsert) return;
 369
 370	auto* node = this->getNodeFromTarget(target);
 371	if (node == nullptr) return;
 372
 373	auto window = node->as_window();
 374
 375	hy3_log(
 376	    LOG,
 377	    "removing target (window {:x} as node {:x}) from node {:x}",
 378	    (uintptr_t) window.get(),
 379	    (uintptr_t) node,
 380	    (uintptr_t) node->parent.get()
 381	);
 382
 383	window->m_ruleApplicator->resetProps(Desktop::Rule::RULE_PROP_ALL, Desktop::Types::PRIORITY_LAYOUT);
 384
 385	auto* parent_node = node->parent.get();
 386	// Extracted UP drops โ€” node is destroyed.
 387	parent_node->extractAndMerge(*node, nullptr, CollapsePolicy::InvalidOnly);
 388	this->recalcGeometry();
 389
 390	this->updateGroupBorderColors();
 391}
 392
 393void Hy3Layout::onWindowFocusChange(PHLWINDOW window) {
 394	auto* node = this->getNodeFromWindow(window.get());
 395	if (node == nullptr) return;
 396
 397	hy3_log(
 398	    TRACE,
 399	    "changing window focus to window {:x} as node {:x}",
 400	    (uintptr_t) window.get(),
 401	    (uintptr_t) node
 402	);
 403
 404	node->markFocused();
 405	this->recalcGeometry();
 406
 407	this->updateGroupBorderColors();
 408}
 409
 410void Hy3Layout::updateGroupBorderColors() {
 411	if (!this->root) return;
 412	static auto active_color = CConfigValue<Config::IComplexConfigValue>("general:col.active_border");
 413	auto* const active_color_data = sc<Config::CGradientValueData*>(active_color.ptr());
 414
 415	for (auto& w: this->root->windows()) {
 416		if (this->shouldRenderSelected(&w)) {
 417			w.m_ruleApplicator->inactiveBorderColor().set(*active_color_data, Desktop::Types::PRIORITY_LAYOUT);
 418		} else {
 419			w.m_ruleApplicator->inactiveBorderColor().unset(Desktop::Types::PRIORITY_LAYOUT);
 420		}
 421
 422		w.presentation().refreshValues();
 423	}
 424}
 425
 426void Hy3Layout::recalculate(Layout::eRecalculateReason) { this->recalcGeometry(); }
 427
 428void Hy3Layout::recalcGeometry(bool no_animation) {
 429	auto algo = m_parent.lock();
 430	if (!algo) return;
 431	auto space = algo->space();
 432	if (!space) return;
 433	auto workspace = space->workspace();
 434	if (!workspace) return;
 435
 436	hy3_log(LOG, "recalculating workspace {}", workspace->addressableName());
 437
 438	auto ma = workspace->m_monitor->logicalBoxMinusReserved();
 439	auto wa = space->workArea();
 440
 441	if (this->root) {
 442	this->root->visualBox = wa;
 443	this->root->recalcSizePosRecursive(CBox{
 444	    wa.x - ma.x,
 445	    wa.y - ma.y,
 446	    (ma.x + ma.w) - (wa.x + wa.w),
 447	    (ma.y + ma.h) - (wa.y + wa.h),
 448	}, no_animation);
 449	}
 450}
 451
 452ShiftDirection reverse(ShiftDirection direction) {
 453	switch (direction) {
 454	case ShiftDirection::Left: return ShiftDirection::Right;
 455	case ShiftDirection::Right: return ShiftDirection::Left;
 456	case ShiftDirection::Up: return ShiftDirection::Down;
 457	case ShiftDirection::Down: return ShiftDirection::Up;
 458	default: return direction;
 459	}
 460}
 461
 462void Hy3Layout::resizeTarget(const Vector2D& delta, SP<Layout::ITarget> target, Layout::eRectCorner corner) {
 463	auto* node = target ? this->getNodeFromTarget(target) : nullptr;
 464	if (node == nullptr) return;
 465
 466	auto window = node->as_window();
 467	if (!valid(window)) return;
 468
 469	node = &node->getExpandActor();
 470
 471	// Compare against work area since node position/size is the visible area
 472	CBox workArea = {};
 473	auto algo = this->m_parent.lock();
 474	if (algo) {
 475		auto space = algo->space();
 476		if (space) workArea = space->workArea();
 477	}
 478
 479	const bool display_left = STICKS(node->visualBox.x, workArea.x);
 480	const bool display_right = STICKS(node->visualBox.x + node->visualBox.w, workArea.x + workArea.w);
 481	const bool display_top = STICKS(node->visualBox.y, workArea.y);
 482	const bool display_bottom = STICKS(node->visualBox.y + node->visualBox.h, workArea.y + workArea.h);
 483
 484	Vector2D resize_delta = delta;
 485	bool node_is_root =
 486	    node->is_root()
 487	    || (node->is_target() && node->parent->is_root());
 488
 489	if (node_is_root) {
 490		if (display_left && display_right) resize_delta.x = 0;
 491		if (display_top && display_bottom) resize_delta.y = 0;
 492	}
 493
 494	if (resize_delta.x == 0 && resize_delta.y == 0) return;
 495
 496	ShiftDirection target_edge_x;
 497	ShiftDirection target_edge_y;
 498
 499	if (corner == Layout::CORNER_NONE) {
 500		target_edge_x = display_right ? ShiftDirection::Left : ShiftDirection::Right;
 501		target_edge_y = display_bottom ? ShiftDirection::Up : ShiftDirection::Down;
 502
 503		if (target_edge_x == ShiftDirection::Left) resize_delta.x = -resize_delta.x;
 504		if (target_edge_y == ShiftDirection::Up) resize_delta.y = -resize_delta.y;
 505	} else {
 506		target_edge_x = corner & Layout::CORNER_LEFT ? ShiftDirection::Left : ShiftDirection::Right;
 507		target_edge_y = corner & Layout::CORNER_TOP ? ShiftDirection::Up : ShiftDirection::Down;
 508	}
 509
 510	auto horizontal_neighbor = node->findNeighbor(target_edge_x);
 511	auto vertical_neighbor = node->findNeighbor(target_edge_y);
 512
 513	static const auto animate = CConfigValue<Config::INTEGER>("misc:animate_manual_resizes");
 514
 515	if (horizontal_neighbor) {
 516		horizontal_neighbor->resize(reverse(target_edge_x), resize_delta.x, *animate == 0);
 517	}
 518
 519	if (vertical_neighbor) {
 520		vertical_neighbor->resize(reverse(target_edge_y), resize_delta.y, *animate == 0);
 521	}
 522}
 523
 524void Hy3Layout::swapTargets(SP<Layout::ITarget> a, SP<Layout::ITarget> b) {
 525	// todo
 526}
 527
 528void Hy3Layout::moveTargetInDirection(SP<Layout::ITarget> t, Math::eDirection dir, bool silent) {
 529	auto* node = t ? this->getNodeFromTarget(t) : nullptr;
 530	if (node == nullptr) return;
 531
 532	ShiftDirection shift;
 533	switch (dir) {
 534	case Math::DIRECTION_LEFT: shift = ShiftDirection::Left; break;
 535	case Math::DIRECTION_RIGHT: shift = ShiftDirection::Right; break;
 536	case Math::DIRECTION_UP: shift = ShiftDirection::Up; break;
 537	case Math::DIRECTION_DOWN: shift = ShiftDirection::Down; break;
 538	default: return;
 539	}
 540
 541	this->shiftNode(*node, shift, false, false);
 542}
 543
 544Config::ErrorResult Hy3Layout::layoutMsg(const std::string_view& sv) {
 545	std::string content(sv);
 546
 547	if (content == "togglesplit") {
 548		auto window = Desktop::focusState()->window();
 549		if (!window) return {};
 550		auto* node = this->getNodeFromWindow(window.get());
 551		if (node != nullptr) {
 552			node->assertNotRoot();
 553			auto& layout = node->parent->as_group().layout;
 554
 555			switch (layout) {
 556			case Hy3GroupLayout::SplitH:
 557				layout = Hy3GroupLayout::SplitV;
 558				this->recalcGeometry();
 559				break;
 560			case Hy3GroupLayout::SplitV:
 561				layout = Hy3GroupLayout::SplitH;
 562				this->recalcGeometry();
 563				break;
 564			case Hy3GroupLayout::Root: break;
 565			case Hy3GroupLayout::Tabbed: break;
 566			}
 567		}
 568	}
 569
 570	return {};
 571}
 572
 573std::optional<Vector2D> Hy3Layout::predictSizeForNewTarget() {
 574	return std::nullopt;
 575}
 576
 577SP<Layout::ITarget> Hy3Layout::getNextCandidate(SP<Layout::ITarget> old) {
 578	auto window = old ? old->window() : nullptr;
 579	if (!window) return nullptr;
 580
 581	auto candidate = this->findTiledWindowCandidate(window.get());
 582	if (!candidate) return nullptr;
 583
 584	auto* node = this->getNodeFromWindow(candidate.get());
 585	if (!node) return nullptr;
 586	return node->as_target();
 587}
 588
 589PHLWINDOW Hy3Layout::findTiledWindowCandidate(const CWindow* from) {
 590	auto* node = this->getWorkspaceFocusedNode(from->m_workspace.get(), true);
 591	if (node != nullptr && node->is_target()) {
 592		return node->as_window();
 593	}
 594
 595	return PHLWINDOW();
 596}
 597
 598PHLWINDOW Hy3Layout::findFloatingWindowCandidate(const CWindow* from) {
 599	// return the first floating window on the same workspace that has not asked not to be focused
 600	for (const auto& w: Desktop::windowState()->windows() | std::views::reverse) {
 601		if (w->mapped() && !w->isHidden() && w->isFloating() && !w->backend().traits().overrideRedirect
 602		    && w->m_workspace == from->m_workspace && !w->shouldntFocus()
 603		    && !w->m_ruleApplicator->noFocus().valueOrDefault() && w.get() != from)
 604		{
 605			return w;
 606		}
 607	}
 608
 609	return nullptr;
 610}
 611
 612void Hy3Layout::makeGroupOnWorkspace(
 613    const Workspace::CHLWorkspace* workspace,
 614    Hy3GroupLayout layout,
 615    GroupEphemeralityOption ephemeral,
 616    bool toggle
 617) {
 618	auto* node = this->getWorkspaceFocusedNode(workspace);
 619	if (node == nullptr) return;
 620	node = &node->getPlacementActor();
 621
 622	if (toggle) {
 623		auto* parent = node->parent.get();
 624		auto& group = parent->as_group();
 625
 626		if (group.children.size() == 1 && group.layout == layout) {
 627			auto* collapsed = parent->collapseParents(CollapsePolicy::SingleNodeGroups);
 628
 629			if (collapsed && !collapsed->is_root()) {
 630				collapsed->parent->updateTabBarRecursive();
 631				this->recalcGeometry();
 632			}
 633
 634			return;
 635		}
 636	}
 637
 638	this->makeGroupOn(*node, layout, ephemeral);
 639}
 640
 641void Hy3Layout::makeOppositeGroupOnWorkspace(
 642    const Workspace::CHLWorkspace* workspace,
 643    GroupEphemeralityOption ephemeral
 644) {
 645	auto* node = this->getWorkspaceFocusedNode(workspace);
 646	if (node == nullptr) return;
 647	node = &node->getPlacementActor();
 648	this->makeOppositeGroupOn(*node, ephemeral);
 649}
 650
 651void Hy3Layout::changeGroupOnWorkspace(const Workspace::CHLWorkspace* workspace, Hy3GroupLayout layout) {
 652	auto* node = this->getWorkspaceFocusedNode(workspace);
 653	if (node == nullptr) return;
 654	node = &node->getPlacementActor();
 655
 656	this->changeGroupOn(*node, layout);
 657}
 658
 659void Hy3Layout::untabGroupOnWorkspace(const Workspace::CHLWorkspace* workspace) {
 660	auto* node = this->getWorkspaceFocusedNode(workspace);
 661	if (node == nullptr) return;
 662	node = &node->getPlacementActor();
 663
 664	this->untabGroupOn(*node);
 665}
 666
 667void Hy3Layout::toggleTabGroupOnWorkspace(const Workspace::CHLWorkspace* workspace) {
 668	auto* node = this->getWorkspaceFocusedNode(workspace);
 669	if (node == nullptr) return;
 670	node = &node->getPlacementActor();
 671
 672	this->toggleTabGroupOn(*node);
 673}
 674
 675void Hy3Layout::changeGroupToOppositeOnWorkspace(const Workspace::CHLWorkspace* workspace) {
 676	auto* node = this->getWorkspaceFocusedNode(workspace);
 677	if (node == nullptr) return;
 678	node = &node->getPlacementActor();
 679
 680	this->changeGroupToOppositeOn(*node);
 681}
 682
 683void Hy3Layout::changeGroupEphemeralityOnWorkspace(const Workspace::CHLWorkspace* workspace, bool ephemeral) {
 684	auto* node = this->getWorkspaceFocusedNode(workspace);
 685	if (node == nullptr) return;
 686	node = &node->getPlacementActor();
 687
 688	this->changeGroupEphemeralityOn(*node, ephemeral);
 689}
 690
 691void Hy3Layout::makeGroupOn(
 692    Hy3Node& node,
 693    Hy3GroupLayout layout,
 694    GroupEphemeralityOption ephemeral
 695) {
 696	node.assertNotRoot();
 697
 698	hy3_log(LOG, "mkGrp on {:x} b4\n{}", (uintptr_t)&node, debugNodes());
 699
 700	node.wrap(layout, ephemeral);
 701	node.parent->collapseParents(CollapsePolicy::InvalidOnly);
 702	this->recalcGeometry();
 703}
 704
 705void Hy3Layout::makeOppositeGroupOn(Hy3Node& node, GroupEphemeralityOption ephemeral) {
 706	node.assertNotRoot();
 707
 708	auto& group = node.parent->as_group();
 709	auto layout =
 710	    group.layout == Hy3GroupLayout::SplitH ? Hy3GroupLayout::SplitV : Hy3GroupLayout::SplitH;
 711
 712	if (group.children.size() == 1) {
 713		group.setLayout(layout);
 714		group.setEphemeral(ephemeral);
 715		this->recalcGeometry();
 716		return;
 717	}
 718
 719	node.wrap(layout, ephemeral);
 720}
 721
 722void Hy3Layout::changeGroupOn(Hy3Node& node, Hy3GroupLayout layout) {
 723	node.assertNotRoot();
 724	auto& group = node.parent->as_group();
 725	group.setLayout(layout);
 726	node.parent->updateTabBarRecursive();
 727	this->recalcGeometry();
 728}
 729
 730void Hy3Layout::untabGroupOn(Hy3Node& node) {
 731	node.assertNotRoot();
 732	auto& group = node.parent->as_group();
 733	if (!group.isTab()) return;
 734
 735	changeGroupOn(node, group.previous_nontab_layout);
 736}
 737
 738void Hy3Layout::toggleTabGroupOn(Hy3Node& node) {
 739	node.assertNotRoot();
 740	auto& group = node.parent->as_group();
 741	if (!group.isTab()) changeGroupOn(node, Hy3GroupLayout::Tabbed);
 742	else changeGroupOn(node, group.previous_nontab_layout);
 743}
 744
 745void Hy3Layout::changeGroupToOppositeOn(Hy3Node& node) {
 746	node.assertNotRoot();
 747	auto& group = node.parent->as_group();
 748
 749	if (group.isTab()) {
 750		group.setLayout(group.previous_nontab_layout);
 751	} else {
 752		group.setLayout(
 753		    group.layout == Hy3GroupLayout::SplitH ? Hy3GroupLayout::SplitV : Hy3GroupLayout::SplitH
 754		);
 755	}
 756
 757	this->recalcGeometry();
 758}
 759
 760void Hy3Layout::changeGroupEphemeralityOn(Hy3Node& node, bool ephemeral) {
 761	node.assertNotRoot();
 762	auto& group = node.parent->as_group();
 763	group.setEphemeral(
 764	    ephemeral ? GroupEphemeralityOption::ForceEphemeral : GroupEphemeralityOption::Standard
 765	);
 766}
 767
 768void Hy3Layout::shiftNode(Hy3Node& node, ShiftDirection direction, bool once, bool visible) {
 769	this->shiftOrGetFocus(node, direction, true, once, visible);
 770}
 771
 772void Hy3Layout::shiftWindow(
 773    const Workspace::CHLWorkspace* workspace,
 774    ShiftDirection direction,
 775    bool once,
 776    bool visible
 777) {
 778	auto* node = this->getWorkspaceFocusedNode(workspace);
 779	if (node == nullptr) return;
 780
 781	this->shiftNode(*node, direction, once, visible);
 782}
 783
 784void Hy3Layout::shiftFocus(
 785    const Workspace::CHLWorkspace* workspace,
 786    ShiftDirection direction,
 787    bool visible,
 788    bool warp
 789) {
 790	auto current_window = Desktop::focusState()->window();
 791
 792	if (current_window != nullptr) {
 793		if (Fullscreen::controller()->hasFullscreen(current_window->m_workspace)) {
 794			return;
 795		}
 796
 797		if (current_window->isFloating()) {
 798			auto next_window = Desktop::windowState()->query().inDirection(
 799			    current_window,
 800			    shiftToMathDirection(direction)
 801			);
 802
 803			if (next_window != nullptr) {
 804				g_pInputManager->unconstrainMouse();
 805				Desktop::focusState()->fullWindowFocus(next_window, Desktop::FOCUS_REASON_KEYBIND);
 806				if (warp) {
 807					const auto box = next_window->layoutBox();
 808					Hy3Layout::warpCursorToBox(box.pos(), box.size());
 809				}
 810			}
 811			return;
 812		}
 813	}
 814
 815	auto* node = this->getWorkspaceFocusedNode(workspace);
 816	if (node == nullptr) {
 817		focusMonitor(direction);
 818		return;
 819	}
 820
 821	auto* target = this->shiftOrGetFocus(*node, direction, false, false, visible);
 822
 823	if (target != nullptr) {
 824		if (warp) {
 825			// don't warp for nodes in the same tab
 826			warp = node->parent != target->parent
 827			    || !node->parent->as_group().isTab();
 828		}
 829
 830		target->focus(warp, Desktop::FOCUS_REASON_KEYBIND);
 831		this->recalcGeometry();
 832	}
 833}
 834
 835Hy3Node* Hy3Layout::focusMonitor(ShiftDirection direction) {
 836	auto next_monitor = State::monitorState()
 837													->query()
 838													.relativeTo(this->monitor().lock())
 839													.inDirection(shiftToMathDirection(direction))
 840													.run();
 841
 842	if (next_monitor) {
 843		bool found = false;
 844		Desktop::focusState()->rawMonitorFocus(next_monitor);
 845		auto next_workspace = next_monitor->m_activeWorkspace;
 846
 847		if (next_workspace) {
 848			auto target_window = next_workspace->getLastFocusedWindow();
 849			if (target_window) {
 850				found = true;
 851
 852				if (auto* hy3 = hy3InstanceForWorkspace(next_workspace)) {
 853					auto found_node = hy3->getNodeFromWindow(target_window.get());
 854					if (found_node) {
 855						found_node->focus(true, Desktop::FOCUS_REASON_KEYBIND);
 856						return found_node;
 857					}
 858				} else {
 859					Desktop::focusState()->fullWindowFocus(target_window, Desktop::FOCUS_REASON_KEYBIND);
 860					return nullptr;
 861				}
 862			}
 863		}
 864
 865		if (!found) {
 866			Hy3Layout::warpCursorWithFocus(next_monitor->m_position + next_monitor->m_size / 2);
 867		}
 868	}
 869	return nullptr;
 870}
 871
 872bool Hy3Layout::shiftMonitor(Hy3Node& node, ShiftDirection direction, bool follow) {
 873	auto next_monitor = State::monitorState()
 874													->query()
 875													.relativeTo(this->monitor().lock())
 876													.inDirection(shiftToMathDirection(direction))
 877													.run();
 878
 879
 880	if (next_monitor) {
 881		Desktop::focusState()->rawMonitorFocus(next_monitor);
 882		auto next_workspace = next_monitor->m_activeWorkspace;
 883		if (next_workspace) {
 884			moveNodeToWorkspace(node.layout()->workspace().get(), next_workspace->addressableName(), follow, false);
 885			return true;
 886		}
 887	}
 888	return false;
 889}
 890
 891void Hy3Layout::toggleFocusLayer(const Workspace::CHLWorkspace* workspace, bool warp) {
 892	auto current_window = Desktop::focusState()->window();
 893	if (!current_window) return;
 894
 895	PHLWINDOW target;
 896	if (current_window->isFloating()) {
 897		target = this->findTiledWindowCandidate(current_window.get());
 898	} else {
 899		target = this->findFloatingWindowCandidate(current_window.get());
 900	}
 901
 902	if (!target) return;
 903
 904	Desktop::focusState()->fullWindowFocus(target, Desktop::FOCUS_REASON_KEYBIND);
 905
 906	if (warp) {
 907		Hy3Layout::warpCursorWithFocus(target->middle());
 908	}
 909}
 910
 911void Hy3Layout::warpCursor() {
 912	auto current_window = Desktop::focusState()->window();
 913
 914	if (current_window != nullptr) {
 915		if (current_window != nullptr) {
 916			Hy3Layout::warpCursorWithFocus(current_window->middle(), true);
 917		}
 918	} else {
 919		auto* node =
 920		    this->getWorkspaceFocusedNode(Desktop::focusState()->monitor()->m_activeWorkspace.get());
 921
 922		if (node != nullptr) {
 923			Hy3Layout::warpCursorWithFocus(node->visualBox.pos() + node->visualBox.size() / 2);
 924		}
 925	}
 926}
 927
 928static void updateTreeTabBars(Hy3Node& node) {
 929	node.updateTabBar();
 930	if (node.is_group()) {
 931		for (auto& child: node.as_group().children) {
 932			updateTreeTabBars(*child);
 933		}
 934	}
 935}
 936
 937
 938void Hy3Layout::moveNodeToWorkspace(
 939    Workspace::CHLWorkspace* origin,
 940    std::string wsname,
 941    bool follow,
 942    bool warp
 943) {
 944	const auto targetName = operationWorkspaceForName(wsname);
 945	const bool numbered   = std::regex_match(targetName, std::regex(R"(^\d+$)"));
 946
 947	if (!numbered && !targetName.starts_with("special") && !targetName.starts_with("name:")) {
 948		hy3_log(ERR, "moveNodeToWorkspace called with invalid workspace {}", wsname);
 949		return;
 950	}
 951
 952	auto workspace = State::workspaceState()->query().input(targetName).run();
 953
 954	if (origin == workspace.get()) return;
 955
 956	auto* node = this->getWorkspaceFocusedNode(origin);
 957	auto focused_window = Desktop::focusState()->window();
 958	auto* focused_window_node = this->getNodeFromWindow(focused_window.get());
 959
 960	auto origin_ws = node != nullptr           ? node->layout()->workspace()
 961	               : focused_window != nullptr ? focused_window->m_workspace
 962	                                           : nullptr;
 963
 964	if (!valid(origin_ws)) return;
 965
 966	if (workspace == nullptr) {
 967		hy3_log(LOG, "creating target workspace {} for node move", targetName);
 968
 969		auto origin_monitor = origin_ws->m_monitor.lock();
 970		if (!origin_monitor) return;
 971
 972		if (numbered) {
 973			workspace = State::workspaceState()->createNumbered(
 974			    Workspace::SWorkspaceNumberedID{static_cast<Workspace::WorkspaceIDContainer>(std::stol(targetName))},
 975			    origin_monitor
 976			);
 977		} else if (targetName == "special") {
 978			workspace = State::workspaceState()->createSpecial("special", origin_monitor);
 979		} else if (targetName.starts_with("special:") && targetName.size() > 8) {
 980			workspace = State::workspaceState()->createSpecial(targetName.substr(8), origin_monitor);
 981		} else {
 982			const auto address = targetName.starts_with("name:") ? targetName.substr(5) : targetName;
 983			workspace = State::workspaceState()->createNamed(address, origin_monitor, address);
 984		}
 985	}
 986
 987	if (focused_window != nullptr
 988	    && (focused_window_node == nullptr || Fullscreen::controller()->isFullscreen(focused_window)))
 989	{
 990		g_pHyprRenderer->damageWindow(focused_window);
 991		Desktop::globalWindowController()->moveWindowToWorkspace(focused_window, workspace);
 992	} else {
 993		if (node == nullptr) return;
 994
 995		hy3_log(
 996		    LOG,
 997		    "moving node {:x} from workspace {} to workspace {} (follow: {})",
 998		    (uintptr_t) node,
 999		    origin->addressableName(),
1000		    workspace->addressableName(),
1001		    follow
1002		);
1003
1004		auto* parent_node = node->parent.get();
1005		auto node_up = parent_node->extractAndMerge(*node, nullptr);
1006		auto* destHy3 = hy3InstanceForWorkspace(workspace);
1007		auto* destLayout = destHy3 ? destHy3 : this;
1008
1009		g_suppressInsert = true;
1010
1011		for (auto& window: node->windows()) {
1012			window.layoutTarget()->assignToSpace(workspace->space());
1013		}
1014
1015		g_suppressInsert = false;
1016
1017		destLayout->insertNode(std::move(node_up));
1018
1019		Desktop::Rule::ruleEngine()->updateAllRules();
1020
1021		updateTreeTabBars(*node);
1022		node->updateTabBarRecursive();
1023		this->recalcGeometry();
1024	}
1025
1026	if (follow) {
1027		auto& monitor = workspace->m_monitor;
1028
1029		if (workspace->type() == Workspace::eWorkspaceType::SPECIAL) {
1030			monitor->setSpecialWorkspace(workspace);
1031		} else if (origin_ws->type() == Workspace::eWorkspaceType::SPECIAL) {
1032			origin_ws->m_monitor->setSpecialWorkspace(nullptr);
1033		}
1034
1035		monitor->changeWorkspace(workspace);
1036
1037		node->layout()->recalcGeometry();
1038		node->focus(warp, Desktop::FOCUS_REASON_KEYBIND);
1039	}
1040}
1041
1042void Hy3Layout::changeFocus(const Workspace::CHLWorkspace* workspace, FocusShift shift) {
1043	auto* node = this->getWorkspaceFocusedNode(workspace);
1044	if (node == nullptr) return;
1045
1046	switch (shift) {
1047	case FocusShift::Bottom: goto bottom;
1048	case FocusShift::Top:
1049		this->root->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1050		this->updateGroupBorderColors();
1051		return;
1052	case FocusShift::Raise:
1053		if (node->is_root_group()) goto bottom;
1054		node->parent->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1055		this->updateGroupBorderColors();
1056		return;
1057	case FocusShift::Lower:
1058		if (node->is_group() && node->as_group().focused_child != nullptr)
1059			node->as_group().focused_child->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1060		this->updateGroupBorderColors();
1061		return;
1062	case FocusShift::Tab:
1063		for (auto& n: node->ancestors()) {
1064			if (n.parent->as_group().isTab()) {
1065				n.parent->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1066				this->updateGroupBorderColors();
1067				return;
1068			}
1069		}
1070		return;
1071	case FocusShift::TabNode:
1072		for (auto& n: node->ancestors()) {
1073			if (n.parent->as_group().isTab()) {
1074				n.focus(false, Desktop::FOCUS_REASON_KEYBIND);
1075				this->updateGroupBorderColors();
1076				return;
1077			}
1078		}
1079		return;
1080	}
1081
1082bottom:
1083	while (node->is_group() && node->as_group().focused_child != nullptr) {
1084		node = node->as_group().focused_child;
1085	}
1086
1087	node->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1088	this->updateGroupBorderColors();
1089	return;
1090}
1091
1092Hy3Node* findTabBarAt(Hy3Node& node, Vector2D pos, Hy3Node** focused_node) {
1093	// clang-format off
1094	static const auto p_gaps_in = CConfigValue<Config::IComplexConfigValue>("general:gaps_in");
1095	static const auto tab_bar_height = CConfigValue<Config::INTEGER>("plugin:hy3:tabs:height");
1096	static const auto tab_bar_padding = CConfigValue<Config::INTEGER>("plugin:hy3:tabs:padding");
1097	// clang-format on
1098
1099	auto workspace_rule = Config::workspaceRuleMgr()->getWorkspaceRuleFor(node.layout()->workspace());
1100	auto gaps_in = workspace_rule.and_then([](auto r) { return r.m_gapsIn; }).value_or(*sc<Config::CCssGapData*>(p_gaps_in.ptr()));
1101
1102	auto inset = *tab_bar_height + *tab_bar_padding + gaps_in.m_top;
1103
1104	if (node.is_group()) {
1105		if (node.hidden) return nullptr;
1106		// note: tab bar clicks ignore animations
1107		if (node.visualBox.x > pos.x || node.logicalBox.y > pos.y || node.visualBox.x + node.visualBox.w < pos.x
1108		    || node.visualBox.y + node.visualBox.h < pos.y)
1109			return nullptr;
1110
1111		auto& group = node.as_group();
1112
1113		if (group.isTab() && group.tab_bar) {
1114			if (pos.y < node.visualBox.y + inset) {
1115				auto& children = group.children;
1116				auto& tab_bar = *group.tab_bar.get();
1117
1118				auto size = tab_bar.size->value();
1119				auto x = pos.x - tab_bar.pos->value().x;
1120				auto child_iter = children.begin();
1121
1122				for (auto& tab: tab_bar.bar.entries) {
1123					if (child_iter == children.end()) break;
1124
1125					if (x > tab.offset->value() * size.x
1126					    && x < (tab.offset->value() + tab.width->value()) * size.x)
1127					{
1128						*focused_node = child_iter->get();
1129						return &node;
1130					}
1131
1132					child_iter = std::next(child_iter);
1133				}
1134			}
1135
1136			if (group.focused_child != nullptr) {
1137				return findTabBarAt(*group.focused_child, pos, focused_node);
1138			}
1139		} else {
1140			for (auto& child: group.children) {
1141				if (findTabBarAt(*child, pos, focused_node)) return child.get();
1142			}
1143		}
1144	}
1145
1146	return nullptr;
1147}
1148
1149void Hy3Layout::focusTab(
1150    const Workspace::CHLWorkspace* workspace,
1151    TabFocus target,
1152    TabFocusMousePriority mouse,
1153    bool wrap_scroll,
1154    int index
1155) {
1156	auto* node = this->getWorkspaceRootGroup(workspace);
1157	if (node == nullptr) return;
1158
1159	Hy3Node* tab_node = nullptr;
1160	Hy3Node* tab_focused_node;
1161
1162	if (target == TabFocus::MouseLocation || mouse != TabFocusMousePriority::Ignore) {
1163		// no surf focused at all
1164		auto ptrSurfaceResource = g_pSeatManager->m_state.pointerFocus.lock();
1165		if (!ptrSurfaceResource) return;
1166
1167		auto ptrSurface = CWLSurface::fromResource(ptrSurfaceResource);
1168		if (!ptrSurface) return;
1169
1170		// non window-parented surface focused, cant have a tab
1171		auto view = ptrSurface->view();
1172		auto* window = dynamic_cast<CWindow*>(view.get());
1173		if (!window || window->isFloating()) return;
1174
1175		auto mouse_pos = g_pInputManager->getMouseCoordsInternal();
1176		tab_node = findTabBarAt(*node, mouse_pos, &tab_focused_node);
1177		if (tab_node != nullptr) goto hastab;
1178
1179		if (target == TabFocus::MouseLocation || mouse == TabFocusMousePriority::Require) return;
1180	}
1181
1182	if (tab_node == nullptr) {
1183		tab_node = this->getWorkspaceFocusedNode(workspace);
1184		if (tab_node == nullptr) return;
1185
1186		while (tab_node != nullptr
1187		       && (tab_node->is_target()
1188		           || !tab_node->as_group().isTab())
1189		       && !tab_node->is_root())
1190			tab_node = tab_node->parent.get();
1191
1192		if (tab_node == nullptr || tab_node->is_target()
1193		    || !tab_node->as_group().isTab())
1194			return;
1195	}
1196
1197hastab:
1198	if (target != TabFocus::MouseLocation) {
1199		auto& group = tab_node->as_group();
1200		if (group.focused_child == nullptr || group.children.size() < 2) return;
1201
1202		auto& children = group.children;
1203		if (target == TabFocus::Index) {
1204			int i = 1;
1205
1206			for (auto& n: children) {
1207				if (i == index) {
1208					tab_focused_node = n.get();
1209					goto cont;
1210				}
1211
1212				i++;
1213			}
1214
1215			return;
1216		cont:;
1217		} else {
1218			auto node_iter = group.findChild(*group.focused_child);
1219			if (node_iter == children.end()) return;
1220			if (target == TabFocus::Left) {
1221				if (node_iter == children.begin()) {
1222					if (wrap_scroll) node_iter = std::prev(children.end());
1223					else return;
1224				} else node_iter = std::prev(node_iter);
1225
1226				tab_focused_node = node_iter->get();
1227			} else {
1228				if (node_iter == std::prev(children.end())) {
1229					if (wrap_scroll) node_iter = children.begin();
1230					else return;
1231				} else node_iter = std::next(node_iter);
1232
1233				tab_focused_node = node_iter->get();
1234			}
1235		}
1236	}
1237
1238	auto* focus = tab_focused_node;
1239	while (focus->is_group() && !focus->as_group().group_focused
1240	       && focus->as_group().focused_child != nullptr)
1241		focus = focus->as_group().focused_child;
1242
1243	focus->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1244	this->recalcGeometry();
1245}
1246
1247void Hy3Layout::setNodeSwallow(const Workspace::CHLWorkspace* workspace, SetSwallowOption option) {
1248	auto* node = this->getWorkspaceFocusedNode(workspace);
1249	if (node == nullptr) return;
1250	node->assertNotRoot();
1251
1252	auto* containment = &node->parent->as_group().containment;
1253	switch (option) {
1254	case SetSwallowOption::NoSwallow: *containment = false; break;
1255	case SetSwallowOption::Swallow: *containment = true; break;
1256	case SetSwallowOption::Toggle: *containment = !*containment; break;
1257	}
1258}
1259
1260void Hy3Layout::killFocusedNode(const Workspace::CHLWorkspace* workspace) {
1261	auto last_window = Desktop::focusState()->window();
1262	if (last_window != nullptr && last_window->isFloating()) {
1263		last_window->sendClose();
1264	} else {
1265		auto* node = this->getWorkspaceFocusedNode(workspace);
1266		if (node == nullptr) return;
1267
1268		std::vector<PHLWINDOW> windows;
1269		for (auto& w: node->windows()) windows.push_back(w.m_self.lock());
1270
1271		for (auto& window: windows) {
1272			window->setHidden(false);
1273			window->sendClose();
1274		}
1275	}
1276}
1277
1278void Hy3Layout::expand(
1279    const Workspace::CHLWorkspace* workspace,
1280    ExpandOption option,
1281    ExpandFullscreenOption fs_option
1282) {
1283	auto* node = this->getWorkspaceFocusedNode(workspace, false, true);
1284	if (node == nullptr) return;
1285	PHLWINDOW window;
1286
1287	switch (option) {
1288	case ExpandOption::Expand: {
1289		node->assertNotRoot();
1290
1291		if (node->is_group() && !node->as_group().group_focused)
1292			node->as_group().expand_focused = ExpandFocusType::Stack;
1293
1294		auto& group = node->parent->as_group();
1295		group.focused_child = node;
1296		group.expand_focused = ExpandFocusType::Latch;
1297
1298		this->recalcGeometry();
1299
1300		if (node->parent->is_root()) {
1301			switch (fs_option) {
1302			case ExpandFullscreenOption::MaximizeAsFullscreen: // goto fullscreen;
1303			case ExpandFullscreenOption::MaximizeIntermediate:
1304			case ExpandFullscreenOption::MaximizeOnly: return;
1305			}
1306		}
1307	} break;
1308	case ExpandOption::Shrink:
1309		if (node->is_group()) {
1310			auto& group = node->as_group();
1311
1312			group.expand_focused = ExpandFocusType::NotExpanded;
1313			if (group.focused_child->is_group())
1314				group.focused_child->as_group().expand_focused = ExpandFocusType::Latch;
1315
1316			this->recalcGeometry();
1317		}
1318		break;
1319	case ExpandOption::Base: {
1320		if (node->is_group()) {
1321			node->as_group().collapseExpansions();
1322			this->recalcGeometry();
1323		}
1324		break;
1325	}
1326	case ExpandOption::Maximize: break;
1327	case ExpandOption::Fullscreen: break;
1328	}
1329
1330	return;
1331}
1332
1333void Hy3Layout::setTabLock(const Workspace::CHLWorkspace* workspace, TabLockMode mode) {
1334	auto* focused = this->getWorkspaceFocusedNode(workspace);
1335	if (focused == nullptr) return;
1336
1337	for (auto& node: focused->ancestors()) {
1338		auto& group = node.parent->as_group();
1339		if (!group.isTab())
1340			continue;
1341
1342		switch (mode) {
1343		case TabLockMode::Lock: group.locked = true; break;
1344		case TabLockMode::Unlock: group.locked = false; break;
1345		case TabLockMode::Toggle: group.locked = !group.locked; break;
1346		}
1347
1348		node.parent->updateTabBar();
1349		return;
1350	}
1351}
1352
1353static void equalizeRecursive(Hy3Node* node, bool recursive) {
1354	node->size_ratio = 1.0f;
1355
1356	if (recursive && node->is_group()) {
1357		for (auto& child: node->as_group().children) {
1358			equalizeRecursive(child.get(), true);
1359		}
1360	}
1361}
1362
1363void Hy3Layout::equalize(const Workspace::CHLWorkspace* workspace, bool recursive) {
1364	auto* focused = this->getWorkspaceFocusedNode(workspace);
1365	if (focused == nullptr) return;
1366
1367	Hy3Node* target = nullptr;
1368
1369	if (recursive) {
1370		target = this->getWorkspaceRootGroup(workspace);
1371		if (target != nullptr) {
1372			equalizeRecursive(target, true);
1373		}
1374	} else {
1375		focused->assertNotRoot();
1376		auto* parent = focused->parent.get();
1377		equalizeRecursive(parent, false);
1378		target = parent;
1379	}
1380
1381	if (target != nullptr) {
1382		this->recalcGeometry();
1383	}
1384}
1385
1386void Hy3Layout::warpCursorToBox(const Vector2D& pos, const Vector2D& size) {
1387	auto cursorpos = Pointer::mgr()->position();
1388
1389	if (cursorpos.x < pos.x || cursorpos.x >= pos.x + size.x || cursorpos.y < pos.y
1390	    || cursorpos.y >= pos.y + size.y)
1391	{
1392		Hy3Layout::warpCursorWithFocus(pos + size / 2, true);
1393	}
1394}
1395
1396void Hy3Layout::warpCursorWithFocus(const Vector2D& target, bool force) {
1397	static const auto input_follows_mouse = CConfigValue<Config::INTEGER>("input:follow_mouse");
1398	static const auto no_warps = CConfigValue<Config::INTEGER>("cursor:no_warps");
1399
1400	Pointer::pointerController()->warpTo(target, force);
1401
1402	if (*no_warps && !force) return;
1403
1404	if (*input_follows_mouse) {
1405		g_pInputManager->simulateMouseMovement();
1406	}
1407}
1408
1409std::string Hy3Layout::debugNodes() {
1410	std::string output;
1411
1412	for (auto* hy3: g_hy3Instances) {
1413		if (!hy3->root) continue;
1414		output += hy3->root->debugNode();
1415		output += "\n";
1416	}
1417
1418	return output;
1419}
1420
1421bool Hy3Layout::shouldRenderSelected(const CWindow* window) {
1422	if (window == nullptr) return false;
1423	if (Desktop::focusState()->window()) return false;
1424
1425	auto* root = this->getWorkspaceRootGroup(window->m_workspace.get());
1426	if (root == nullptr || root->as_group().focused_child == nullptr) return false;
1427	auto* focused = &root->getFocusedNode();
1428
1429	switch (focused->type()) {
1430	case Hy3NodeType::Target: return focused->as_window().get() == window;
1431	case Hy3NodeType::Group: {
1432		auto* node = this->getNodeFromWindow(window);
1433		if (node == nullptr) return false;
1434		return focused->as_group().hasChild(*node);
1435	}
1436	}
1437	return false;
1438}
1439
1440Hy3Node* Hy3Layout::getWorkspaceRootGroup(const Workspace::CHLWorkspace* workspace) {
1441	if (!this->root) return nullptr;
1442	auto& group = this->root->as_group();
1443	if (group.children.empty()) return nullptr;
1444	return group.children.front().get();
1445}
1446
1447Hy3Node* Hy3Layout::getWorkspaceFocusedNode(
1448    const Workspace::CHLWorkspace* workspace,
1449    bool ignore_group_focus,
1450    bool stop_at_expanded
1451) {
1452	auto* rootNode = this->getWorkspaceRootGroup(workspace);
1453	if (rootNode == nullptr) return nullptr;
1454	return &rootNode->getFocusedNode(ignore_group_focus, stop_at_expanded);
1455}
1456
1457Hy3Node* Hy3Layout::getNodeFromWindow(const CWindow* window) {
1458	if (!this->root || !window) return nullptr;
1459	for (auto& w: this->root->windows()) {
1460		if (&w == window) return getNodeFromTarget(w.layoutTarget());
1461	}
1462	return nullptr;
1463}
1464
1465static Hy3Node* findNodeFromTargetRecursive(Hy3Node* node, SP<Layout::ITarget> target) {
1466	if (!node) return nullptr;
1467	if (node->is_target() && node->as_target() == target) {
1468		return node;
1469	}
1470	if (node->is_group()) {
1471		for (auto& child: node->as_group().children) {
1472			auto* result = findNodeFromTargetRecursive(child.get(), target);
1473			if (result) return result;
1474		}
1475	}
1476	return nullptr;
1477}
1478
1479Hy3Node* Hy3Layout::getNodeFromTarget(SP<Layout::ITarget> target) {
1480	return findNodeFromTargetRecursive(this->root.get(), target);
1481}
1482
1483bool shiftIsForward(ShiftDirection direction) {
1484	return direction == ShiftDirection::Right || direction == ShiftDirection::Down;
1485}
1486
1487bool shiftIsVertical(ShiftDirection direction) {
1488	return direction == ShiftDirection::Up || direction == ShiftDirection::Down;
1489}
1490
1491bool shiftMatchesLayout(Hy3GroupLayout layout, ShiftDirection direction) {
1492	if (layout == Hy3GroupLayout::Root) return false;
1493	return (layout == Hy3GroupLayout::SplitV && shiftIsVertical(direction))
1494	    || (layout != Hy3GroupLayout::SplitV && !shiftIsVertical(direction));
1495}
1496
1497Hy3Node* Hy3Layout::shiftOrGetFocus(
1498    Hy3Node& node,
1499    ShiftDirection direction,
1500    bool shift,
1501    bool once,
1502    bool visible
1503) {
1504	auto* expand_actor = &node.getExpandActor();
1505	auto* break_origin = &expand_actor->getPlacementActor();
1506	auto* shift_actor = break_origin;
1507	auto* break_parent = break_origin->parent.get();
1508
1509	auto has_broken_once = false;
1510
1511	// break parents until we hit a container oriented the same way as the shift
1512	// direction
1513	while (true) {
1514		if (break_parent == nullptr) return nullptr;
1515
1516		auto& group = break_parent->as_group(); // must be a group in order to be a parent
1517
1518		if (shiftMatchesLayout(group.layout, direction)
1519		    && (!visible || !group.isTab()))
1520		{
1521			// group has the correct orientation
1522
1523			if (once && shift && has_broken_once) break;
1524			if (break_origin != shift_actor) has_broken_once = true;
1525
1526			// if this movement would break out of the group, continue the break loop
1527			// (do not enter this if) otherwise break.
1528			if ((has_broken_once && once && shift)
1529			    || !(
1530			        (!shiftIsForward(direction) && group.children.front().get() == break_origin)
1531			        || (shiftIsForward(direction) && group.children.back().get() == break_origin)
1532			    ))
1533				break;
1534		}
1535
1536		if (break_parent->is_root()) {
1537			if (!shift) return focusMonitor(direction);
1538
1539			auto new_layout =
1540			    shiftIsVertical(direction) ? Hy3GroupLayout::SplitV : Hy3GroupLayout::SplitH;
1541			break_origin->wrap(new_layout, GroupEphemeralityOption::Standard);
1542			break_parent = break_origin->parent.get();
1543			break;
1544		}
1545
1546		// special case 1-child nodes so once will only break the group
1547		if (once && shift && break_origin->is_group() && break_origin->as_group().children.size() == 1) {
1548			break;
1549		}
1550
1551		break_origin = break_parent;
1552		break_parent = break_origin->parent.get();
1553	}
1554
1555	auto& parent_group = break_parent->as_group();
1556	Hy3Node* target_group = break_parent;
1557	std::list<UP<Hy3Node>>::iterator insert;
1558
1559	if (break_origin == parent_group.children.front().get() && !shiftIsForward(direction)) {
1560		if (!shift) return nullptr;
1561		insert = parent_group.children.begin();
1562	} else if (break_origin == parent_group.children.back().get() && shiftIsForward(direction)) {
1563		if (!shift) return nullptr;
1564		insert = parent_group.children.end();
1565	} else {
1566		auto& group_data = target_group->as_group();
1567
1568		auto iter = group_data.findChild(*break_origin);
1569		if (shiftIsForward(direction)) iter = std::next(iter);
1570		else iter = std::prev(iter);
1571
1572		auto& node = **iter;
1573		if (node.is_target()
1574				|| (node.is_group()
1575						&& (node.as_group().expand_focused != ExpandFocusType::NotExpanded
1576								|| node.as_group().locked))
1577				|| (shift && once && has_broken_once))
1578		{
1579			if (shift) {
1580				if (target_group == shift_actor->parent.get()) {
1581					if (shiftIsForward(direction)) insert = std::next(iter);
1582					else insert = iter;
1583				} else {
1584					if (shiftIsForward(direction)) insert = iter;
1585					else insert = std::next(iter);
1586				}
1587			} else return &(*iter)->getFocusedNode();
1588		} else {
1589			// break into neighboring groups until we hit a window
1590			while (true) {
1591				target_group = iter->get();
1592				auto& group_data = target_group->as_group();
1593
1594				if (group_data.children.empty()) return nullptr; // in theory this would never happen
1595
1596				bool shift_after = false;
1597
1598				if (!shift && group_data.isTab()
1599				    && group_data.focused_child != nullptr)
1600				{
1601					iter = group_data.findChild(*group_data.focused_child);
1602				} else if (visible && group_data.isTab()
1603				           && group_data.focused_child != nullptr)
1604				{
1605					// if the group is tabbed and we're going by visible nodes, jump to the current entry
1606					iter = group_data.findChild(*group_data.focused_child);
1607					shift_after = true;
1608				} else if (shiftMatchesLayout(group_data.layout, direction)
1609				           || (visible && group_data.isTab()))
1610				{
1611					// if the group has the same orientation as movement pick the
1612					// last/first child based on movement direction
1613					if (shiftIsForward(direction)) iter = group_data.children.begin();
1614					else {
1615						iter = std::prev(group_data.children.end());
1616						shift_after = true;
1617					}
1618				} else {
1619					if (group_data.focused_child != nullptr) {
1620						iter = group_data.findChild(*group_data.focused_child);
1621						shift_after = true;
1622					} else {
1623						iter = group_data.children.begin();
1624					}
1625				}
1626
1627				if (shift && once) {
1628					if (shift_after) insert = std::next(iter);
1629					else insert = iter;
1630					break;
1631				}
1632
1633				if ((*iter)->is_target()
1634				    || ((*iter)->is_group()
1635				        && (*iter)->as_group().expand_focused != ExpandFocusType::NotExpanded))
1636				{
1637					if (shift) {
1638						if (shift_after) insert = std::next(iter);
1639						else insert = iter;
1640						break;
1641					} else {
1642						return &(*iter)->getFocusedNode();
1643					}
1644				}
1645			}
1646		}
1647	}
1648
1649	auto& group_data = target_group->as_group();
1650
1651	if (target_group == shift_actor->parent.get()) {
1652		// Reorder within the same group via splice (handles boundary no-ops naturally)
1653		auto shift_it = group_data.findChild(*shift_actor);
1654		group_data.children.splice(insert, group_data.children, shift_it);
1655		shift_actor->parent->collapseParents(nodeCollapsePolicy());
1656	} else if (!shift_actor->parent->is_root() && shift_actor->parent->as_group().children.size() == 1 && target_group == shift_actor->parent->parent.get()) {
1657		// special cased to prevent size being reset to 1 on group break
1658		auto shift_parent = shift_actor->parent;
1659		auto shift_actor_u = shift_parent->as_group().extractChildRaw(*shift_actor);
1660		auto iter = std::ranges::find_if(group_data.children, [&](const auto& other) {
1661			return other == shift_parent;
1662		});
1663		group_data.replaceChild(iter, std::move(shift_actor_u));
1664	} else {
1665		auto target_group_p = target_group->self;
1666		auto* shift_parent = shift_actor->parent.get();
1667		auto shift_actor_u = shift_parent->as_group().extractChild(*shift_actor);
1668
1669		group_data.insertChild(insert, std::move(shift_actor_u));
1670
1671		shift_parent = shift_parent->collapseParents(CollapsePolicy::InvalidOnly);
1672
1673		if (shift_parent != nullptr) {
1674			shift_parent->updateTabBarRecursive();
1675		}
1676
1677		// Collapse any single-child groups left over from wrapping/extraction
1678		if (target_group_p) {
1679			target_group_p->collapseParents(nodeCollapsePolicy());
1680		}
1681	}
1682
1683	node.updateTabBarRecursive();
1684	node.focus(false, Desktop::FOCUS_REASON_KEYBIND);
1685	this->recalcGeometry();
1686
1687	return nullptr;
1688}
1689
1690void Hy3Layout::updateAutotileWorkspaces() {
1691	static const auto autotile_raw_workspaces =
1692	    CConfigValue<Config::STRING>("plugin:hy3:autotile:workspaces");
1693
1694	if (*autotile_raw_workspaces == this->autotile.raw_workspaces) {
1695		return;
1696	}
1697
1698	this->autotile.raw_workspaces = *autotile_raw_workspaces;
1699	this->autotile.workspaces.clear();
1700
1701	if (this->autotile.raw_workspaces == "all") {
1702		return;
1703	}
1704
1705	this->autotile.workspace_blacklist = this->autotile.raw_workspaces.rfind("not:", 0) == 0;
1706
1707	const auto autotile_raw_workspaces_filtered = (this->autotile.workspace_blacklist)
1708	                                                ? this->autotile.raw_workspaces.substr(4)
1709	                                                : this->autotile.raw_workspaces;
1710
1711	// split on space and comma
1712	const std::regex regex {R"([\s,]+)"};
1713	const auto begin = std::sregex_token_iterator(
1714	    autotile_raw_workspaces_filtered.begin(),
1715	    autotile_raw_workspaces_filtered.end(),
1716	    regex,
1717	    -1
1718	);
1719	const auto end = std::sregex_token_iterator();
1720
1721	for (auto s = begin; s != end; ++s) {
1722		try {
1723			this->autotile.workspaces.insert(std::stoi(*s));
1724		} catch (...) {
1725			hy3_log(ERR, "autotile:workspaces: invalid workspace id: {}", (std::string) *s);
1726		}
1727	}
1728}
1729
1730bool Hy3Layout::shouldAutotileWorkspace(const Workspace::CHLWorkspace* workspace) {
1731	if (this->autotile.workspace_blacklist) {
1732		return !this->autotile.workspaces.contains(static_cast<int>(workspace->numberedID().value_or(0)));
1733	} else {
1734		return this->autotile.workspaces.empty()
1735		    || this->autotile.workspaces.contains(static_cast<int>(workspace->numberedID().value_or(0)));
1736	}
1737}