From 5a6fe00c9bd717f424cc05f167a6313de6d666ed Mon Sep 17 00:00:00 2001 From: WayfinderAK Date: Mon, 16 Mar 2026 15:25:26 -0800 Subject: [PATCH] Initial commit --- .gitignore | 1 + LICENSE | 21 +++++ Makefile | 23 ++++++ README.md | 49 ++++++++++++ src/main.cpp | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 314 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d6d24ca --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +libhyprcolumns.so diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd1353d --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +PLUGIN_NAME := libhyprcolumns.so +PREFIX ?= $(HOME)/.local +LIBDIR := $(PREFIX)/lib/hyprland +CXX ?= c++ +CXXFLAGS ?= -std=gnu++23 -O2 -fPIC -Wall -Wextra -Wpedantic +PKG_CFLAGS := $(shell pkg-config --cflags hyprland) +PKG_LIBS := $(shell pkg-config --libs hyprland) + +SRC := src/main.cpp + +all: $(PLUGIN_NAME) + +$(PLUGIN_NAME): $(SRC) + $(CXX) $(CXXFLAGS) $(PKG_CFLAGS) -shared -o $@ $(SRC) $(PKG_LIBS) + +install: $(PLUGIN_NAME) + mkdir -p $(LIBDIR) + install -m755 $(PLUGIN_NAME) $(LIBDIR)/$(PLUGIN_NAME) + +clean: + rm -f $(PLUGIN_NAME) + +.PHONY: all install clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5634fa --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# hyprland-columns-plugin + +A custom Hyprland tiled layout plugin that arranges all tiled windows as equal-width vertical columns so every window remains visible on screen. + +## Layout name + +`columns` + +## Build + +```bash +cd ~/src/hyprland-columns-plugin +make +make install +``` + +This installs the plugin to: + +```bash +~/.local/lib/hyprland/libhyprcolumns.so +``` + +## Load now + +```bash +hyprctl plugin load ~/.local/lib/hyprland/libhyprcolumns.so +``` + +## Use it on the current workspace + +```bash +hyprctl keyword workspace "$(hyprctl activeworkspace -j | jq -r '.id'), layout:columns" +``` + +## Persist across logins + +Load it during Hyprland startup, e.g. from `~/.config/hypr/autostart.conf`: + +```conf +exec-once = hyprctl plugin load /home/wayfinderak/.local/lib/hyprland/libhyprcolumns.so +``` + +## Behavior + +- All tiled windows are shown at once. +- Each tiled window gets an equal-width column. +- Columns fill the workspace height. +- Left/right move and swap operations work naturally for the column order. +- Interactive resize is intentionally ignored to preserve equal columns. diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..3747248 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,220 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +static HANDLE g_pluginHandle = nullptr; + +namespace Layout::Tiled { + class CColumnsAlgorithm final : public ITiledAlgorithm { + public: + void newTarget(SP target) override { + addTarget(std::move(target)); + recalculate(); + } + + void movedTarget(SP target, std::optional /*focalPoint*/ = std::nullopt) override { + addTarget(std::move(target)); + recalculate(); + } + + void removeTarget(SP target) override { + cleanup(); + std::erase_if(m_targets, [&](const WP& weak) { + const auto locked = weak.lock(); + return !locked || locked == target; + }); + recalculate(); + } + + void resizeTarget(const Vector2D& /*delta*/, SP /*target*/, eRectCorner /*corner*/ = CORNER_NONE) override { + // Intentionally ignored: this layout always keeps equal-width columns. + } + + void recalculate() override { + cleanup(); + + const auto parent = m_parent.lock(); + if (!parent) + return; + + const auto space = parent->space(); + if (!space) + return; + + std::vector> tiledTargets; + tiledTargets.reserve(m_targets.size()); + + for (auto const& weak : m_targets) { + const auto target = weak.lock(); + if (target) + tiledTargets.push_back(target); + } + + if (tiledTargets.empty()) + return; + + const auto area = space->workArea(false); + const auto count = tiledTargets.size(); + + for (size_t i = 0; i < count; ++i) { + const double left = area.x + (area.w * static_cast(i) / static_cast(count)); + const double right = area.x + (area.w * static_cast(i + 1) / static_cast(count)); + const CBox box(left, area.y, right - left, area.h); + + tiledTargets[i]->setPositionGlobal(box); + tiledTargets[i]->recalc(); + } + } + + SP getNextCandidate(SP old) override { + cleanup(); + auto targets = liveTargets(); + + if (targets.empty()) + return nullptr; + + if (!old) + return targets.front(); + + const auto it = std::ranges::find(targets, old); + if (it == targets.end()) + return targets.front(); + + const auto idx = static_cast(std::distance(targets.begin(), it)); + return targets[(idx + 1) % targets.size()]; + } + + std::expected layoutMsg(const std::string_view& sv) override { + if (sv == "togglesplit" || sv == "swapsplit") + return {}; + + if (sv == "recalculate") { + recalculate(); + return {}; + } + + return std::unexpected(std::string{"columns: unsupported layout message: "} + std::string{sv}); + } + + std::optional predictSizeForNewTarget() override { + const auto parent = m_parent.lock(); + if (!parent) + return std::nullopt; + + const auto space = parent->space(); + if (!space) + return std::nullopt; + + const auto area = space->workArea(false); + const auto n = std::max(1, liveTargets().size() + 1); + return Vector2D(area.w / static_cast(n), area.h); + } + + void swapTargets(SP a, SP b) override { + cleanup(); + + auto ia = indexOf(a); + auto ib = indexOf(b); + if (!ia.has_value() || !ib.has_value() || ia == ib) + return; + + std::swap(m_targets[*ia], m_targets[*ib]); + recalculate(); + } + + void moveTargetInDirection(SP t, Math::eDirection dir, bool /*silent*/) override { + cleanup(); + + const auto idx = indexOf(t); + if (!idx.has_value()) + return; + + if (dir == Math::DIRECTION_LEFT && *idx > 0) { + std::swap(m_targets[*idx], m_targets[*idx - 1]); + recalculate(); + return; + } + + if (dir == Math::DIRECTION_RIGHT && *idx + 1 < m_targets.size()) { + std::swap(m_targets[*idx], m_targets[*idx + 1]); + recalculate(); + return; + } + } + + private: + std::vector> m_targets; + + void addTarget(SP target) { + cleanup(); + + const auto exists = std::ranges::any_of(m_targets, [&](const WP& weak) { + const auto locked = weak.lock(); + return locked && locked == target; + }); + + if (!exists) + m_targets.emplace_back(target); + } + + void cleanup() { + std::erase_if(m_targets, [](const WP& weak) { return weak.expired(); }); + } + + std::vector> liveTargets() const { + std::vector> out; + out.reserve(m_targets.size()); + + for (auto const& weak : m_targets) { + const auto locked = weak.lock(); + if (locked) + out.push_back(locked); + } + + return out; + } + + std::optional indexOf(SP target) const { + for (size_t i = 0; i < m_targets.size(); ++i) { + const auto locked = m_targets[i].lock(); + if (locked && locked == target) + return i; + } + + return std::nullopt; + } + }; +} + +APICALL EXPORT std::string PLUGIN_API_VERSION() { + return HYPRLAND_API_VERSION; +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + g_pluginHandle = handle; + + const bool ok = HyprlandAPI::addTiledAlgo(handle, "columns", &typeid(Layout::Tiled::CColumnsAlgorithm), []() -> UP { + return makeUnique(); + }); + + if (!ok) + HyprlandAPI::addNotification(handle, "hyprcolumns: failed to register columns layout", CHyprColor{1.0, 0.2, 0.2, 1.0}, 5000); + else + HyprlandAPI::addNotification(handle, "hyprcolumns: registered columns layout", CHyprColor{0.2, 1.0, 0.6, 1.0}, 3000); + + return {.name = "hyprcolumns", .description = "Equal-width tiled columns layout", .author = "OpenAI", .version = "0.1.0"}; +} + +APICALL EXPORT void PLUGIN_EXIT() { + if (g_pluginHandle) + HyprlandAPI::removeAlgo(g_pluginHandle, "columns"); +}