e385b2625912202861c24ff4e37c51c7a6e6dcac

Author
outfoxxed <outfoxxed@outfoxxed.me>
Committer
outfoxxed <outfoxxed@outfoxxed.me>
Date

Message

massive refactor + rebase on 0.54

Diff

This diff is truncated to protect this page.

  1diff --git a/CHANGELOG.md b/CHANGELOG.md
  2index 63784ccb03e0c7c3b8f82f029a313e6f65a92715..2bdfe7ca43e79d45a8a2ee841c6e4c339a9342e3 100644
  3--- a/CHANGELOG.md
  4+++ b/CHANGELOG.md
  5@@ -1,5 +1,10 @@
  6 # Changelog
  7 
  8+# Upcoming
  9+
 10+- Removed no_gaps_when_only in favor of workspace rules.
 11+- Massive internal refactors, unknown bugs have been added and removed.
 12+
 13 # hl0.53.0.1 and before
 14 
 15 - Fixed tab bar stencil and damage rendering
 16diff --git a/README.md b/README.md
 17index 36cbc0e93eae0d389ffbc6c405b0f6b6af72cdc3..d22016f103d5dfef0f3634ff8efd9fa7279c8e23 100644
 18--- a/README.md
 19+++ b/README.md
 20@@ -225,12 +225,6 @@ configuration options, and some explanation as to what they do.
 21 ```conf
 22 plugin {
 23   hy3 {
 24-    # disable gaps when only one window is onscreen
 25-    # 0 - always show gaps
 26-    # 1 - hide gaps with a single window onscreen
 27-    # 2 - 1 but also show the window border
 28-    no_gaps_when_only = <int> # default: 0
 29-
 30     # policy controlling what happens when a node is removed from a group,
 31     # leaving only a group
 32     # 0 = remove the nested group
 33diff --git a/flake.lock b/flake.lock
 34index 51641dc19da0bb015d75e312b21f85c8b7a4cbbb..51f7d10b927bb0cb1f6b63de995bff0aaa21c89a 100644
 35--- a/flake.lock
 36+++ b/flake.lock
 37@@ -20,11 +20,11 @@
 38         ]
 39       },
 40       "locked": {
 41-        "lastModified": 1767024902,
 42-        "narHash": "sha256-sMdk6QkMDhIOnvULXKUM8WW8iyi551SWw2i6KQHbrrU=",
 43+        "lastModified": 1772460678,
 44+        "narHash": "sha256-NYaWs8fYJ38IgFld0hGSdT2LEVhrgO8SiRReBjIH7YY=",
 45         "owner": "hyprwm",
 46         "repo": "aquamarine",
 47-        "rev": "b8a0c5ba5a9fbd2c660be7dd98bdde0ff3798556",
 48+        "rev": "5d2cb726b16ee349df443f84b64cff53221b6983",
 49         "type": "github"
 50       },
 51       "original": {
 52@@ -87,11 +87,11 @@
 53         ]
 54       },
 55       "locked": {
 56-        "lastModified": 1753964049,
 57-        "narHash": "sha256-lIqabfBY7z/OANxHoPeIrDJrFyYy9jAM4GQLzZ2feCM=",
 58+        "lastModified": 1772461003,
 59+        "narHash": "sha256-pVICsV7FtcEeVwg5y/LFh3XFUkVJninm/P1j/JHzEbM=",
 60         "owner": "hyprwm",
 61         "repo": "hyprcursor",
 62-        "rev": "44e91d467bdad8dcf8bbd2ac7cf49972540980a5",
 63+        "rev": "b62396457b9cfe2ebf24fe05404b09d2a40f8ed7",
 64         "type": "github"
 65       },
 66       "original": {
 67@@ -116,11 +116,11 @@
 68         ]
 69       },
 70       "locked": {
 71-        "lastModified": 1766946335,
 72-        "narHash": "sha256-MRD+Jr2bY11MzNDfenENhiK6pvN+nHygxdHoHbZ1HtE=",
 73+        "lastModified": 1772461523,
 74+        "narHash": "sha256-mI6A51do+hEUzeJKk9YSWfVHdI/SEEIBi2tp5Whq5mI=",
 75         "owner": "hyprwm",
 76         "repo": "hyprgraphics",
 77-        "rev": "4af02a3925b454deb1c36603843da528b67ded6c",
 78+        "rev": "7d63c04b4a2dd5e59ef943b4b143f46e713df804",
 79         "type": "github"
 80       },
 81       "original": {
 82@@ -146,17 +146,17 @@
 83         "xdph": "xdph"
 84       },
 85       "locked": {
 86-        "lastModified": 1768908865,
 87-        "narHash": "sha256-KkzYP2+FVo3wIb2oom2BT7RRGmlvTiEaDJApCpTdcWw=",
 88+        "lastModified": 1773149218,
 89+        "narHash": "sha256-m5Ewi+rgn18qPP54Um4O2zwZ02oiomlZssHvXYEUv3U=",
 90         "owner": "hyprwm",
 91         "repo": "hyprland",
 92-        "rev": "8f547c6fa089f91e7577947c426f692397e9a5cb",
 93+        "rev": "59f9f2688ac508a0584d1462151195a6c4992f99",
 94         "type": "github"
 95       },
 96       "original": {
 97         "owner": "hyprwm",
 98         "repo": "hyprland",
 99-        "rev": "8f547c6fa089f91e7577947c426f692397e9a5cb",
100+        "rev": "59f9f2688ac508a0584d1462151195a6c4992f99",
101         "type": "github"
102       }
103     },
104@@ -193,11 +193,11 @@
105         ]
106       },
107       "locked": {
108-        "lastModified": 1767023960,
109-        "narHash": "sha256-R2HgtVS1G3KSIKAQ77aOZ+Q0HituOmPgXW9nBNkpp3Q=",
110+        "lastModified": 1772467975,
111+        "narHash": "sha256-kipyuDBxrZq+beYpZqWzGvFWm4QbayW9agAvi94vDXY=",
112         "owner": "hyprwm",
113         "repo": "hyprland-guiutils",
114-        "rev": "c2e906261142f5dd1ee0bfc44abba23e2754c660",
115+        "rev": "5e1c6b9025aaf4d578f3eff7c0eb1f0c197a9507",
116         "type": "github"
117       },
118       "original": {
119@@ -218,11 +218,11 @@
120         ]
121       },
122       "locked": {
123-        "lastModified": 1765214753,
124-        "narHash": "sha256-P9zdGXOzToJJgu5sVjv7oeOGPIIwrd9hAUAP3PsmBBs=",
125+        "lastModified": 1772460177,
126+        "narHash": "sha256-/6G/MsPvtn7bc4Y32pserBT/Z4SUUdBd4XYJpOEKVR4=",
127         "owner": "hyprwm",
128         "repo": "hyprland-protocols",
129-        "rev": "3f3860b869014c00e8b9e0528c7b4ddc335c21ab",
130+        "rev": "1cb6db5fd6bb8aee419f4457402fa18293ace917",
131         "type": "github"
132       },
133       "original": {
134@@ -247,11 +247,11 @@
135         ]
136       },
137diff --git a/flake.nix b/flake.nix
138index acc511613f1588f12206e0753414c11624c1b908..e0b434451283036c4513e4f603588cbdc08246a3 100644
139--- a/flake.nix
140+++ b/flake.nix
141@@ -1,6 +1,6 @@
142 {
143   inputs = {
144-    hyprland.url = "github:hyprwm/hyprland/8f547c6fa089f91e7577947c426f692397e9a5cb";
145+    hyprland.url = "github:hyprwm/hyprland/59f9f2688ac508a0584d1462151195a6c4992f99";
146   };
147 
148   outputs = {
149diff --git a/src/Hy3Layout.cpp b/src/Hy3Layout.cpp
150index 9aecf48087e97739346aa28c4917cce66801f40c..2b5ca893cb16533b8da16655c3a7a81d3848b84b 100644
151--- a/src/Hy3Layout.cpp
152+++ b/src/Hy3Layout.cpp
153@@ -1,4 +1,3 @@
154-#include <any>
155 #include <cstdint>
156 #include <regex>
157 #include <set>
158@@ -10,7 +9,6 @@
159 #include <hyprland/src/desktop/DesktopTypes.hpp>
160 #include <hyprland/src/desktop/Workspace.hpp>
161 #include <hyprland/src/desktop/rule/Engine.hpp>
162-#include <hyprland/src/managers/LayoutManager.hpp>
163 #include <hyprland/src/managers/PointerManager.hpp>
164 #include <hyprland/src/managers/SeatManager.hpp>
165 #include <hyprland/src/managers/input/InputManager.hpp>
166@@ -25,24 +23,28 @@
167 #include "Hy3Node.hpp"
168 #include "TabGroup.hpp"
169 #include "globals.hpp"
170-#include "src/SharedDefs.hpp"
171-#include "src/desktop/view/WLSurface.hpp"
172-#include "src/desktop/view/Window.hpp"
173-#include "src/desktop/rule/Rule.hpp"
174-#include "src/desktop/types/OverridableVar.hpp"
175-#include "src/devices/IPointer.hpp"
176 
177 
178 using namespace Desktop::View;
179 
180-PHLWORKSPACE workspace_for_action(bool allow_fullscreen) {
181-	if (g_pLayoutManager->getCurrentLayout() != g_Hy3Layout.get()) return nullptr;
182+static CollapsePolicy nodeCollapsePolicy() {
183+	static const auto node_collapse_policy =
184+	    ConfigValue<Hyprlang::INT>("plugin:hy3:node_collapse_policy");
185+
186+	switch (*node_collapse_policy) {
187+	case 0: return CollapsePolicy::SingleNodeGroups;
188+	case 1: return CollapsePolicy::InvalidOnly;
189+	default: return CollapsePolicy::EmptySplits;
190+	}
191+}
192 
193+PHLWORKSPACE workspace_for_action(bool allow_fullscreen) {
194 	auto workspace = Desktop::focusState()->monitor()->m_activeSpecialWorkspace;
195 	if (!valid(workspace)) workspace = Desktop::focusState()->monitor()->m_activeWorkspace;
196 
197 	if (!valid(workspace)) return nullptr;
198 	if (!allow_fullscreen && workspace->m_hasFullscreenWindow) return nullptr;
199+	if (!hy3InstanceForWorkspace(workspace)) return nullptr;
200 
201 	return workspace;
202 }
203@@ -66,174 +68,197 @@ std::string operationWorkspaceForName(const std::string& workspace) {
204 	return workspace;
205 }
206 
207-SP<HOOK_CALLBACK_FN> renderHookPtr;
208-SP<HOOK_CALLBACK_FN> windowTitleHookPtr;
209-SP<HOOK_CALLBACK_FN> urgentHookPtr;
210-SP<HOOK_CALLBACK_FN> tickHookPtr;
211-SP<HOOK_CALLBACK_FN> mouseButtonPtr;
212+Hy3Node* findTabBarAt(Hy3Node& node, Vector2D pos, Hy3Node** focused_node);
213+
214+Hy3Layout::Hy3Layout() {
215+	g_hy3Instances.insert(this);
216+
217+	m_windowActiveListener = Event::bus()->m_events.window.active.listen(
218+	    [this](PHLWINDOW window, Desktop::eFocusReason) {
219+		    if (!window) return;
220+		    auto* node = this->getNodeFromWindow(window.get());
221+		    if (!node) return;
222+		    this->onWindowFocusChange(window);
223+	    }
224+	);
225+
226+	m_mouseButtonListener = Event::bus()->m_events.input.mouse.button.listen(
227+	    [this](IPointer::SButtonEvent event, Event::SCallbackInfo& info) {
228+		    if (event.state != 1 || event.button != 272) return;
229+
230+		    auto ptr_surface_resource = g_pSeatManager->m_state.pointerFocus.lock();
231+		    if (!ptr_surface_resource) return;
232+
233+		    auto ptr_surface = CWLSurface::fromResource(ptr_surface_resource);
234+		    if (!ptr_surface) return;
235+
236+		    auto view = ptr_surface->view();
237+		    auto* window = dynamic_cast<Desktop::View::CWindow*>(view.get());
238+		    if (!window || window->m_isFloating || window->isFullscreen()) return;
239+
240+		    auto* node = this->getNodeFromWindow(window);
241+		    if (!node) return;
242+
243+		    Hy3Node* focus = nullptr;
244+		    auto mouse_pos = g_pInputManager->getMouseCoordsInternal();
245+		    auto* tab_node = findTabBarAt(*this->root, mouse_pos, &focus);
246+		    if (!tab_node) return;
247+
248+		    while (focus->is_group() && !focus->as_group().group_focused
249+		           && focus->as_group().focused_child != nullptr)
250+			    focus = focus->as_group().focused_child;
251+
252+		    focus->focus(false, Desktop::FOCUS_REASON_CLICK);
253diff --git a/src/Hy3Layout.hpp b/src/Hy3Layout.hpp
254index 049e19b84766402a8ffff2299fcbf195929bfaa0..d4bb5bfd9119518309f685d8f446d49a905b66af 100644
255--- a/src/Hy3Layout.hpp
256+++ b/src/Hy3Layout.hpp
257@@ -9,10 +9,15 @@ enum class GroupEphemeralityOption {
258 	ForceEphemeral,
259 };
260 
261-#include <list>
262 #include <set>
263 
264-#include <hyprland/src/layout/IHyprLayout.hpp>
265+#include <hyprland/src/layout/algorithm/TiledAlgorithm.hpp>
266+#include <hyprland/src/layout/algorithm/Algorithm.hpp>
267+#include <hyprland/src/layout/space/Space.hpp>
268+#include <hyprland/src/layout/target/Target.hpp>
269+#include <hyprland/src/layout/LayoutManager.hpp>
270+#include <hyprland/src/helpers/signal/Signal.hpp>
271+#include <hyprland/src/event/EventBus.hpp>
272 
273 enum class ShiftDirection {
274 	Left,
275@@ -27,6 +32,16 @@ inline static constexpr char getShiftDirectionChar(ShiftDirection direction) {
276 	                                         : 'r';
277 }
278 
279+inline static Math::eDirection shiftToMathDirection(ShiftDirection direction) {
280+	switch (direction) {
281+	case ShiftDirection::Left: return Math::DIRECTION_LEFT;
282+	case ShiftDirection::Right: return Math::DIRECTION_RIGHT;
283+	case ShiftDirection::Up: return Math::DIRECTION_UP;
284+	case ShiftDirection::Down: return Math::DIRECTION_DOWN;
285+	}
286+	return Math::DIRECTION_DEFAULT;
287+}
288+
289 enum class Axis { None, Horizontal, Vertical };
290 
291 #include "Hy3Node.hpp"
292@@ -82,35 +97,29 @@ enum class ExpandFullscreenOption {
293 
294 PHLWORKSPACE workspace_for_action(bool allow_fullscreen = false);
295 
296-class Hy3Layout: public IHyprLayout {
297+class Hy3Layout: public Layout::ITiledAlgorithm {
298 public:
299-	void onWindowCreatedTiling(PHLWINDOW, eDirection = DIRECTION_DEFAULT) override;
300-	void onWindowRemovedTiling(PHLWINDOW) override;
301-	void onWindowFocusChange(PHLWINDOW) override;
302-	bool isWindowTiled(PHLWINDOW) override;
303-	void recalculateMonitor(const MONITORID& monitor_id) override;
304-	void recalculateWindow(PHLWINDOW) override;
305-	void resizeActiveWindow(const Vector2D& delta, eRectCorner corner, PHLWINDOW pWindow = nullptr)
306-	    override;
307-	void
308-	fullscreenRequestForWindow(PHLWINDOW, eFullscreenMode current_mode, eFullscreenMode target_mode)
309-	    override;
310-	std::any layoutMessage(SLayoutMessageHeader header, std::string content) override;
311-	SWindowRenderLayoutHints requestRenderHints(PHLWINDOW) override;
312-	void switchWindows(PHLWINDOW, PHLWINDOW) override;
313-	void moveWindowTo(PHLWINDOW, const std::string& direction, bool silent) override;
314-	void alterSplitRatio(PHLWINDOW, float, bool) override;
315-	std::string getLayoutName() override;
316-	PHLWINDOW getNextWindowCandidate(PHLWINDOW) override;
317-	void replaceWindowDataWith(PHLWINDOW from, PHLWINDOW to) override;
318-	bool isWindowReachable(PHLWINDOW) override;
319-	void bringWindowToTop(PHLWINDOW) override;
320-	Vector2D predictSizeForNewWindowTiled() override { return Vector2D(); }
321-
322-	void onEnable() override;
323-	void onDisable() override;
324-
325-	void insertNode(Hy3Node& node);
326+	Hy3Layout();
327+	~Hy3Layout() override;
328+
329+	// ITiledAlgorithm / IModeAlgorithm overrides
330+	void newTarget(SP<Layout::ITarget> target) override;
331+	void movedTarget(SP<Layout::ITarget> target, std::optional<Vector2D> focalPoint = std::nullopt) override;
332+	void removeTarget(SP<Layout::ITarget> target) override;
333+	void resizeTarget(const Vector2D& delta, SP<Layout::ITarget> target, Layout::eRectCorner corner = Layout::CORNER_NONE) override;
334+	void recalculate() override;
335+	void recalcGeometry(bool no_animation = false);
336+	void swapTargets(SP<Layout::ITarget> a, SP<Layout::ITarget> b) override;
337+	void moveTargetInDirection(SP<Layout::ITarget> t, Math::eDirection dir, bool silent) override;
338+	std::expected<void, std::string> layoutMsg(const std::string_view& sv) override;
339+	std::optional<Vector2D> predictSizeForNewTarget() override;
340+	SP<Layout::ITarget> getNextCandidate(SP<Layout::ITarget> old) override;
341+
342+	// Hy3-specific public methods
343+	void insertNode(UP<Hy3Node> node, std::optional<Vector2D> focalPoint = std::nullopt);
344+	void onWindowFocusChange(PHLWINDOW window);
345+	void updateGroupBorderColors();
346+
347 	void makeGroupOnWorkspace(
348 	    const CWorkspace* workspace,
349 	    Hy3GroupLayout,
350@@ -123,8 +132,8 @@ public:
351 	void toggleTabGroupOnWorkspace(const CWorkspace* workspace);
352 	void changeGroupToOppositeOnWorkspace(const CWorkspace* workspace);
353 	void changeGroupEphemeralityOnWorkspace(const CWorkspace* workspace, bool ephemeral);
354-	void makeGroupOn(Hy3Node*, Hy3GroupLayout, GroupEphemeralityOption);
355-	void makeOppositeGroupOn(Hy3Node*, GroupEphemeralityOption);
356+	void makeGroupOn(Hy3Node&, Hy3GroupLayout, GroupEphemeralityOption);
357diff --git a/src/Hy3Node.cpp b/src/Hy3Node.cpp
358index 3c777862ebe5eedd80fad2ed3a69de60379b23fd..23395fbb047762e9abbd563efc71862a1c11fe4d 100644
359--- a/src/Hy3Node.cpp
360+++ b/src/Hy3Node.cpp
361@@ -1,6 +1,6 @@
362+#include <cstdint>
363 #include <sstream>
364 #include <stdexcept>
365-#include <variant>
366 
367 #include <bits/ranges_util.h>
368 #include <hyprland/src/Compositor.hpp>
369@@ -14,410 +14,358 @@
370 #include "Hy3Layout.hpp"
371 #include "Hy3Node.hpp"
372 #include "globals.hpp"
373-#include "src/managers/input/InputManager.hpp"
374 
375-const float MIN_RATIO = 0.0f;
376+using Desktop::View::CWindow;
377 
378-// Hy3GroupData //
379+const float MIN_RATIO = 0.0f;
380 
381-Hy3GroupData::Hy3GroupData(Hy3GroupLayout layout): layout(layout) {
382-	if (layout != Hy3GroupLayout::Tabbed) {
383+Hy3GroupNode::Hy3GroupNode(Hy3GroupLayout layout): layout(layout) {
384+	if (!isTab()) {
385 		this->previous_nontab_layout = layout;
386 	}
387 }
388 
389-Hy3GroupData::Hy3GroupData(Hy3GroupData&& from) {
390-	this->layout = from.layout;
391-	this->locked = from.locked;
392-	this->previous_nontab_layout = from.previous_nontab_layout;
393-	this->children = std::move(from.children);
394-	this->group_focused = from.group_focused;
395-	this->expand_focused = from.expand_focused;
396-	this->focused_child = from.focused_child;
397-	from.focused_child = nullptr;
398-	this->tab_bar = from.tab_bar;
399-	from.tab_bar = nullptr;
400+bool Hy3Node::is_root() { return is_group() && as_group().layout == Hy3GroupLayout::Root; }
401+bool Hy3Node::is_root_group() { return !is_root() && parent->is_root(); }
402+
403+Hy3RootNode::Hy3RootNode(Hy3Layout* layout)
404+    : Hy3GroupNode(Hy3GroupLayout::Root), algo(layout) {}
405+
406+Hy3RootNode* Hy3Node::root() {
407+	auto* node = this;
408+	while (!node->is_root() && node->parent.get() != nullptr) {
409+		node = node->parent.get();
410+	}
411+	return dynamic_cast<Hy3RootNode*>(node);
412+}
413+
414+Hy3Layout* Hy3Node::layout() {
415+	auto* r = root();
416+	return r ? r->algo : nullptr;
417 }
418 
419-Hy3GroupData::~Hy3GroupData() {
420-	if (this->tab_bar != nullptr) this->tab_bar->bar.beginDestroy();
421+void Hy3Node::assertNotRoot() {
422+	if (this->is_root()) {
423+		hy3_log(ERR, "assertNotRoot failed: node {:x} is root", (uintptr_t) this);
424+		throw std::runtime_error("operation called on root node");
425+	}
426 }
427 
428-bool Hy3GroupData::hasChild(Hy3Node* node) {
429-	for (auto child: this->children) {
430-		if (child == node) return true;
431+bool Hy3GroupNode::hasChild(Hy3Node& node) {
432+	for (auto& child: this->children) {
433+		if (child.get() == &node) return true;
434 
435-		if (child->data.is_group()) {
436-			if (child->data.as_group().hasChild(node)) return true;
437+		if (child->is_group()) {
438+			if (child->as_group().hasChild(node)) return true;
439 		}
440 	}
441 
442 	return false;
443 }
444 
445-void Hy3GroupData::collapseExpansions() {
446+auto Hy3GroupNode::findChild(Hy3Node& child) -> std::list<UP<Hy3Node>>::iterator {
447+	for (auto it = children.begin(); it != children.end(); ++it) {
448+		if (it->get() == &child) return it;
449+	}
450+	return children.end();
451+}
452+
453+void Hy3GroupNode::insertChild(std::list<UP<Hy3Node>>::iterator pos, UP<Hy3Node> child) {
454+	child->parent = this->self;
455+	if (focused_child == nullptr) focused_child = child.get();
456+	children.insert(pos, std::move(child));
457+	if (ephemeral == Ephemeral::Staged && children.size() >= 2)
458+		ephemeral = Ephemeral::Active;
459+}
460+
461diff --git a/src/Hy3Node.hpp b/src/Hy3Node.hpp
462index 876a235ebb95165cf5ba82269aca7b5771df8f5a..5a9bf518af5b3a56fb0632a88009b7a4526ef0c8 100644
463--- a/src/Hy3Node.hpp
464+++ b/src/Hy3Node.hpp
465@@ -1,25 +1,29 @@
466 #pragma once
467 
468 struct Hy3Node;
469-struct Hy3GroupData;
470+struct Hy3TargetNode;
471+struct Hy3GroupNode;
472+struct Hy3RootNode;
473 enum class Hy3GroupLayout;
474 
475-#include <variant>
476+#include <generator>
477 
478 #include <hyprland/src/defines.hpp>
479 #include <hyprland/src/desktop/view/Window.hpp>
480+#include <hyprland/src/layout/target/Target.hpp>
481 
482 #include "Hy3Layout.hpp"
483 #include "TabGroup.hpp"
484 
485 enum class Hy3GroupLayout {
486+	Root,
487 	SplitH,
488 	SplitV,
489 	Tabbed,
490 };
491 
492 enum class Hy3NodeType {
493-	Window,
494+	Target,
495 	Group,
496 };
497 
498@@ -29,88 +33,63 @@ enum class ExpandFocusType {
499 	Stack,
500 };
501 
502-struct Hy3GroupData {
503-	Hy3GroupLayout layout = Hy3GroupLayout::SplitH;
504-	Hy3GroupLayout previous_nontab_layout = Hy3GroupLayout::SplitH;
505-	std::list<Hy3Node*> children;
506-	bool group_focused = true;
507-	Hy3Node* focused_child = nullptr;
508-	ExpandFocusType expand_focused = ExpandFocusType::NotExpanded;
509-	bool ephemeral = false;
510-	bool locked = false;
511-	bool containment = false;
512-	Hy3TabGroup* tab_bar = nullptr;
513-
514-	Hy3GroupData(Hy3GroupLayout layout);
515-	~Hy3GroupData();
516-
517-	bool hasChild(Hy3Node* child);
518-	void collapseExpansions();
519-	void setLayout(Hy3GroupLayout layout);
520-	void setEphemeral(GroupEphemeralityOption ephemeral);
521-
522-	Hy3GroupData(Hy3GroupData&&);
523-	Hy3GroupData(const Hy3GroupData&) = delete;
524+enum class Ephemeral {
525+	Off,
526+	Staged,
527+	Active,
528+};
529 
530-	friend class Hy3NodeData;
531+enum class CollapsePolicy {
532+	InvalidOnly,
533+	EmptySplits,
534+	SingleNodeGroups,
535 };
536 
537-class Hy3NodeData {
538-public:
539-	Hy3NodeData() = default;
540-	Hy3NodeData(Hy3GroupData);
541-	Hy3NodeData(PHLWINDOW window);
542-	Hy3NodeData(Hy3GroupLayout layout);
543-	Hy3NodeData(Hy3NodeData&&);
544-	~Hy3NodeData() = default;
545+struct Hy3Node {
546+	WP<Hy3Node> parent;
547+	WP<Hy3Node> self; // set from owning UP at creation time
548+	CBox logicalBox;
549+	CBox visualBox;
550+	float size_ratio = 1.0;
551+	bool hidden = false;
552 
553-	Hy3NodeData& operator=(PHLWINDOW);
554-	Hy3NodeData& operator=(Hy3GroupLayout);
555-	Hy3NodeData& operator=(Hy3NodeData&&);
556+	virtual ~Hy3Node() = default;
557+	Hy3Node(const Hy3Node&) = delete;
558+	Hy3Node& operator=(const Hy3Node&) = delete;
559 
560-	bool operator==(const Hy3NodeData&) const;
561+	template<typename T> bool is() const { return dynamic_cast<const T*>(this) != nullptr; }
562+	template<typename T> T& as() { return dynamic_cast<T&>(*this); }
563+	template<typename T> const T& as() const { return dynamic_cast<const T&>(*this); }
564 
565diff --git a/src/TabGroup.cpp b/src/TabGroup.cpp
566index 9bf79212dba48a003ffcb3a2a3ff5fefcd30775d..e135c0f3c3d50a35f20734c940ecd20b9175d07e 100644
567--- a/src/TabGroup.cpp
568+++ b/src/TabGroup.cpp
569@@ -46,7 +46,7 @@ CHyprColor merge_colors(Args... colors) {
570 	return CHyprColor(Hyprgraphics::CColor(oklab), alpha);
571 }
572 
573-Hy3TabBarEntry::Hy3TabBarEntry(Hy3TabBar& tab_bar, Hy3Node& node): tab_bar(tab_bar), node(node) {
574+Hy3TabBarEntry::Hy3TabBarEntry(Hy3TabBar& tab_bar, Hy3Node& node): tab_bar(tab_bar), node(&node) {
575 	g_pAnimationManager->createAnimation(
576 	    0.0F,
577 	    this->active,
578@@ -122,7 +122,7 @@ Hy3TabBarEntry::Hy3TabBarEntry(Hy3TabBar& tab_bar, Hy3Node& node): tab_bar(tab_b
579 	*this->fade_opacity = 1.0;
580 }
581 
582-bool Hy3TabBarEntry::operator==(const Hy3Node& node) const { return this->node == node; }
583+bool Hy3TabBarEntry::operator==(const Hy3Node& node) const { return this->node == &node; }
584 
585 bool Hy3TabBarEntry::operator==(const Hy3TabBarEntry& entry) const {
586 	return this->node == entry.node;
587@@ -458,88 +458,78 @@ void Hy3TabBar::tick() {
588 	if (this->entries.empty()) this->destroy = true;
589 }
590 
591-void Hy3TabBar::updateNodeList(std::list<Hy3Node*>& nodes) {
592-	std::list<std::list<Hy3TabBarEntry>::iterator> removed_entries;
593+void Hy3TabBar::updateNodeList(std::list<UP<Hy3Node>>& nodes) {
594+	std::list<Hy3TabBarEntry> pool;
595+	pool.splice(pool.begin(), this->entries);
596 
597-	auto entry = this->entries.begin();
598-	auto node = nodes.begin();
599-
600-	// move any out of order entries to removed_entries
601-	while (node != nodes.end()) {
602-		while (true) {
603-			if (entry == this->entries.end()) goto exitloop;
604-			if (*entry == **node) break;
605-			removed_entries.push_back(entry);
606-			entry = std::next(entry);
607-		}
608+	for (auto node = nodes.begin(); node != nodes.end(); ++node) {
609+		auto match = std::find_if(pool.begin(), pool.end(), [&node](auto& entry) {
610+			return **node == entry;
611+		});
612 
613-		node = std::next(node);
614-		entry = std::next(entry);
615+		if (match != pool.end()) {
616+			match->node = node->get();
617+			this->entries.splice(this->entries.end(), pool, match);
618+		}
619 	}
620 
621-exitloop:
622+	// TODO: index match is wrong if a move results in the addition of a node and destruction of another in one op
623+	auto entry = this->entries.begin();
624+	int node_index = 0;
625+	for (auto node = nodes.begin(); node != nodes.end(); ++node, ++node_index) {
626+		if (entry != this->entries.end() && entry->node == node->get()) {
627+			++entry;
628+			continue;
629+		}
630 
631-	// move any extra entries to removed_entries
632-	while (entry != this->entries.end()) {
633-		removed_entries.push_back(entry);
634-		entry = std::next(entry);
635+		auto match = std::find_if(pool.begin(), pool.end(), [&node_index](auto& entry) {
636+			return entry.lastIndex == node_index;
637+		});
638+
639+		if (match != pool.end()) {
640+			match->node = node->get();
641+			this->entries.splice(entry, pool, match);
642+		}
643 	}
644 
645 	entry = this->entries.begin();
646-	node = nodes.begin();
647-
648-	// add missing entries, taking first from removed_entries
649-	while (node != nodes.end()) {
650-		if (entry == this->entries.end() || *entry != **node) {
651-			if (std::find(removed_entries.begin(), removed_entries.end(), entry) != removed_entries.end())
652-			{
653-				entry = std::next(entry);
654-				continue;
655-			}
656-
657-			auto moved =
658-			    std::find_if(removed_entries.begin(), removed_entries.end(), [&node](auto entry) {
659-				    return **node == *entry;
660-			    });
661-
662-			if (moved != removed_entries.end()) {
663-				this->entries.splice(entry, this->entries, *moved);
664-				entry = *moved;
665-				removed_entries.erase(moved);
666-			} else {
667-				entry = this->entries.emplace(entry, *this, **node);
668-			}
669diff --git a/src/TabGroup.hpp b/src/TabGroup.hpp
670index 216396a1dcb87e4890fd5f36809bbd1cb5330129..dfddfc1cc18cbf59b96e5fa1f73f22e47fdf8953 100644
671--- a/src/TabGroup.hpp
672+++ b/src/TabGroup.hpp
673@@ -12,6 +12,23 @@ class Hy3TabBar;
674 #include <hyprland/src/render/Renderer.hpp>
675 #include <hyprland/src/render/Texture.hpp>
676 
677+struct Hy3TabGroupWrapper {
678+	UP<Hy3TabGroup> inner;
679+
680+	Hy3TabGroupWrapper();
681+	Hy3TabGroupWrapper(UP<Hy3TabGroup> tg);
682+	Hy3TabGroupWrapper(Hy3TabGroupWrapper&&);
683+	Hy3TabGroupWrapper& operator=(Hy3TabGroupWrapper&&);
684+	~Hy3TabGroupWrapper();
685+
686+	void release();
687+	Hy3TabGroupWrapper& operator=(UP<Hy3TabGroup> tg);
688+
689+	Hy3TabGroup* operator->() { return inner.get(); }
690+	Hy3TabGroup* get() { return inner.get(); }
691+	operator bool() const { return inner.get() != nullptr; }
692+};
693+
694 #include "Hy3Node.hpp"
695 
696 struct Hy3TabBarEntry {
697@@ -27,7 +44,8 @@ struct Hy3TabBarEntry {
698 	PHLANIMVAR<float> vertical_pos; // 0.0-1.0, user specified direction
699 	PHLANIMVAR<float> fade_opacity; // 0.0-1.0
700 	Hy3TabBar& tab_bar;
701-	Hy3Node& node; // only used for comparison. do not deref.
702+	Hy3Node* node; // only used for comparison. do not deref.
703+	int lastIndex = -1;
704 
705 	struct {
706 		float scale = 0.0;
707@@ -88,14 +106,13 @@ public:
708 	void damageBox(const Vector2D* position, const Vector2D* size);
709 
710 	void tick();
711-	void updateNodeList(std::list<Hy3Node*>& nodes);
712+	void updateNodeList(std::list<UP<Hy3Node>>& nodes);
713 	void updateAnimations(bool warp = false);
714 	void setSize(Vector2D);
715 
716 	std::list<Hy3TabBarEntry> entries;
717 
718 private:
719-	Hy3Node* focused_node = nullptr;
720 	Vector2D size;
721 
722 	// Tab bar entries take a reference to `this`.
723@@ -125,6 +142,10 @@ public:
724 	Hy3TabBar bar;
725 	PHLANIMVAR<Vector2D> pos;
726 	PHLANIMVAR<Vector2D> size;
727+	WP<Hy3TabGroup> self;
728+
729+	// Factory: creates a tab group, sets self WP, registers in g_tabGroups.
730+	static UP<Hy3TabGroup> create(Hy3Node& node);
731 
732 	// initialize a group with the given node. UB if node is not a group.
733 	Hy3TabGroup(Hy3Node&);
734diff --git a/src/dispatchers.cpp b/src/dispatchers.cpp
735index eaa3642d8806e9ab52dc7064305716fdffe4738b..43d694a6f18910095136fe81223cff3aafdbc0e6 100644
736--- a/src/dispatchers.cpp
737+++ b/src/dispatchers.cpp
738@@ -9,9 +9,16 @@
739 #include "globals.hpp"
740 #include "src/SharedDefs.hpp"
741 
742+static Hy3Layout* hy3InstanceForAction(bool allow_fullscreen = false) {
743+	auto workspace = workspace_for_action(allow_fullscreen);
744+	if (!valid(workspace)) return nullptr;
745+	return hy3InstanceForWorkspace(workspace);
746+}
747+
748 static SDispatchResult dispatch_makegroup(std::string value) {
749-	auto workspace = workspace_for_action();
750-	if (!valid(workspace)) return SDispatchResult {};
751+	auto* hy3 = hy3InstanceForAction();
752+	if (!hy3) return SDispatchResult {};
753+	auto ws = hy3->workspace();
754 
755 	auto args = CVarList(value);
756 
757@@ -26,48 +33,49 @@ static SDispatchResult dispatch_makegroup(std::string value) {
758 	}
759 
760 	if (args[0] == "h") {
761-		g_Hy3Layout->makeGroupOnWorkspace(workspace.get(), Hy3GroupLayout::SplitH, ephemeral, toggle);
762+		hy3->makeGroupOnWorkspace(ws.get(), Hy3GroupLayout::SplitH, ephemeral, toggle);
763 	} else if (args[0] == "v") {
764-		g_Hy3Layout->makeGroupOnWorkspace(workspace.get(), Hy3GroupLayout::SplitV, ephemeral, toggle);
765+		hy3->makeGroupOnWorkspace(ws.get(), Hy3GroupLayout::SplitV, ephemeral, toggle);
766 	} else if (args[0] == "tab") {
767-		g_Hy3Layout->makeGroupOnWorkspace(workspace.get(), Hy3GroupLayout::Tabbed, ephemeral, toggle);
768+		hy3->makeGroupOnWorkspace(ws.get(), Hy3GroupLayout::Tabbed, ephemeral, toggle);
769 	} else if (args[0] == "opposite") {
770-		g_Hy3Layout->makeOppositeGroupOnWorkspace(workspace.get(), ephemeral);
771+		hy3->makeOppositeGroupOnWorkspace(ws.get(), ephemeral);
772 	}
773 	return SDispatchResult {};
774 }
775 
776 static SDispatchResult dispatch_changegroup(std::string value) {
777-	auto workspace = workspace_for_action();
778-	if (!valid(workspace)) return SDispatchResult {};
779+	auto* hy3 = hy3InstanceForAction();
780+	if (!hy3) return SDispatchResult {};
781+	auto ws = hy3->workspace();
782 
783 	auto args = CVarList(value);
784 
785 	if (args[0] == "h") {
786-		g_Hy3Layout->changeGroupOnWorkspace(workspace.get(), Hy3GroupLayout::SplitH);
787+		hy3->changeGroupOnWorkspace(ws.get(), Hy3GroupLayout::SplitH);
788 	} else if (args[0] == "v") {
789-		g_Hy3Layout->changeGroupOnWorkspace(workspace.get(), Hy3GroupLayout::SplitV);
790+		hy3->changeGroupOnWorkspace(ws.get(), Hy3GroupLayout::SplitV);
791 	} else if (args[0] == "tab") {
792-		g_Hy3Layout->changeGroupOnWorkspace(workspace.get(), Hy3GroupLayout::Tabbed);
793+		hy3->changeGroupOnWorkspace(ws.get(), Hy3GroupLayout::Tabbed);
794 	} else if (args[0] == "untab") {
795-		g_Hy3Layout->untabGroupOnWorkspace(workspace.get());
796+		hy3->untabGroupOnWorkspace(ws.get());
797 	} else if (args[0] == "toggletab") {
798-		g_Hy3Layout->toggleTabGroupOnWorkspace(workspace.get());
799+		hy3->toggleTabGroupOnWorkspace(ws.get());
800 	} else if (args[0] == "opposite") {
801-		g_Hy3Layout->changeGroupToOppositeOnWorkspace(workspace.get());
802+		hy3->changeGroupToOppositeOnWorkspace(ws.get());
803 	}
804 	return SDispatchResult {};
805 }
806 
807 static SDispatchResult dispatch_setephemeral(std::string value) {
808-	auto workspace = workspace_for_action();
809-	if (!valid(workspace)) return SDispatchResult {};
810+	auto* hy3 = hy3InstanceForAction();
811+	if (!hy3) return SDispatchResult {};
812 
813 	auto args = CVarList(value);
814 
815 	bool ephemeral = args[0] == "true";
816 
817-	g_Hy3Layout->changeGroupEphemeralityOnWorkspace(workspace.get(), ephemeral);
818+	hy3->changeGroupEphemeralityOnWorkspace(hy3->workspace().get(), ephemeral);
819 	return SDispatchResult {};
820 }
821 
822@@ -80,8 +88,8 @@ std::optional<ShiftDirection> parseShiftArg(std::string arg) {
823 }
824 
825 static SDispatchResult dispatch_movewindow(std::string value) {
826-	auto workspace = workspace_for_action();
827-	if (!valid(workspace)) return SDispatchResult {};
828+	auto* hy3 = hy3InstanceForAction();
829+	if (!hy3) return SDispatchResult {};
830 
831 	auto args = CVarList(value);
832 
833@@ -100,14 +108,15 @@ static SDispatchResult dispatch_movewindow(std::string value) {
834 			i++;
835 		}
836 
837-		g_Hy3Layout->shiftWindow(workspace.get(), shift.value(), once, visible);
838diff --git a/src/globals.hpp b/src/globals.hpp
839index a286a3d440aafebc0f90b2eb5a4c63ca62e7631e..54062a0d38fc3d2113e27fc19d72982d28955383 100644
840--- a/src/globals.hpp
841+++ b/src/globals.hpp
842@@ -1,16 +1,37 @@
843 #pragma once
844 
845+#include <set>
846 #include <type_traits>
847+#include <vector>
848 
849 #include <hyprland/src/desktop/Workspace.hpp>
850 #include <hyprland/src/plugins/PluginAPI.hpp>
851 #include <hyprlang.hpp>
852 
853 #include "Hy3Layout.hpp"
854+#include "TabGroup.hpp"
855 #include "log.hpp"
856 
857 inline HANDLE PHANDLE = nullptr;
858-inline std::unique_ptr<Hy3Layout> g_Hy3Layout;
859+
860+// Suppress algorithm callbacks (newTarget/movedTarget/removeTarget) during
861+// cross-workspace moves where we manually manage the tree.
862+inline bool g_suppressInsert = false;
863+
864+inline std::set<Hy3Layout*> g_hy3Instances;
865+
866+inline std::vector<WP<Hy3TabGroup>> g_tabGroups;
867+inline std::vector<UP<Hy3TabGroup>> g_destroyingTabGroups;
868+
869+inline CHyprSignalListener g_renderListener;
870+inline CHyprSignalListener g_tickListener;
871+inline CHyprSignalListener g_windowTitleListener;
872+inline CHyprSignalListener g_urgentListener;
873+
874+inline Hy3Layout* hy3InstanceForWorkspace(PHLWORKSPACE ws) {
875+	if (!ws || !ws->m_space || !ws->m_space->algorithm()) return nullptr;
876+	return dynamic_cast<Hy3Layout*>(ws->m_space->algorithm()->tiledAlgo().get());
877+}
878 
879 inline void errorNotif() {
880 	HyprlandAPI::addNotificationV2(
881diff --git a/src/main.cpp b/src/main.cpp
882index d935b2f416adce697bed7e80e01838943681f04a..50829b3408152724ee250d06a98f17f690a3dba5 100644
883--- a/src/main.cpp
884+++ b/src/main.cpp
885@@ -1,11 +1,14 @@
886 #include <hyprland/src/Compositor.hpp>
887 #include <hyprland/src/config/ConfigDataValues.hpp>
888 #include <hyprland/src/plugins/PluginAPI.hpp>
889+#include <hyprland/src/render/Renderer.hpp>
890+#include <hyprland/src/render/OpenGL.hpp>
891 #include <hyprland/src/version.h>
892 #include <hyprlang.hpp>
893 
894 #include "dispatchers.hpp"
895 #include "globals.hpp"
896+#include "TabGroup.hpp"
897 
898 APICALL EXPORT std::string PLUGIN_API_VERSION() { return HYPRLAND_API_VERSION; }
899 
900@@ -82,8 +85,66 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
901 
902 #undef CONF
903 
904-	g_Hy3Layout = std::make_unique<Hy3Layout>();
905-	HyprlandAPI::addLayout(PHANDLE, "hy3", g_Hy3Layout.get());
906+	HyprlandAPI::addTiledAlgo(PHANDLE, "hy3", &typeid(Hy3Layout), []() -> UP<Layout::ITiledAlgorithm> {
907+		return makeUnique<Hy3Layout>();
908+	});
909+
910+	g_renderListener = Event::bus()->m_events.render.stage.listen([](eRenderStage stage) {
911+		static bool rendering_normally = false;
912+		static std::vector<Hy3TabGroup*> rendered_groups;
913+
914+		switch (stage) {
915+		case RENDER_PRE_WINDOWS:
916+			rendering_normally = true;
917+			rendered_groups.clear();
918+			break;
919+		case RENDER_POST_WINDOW:
920+			if (!rendering_normally) break;
921+
922+			for (auto& wp: g_tabGroups) {
923+				auto* entry = wp.get();
924+				if (!entry) continue;
925+				if (!entry->hidden
926+				    && entry->target_window == g_pHyprOpenGL->m_renderData.currentWindow.lock()
927+				    && std::find(rendered_groups.begin(), rendered_groups.end(), entry)
928+				           == rendered_groups.end())
929+				{
930+					g_pHyprRenderer->m_renderPass.add(makeUnique<Hy3TabPassElement>(entry));
931+					rendered_groups.push_back(entry);
932+				}
933+			}
934+			break;
935+		case RENDER_POST_WINDOWS: rendering_normally = false; break;
936+		default: break;
937+		}
938+	});
939+
940+	g_tickListener = Event::bus()->m_events.tick.listen([]() {
941+		for (auto& wp: g_tabGroups) {
942+			if (auto* tg = wp.get()) tg->tick();
943+		}
944+		std::erase_if(g_destroyingTabGroups, [](auto& up) { return up->bar.destroy; });
945+		std::erase_if(g_tabGroups, [](auto& wp) { return !wp; });
946+	});
947+
948+	g_windowTitleListener = Event::bus()->m_events.window.title.listen([](PHLWINDOW window) {
949+		if (!window) return;
950+		auto* hy3 = hy3InstanceForWorkspace(window->m_workspace);
951+		if (!hy3) return;
952+		auto* node = hy3->getNodeFromWindow(window.get());
953+		if (!node) return;
954+		node->updateTabBarRecursive();
955+	});
956+
957+	g_urgentListener = Event::bus()->m_events.window.urgent.listen([](PHLWINDOW window) {
958+		if (!window) return;
959+		window->m_isUrgent = true;
960+		auto* hy3 = hy3InstanceForWorkspace(window->m_workspace);
961+		if (!hy3) return;
962+		auto* node = hy3->getNodeFromWindow(window.get());
963+		if (!node) return;
964+		node->updateTabBarRecursive();
965+	});
966 
967 	registerDispatchers();
968 
969@@ -92,4 +153,12 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
970 	return {"hy3", "i3 like layout for hyprland", "outfoxxed", "0.1"};
971 }
972 
973-APICALL EXPORT void PLUGIN_EXIT() {}
974+APICALL EXPORT void PLUGIN_EXIT() {
975+	g_renderListener.reset();
976+	g_tickListener.reset();
977+	g_windowTitleListener.reset();
978+	g_urgentListener.reset();
979+
980+	g_tabGroups.clear();
981+	g_destroyingTabGroups.clear();
982+}