Skip to content

Instantly share code, notes, and snippets.

@Atrate
Last active March 22, 2025 13:01
Show Gist options
  • Save Atrate/b08c5b67172abafa5e7286f4a952ca4d to your computer and use it in GitHub Desktop.
Save Atrate/b08c5b67172abafa5e7286f4a952ca4d to your computer and use it in GitHub Desktop.
i3-like tabs for Hyprland. Usage: save the script as `~/.config/hypr/hyprtabs.sh` or somewhere else and add the following to your `hyprland.conf`, changing the keybind or path as you see fit: `bind = $mainMod SHIFT, w, exec, ~/.config/hypr/hyprtabs.sh`
#!/bin/bash --posix
# ------------------------------------------------------------------------------
# Copyright (C) 2024 Atrate
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# ------------------------------------------------------------------------------
# ------------------------------------------------------------------------------
# Script to simulate the way i3 creates tabs, but in Hyprland!
# ---
# Version: 0.5.0
# ---
# Known issues:
# - Hyprland crashes when using this script with too many windows. Sounds like
# a Hyprland issue, but if that gets too annoying for me I'll find some
# workaround for this script.
# - Grouping does not work if the layout is too "deep".
# ------------------------------------------------------------------------------
# Set POSIX-compliant mode for security and unset possible overrides
# NOTE: This does not mean that we are restricted to POSIX-only constructs
# ------------------------------------------------------------------------
POSIXLY_CORRECT=1
set -o posix
readonly POSIXLY_CORRECT
export POSIXLY_CORRECT
# Set IFS explicitly. POSIX does not enforce whether IFS should be inherited
# from the environment, so it's safer to set it expliticly
# --------------------------------------------------------------------------
IFS=$' \t\n'
export IFS
# Set up fd 3 for discarding output, necessary for set -r
# -------------------------------------------------------
exec 3>/dev/null
# ------------------------------------------------------------------------------
# Options description:
# -o pipefail: exit on error in any part of pipeline
# -eE: exit on any error, go through error handler
# -u: exit on accessing uninitialized variable
# -r: set bash restricted mode for security
# The restricted mode option necessitates the usage of tee
# instead of simple output redirection when writing to files
# ------------------------------------------------------------------------------
set -o pipefail -eEur
# Speed up script by not using unicode
# ------------------------------------
LC_ALL=C
LANG=C
# Check whether to group or ungroup windows
# -----------------------------------------
if hyprctl -j activewindow | jq -cr '.grouped' | grep -vFq '['
then
# --------------------------------------------------------------------------
# Ungroup current window group
# --------------------------------------------------------------------------
hyprctl dispatch togglegroup
else
# --------------------------------------------------------------------------
# Group all windows on focused workspace
# --------------------------------------------------------------------------
# Save original window's address
# ------------------------------
ORIGWINDOW="$(hyprctl -j activewindow | jq -cr '.address')"
# Get current workspace's windows' addresses
# ------------------------------------------
WINDOWS="$(hyprctl -j clients | jq -cr ".[] | select(.workspace.id == $(hyprctl activeworkspace -j | jq -cr '.id')) | .address")"
# If there's just one window, just group it normally for better UX
# ----------------------------------------------------------------
if [ "$(echo "$WINDOWS" | wc -l)" -eq 1 ]
then
hyprctl dispatch togglegroup
else
# Move to each window and try to group it every which way
# -------------------------------------------------------
window_args=""
for window in $WINDOWS
do
window_args="$window_args dispatch focuswindow address:$window; dispatch moveintogroup l; dispatch moveintogroup r; dispatch moveintogroup u; dispatch moveintogroup d;"
done
# Group the first window
# ----------------------
batch_args="dispatch togglegroup;"
# Group all other windows twice (once isn't enough in case of very
# "deep" layouts". This ugly workaround could be fixed if hyprland
# allowed moving into groups based on addresses and not positions
# ----------------------------------------------------------------
batch_args="$batch_args $window_args $window_args"
# Also focus the original window at the very end
# ----------------------------------------------
batch_args="$batch_args dispatch focuswindow address:$ORIGWINDOW"
# Execute the grouping using hyprctl --batch for performance
# ----------------------------------------------------------
hyprctl --batch "$batch_args"
fi
fi
@0x00Jeff
Copy link

0x00Jeff commented Dec 6, 2024

how to cycle between tabs with the keyboard after executing this?

@Atrate
Copy link
Author

Atrate commented Dec 6, 2024

I use Alt+Tab and Alt+Shift+Tab with those bindings @0x00Jeff

bind = $mainMod, Tab, changegroupactive, f
bind = $mainMod SHIFT, Tab, changegroupactive, b

@duyquang6
Copy link

duyquang6 commented Mar 22, 2025

Hi, nice script, I re-write it to Python script for non bash shell experience ones, also tweak a little bit and support dry-run for easy debug

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import subprocess
import json
import argparse
parser = argparse.ArgumentParser()
import traceback


EXCLUDE_TITLE = "Picture-in-Picture"

def shell_exec(cmd, dry_run = False):
    if dry_run:
        print(cmd)
        return 
    subprocess.call(cmd, shell=True)

if __name__ == "__main__":
    parser.add_argument("--enable-notify")
    parser.add_argument("--dry-run")
    args = vars(parser.parse_args())
    for k in list(args.keys()):
        if args[k] is None:
            args.pop(k)

    enable_notify = args.get('enable_notify', 'false').lower() == 'true'
    dry_run = args.get('dry_run', 'false').lower() == 'true'

    print('Run with mode: ', args)
    try:
        active_window = json.loads(subprocess.check_output("hyprctl -j activewindow", shell=True))
        if len(active_window['grouped']) > 0:
            shell_exec("hyprctl dispatch togglegroup", dry_run)
        else:
            active_window_address = active_window['address']
            active_space_id = active_window['workspace']['id']
            windows = json.loads(subprocess.check_output(["hyprctl" ,"-j", "clients"]))
            window_on_active_space = [w for w in windows if w['workspace']['id'] == active_space_id]
            should_group_windows = [w for w in window_on_active_space if EXCLUDE_TITLE not in w['title']]
            should_group_windows.sort(key=lambda w: (w['at'][0], w['at'][1]))

            if len(should_group_windows) == 1:
                shell_exec("hyprctl dispatch togglegroup", dry_run)
            else:
                first_window = should_group_windows.pop(0)
                window_args = f'dispatch focuswindow address:{first_window['address']}; dispatch togglegroup; '

                for w in should_group_windows:
                    window_args += f'dispatch focuswindow address:{w['address']}; '
                    for d in ['l','r','u','d']:
                        window_args += f'dispatch moveintogroup {d}; '

                batch_args = f'{window_args} dispatch focuswindow address:{active_window_address}'
                cmd = f'hyprctl --batch "{batch_args}"'
                shell_exec(cmd, dry_run)
    except Exception as e:
        if enable_notify:
            # FIXME: Change to your notification
            subprocess.call(
                "notify-send -a toggle-tab.py 'something wrong, please check log' ",
                shell=True,
            )
        print(traceback.format_exc())
    

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment