diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3819313 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.swp +*.swo diff --git a/layout_manager.sh b/layout_manager.sh new file mode 100755 index 0000000..211bd37 --- /dev/null +++ b/layout_manager.sh @@ -0,0 +1,288 @@ +#!/bin/bash +# Author: klaxalk (klaxalk@gmail.com, github.com/klaxalk) +# Dependencies: +# - vim/nvim : scriptable file editing +# - jq : json manipulation +# - rofi : nice dmenu alternative +# - xdotool : window manipulation +# - xrandr : getting info of current monitor +# - i3-msg : i3 tui +# - awk+sed+cat ... + +LAYOUT_PATH=~/.layouts + +# make directory for storing layouts +mkdir -p $LAYOUT_PATH > /dev/null 2>&1 + +# logs +LOG_FILE=/tmp/i3_layout_manager.txt + +# if operating using dmenu +if [ -z $1 ]; then + + ACTION=$(echo "LOAD LAYOUT +SAVE LAYOUT +DELETE LAYOUT" | rofi -i -dmenu -no-custom -p "Select action") + + if [ -z "$ACTION" ]; then + exit + fi + + # get me layout names based on existing file names in the LAYOUT_PATH + LAYOUT_NAMES=$(ls -a $LAYOUT_PATH | grep "layout.*json" | sed -nr 's/layout-(.*)\.json/\1/p' | sed 's/\s/\n/g') # layout names + LAYOUT_NAME=$(echo "$LAYOUT_NAMES" | rofi -dmenu -p "Select layout (you may type new name when creating)") # ask for selection + LAYOUT_NAME=${LAYOUT_NAME^^} # upper case + +# getting argument from command line +else + + ACTION="LOAD LAYOUT" + LAYOUT_NAME="${1^^}" + +fi + +# no action, exit +if [ -z "$LAYOUT_NAME" ]; then + exit +fi + +LAYOUT_FILE=$LAYOUT_PATH/layout-"$LAYOUT_NAME".json + +# get current workspace ID +WORKSPACE_ID=$(i3-msg -t get_workspaces | jq '.[] | select(.focused==true).num' | cut -d"\"" -f2) + +if [[ "$ACTION" = "LOAD LAYOUT" ]]; then + + # updating the workspace to the new layout is tricky + # normally it does not influence existing windows + # For it to apply to existing windows, we need to + # first remove them from the workspace and then + # add them back while we remove any empty placeholders + # which would normally cause mess. The placeholders + # are recognize by having no process inside them. + + # get the list of windows on the current workspace + WINDOWS=$(xdotool search --all --onlyvisible --desktop $(xprop -notype -root _NET_CURRENT_DESKTOP | cut -c 24-) "" 2>/dev/null) + + for window in $WINDOWS; do + + HAS_PID=$(xdotool getwindowpid $window 2>&1 | grep "pid" | wc -l) + + if [ ! $HAS_PID -eq 0 ]; then + echo "$window does not have a process" + else + xdotool windowunmap $window + fi + + done + + # delete all empty layout windows from the workspace + for (( i=0 ; $a-20 ; a=$a+1 )); do + i3-msg "focus parent, kill" > $LOG_FILE 2>&1 + done + + # then we can apply to chosen layout + i3-msg "append_layout $LAYOUT_FILE" > $LOG_FILE 2>&1 + + # and then we can reintroduce the windows back to the workspace + for window in $WINDOWS; do + HAS_PID=$(xdotool getwindowpid $window 2>&1 | grep "pid" | wc -l) + + if [ ! $HAS_PID -eq 0 ]; then + echo "$window does not have a process" + else + xdotool windowmap $window + fi + done + +fi + +if [[ "$ACTION" = "SAVE LAYOUT" ]]; then + + ACTION=$(echo "DEFAULT (INSTANCE) +SPECIFIC (CHOOSE) +MATCH ANY" | rofi -i -dmenu -p "How to identify windows? (xprop style)") + + + if [[ "$ACTION" = "DEFAULT (INSTANCE)" ]]; then + CRITERION="default" + elif [[ "$ACTION" = "SPECIFIC (CHOOSE)" ]]; then + CRITERION="specific" + elif [[ "$ACTION" = "MATCH ANY" ]]; then + CRITERION="any" + fi + + ALL_WS_FILE=$LAYOUT_PATH/all-layouts.json + + CURRENT_MONITOR=$(xrandr | grep -w connected | awk '{print $1}') + + # get the i3-tree for all workspaces for the current monitor + i3-save-tree --output "$CURRENT_MONITOR" > "$ALL_WS_FILE" 2>&1 + + # get the i3-tree for the current workspace + i3-save-tree --workspace "$WORKSPACE_ID" > "$LAYOUT_FILE" 2>&1 + + # back the output file.. we are gonna modify it and alter we will need it back + BACKUP_FILE=$LAYOUT_PATH/.layout_backup.txt + cp $LAYOUT_FILE $BACKUP_FILE + + # get me vim, we will be using it alot to postprocess the generated json files + if [ -x "$(whereis nvim | awk '{print $2}')" ]; then + VIM_BIN="$(whereis nvim | awk '{print $2}')" + HEADLESS="--headless" + elif [ -x "$(whereis vim | awk '{print $2}')" ]; then + VIM_BIN="$(whereis vim | awk '{print $2}')" + HEADLESS="" + fi + + # the allaround task is to produce a single json file with the description + # of the current layout on the focused workspace. However, the + # i3-save-tree --workspace + # command only outputs the inner containers, without wrapping them into the + # root container of the workspace, which leads to loosing the information + # about the initial split .. vertical? or horizontal?... + # We can solve it by asking for a tree, which contains all workspaces, + # including the root splits and borrowing the root split info from there. + # I do it by locating the right place in the all-tree by mathing the + # workspace tree and then extracting the split part and adding it back + # to the workspace json. + + # first we need to do some preprocessing, before we can find, where in the + # all-tree file we can find the workspace part. + + # remove comments + $VIM_BIN $HEADLESS -nEs -c '%g/\/\//norm dd' -c "wqa" -- "$LAYOUT_FILE" + $VIM_BIN $HEADLESS -nEs -c '%g/\/\//norm dd' -c "wqa" -- "$ALL_WS_FILE" + + # remove indents + $VIM_BIN $HEADLESS -nEs -c '%g/^/norm 0d^' -c "wqa" -- "$LAYOUT_FILE" + $VIM_BIN $HEADLESS -nEs -c '%g/^/norm 0d^' -c "wqa" -- "$ALL_WS_FILE" + + # remove commas + $VIM_BIN $HEADLESS -nEs -c '%s/^},$/}/g' -c "wqa" -- "$LAYOUT_FILE" + $VIM_BIN $HEADLESS -nEs -c '%s/^},$/}/g' -c "wqa" -- "$ALL_WS_FILE" + + # remove empty lines in the the workspace file + $VIM_BIN $HEADLESS -nEs -c '%g/^$/norm dd' -c "wqa" -- "$LAYOUT_FILE" + + # now I will try to find the part in the big file which containts the + # small file. I have not found a suitable solution using off-the-shelf + # tools, so custom bash it is... + + MATCH=0 + PATTERN_LINES=`cat $LAYOUT_FILE | wc -l` # get me the number of lines in the small file + SOURCE_LINES=`cat $ALL_WS_FILE | wc -l` # get me the number of lines in the big file + + N_ITER=$(expr $SOURCE_LINES - $PATTERN_LINES) + readarray pattern < $LAYOUT_FILE + + MATCH_LINE=0 + for (( a=1 ; $a-$N_ITER ; a=$a+1 )); do + + CURR_LINE=0 + MATCHED_LINES=0 + while read -r line1; do + + PATTERN_LINE=$(echo ${pattern[$CURR_LINE]} | tr -d '\n') + + if [[ "$line1" == "$PATTERN_LINE" ]]; then + MATCHED_LINES=$(expr $MATCHED_LINES + 1) + else + break + fi + + CURR_LINE=$(expr $CURR_LINE + 1) + done <<< $(cat "$ALL_WS_FILE" | tail -n +"$a") + + if [[ "$MATCHED_LINES" == "$PATTERN_LINES" ]]; + then + MATCH_LINE="$a" + break + fi + done + + # lets extract the key part, containing the block with the root split + + # load old workspace file (we destroyed the old one, remember?) + mv $BACKUP_FILE $LAYOUT_FILE + + # delete the part below and above the block + $VIM_BIN $HEADLESS -nEs -c "normal ${MATCH_LINE}ggdGG{kdgg" -c "wqa" -- "$ALL_WS_FILE" + # rename the "workspace to "con" (container) + $VIM_BIN $HEADLESS -nEs -c '%g/type/norm ^Wlciwcon' -c "wqa" -- "$ALL_WS_FILE" + # change the fullscrean to 0 + $VIM_BIN $HEADLESS -nEs -c '%g/fullscreen/norm ^Wr0' -c "wqa" -- "$ALL_WS_FILE" + + # extract the needed part of the file and add it to the workspace file + # this part is mostly according to the i3 manual, except we actually put there + # the information about the split type + cat $ALL_WS_FILE | cat - $LAYOUT_FILE > /tmp/tmp.txt && mv /tmp/tmp.txt $LAYOUT_FILE + # add closing bracked at the end + $VIM_BIN $HEADLESS -nEs -c "normal Go] }" -c "wqa" -- "$LAYOUT_FILE" + + # now we have to do some postprocessing on it, all is even advices on the official website + # https://i3wm.org/docs/layout-saving.html + + # uncomment the instance swallow rule + if [[ "$CRITERION" = "default" ]]; then + $VIM_BIN $HEADLESS -nEs -c "%g/instance/norm ^dW" -c "wqa" -- "$LAYOUT_FILE" + elif [[ "$CRITERION" = "any" ]]; then + echo any + $VIM_BIN $HEADLESS -nEs -c '%g/instance/norm ^dW3f"di"' -c "wqa" -- "$LAYOUT_FILE" + elif [[ "$CRITERION" = "specific" ]]; then + + LAST_LINE=1 + + while true; do + + LINE_NUM=$(cat $LAYOUT_FILE | tail -n +$LAST_LINE | grep '// "class' -n | awk '{print $1}') + HAS_INSTANCE=$(echo $LINE_NUM | wc -l) + + if [ ! -z "$LINE_NUM" ]; then + + LINE_NUM=$(echo $LINE_NUM | awk '{print $1}') + LINE_NUM=${LINE_NUM%:} + LINE_NUM=$(expr $LINE_NUM - 1) + LINE_NUM=$(expr $LINE_NUM + $LAST_LINE ) + + NAME=$(cat $LAYOUT_FILE | sed -n "$(expr ${LINE_NUM} - 4)p" | awk '{$1="";print $0}') + + SELECTED_OPTION=$(cat -n $LAYOUT_FILE | sed -n "${LINE_NUM},$(expr $LINE_NUM + 2)p" | awk '{$2="";print $0}' | rofi -i -dmenu -no-custom -p "Choose the matching method for${NAME%,}" | awk '{print $1}') + + # when user does not select, choose "instance" (class+1) + if [ -z "$SELECTED_OPTION" ]; then + SELECTED_OPTION=$(expr ${LINE_NUM} + 1) + fi + + $VIM_BIN $HEADLESS -nEs -c "norm ${SELECTED_OPTION}gg^dW" -c "wqa" -- "$LAYOUT_FILE" + + LAST_LINE=$( expr $SELECTED_OPTION) + + else + break + fi + + done + fi + + # uncomment the transient_for + $VIM_BIN $HEADLESS -nEs -c '%g/transient_for/norm ^dW' -c "wqa" -- "$LAYOUT_FILE" + + # delete all comments + $VIM_BIN $HEADLESS -nEs -c '%g/\/\//norm dd' -c "wqa" -- "$LAYOUT_FILE" + # add a missing comma to the last element of array we just deleted + $VIM_BIN $HEADLESS -nEs -c '%g/swallows/norm j^%k:s/,$//g ' -c "wqa" -- "$LAYOUT_FILE" + # delete all empty lines + $VIM_BIN $HEADLESS -nEs -c '%g/^$/norm dd' -c "wqa" -- "$LAYOUT_FILE" + # add missing commas between the newly created inner parts of the root element + $VIM_BIN $HEADLESS -nEs -c '%s/}\n{/}, {/g' -c "wqa" -- "$LAYOUT_FILE" + # autoformat the file + $VIM_BIN $HEADLESS -nEs -c 'normal gg=G' -c "wqa" -- "$LAYOUT_FILE" + + notify-send -u low -t 2000 "Layout saved" -h string:x-canonical-private-synchronous:anything + +fi + +if [[ "$ACTION" = "DELETE LAYOUT" ]]; then + rm "$LAYOUT_FILE" +fi