Parent directory

Hy3Layout.cpp

51109 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/desktop/Workspace.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()->m_id : -1
 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 id {}",
 212		    (uintptr_t) node_up.get(),
 213		    ws ? ws->m_id : -1
 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->m_id);
 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 CWorkspace* 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 CWorkspace* 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 CWorkspace* 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 CWorkspace* 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 CWorkspace* 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 CWorkspace* 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 CWorkspace* 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 CWorkspace* 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 CWorkspace* 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->m_name, follow, false);
 885			return true;
 886		}
 887	}
 888	return false;
 889}
 890
 891void Hy3Layout::toggleFocusLayer(const CWorkspace* 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    CWorkspace* origin,
 940    std::string wsname,
 941    bool follow,
 942    bool warp
 943) {
 944	auto target = getWorkspaceIDNameFromString(operationWorkspaceForName(wsname));
 945
 946	if (target.id == WORKSPACE_INVALID) {
 947		hy3_log(ERR, "moveNodeToWorkspace called with invalid workspace {}", wsname);
 948		return;
 949	}
 950
 951	auto workspace = State::workspaceState()->query().id(target.id).run();
 952
 953	if (origin == workspace.get()) return;
 954
 955	auto* node = this->getWorkspaceFocusedNode(origin);
 956	auto focused_window = Desktop::focusState()->window();
 957	auto* focused_window_node = this->getNodeFromWindow(focused_window.get());
 958
 959	auto origin_ws = node != nullptr           ? node->layout()->workspace()
 960	               : focused_window != nullptr ? focused_window->m_workspace
 961	                                           : nullptr;
 962
 963	if (!valid(origin_ws)) return;
 964
 965	if (workspace == nullptr) {
 966		hy3_log(LOG, "creating target workspace {} for node move", target.id);
 967
 968		workspace = State::workspaceState()->create(target.id, origin_ws->monitorID(), target.name);
 969	}
 970
 971	if (focused_window != nullptr
 972	    && (focused_window_node == nullptr || Fullscreen::controller()->isFullscreen(focused_window)))
 973	{
 974		g_pHyprRenderer->damageWindow(focused_window);
 975		Desktop::globalWindowController()->moveWindowToWorkspace(focused_window, workspace);
 976	} else {
 977		if (node == nullptr) return;
 978
 979		hy3_log(
 980		    LOG,
 981		    "moving node {:x} from workspace {} to workspace {} (follow: {})",
 982		    (uintptr_t) node,
 983		    origin->m_id,
 984		    workspace->m_id,
 985		    follow
 986		);
 987
 988		auto* parent_node = node->parent.get();
 989		auto node_up = parent_node->extractAndMerge(*node, nullptr);
 990		auto* destHy3 = hy3InstanceForWorkspace(workspace);
 991		auto* destLayout = destHy3 ? destHy3 : this;
 992
 993		g_suppressInsert = true;
 994
 995		for (auto& window: node->windows()) {
 996			window.layoutTarget()->assignToSpace(workspace->m_space);
 997		}
 998
 999		g_suppressInsert = false;
1000
1001		destLayout->insertNode(std::move(node_up));
1002
1003		Desktop::Rule::ruleEngine()->updateAllRules();
1004
1005		updateTreeTabBars(*node);
1006		node->updateTabBarRecursive();
1007		this->recalcGeometry();
1008	}
1009
1010	if (follow) {
1011		auto& monitor = workspace->m_monitor;
1012
1013		if (workspace->m_isSpecialWorkspace) {
1014			monitor->setSpecialWorkspace(workspace);
1015		} else if (origin_ws->m_isSpecialWorkspace) {
1016			origin_ws->m_monitor->setSpecialWorkspace(nullptr);
1017		}
1018
1019		monitor->changeWorkspace(workspace);
1020
1021		node->layout()->recalcGeometry();
1022		node->focus(warp, Desktop::FOCUS_REASON_KEYBIND);
1023	}
1024}
1025
1026void Hy3Layout::changeFocus(const CWorkspace* workspace, FocusShift shift) {
1027	auto* node = this->getWorkspaceFocusedNode(workspace);
1028	if (node == nullptr) return;
1029
1030	switch (shift) {
1031	case FocusShift::Bottom: goto bottom;
1032	case FocusShift::Top:
1033		this->root->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1034		this->updateGroupBorderColors();
1035		return;
1036	case FocusShift::Raise:
1037		if (node->is_root_group()) goto bottom;
1038		node->parent->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1039		this->updateGroupBorderColors();
1040		return;
1041	case FocusShift::Lower:
1042		if (node->is_group() && node->as_group().focused_child != nullptr)
1043			node->as_group().focused_child->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1044		this->updateGroupBorderColors();
1045		return;
1046	case FocusShift::Tab:
1047		for (auto& n: node->ancestors()) {
1048			if (n.parent->as_group().isTab()) {
1049				n.parent->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1050				this->updateGroupBorderColors();
1051				return;
1052			}
1053		}
1054		return;
1055	case FocusShift::TabNode:
1056		for (auto& n: node->ancestors()) {
1057			if (n.parent->as_group().isTab()) {
1058				n.focus(false, Desktop::FOCUS_REASON_KEYBIND);
1059				this->updateGroupBorderColors();
1060				return;
1061			}
1062		}
1063		return;
1064	}
1065
1066bottom:
1067	while (node->is_group() && node->as_group().focused_child != nullptr) {
1068		node = node->as_group().focused_child;
1069	}
1070
1071	node->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1072	this->updateGroupBorderColors();
1073	return;
1074}
1075
1076Hy3Node* findTabBarAt(Hy3Node& node, Vector2D pos, Hy3Node** focused_node) {
1077	// clang-format off
1078	static const auto p_gaps_in = CConfigValue<Config::IComplexConfigValue>("general:gaps_in");
1079	static const auto tab_bar_height = CConfigValue<Config::INTEGER>("plugin:hy3:tabs:height");
1080	static const auto tab_bar_padding = CConfigValue<Config::INTEGER>("plugin:hy3:tabs:padding");
1081	// clang-format on
1082
1083	auto workspace_rule = Config::workspaceRuleMgr()->getWorkspaceRuleFor(node.layout()->workspace());
1084	auto gaps_in = workspace_rule.and_then([](auto r) { return r.m_gapsIn; }).value_or(*sc<Config::CCssGapData*>(p_gaps_in.ptr()));
1085
1086	auto inset = *tab_bar_height + *tab_bar_padding + gaps_in.m_top;
1087
1088	if (node.is_group()) {
1089		if (node.hidden) return nullptr;
1090		// note: tab bar clicks ignore animations
1091		if (node.visualBox.x > pos.x || node.logicalBox.y > pos.y || node.visualBox.x + node.visualBox.w < pos.x
1092		    || node.visualBox.y + node.visualBox.h < pos.y)
1093			return nullptr;
1094
1095		auto& group = node.as_group();
1096
1097		if (group.isTab() && group.tab_bar) {
1098			if (pos.y < node.visualBox.y + inset) {
1099				auto& children = group.children;
1100				auto& tab_bar = *group.tab_bar.get();
1101
1102				auto size = tab_bar.size->value();
1103				auto x = pos.x - tab_bar.pos->value().x;
1104				auto child_iter = children.begin();
1105
1106				for (auto& tab: tab_bar.bar.entries) {
1107					if (child_iter == children.end()) break;
1108
1109					if (x > tab.offset->value() * size.x
1110					    && x < (tab.offset->value() + tab.width->value()) * size.x)
1111					{
1112						*focused_node = child_iter->get();
1113						return &node;
1114					}
1115
1116					child_iter = std::next(child_iter);
1117				}
1118			}
1119
1120			if (group.focused_child != nullptr) {
1121				return findTabBarAt(*group.focused_child, pos, focused_node);
1122			}
1123		} else {
1124			for (auto& child: group.children) {
1125				if (findTabBarAt(*child, pos, focused_node)) return child.get();
1126			}
1127		}
1128	}
1129
1130	return nullptr;
1131}
1132
1133void Hy3Layout::focusTab(
1134    const CWorkspace* workspace,
1135    TabFocus target,
1136    TabFocusMousePriority mouse,
1137    bool wrap_scroll,
1138    int index
1139) {
1140	auto* node = this->getWorkspaceRootGroup(workspace);
1141	if (node == nullptr) return;
1142
1143	Hy3Node* tab_node = nullptr;
1144	Hy3Node* tab_focused_node;
1145
1146	if (target == TabFocus::MouseLocation || mouse != TabFocusMousePriority::Ignore) {
1147		// no surf focused at all
1148		auto ptrSurfaceResource = g_pSeatManager->m_state.pointerFocus.lock();
1149		if (!ptrSurfaceResource) return;
1150
1151		auto ptrSurface = CWLSurface::fromResource(ptrSurfaceResource);
1152		if (!ptrSurface) return;
1153
1154		// non window-parented surface focused, cant have a tab
1155		auto view = ptrSurface->view();
1156		auto* window = dynamic_cast<CWindow*>(view.get());
1157		if (!window || window->isFloating()) return;
1158
1159		auto mouse_pos = g_pInputManager->getMouseCoordsInternal();
1160		tab_node = findTabBarAt(*node, mouse_pos, &tab_focused_node);
1161		if (tab_node != nullptr) goto hastab;
1162
1163		if (target == TabFocus::MouseLocation || mouse == TabFocusMousePriority::Require) return;
1164	}
1165
1166	if (tab_node == nullptr) {
1167		tab_node = this->getWorkspaceFocusedNode(workspace);
1168		if (tab_node == nullptr) return;
1169
1170		while (tab_node != nullptr
1171		       && (tab_node->is_target()
1172		           || !tab_node->as_group().isTab())
1173		       && !tab_node->is_root())
1174			tab_node = tab_node->parent.get();
1175
1176		if (tab_node == nullptr || tab_node->is_target()
1177		    || !tab_node->as_group().isTab())
1178			return;
1179	}
1180
1181hastab:
1182	if (target != TabFocus::MouseLocation) {
1183		auto& group = tab_node->as_group();
1184		if (group.focused_child == nullptr || group.children.size() < 2) return;
1185
1186		auto& children = group.children;
1187		if (target == TabFocus::Index) {
1188			int i = 1;
1189
1190			for (auto& n: children) {
1191				if (i == index) {
1192					tab_focused_node = n.get();
1193					goto cont;
1194				}
1195
1196				i++;
1197			}
1198
1199			return;
1200		cont:;
1201		} else {
1202			auto node_iter = group.findChild(*group.focused_child);
1203			if (node_iter == children.end()) return;
1204			if (target == TabFocus::Left) {
1205				if (node_iter == children.begin()) {
1206					if (wrap_scroll) node_iter = std::prev(children.end());
1207					else return;
1208				} else node_iter = std::prev(node_iter);
1209
1210				tab_focused_node = node_iter->get();
1211			} else {
1212				if (node_iter == std::prev(children.end())) {
1213					if (wrap_scroll) node_iter = children.begin();
1214					else return;
1215				} else node_iter = std::next(node_iter);
1216
1217				tab_focused_node = node_iter->get();
1218			}
1219		}
1220	}
1221
1222	auto* focus = tab_focused_node;
1223	while (focus->is_group() && !focus->as_group().group_focused
1224	       && focus->as_group().focused_child != nullptr)
1225		focus = focus->as_group().focused_child;
1226
1227	focus->focus(false, Desktop::FOCUS_REASON_KEYBIND);
1228	this->recalcGeometry();
1229}
1230
1231void Hy3Layout::setNodeSwallow(const CWorkspace* workspace, SetSwallowOption option) {
1232	auto* node = this->getWorkspaceFocusedNode(workspace);
1233	if (node == nullptr) return;
1234	node->assertNotRoot();
1235
1236	auto* containment = &node->parent->as_group().containment;
1237	switch (option) {
1238	case SetSwallowOption::NoSwallow: *containment = false; break;
1239	case SetSwallowOption::Swallow: *containment = true; break;
1240	case SetSwallowOption::Toggle: *containment = !*containment; break;
1241	}
1242}
1243
1244void Hy3Layout::killFocusedNode(const CWorkspace* workspace) {
1245	auto last_window = Desktop::focusState()->window();
1246	if (last_window != nullptr && last_window->isFloating()) {
1247		last_window->sendClose();
1248	} else {
1249		auto* node = this->getWorkspaceFocusedNode(workspace);
1250		if (node == nullptr) return;
1251
1252		std::vector<PHLWINDOW> windows;
1253		for (auto& w: node->windows()) windows.push_back(w.m_self.lock());
1254
1255		for (auto& window: windows) {
1256			window->setHidden(false);
1257			window->sendClose();
1258		}
1259	}
1260}
1261
1262void Hy3Layout::expand(
1263    const CWorkspace* workspace,
1264    ExpandOption option,
1265    ExpandFullscreenOption fs_option
1266) {
1267	auto* node = this->getWorkspaceFocusedNode(workspace, false, true);
1268	if (node == nullptr) return;
1269	PHLWINDOW window;
1270
1271	switch (option) {
1272	case ExpandOption::Expand: {
1273		node->assertNotRoot();
1274
1275		if (node->is_group() && !node->as_group().group_focused)
1276			node->as_group().expand_focused = ExpandFocusType::Stack;
1277
1278		auto& group = node->parent->as_group();
1279		group.focused_child = node;
1280		group.expand_focused = ExpandFocusType::Latch;
1281
1282		this->recalcGeometry();
1283
1284		if (node->parent->is_root()) {
1285			switch (fs_option) {
1286			case ExpandFullscreenOption::MaximizeAsFullscreen: // goto fullscreen;
1287			case ExpandFullscreenOption::MaximizeIntermediate:
1288			case ExpandFullscreenOption::MaximizeOnly: return;
1289			}
1290		}
1291	} break;
1292	case ExpandOption::Shrink:
1293		if (node->is_group()) {
1294			auto& group = node->as_group();
1295
1296			group.expand_focused = ExpandFocusType::NotExpanded;
1297			if (group.focused_child->is_group())
1298				group.focused_child->as_group().expand_focused = ExpandFocusType::Latch;
1299
1300			this->recalcGeometry();
1301		}
1302		break;
1303	case ExpandOption::Base: {
1304		if (node->is_group()) {
1305			node->as_group().collapseExpansions();
1306			this->recalcGeometry();
1307		}
1308		break;
1309	}
1310	case ExpandOption::Maximize: break;
1311	case ExpandOption::Fullscreen: break;
1312	}
1313
1314	return;
1315}
1316
1317void Hy3Layout::setTabLock(const CWorkspace* workspace, TabLockMode mode) {
1318	auto* focused = this->getWorkspaceFocusedNode(workspace);
1319	if (focused == nullptr) return;
1320
1321	for (auto& node: focused->ancestors()) {
1322		auto& group = node.parent->as_group();
1323		if (!group.isTab())
1324			continue;
1325
1326		switch (mode) {
1327		case TabLockMode::Lock: group.locked = true; break;
1328		case TabLockMode::Unlock: group.locked = false; break;
1329		case TabLockMode::Toggle: group.locked = !group.locked; break;
1330		}
1331
1332		node.parent->updateTabBar();
1333		return;
1334	}
1335}
1336
1337static void equalizeRecursive(Hy3Node* node, bool recursive) {
1338	node->size_ratio = 1.0f;
1339
1340	if (recursive && node->is_group()) {
1341		for (auto& child: node->as_group().children) {
1342			equalizeRecursive(child.get(), true);
1343		}
1344	}
1345}
1346
1347void Hy3Layout::equalize(const CWorkspace* workspace, bool recursive) {
1348	auto* focused = this->getWorkspaceFocusedNode(workspace);
1349	if (focused == nullptr) return;
1350
1351	Hy3Node* target = nullptr;
1352
1353	if (recursive) {
1354		target = this->getWorkspaceRootGroup(workspace);
1355		if (target != nullptr) {
1356			equalizeRecursive(target, true);
1357		}
1358	} else {
1359		focused->assertNotRoot();
1360		auto* parent = focused->parent.get();
1361		equalizeRecursive(parent, false);
1362		target = parent;
1363	}
1364
1365	if (target != nullptr) {
1366		this->recalcGeometry();
1367	}
1368}
1369
1370void Hy3Layout::warpCursorToBox(const Vector2D& pos, const Vector2D& size) {
1371	auto cursorpos = Pointer::mgr()->position();
1372
1373	if (cursorpos.x < pos.x || cursorpos.x >= pos.x + size.x || cursorpos.y < pos.y
1374	    || cursorpos.y >= pos.y + size.y)
1375	{
1376		Hy3Layout::warpCursorWithFocus(pos + size / 2, true);
1377	}
1378}
1379
1380void Hy3Layout::warpCursorWithFocus(const Vector2D& target, bool force) {
1381	static const auto input_follows_mouse = CConfigValue<Config::INTEGER>("input:follow_mouse");
1382	static const auto no_warps = CConfigValue<Config::INTEGER>("cursor:no_warps");
1383
1384	Pointer::pointerController()->warpTo(target, force);
1385
1386	if (*no_warps && !force) return;
1387
1388	if (*input_follows_mouse) {
1389		g_pInputManager->simulateMouseMovement();
1390	}
1391}
1392
1393std::string Hy3Layout::debugNodes() {
1394	std::string output;
1395
1396	for (auto* hy3: g_hy3Instances) {
1397		if (!hy3->root) continue;
1398		output += hy3->root->debugNode();
1399		output += "\n";
1400	}
1401
1402	return output;
1403}
1404
1405bool Hy3Layout::shouldRenderSelected(const CWindow* window) {
1406	if (window == nullptr) return false;
1407	if (Desktop::focusState()->window()) return false;
1408
1409	auto* root = this->getWorkspaceRootGroup(window->m_workspace.get());
1410	if (root == nullptr || root->as_group().focused_child == nullptr) return false;
1411	auto* focused = &root->getFocusedNode();
1412
1413	switch (focused->type()) {
1414	case Hy3NodeType::Target: return focused->as_window().get() == window;
1415	case Hy3NodeType::Group: {
1416		auto* node = this->getNodeFromWindow(window);
1417		if (node == nullptr) return false;
1418		return focused->as_group().hasChild(*node);
1419	}
1420	}
1421	return false;
1422}
1423
1424Hy3Node* Hy3Layout::getWorkspaceRootGroup(const CWorkspace* workspace) {
1425	if (!this->root) return nullptr;
1426	auto& group = this->root->as_group();
1427	if (group.children.empty()) return nullptr;
1428	return group.children.front().get();
1429}
1430
1431Hy3Node* Hy3Layout::getWorkspaceFocusedNode(
1432    const CWorkspace* workspace,
1433    bool ignore_group_focus,
1434    bool stop_at_expanded
1435) {
1436	auto* rootNode = this->getWorkspaceRootGroup(workspace);
1437	if (rootNode == nullptr) return nullptr;
1438	return &rootNode->getFocusedNode(ignore_group_focus, stop_at_expanded);
1439}
1440
1441Hy3Node* Hy3Layout::getNodeFromWindow(const CWindow* window) {
1442	if (!this->root || !window) return nullptr;
1443	for (auto& w: this->root->windows()) {
1444		if (&w == window) return getNodeFromTarget(w.layoutTarget());
1445	}
1446	return nullptr;
1447}
1448
1449static Hy3Node* findNodeFromTargetRecursive(Hy3Node* node, SP<Layout::ITarget> target) {
1450	if (!node) return nullptr;
1451	if (node->is_target() && node->as_target() == target) {
1452		return node;
1453	}
1454	if (node->is_group()) {
1455		for (auto& child: node->as_group().children) {
1456			auto* result = findNodeFromTargetRecursive(child.get(), target);
1457			if (result) return result;
1458		}
1459	}
1460	return nullptr;
1461}
1462
1463Hy3Node* Hy3Layout::getNodeFromTarget(SP<Layout::ITarget> target) {
1464	return findNodeFromTargetRecursive(this->root.get(), target);
1465}
1466
1467bool shiftIsForward(ShiftDirection direction) {
1468	return direction == ShiftDirection::Right || direction == ShiftDirection::Down;
1469}
1470
1471bool shiftIsVertical(ShiftDirection direction) {
1472	return direction == ShiftDirection::Up || direction == ShiftDirection::Down;
1473}
1474
1475bool shiftMatchesLayout(Hy3GroupLayout layout, ShiftDirection direction) {
1476	if (layout == Hy3GroupLayout::Root) return false;
1477	return (layout == Hy3GroupLayout::SplitV && shiftIsVertical(direction))
1478	    || (layout != Hy3GroupLayout::SplitV && !shiftIsVertical(direction));
1479}
1480
1481Hy3Node* Hy3Layout::shiftOrGetFocus(
1482    Hy3Node& node,
1483    ShiftDirection direction,
1484    bool shift,
1485    bool once,
1486    bool visible
1487) {
1488	auto* expand_actor = &node.getExpandActor();
1489	auto* break_origin = &expand_actor->getPlacementActor();
1490	auto* shift_actor = break_origin;
1491	auto* break_parent = break_origin->parent.get();
1492
1493	auto has_broken_once = false;
1494
1495	// break parents until we hit a container oriented the same way as the shift
1496	// direction
1497	while (true) {
1498		if (break_parent == nullptr) return nullptr;
1499
1500		auto& group = break_parent->as_group(); // must be a group in order to be a parent
1501
1502		if (shiftMatchesLayout(group.layout, direction)
1503		    && (!visible || !group.isTab()))
1504		{
1505			// group has the correct orientation
1506
1507			if (once && shift && has_broken_once) break;
1508			if (break_origin != shift_actor) has_broken_once = true;
1509
1510			// if this movement would break out of the group, continue the break loop
1511			// (do not enter this if) otherwise break.
1512			if ((has_broken_once && once && shift)
1513			    || !(
1514			        (!shiftIsForward(direction) && group.children.front().get() == break_origin)
1515			        || (shiftIsForward(direction) && group.children.back().get() == break_origin)
1516			    ))
1517				break;
1518		}
1519
1520		if (break_parent->is_root()) {
1521			if (!shift) return focusMonitor(direction);
1522
1523			auto new_layout =
1524			    shiftIsVertical(direction) ? Hy3GroupLayout::SplitV : Hy3GroupLayout::SplitH;
1525			break_origin->wrap(new_layout, GroupEphemeralityOption::Standard);
1526			break_parent = break_origin->parent.get();
1527			break;
1528		}
1529
1530		// special case 1-child nodes so once will only break the group
1531		if (once && shift && break_origin->is_group() && break_origin->as_group().children.size() == 1) {
1532			break;
1533		}
1534
1535		break_origin = break_parent;
1536		break_parent = break_origin->parent.get();
1537	}
1538
1539	auto& parent_group = break_parent->as_group();
1540	Hy3Node* target_group = break_parent;
1541	std::list<UP<Hy3Node>>::iterator insert;
1542
1543	if (break_origin == parent_group.children.front().get() && !shiftIsForward(direction)) {
1544		if (!shift) return nullptr;
1545		insert = parent_group.children.begin();
1546	} else if (break_origin == parent_group.children.back().get() && shiftIsForward(direction)) {
1547		if (!shift) return nullptr;
1548		insert = parent_group.children.end();
1549	} else {
1550		auto& group_data = target_group->as_group();
1551
1552		auto iter = group_data.findChild(*break_origin);
1553		if (shiftIsForward(direction)) iter = std::next(iter);
1554		else iter = std::prev(iter);
1555
1556		auto& node = **iter;
1557		if (node.is_target()
1558				|| (node.is_group()
1559						&& (node.as_group().expand_focused != ExpandFocusType::NotExpanded
1560								|| node.as_group().locked))
1561				|| (shift && once && has_broken_once))
1562		{
1563			if (shift) {
1564				if (target_group == shift_actor->parent.get()) {
1565					if (shiftIsForward(direction)) insert = std::next(iter);
1566					else insert = iter;
1567				} else {
1568					if (shiftIsForward(direction)) insert = iter;
1569					else insert = std::next(iter);
1570				}
1571			} else return &(*iter)->getFocusedNode();
1572		} else {
1573			// break into neighboring groups until we hit a window
1574			while (true) {
1575				target_group = iter->get();
1576				auto& group_data = target_group->as_group();
1577
1578				if (group_data.children.empty()) return nullptr; // in theory this would never happen
1579
1580				bool shift_after = false;
1581
1582				if (!shift && group_data.isTab()
1583				    && group_data.focused_child != nullptr)
1584				{
1585					iter = group_data.findChild(*group_data.focused_child);
1586				} else if (visible && group_data.isTab()
1587				           && group_data.focused_child != nullptr)
1588				{
1589					// if the group is tabbed and we're going by visible nodes, jump to the current entry
1590					iter = group_data.findChild(*group_data.focused_child);
1591					shift_after = true;
1592				} else if (shiftMatchesLayout(group_data.layout, direction)
1593				           || (visible && group_data.isTab()))
1594				{
1595					// if the group has the same orientation as movement pick the
1596					// last/first child based on movement direction
1597					if (shiftIsForward(direction)) iter = group_data.children.begin();
1598					else {
1599						iter = std::prev(group_data.children.end());
1600						shift_after = true;
1601					}
1602				} else {
1603					if (group_data.focused_child != nullptr) {
1604						iter = group_data.findChild(*group_data.focused_child);
1605						shift_after = true;
1606					} else {
1607						iter = group_data.children.begin();
1608					}
1609				}
1610
1611				if (shift && once) {
1612					if (shift_after) insert = std::next(iter);
1613					else insert = iter;
1614					break;
1615				}
1616
1617				if ((*iter)->is_target()
1618				    || ((*iter)->is_group()
1619				        && (*iter)->as_group().expand_focused != ExpandFocusType::NotExpanded))
1620				{
1621					if (shift) {
1622						if (shift_after) insert = std::next(iter);
1623						else insert = iter;
1624						break;
1625					} else {
1626						return &(*iter)->getFocusedNode();
1627					}
1628				}
1629			}
1630		}
1631	}
1632
1633	auto& group_data = target_group->as_group();
1634
1635	if (target_group == shift_actor->parent.get()) {
1636		// Reorder within the same group via splice (handles boundary no-ops naturally)
1637		auto shift_it = group_data.findChild(*shift_actor);
1638		group_data.children.splice(insert, group_data.children, shift_it);
1639		shift_actor->parent->collapseParents(nodeCollapsePolicy());
1640	} else if (!shift_actor->parent->is_root() && shift_actor->parent->as_group().children.size() == 1 && target_group == shift_actor->parent->parent.get()) {
1641		// special cased to prevent size being reset to 1 on group break
1642		auto shift_parent = shift_actor->parent;
1643		auto shift_actor_u = shift_parent->as_group().extractChildRaw(*shift_actor);
1644		auto iter = std::ranges::find_if(group_data.children, [&](const auto& other) {
1645			return other == shift_parent;
1646		});
1647		group_data.replaceChild(iter, std::move(shift_actor_u));
1648	} else {
1649		auto target_group_p = target_group->self;
1650		auto* shift_parent = shift_actor->parent.get();
1651		auto shift_actor_u = shift_parent->as_group().extractChild(*shift_actor);
1652
1653		group_data.insertChild(insert, std::move(shift_actor_u));
1654
1655		shift_parent = shift_parent->collapseParents(CollapsePolicy::InvalidOnly);
1656
1657		if (shift_parent != nullptr) {
1658			shift_parent->updateTabBarRecursive();
1659		}
1660
1661		// Collapse any single-child groups left over from wrapping/extraction
1662		if (target_group_p) {
1663			target_group_p->collapseParents(nodeCollapsePolicy());
1664		}
1665	}
1666
1667	node.updateTabBarRecursive();
1668	node.focus(false, Desktop::FOCUS_REASON_KEYBIND);
1669	this->recalcGeometry();
1670
1671	return nullptr;
1672}
1673
1674void Hy3Layout::updateAutotileWorkspaces() {
1675	static const auto autotile_raw_workspaces =
1676	    CConfigValue<Config::STRING>("plugin:hy3:autotile:workspaces");
1677
1678	if (*autotile_raw_workspaces == this->autotile.raw_workspaces) {
1679		return;
1680	}
1681
1682	this->autotile.raw_workspaces = *autotile_raw_workspaces;
1683	this->autotile.workspaces.clear();
1684
1685	if (this->autotile.raw_workspaces == "all") {
1686		return;
1687	}
1688
1689	this->autotile.workspace_blacklist = this->autotile.raw_workspaces.rfind("not:", 0) == 0;
1690
1691	const auto autotile_raw_workspaces_filtered = (this->autotile.workspace_blacklist)
1692	                                                ? this->autotile.raw_workspaces.substr(4)
1693	                                                : this->autotile.raw_workspaces;
1694
1695	// split on space and comma
1696	const std::regex regex {R"([\s,]+)"};
1697	const auto begin = std::sregex_token_iterator(
1698	    autotile_raw_workspaces_filtered.begin(),
1699	    autotile_raw_workspaces_filtered.end(),
1700	    regex,
1701	    -1
1702	);
1703	const auto end = std::sregex_token_iterator();
1704
1705	for (auto s = begin; s != end; ++s) {
1706		try {
1707			this->autotile.workspaces.insert(std::stoi(*s));
1708		} catch (...) {
1709			hy3_log(ERR, "autotile:workspaces: invalid workspace id: {}", (std::string) *s);
1710		}
1711	}
1712}
1713
1714bool Hy3Layout::shouldAutotileWorkspace(const CWorkspace* workspace) {
1715	if (this->autotile.workspace_blacklist) {
1716		return !this->autotile.workspaces.contains(workspace->m_id);
1717	} else {
1718		return this->autotile.workspaces.empty()
1719		    || this->autotile.workspaces.contains(workspace->m_id);
1720	}
1721}