Skip to content
chpockPublic

About

Tab-group workspaces for Hyprland: one tile visible, the rest behind it as tabs, with group-aware focus/move dispatchers.

Topics

Resources

Stars

12 stars

Watchers

0 watching

Forks

Latest commit

 

History

17 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hyprdeck

A small Lua library for Hyprland that folds standalone tiled windows into a workspace tab group: one tile is visible, the rest sit behind it as tabs. Think monocle layout, but with the tab strip on top and keybinds to flip between cards, reorder them, and throw them at other workspaces or monitors.

It is built on Hyprland's Lua group/fullscreen API from v0.56.0 and is loaded from your hyprland.lua like any other Lua plugin.

What it does

When loaded, hyprdeck keeps one invariant on every non-special workspace:

Every ordinary tiled window is reconciled into a tab group whenever native grouping policy permits. New windows normally join the focused group; any orphan left after mapping joins the leftmost eligible group.

Multiple groupbars are intentional. Existing groups — including one-window groups created by moving a tab past a group edge — are left intact rather than merged implicitly. If every existing group is locked or denied, an orphan bootstraps a new group instead of modifying one of those protected groups.

That happens automatically — you do not need to call anything. Hyprland's native group.auto_group path adds an ordinary new window to the group of the focused window on the same workspace. If mapping leaves a standalone tile, settled reconciliation uses the leftmost group whose exposed policy allows additions. Hidden workspaces use the same fallback without first becoming visible. An unclassified native rejection, including global group lock, is not bypassed with a forced singleton; that orphan remains untouched until a later settled batch can retry. This preserves an explicit lock instead of manufacturing a new group behind it. Floating and fullscreen windows are left alone, and a workspace that contains a fullscreen window is "frozen" — hyprdeck won't touch its groups until the window leaves fullscreen. A grouped window keeps its membership and tab position while fullscreen; a standalone fullscreen window is considered for grouping only after it returns to tiled state.

Automatic reconciliation and fullscreen close-cascade repair intentionally skip special workspaces. This ownership boundary does not disable explicit hyd.dsp.* commands: the same focus and move bindings continue to work with a special source or destination according to their normal contracts, so users do not need separate native hotkeys for scratchpads. Hyprland's required global group.auto_group setting can also group eligible windows on an active special workspace independently of hyprdeck's automatic reconciliation.

Production events are reconciled after their compositor transaction settles, not from inside the event callback. This matters when a fullscreen window is closing: it prevents an already-closing standalone window from being inserted into a group and becoming an artificial nextInGroup fullscreen successor.

On top of that, hyprdeck ships replacements for Hyprland's focus and window.move dispatchers. They behave like the originals everywhere except in three cases that are otherwise painful to script:

  • Group-aware horizontal focus. Inside a multi-tab group, left/right cycle through tabs; at the edge they fall through to spatial focus (so you cross over to the neighbouring monitor as usual).
  • Group-aware horizontal move. Inside a group, left/right reorder the current tab. At the edge they attempt a native directional merge when a tiled neighbour exists on the same workspace. A rejected target leaves source membership unchanged. If no such neighbour exists, the active member of an unlocked multi-member group is split into a focused one-window group; a sole member stays unchanged. A locally locked source always stays unchanged, even if binds.ignore_group_lock would let the native dispatcher bypass it.
  • Detach-before-move. Moving a window to another workspace or monitor directly removes only that member from its group before the destination move, so the receiving workspace doesn't end up with a foreign tab welded to its deck. This explicit cross-target operation bypasses group locks; a failed or unverifiable removal aborts the destination move.

Requirements

  • Hyprland >= v0.56.0 (stable window selectors and group/fullscreen API).

Installation

Clone the repo somewhere your Lua loader can see it. The simplest spot is your Hyprland config directory:

git clone https://github.com/chpock/hyprdeck ~/.config/hypr/hyprdeck

Then in ~/.config/hypr/hyprland.lua, require it once:

local hyd = require("hyprdeck")

That's the whole installation. require runs the module's initialisation, which seeds defaults, subscribes the event handlers, and returns the public API table. There is no separate "enable" step.

Installing to a custom location (advanced)

The default install above works because Hyprland's Lua loader looks for modules under ~/.config/hypr/ — so a clone at ~/.config/hypr/hyprdeck is found by require("hyprdeck") automatically. If you'd rather keep the checkout somewhere else (a shared ~/src, a Nix store path, etc.), you have two equivalent options.

Option 1: symlink. Make the custom location reachable from the default place. Nothing in hyprland.lua has to change:

git clone https://github.com/chpock/hyprdeck /path/to/hyprdeck
ln -s /path/to/hyprdeck ~/.config/hypr/hyprdeck

Option 2: extend package.path. Tell Lua where to look, then require as usual. The line must come before the require("hyprdeck") call. Two patterns work:

-- Point at the PARENT directory of the checkout. `?` is substituted with
-- the module name ("hyprdeck"), so Lua resolves it to
-- /path/to/hyprdeck/init.lua.
package.path = "/path/to/?/init.lua;" .. package.path

-- Or point straight at the lua/ subdirectory inside the checkout. This
-- skips the top-level init.lua shim and loads lua/hyprdeck.lua directly.
package.path = "/path/to/hyprdeck/lua/?.lua;" .. package.path

local hyd = require("hyprdeck")

A common pitfall: writing "/path/to/hyprdeck/?/init.lua" instead of "/path/to/?/init.lua". The ? expands to hyprdeck, so the first form looks for /path/to/hyprdeck/hyprdeck/init.lua and fails with module 'hyprdeck' not found.

Configuration

hyprdeck options

hyprdeck has sensible defaults; calling setup is only needed if you want to override them. It is idempotent, safe to call multiple times, and never produces duplicate event handlers:

local hyd = require("hyprdeck").setup({
  log_level = "info",  -- "error" | "warning" | "info" | "debug" | "trace"
})
Option Type Default Description
log_level string "info" Verbosity of Hyprland-log output (see below).

errors also surface as a 10-second red Hyprland notification, so a misconfigured binding is hard to miss. Setting log_level = "trace" additionally subscribes a set of read-only event handlers that log window/workspace transitions — useful when reporting a bug, noisy otherwise. Each trace window snapshot includes immutable identity, internal and client fullscreen modes, handler routing, pin/fullscreen conversion state, and Hyprland's raw allowed_over_fullscreen override flag. That flag is not the full effective visibility decision: the fullscreen owner, pinned windows, and members of the owner's group are admitted through separate native conditions.

Debugging

hyprdeck writes through Hyprland's Lua logger. Follow Lua log messages in real time with:

script -qfec 'hyprctl rollinglog -f' /dev/null | grep --line-buffered -iF '[Lua]'

Hyprland settings

hyprdeck only works well when a couple of Hyprland options are set the way it expects. The group.auto_group setting in particular is effectively required — without it, every new window would have to be merged by hyprdeck after the fact, and you'd see a visible flicker.

The following block is the recommended baseline. Drop it into your hl.config({ ... }):

hl.config({
  -- OPTIONAL. Hyprdeck works with any tiled layout. Uncomment this block if
  -- you want one groupbar to occupy the master area while additional
  -- groupbars form the slave stack. Each group is one layout target, so
  -- switching tabs or focusing another group does not automatically promote
  -- that group to the master area; Hyprland's master-layout policy controls
  -- which target is master.
  -- general = {
  --   layout = "master",
  -- },

  group = {
    -- REQUIRED. New tiled windows automatically merge into the focused
    -- group on the same workspace. Without this, hyprdeck would have to
    -- merge every window after window.open and you'd see a flicker as the
    -- layout settles.
    auto_group = true,

    -- REQUIRED for a clean directional edge split. Native out_of_group keeps
    -- focus on the source group while hyprdeck wraps the extracted window in
    -- its final singleton, then hyprdeck focuses that singleton explicitly.
    -- Workspace/monitor moves use direct Group:remove and do not depend on
    -- this setting.
    focus_removed_window = false,

    -- Recommended, but not required (and true by default in Hyprland).
    -- With true, a newly opened window or a window moved into an existing
    -- group is inserted immediately after that group's current tab. This
    -- keeps related arrivals close to the tab you were using. With false,
    -- the same window is appended at the end of the group instead. It still
    -- joins the correct group and becomes its current/focused member; only
    -- its position in the tab strip changes. Both values produce the same
    -- result when the target is a one-window group, because "after current"
    -- and "at the end" are then the same insertion position.
    insert_after_current = true,

    -- Recommended, but not required (false by default in Hyprland).
    -- With true, a workspace/monitor move immediately joins the moved window
    -- to the destination's group when that workspace has exactly one visible
    -- grouped tile and the group accepts it. This avoids leaving a loose tile
    -- on a hidden destination, where no activation event would immediately
    -- request reconciliation. With false, the move still succeeds: hyprdeck
    -- bootstraps a one-window group on an empty destination, while a window
    -- moved to a non-empty destination remains standalone until a later
    -- subscribed event (such as workspace activation, window open, or config
    -- refresh) reconciles it into the leftmost eligible group. When the
    -- destination already has multiple groupbars, Hyprland cannot choose one
    -- for this option, so neither value causes an immediate native merge;
    -- later hyprdeck reconciliation still uses the leftmost eligible fallback.
    group_on_movetoworkspace = true,

    -- The tab strip is the surface you're actually looking at, so it's
    -- worth styling. The block below is the author's setup — feel free
    -- to swap colours and sizes; hyprdeck doesn't require any of it.
    groupbar = {
      enabled            = true,
      -- Recommended, but not required (false by default in Hyprland). Keeping
      -- this false makes intentional one-window decks visible and avoids a
      -- content resize when a second tab appears. See the comparison below.
      disable_when_only  = false,
      render_titles      = true,
      scrolling          = true,
      middle_click_close = true,

      height       = 22,
      font_size    = 12,
      font_family  = "Sans",
      text_padding = 0,

      -- Render full per-tab backgrounds instead of just a thin line.
      gradients        = true,
      indicator_height = 0,

      col = {
        active   = "rgba(33ccff30)",
        inactive = "rgba(2a2a2a55)",
      },

      text_color          = "rgba(ffffffdd)",
      text_color_inactive = "rgba(b0b0b0ff)",

      -- Round each tab individually rather than only the outer edges
      -- of the whole bar.
      gradient_rounding         = 8,
      gradient_round_only_edges = false,

      -- Visual separator between tabs (substitutes for a per-tab border).
      gaps_in        = 8,
      gaps_out       = 5,
      keep_upper_gap = false,
    },
  },

  misc = {
    -- Recommended. This prevents native fullscreen retention for ordinary
    -- standalone successors. Hyprland still transfers fullscreen to a
    -- grouped nextInGroup regardless of this option; after the close event
    -- settles, hyprdeck identifies that grouped inheritance and explicitly
    -- unsets it without removing or re-inserting any group member.
    exit_window_retains_fullscreen = false,
  },
})

Singleton groupbar visibility

group.groupbar.disable_when_only changes only whether Hyprland draws and reserves space for a groupbar whose native group has one member. It does not dissolve that group. With either value, an intentional singleton created by a directional edge split remains distinct from a standalone orphan, survives later reconciliation, and can accept another member.

The recommended value is false:

  • one-window decks remain visibly identifiable as groups, including the focused singleton produced by a directional edge split;
  • groupbar titles, lock feedback, and mouse/drag interaction remain available;
  • the reserved groupbar height stays stable when membership changes between one and two windows, avoiding an extra application content resize;
  • the groupbar still consumes vertical space when there is only one window and no tab switching is currently possible.

Set it to true if reclaiming that space and reducing one-window chrome matters more than keeping singleton groups visible:

  • a one-member group gives its groupbar area back to application content;
  • the bar appears automatically when a second member joins and disappears again when the group returns to one member;
  • each 1 -> 2 or 2 -> 1 transition changes the reserved decoration extent and therefore resizes the application content area;
  • an intentional singleton becomes visually indistinguishable from a standalone window even though hyprdeck still preserves its native group membership;
  • while hidden, that singleton has no groupbar surface for title/lock feedback, clicking, or dragging.

Keybinding examples

The dispatcher constructors return closures that you hand straight to hl.bind:

local hyd = require("hyprdeck")
local mod = "SUPER"

-- Group-aware focus. Horizontal cycles tabs inside a group, then falls
-- through to spatial focus. Vertical is always spatial.
hl.bind(mod .. " + left",  hyd.dsp.focus({ direction = "left"  }))
hl.bind(mod .. " + right", hyd.dsp.focus({ direction = "right" }))
hl.bind(mod .. " + up",    hyd.dsp.focus({ direction = "up"    }))
hl.bind(mod .. " + down",  hyd.dsp.focus({ direction = "down"  }))

-- Group-aware move. Reorders inside the group; at the edge merges with
-- the neighbour group or ejects.
hl.bind(mod .. " + SHIFT + left",  hyd.dsp.window.move({ direction = "left"  }))
hl.bind(mod .. " + SHIFT + right", hyd.dsp.window.move({ direction = "right" }))
hl.bind(mod .. " + SHIFT + up",    hyd.dsp.window.move({ direction = "up"    }))
hl.bind(mod .. " + SHIFT + down",  hyd.dsp.window.move({ direction = "down"  }))

-- Switch workspaces, and send the current window to another workspace.
-- The move variant ejects from the current group first.
for i = 1, 10 do
  local key = i % 10 -- 10 maps to key 0
  hl.bind(mod .. " + " .. key,         hyd.dsp.focus({ workspace = i }))
  hl.bind(mod .. " + SHIFT + " .. key, hyd.dsp.window.move({ workspace = i, follow = true }))
end

-- Send the current window to the next/previous monitor. Ejects first.
hl.bind(mod .. " + CONTROL + up",   hyd.dsp.window.move({ monitor = "+1" }))
hl.bind(mod .. " + CONTROL + down", hyd.dsp.window.move({ monitor = "-1" }))

Optional touchpad gestures

Hyprland v0.56 accepts a Lua function as a gesture action. Hyprdeck's dispatcher constructors already return callable closures, so they can be passed directly to hl.gesture without a wrapper or a separate gesture subsystem:

local hyd = require("hyprdeck")

hl.gesture({
  fingers = 4,
  direction = "left",
  action = hyd.dsp.focus({ direction = "left" }),
})

hl.gesture({
  fingers = 4,
  direction = "right",
  action = hyd.dsp.focus({ direction = "right" }),
})

The function action runs once when the gesture ends. It uses exactly the same group-aware focus behavior as the keyboard bindings: a horizontal swipe selects the adjacent tab inside a group, then falls through to spatial focus at the group edge. It can therefore focus another group or monitor instead of wrapping around the current groupbar. The example maps physical left/right motion to the same focus direction; swap the two action directions if content-style movement feels more natural.

Choose the finger count and optional mods for your own configuration. Hyprland matches the finger count and exact modifier mask, then resolves overlapping directions in registration order. Specific left and right gestures with the same fingers/modifiers can coexist. An earlier broad horizontal or swipe gesture (commonly used for workspace switching) can shadow a later specific gesture; registering the specific gesture first gives it precedence over a later broad one. Avoid relying on order when possible: remove the overlap or use a different finger count or modifier.

hl.gesture({
  fingers = 3,
  direction = "left",
  mods = "SUPER",
  action = hyd.dsp.focus({ direction = "left" }),
})

The simple function form receives no cancellation metadata and is invoked even for a cancelled ending. If cancellation matters, the v0.56 runtime also accepts a lifecycle callback table; its finish callback receives the end event:

local focus_left = hyd.dsp.focus({ direction = "left" })

hl.gesture({
  fingers = 4,
  direction = "left",
  action = {
    finish = function(event)
      if not event.cancelled then
        focus_left()
      end
    end,
  },
})

The v0.56 Lua stub declares action as string|function even though the runtime accepts { start?, update?, finish? }, so LuaLS may warn about this advanced form.

Dispatcher reference

All dispatchers live under hyd.dsp. They validate their arguments at bind time; an invalid argument is logged as an error and replaced with a no-op so Hyprland's bind call doesn't crash.

hyd.dsp.focus(args)

Drop-in replacement for hl.dsp.focus(args). Use it everywhere you would use hl.dsp.focus — including bindings that have nothing to do with groups — so that the group-aware behaviour kicks in whenever it applies and you don't have to remember which dispatcher goes where.

Two shapes:

  • { direction = "left" | "right" | "up" | "down" } — group-aware focus. direction must be the only key. Inside a multi-member group, horizontal moves to the previous/next tab; at the edge of the group (or in vertical) it delegates to Hyprland's spatial focus.
  • Anything else — passthrough to hl.dsp.focus(args). If args.workspace is set and hyprsplit is loaded, the workspace selector is translated through hyprsplit before dispatch. This means { workspace = 3 } resolves to the third workspace on the current monitor, not workspace id 3 globally.

hyd.dsp.window.move(args)

Unlike hyd.dsp.focus, this is not a full replacement for hl.dsp.window.move. It implements three specific shapes — and only these three — chosen for moving windows in and out of tab groups. For any other move dispatch (e.g. active, cursor, tag = ...) keep using hl.dsp.window.move directly.

Exactly one of direction / workspace / monitor is required.

  • { direction = "left" | "right" | "up" | "down" } — group-aware move. Within the group, reorders by one slot. At the edge, attempts a native directional merge when a tiled neighbour exists on the same workspace. A locally locked source is always a no-op, regardless of binds.ignore_group_lock. If native policy rejects the target, source membership stays unchanged (a previously standalone target may already have become a singleton during native target resolution). With no neighbour, the active member of a multi-member group is ejected directionally and immediately wrapped in a focused singleton group, so the split survives later reconciliation and can accept newly opened windows. A sole member stays unchanged.
  • { workspace = sel, follow? = bool } — directly remove the active member from its group, then hl.dsp.window.move it to sel. sel is run through hyprsplit translation when hyprsplit is loaded. This explicit cross-target detach ignores local/global group locks and does not emit Hyprland's moveoutofgroup IPC event. If removal cannot be confirmed, the destination move is aborted.
  • { monitor = sel, follow? = bool } — apply the same validated direct detach, then hl.dsp.window.move to sel.

follow controls whether focus follows the moved window. When omitted, Hyprland's default applies.

hyprsplit compatibility

hyprdeck transparently cooperates with hyprsplit (per-monitor workspace sets). When hyprsplit is loaded, any workspace selector passed to hyd.dsp.focus or hyd.dsp.window.move is run through hs.get_workspace_string first, so the same { workspace = 3 } binding means "the third workspace on this monitor" instead of "workspace id 3".

Detection is automatic and survives module-name renames (the lookup is duck-typed against package.loaded). If hyprsplit is not loaded the selectors are passed through unchanged, so you do not need to gate anything in your config.

There is no required load order — require("hyprsplit") and require("hyprdeck") may appear in either order.

Related / alternative projects

  • hy3 — i3/sway-style tree tiling for Hyprland. Different model: explicit horizontal/vertical splits and tabbed/stacked containers as first-class layouts, implemented as a native C++ plugin.
  • hyprland-go monocle script — external Go program that keeps one window visible at a time by manipulating Hyprland over IPC.
  • hyprland-go — Go bindings for Hyprland's IPC, useful if you'd rather script this behaviour from outside the compositor.

Source, bugs, feature requests

The project lives at https://github.com/chpock/hyprdeck.

When filing a bug, please attach the Hyprland log captured with log_level = "trace" — it makes event ordering visible and is usually the difference between a reproducible report and guesswork.

License

BSD 3-Clause. See LICENSE.

About

Tab-group workspaces for Hyprland: one tile visible, the rest behind it as tabs, with group-aware focus/move dispatchers.

Topics

Resources

Stars

12 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages