Skip to content

Instantly share code, notes, and snippets.

@mfischr
Last active March 17, 2023 19:16
Show Gist options
  • Save mfischr/cbec9a32b32bd9e93b0d2696c71b5f03 to your computer and use it in GitHub Desktop.
Save mfischr/cbec9a32b32bd9e93b0d2696c71b5f03 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
set -e
# OVERVIEW
#
# This script is a sort of framework you can use to build a muscle-memory-friendly window
# switcher. It's designed so that the code in the Main section, which controls the apps you
# want to use and how, is easy to modify.
# HOW TO USE
#
# If you have just a handful of apps that you use most frequently (terminal, editor, browser, etc.),
# then you can "pin" these app categories to keyboard shortcuts so they're immediately accessible.
#
# For example, you can bind Win+1 to your terminal. If the terminal is open, great, we'll switch
# to it. If it's not open, we'll launch it. If you have multiple terminals open, we'll switch
# to the most recently used one, then cycle through them in a deterministic order.
#
# You can achieve this by binding this script to OS-wide keyboard shortcuts, so Win+1 runs superwin.bash 1,
# Win+2 runs superwin.bash+2, Win+~ runs superwin.bash 0, etc.
#
# (this was inspired by Windows, where Win+1 will launch or switch to the first app on the taskbar,
# Win+2 the second, etc. Achieving the same thing consistently on Linux is hard, so the result was
# this script)
# IMPLEMENTATION NOTES
#
# You really want this script to execute in around 50ms, so I assume it's desirable to minimize
# the number of non-built-ins called.
#
# In general, use wmctrl to list out window ID, class, and title. Use xprop to get the list of windows in
# z-order, including the active window. Decimal IDs are used everywhere because hex IDs are sometimes
# padded with different numbers of zeros, so they're harder to compare. xdotool is used occasionally
# to simplify some logic.
#
# Settings
# These were easy to extract into variables
#
# Regex that matches all window classes that are managed by the script. For Win+0
MANAGED_WINDOW_CLASSES_REGEX="(terminator\.Terminator|typora\.Typora|code\.Code|gedit\.Gedit|Navigator\.firefox)"
# Regex that gets matched against date "+%u.%H" to determine whether we're within working hours.
WORKING_HOURS_REGEX="[1-5]\.(0[89]|1[0-7])"
# Git repos that are most frequently used during work hours. If you only use one of these,
# leave the other one set to the empty string.
VSCODE_REPO1_ROOT=~/src/github.com/contoso/project
VSCODE_REPO2_ROOT=~/src/github.com/contoso/research
#
# Utility functions
# You shouldn't have to modify these, unless you want to alter the precise window-switching behavior
#
launch() {
# How to start a process and completely detach it from the shell -- no output, doesn't close when the parent shell closes
# https://superuser.com/a/172476/541855
setsid "$@" & > /dev/null 2>&1
}
log() {
# uncomment this to actually log stuff
# echo "$@" >> ~/bin/superwin.log
a=1 # dummy line so we don't get an error about an empty function if everything is commented out
}
is_work_hours() {
[[ $(date "+%u.%H") =~ $WORKING_HOURS_REGEX ]]
}
# Returns the index of needle (first argument) inside haystack (remaining arguments).
find_in_array() {
local needle="$1"
shift
local haystack=("$@")
local i
for (( i = 0; i < ${#haystack[*]}; i++ )); do
if [[ "${haystack[$i]}" == "$needle" ]] ; then
echo $i
return
fi
done
echo "-1"
}
join_by() { local d=$1; shift; echo -n "$1"; shift; printf "%s" "${@/#/$d}"; }
# Returns decimal IDs of windows that match the given regex. The regex will be tested against the
# entire line of output from wmctrl -lx, which includes both the window class and the title.
#
# The output is sorted by the order in which the windows were originally opened. It doesn't change
# as the active window changes.
#
# To invert the match, pass -v as the first argument.
grep_windows() {
local invert="$1"
local class
if [[ $invert == -v ]]; then
shift
class="$1"
else
class="$invert"
invert=""
fi
local windows=
mapfile -t windows < <( wmctrl -lx )
local result=()
for (( i = 0; i < ${#windows[*]}; i++ )); do
log "${windows[$i]}"
if ! [[ ${windows[$i]} =~ ^[^[:space:]]+[[:space:]]+0 ]]; then
# echo "not this desktop"
continue
fi
if [[ -n $invert ]]; then
if [[ ${windows[$i]} =~ $class ]]; then
continue
fi
else
if ! [[ ${windows[$i]} =~ $class ]]; then
continue
fi
fi
id=$(printf "%d" ${windows[$i]:0:10})
log " - $id"
result+=($id)
done
echo "${result[@]}"
}
# Given a list of windows, either reactivate the most recently used among them (if not currently
# focused on one of them), or switch to the next one (if currently focused on one).
# Returns true if the list is non-empty.
reactivate_or_cycle_between() {
local among=("$@")
if [[ -z $among ]]; then
false
return
fi
local stack_order_str=$(xprop -root _NET_CLIENT_LIST_STACKING)
stack_order_str=${stack_order_str:47}
local stack_order=(${stack_order_str//, / })
local len=${#stack_order[*]}
log "among: len=${#among[@]} [[${among[@]}]]"
log "stack order: len=$len [[${stack_order_str[@]}]]"
# stack_order is from back to front, so the last item in the list is the active window.
local active_window=$(printf "%d" ${stack_order[(( $len - 1 ))]})
log "active is $active_window"
local focus_index=$(find_in_array $active_window "${among[@]}")
if [[ $focus_index -ge 0 ]]; then
# The active window is one of the windows we should be switching between
# Move to the next one, in order
focus_index=$(( ($focus_index + 1) % ${#among[@]} ))
wmctrl -ia ${among[$focus_index]}
true
return
fi
# The active window is outside the class, so go back to the most recent window that's
# inside the class
for (( i = ${#stack_order[@]} - 1; i >= 0; i-- )); do
local id=$(printf "%d" ${stack_order[$i]})
if [[ $(find_in_array $id "${among[@]}") -ge 0 ]]; then
wmctrl -ia $id
break
fi
done
true
}
#
# Main
# Change this to make it your own
#
if [[ -z ${1+x} ]]; then
echo >&2 "Usage: superwin.bash <N>"
echo >&2 " N: class of window to launch or switch to (see script)"
exit 1
fi
N=$1
if [[ $N == 0 ]]; then
# Cycle through all the non-managed windows
win=($(grep_windows -v "$MANAGED_WINDOW_CLASSES_REGEX"))
reactivate_or_cycle_between "${win[@]}"
elif [[ $N == 1 ]]; then
# If terminator isn't running, start it. Otherwise, switch to it.
win=($(grep_windows terminator))
if ! reactivate_or_cycle_between "${win[@]}"; then
launch terminator
fi
elif [[ $N == 2 ]]; then
# Ed. note: This command's behavior was too specific to generalize into a utility function, so I left
# it here as an example, and because it's what I've been using and tuning for the last couple years.
#
# If repo1 isn't launched, launch it. If repo2 isn't launched, launch it.
# If either repo1 or repo2 is focused, activate the other one.
# If another code window is open, include that in the cycle as well.
mapfile -t windows < <( wmctrl -lx )
active=$(xdotool getactivewindow)
repo1_id=-1
repo2_id=-1
focus_id=-1
code_windows=()
for (( i = 0; i < ${#windows[*]}; i++ )); do
log "${windows[$i]}"
if ! [[ ${windows[$i]} =~ code\.Code ]]; then
continue
fi
id=$(printf "%d" ${windows[$i]:0:10})
# Match the repo directory in the window's title
if [[ -n $VSCODE_REPO1_ROOT && ${windows[$i]} =~ "- $(basename $VSCODE_REPO1_ROOT) -" ]]; then
repo1_id=$id
elif [[ -n $VSCODE_REPO2_ROOT && ${windows[$i]} =~ "- $(basename $VSCODE_REPO2_ROOT) -" ]]; then
repo2_id=$id
fi
if [[ ${id} =~ ^${active} ]]; then
focus_id=$id
fi
code_windows+=($id)
done
log "active $active focus $focus_id repo1 $repo1_id repo2 $repo2_id "
# Make sure these two windows are open during work hours
if is_work_hours; then
if [[ $focus_id -ge 0 ]]; then
if [[ -n $VSCODE_REPO1_ROOT && $repo1_id -lt 0 ]]; then
launch /usr/share/code/code $VSCODE_REPO1_ROOT
exit 0
elif [[ -n $VSCODE_REPO2_ROOT && $repo2_id -lt 0 && $focus_id == $repo1_id ]]; then
launch /usr/share/code/code $VSCODE_REPO2_ROOT
exit 0
fi
fi
fi
reactivate_or_cycle_between "${code_windows[@]}"
elif [[ $N == 3 ]]; then
win=($(grep_windows Navigator\.firefox))
if ! reactivate_or_cycle_between "${win[@]}"; then
first_page=
# Open all work pages and use the work profile if we're during work hours
if is_work_hours; then
first_page=https://contoso.slack.com
other_pages=(
https://stackoverflow.com
https://chat.openai.com
https://news.ycombinator.com
)
ff_profile="-P work"
launch firefox $ff_profile $first_page
else
launch firefox
fi
# Give the browser some time to start up
sleep 3
for o in "${other_pages[@]}"; do
launch firefox $ff_profile $o
sleep .2
done
fi
elif [[ $N == 4 ]]; then
# no need to launch it if it's not there already
reactivate_or_cycle_between $(grep_windows gedit)
elif [[ $N == 5 ]]; then
if ! reactivate_or_cycle_between $(grep_windows typora); then
launch typora ~/src/github.com/cbd32/
fi
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment