Last active
March 17, 2023 19:16
-
-
Save mfischr/cbec9a32b32bd9e93b0d2696c71b5f03 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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