Skip to content

Instantly share code, notes, and snippets.

@rifazn
Last active September 19, 2021 14:24
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rifazn/584a94d6f79e13b320180e7c9ec81eea to your computer and use it in GitHub Desktop.
Save rifazn/584a94d6f79e13b320180e7c9ec81eea to your computer and use it in GitHub Desktop.
A shell script to toggle between light and dark variants of a GTK theme.
#!/bin/sh
# A small POSIX compliant script to toggle between dark and light variant
# of a theme for GNOME based desktops.
# Copyright (C) 2021 Rifaz Nahiyan
# This code is licensed under the MIT License.
# View the license in its entirety at: https://opensource.org/licenses/MIT
get_current_theme () {
gsettings get org.gnome.desktop.interface gtk-theme | tr --delete \'
}
set_theme () {
gsettings set org.gnome.desktop.interface gtk-theme "$1"
# Unfortunately, gsettings always reports exit status 0
}
## Sanity checks
deps_check () {
deps="gsettings notify-send"
missing=""
for dep in ${deps}; do
command -v "${dep}" >/dev/null || missing="${missing} ${dep}"
done
if [ -n "${missing}" ]
then
die "Missing necessary dependencies: ${missing}"
fi
unset missing
}
# Display a formatted message, then exit with error
die () {
printf "%s: %s\n" "${0##*/}" "${*}" >&2
exit 1
}
# Get the script's basename and trim the '.sh' (if any) at the end
SCRIPTNAME="${0##*/}"
SCRIPTNAME="${SCRIPTNAME%.sh}"
main () {
# Check if necessary and optional dependecies are there, else exit
deps_check
current_theme="$(get_current_theme)"
# Check if the theme name has "dark" or "Dark" at the end of its name,
# then set new theme accordingly
case $current_theme in
*-[dD]ark)
new_theme="${current_theme%-[Dd]ark}"
;;
*)
# Extra check for Arc theme as it breaks convention by using captial 'D'
if [ "$current_theme" = "Arc" ]; then
new_theme="$current_theme"-Dark
else
new_theme="$current_theme"-dark
fi
DARK="dark"
;;
esac
set_theme "$new_theme"
notify_msg="Theme switched to ${DARK:-light} variant."
notify-send -t 5000 "${SCRIPTNAME}" "$notify_msg"
}
main "${@}"
@rbreaves
Copy link

rbreaves commented Jul 3, 2021

What's the license of this script? If I want to include it with a GPLv2 or MIT licensed project w/ attribution would that be ok? Wonderful idea btw, I have a project where I could see adding this to the system tray or setting up a schedule to change the theme based on time of day.

@rifazn
Copy link
Author

rifazn commented Jul 4, 2021

If I want to include it with a GPLv2 or MIT licensed project w/ attribution would that be ok?

Yes!

I've made a minor update to the code along with adding a comment for including the MIT License.

Very glad this could be of use to you, and thank you for bringing this into attention!

@newnix
Copy link

newnix commented Jul 5, 2021

Howdy! Couple of notes:

  1. Nice script! Looks like it should be helpful for many people.
  2. You don't need the additional branches on L31, these can simply be part of your cases
  3. While it's not really necessary for such a short script, I recommend wrapping lines 19-42 in a function (typically main, but the shell really doesn't care
  4. If you do the above, ensure main is called with main "${@}" as the last line
  5. If you do all that, it becomes easy to integrate command-line flags and providing a usage() function
  6. Line 19 doesn't really serve a purpose, you can have this assignment done inside get_current_theme() and simply ensure it's called prior to your case statement
  7. While gsettings may not report an error, if you want, you can make another call to get_current_theme() and verify that "${1}" = "${current_theme}" prior to exiting
  8. Also you seem to have an extraneous # in your documentation comments in line 4
  9. Finally: put this in a repo! Gists are great for sharing various things, but useful tools like this belong in a repo that can grow as you have a need to make more tools. They also make sharing a bit easier as we can then git clone instead of having to fetch data from a gist

@rifazn
Copy link
Author

rifazn commented Jul 7, 2021

@newnix Excellent feedback! Thanks!

  1. You don't need the additional branches on L31, these can simply be part of your cases

I kept it like that to make it obvious that the 'default' branch is for code that handles when current theme is light variant. I think having more than one case statement for handling light variant of theme makes it a little less obvious.

  1. While it's not really necessary for such a short script, I recommend wrapping lines 19-42 in a function (typically main, but the shell really doesn't care

Done! And I do have an idea for a few options so I will add a usage function too but it's not implemented yet.

  1. Line 19 doesn't really serve a purpose, you can have this assignment done inside get_current_theme() and simply ensure it's called prior to your case statement

I think having the assignment improves the readability of the code a little.

  1. While gsettings may not report an error, if you want, you can make another call to get_current_theme() and verify that "${1}" = "${current_theme}" prior to exiting

This does seem like an excellent workaround but still has problems (due to gsettings). Suppose, prior to executing this script, we change the theme to something invalid (dummy-theme in this case) by: gsettings set org.gnome.desktop.interface gtk-theme dummy-theme. gsettings won't complain on this but visually nothing will have changed. Next, when we run dark_toggle the script will just query for current theme using gsettings, which will be an invalid name, then change it to dummy-theme-dark which is still invalid. As far as the workaround goes, it will report a successful theme change still, but visually nothing will have changed really.

  1. Also you seem to have an extraneous # in your documentation comments in line 4

Corrected. I ran !fmt on those two lines so that's how that happened.

I do plan to put this in a utils repo of mine along with some other tools there. And honestly it seemed like too small a script to be appreciated as a repo. I plan to add an option to set a user defined dark theme and an option to suppress notifications. Would you suggest this being a repo in that case?


Again, thank you so very much for the excellent feedback! And also do let me know if you think of a more effective workaround for verifying the theme change!

@newnix
Copy link

newnix commented Jul 8, 2021

I'm not familiar with gsettings or how the theme data is stored, but if there's a world-readable path to check for the availability of a dark variant, which could be used as a sanity-check before giving gsettings a potentially nonexistent theme to use.

I'm also not familiar with notify-send, but depending on the information it reports, it may be beneficial to do something like this:

## Get the script's basename without spawning a subshell
MYNAME="${0##*/}"
MYNAME="${MYNAME%.sh}"
...
## If switched to dark mode, do 'DARK="dark"' for the below expansion to work correctly
## Use parameter expansion rules to only ever assign to notify_msg once (not significant here)
notify_msg="${MYNAME}: Switched to ${DARK:-light} theme"

though if notify-send already gets the process name, then the MYNAME variable only becomes helpful for the usage() function when you want to add functionality.

It looks good! Since nothing can signal failure, attempting to handle invalid switches is harder and depends on information I don't have available off-hand. Everything else is just some stylistic decisions at this scale. Some of these choices might not be as desirable in larger scripts or those with more complex variable usage, but here it's mostly just decisions to be aware of and consider as you write future scripts.

@newnix
Copy link

newnix commented Jul 8, 2021

Oh, just thought of something that might be good to add:

sanity_check() {
  okmap=0
  gsettings --help 2<&- >/dev/null || okmap=$(( okmap + 1 ))
  notify-send --help 2<&- >/dev/null || okmap=$(( okmap + 2 ))
  case "${okmap}" in
    0) die "Need both 'notify-send' and 'gsettings' to work!" ;;
    1) die "Need the 'notify-send' utility!" ;;
    2) die "Need the 'gsettings' utility!" ;;
    3) : ;; ## All necessary external tools available, nothing to report
  esac
  ## Not needed here, but a useful practice to be familiar with as not all sh(1) implementations have local variables
  unset okmap
}

## Display a formatted message, then exit with error
die() {
  printf "%s: %s\n" "${0##*/}" "${*}"
  exit 1
}

@rifazn
Copy link
Author

rifazn commented Jul 8, 2021

Excellent idea about initializing notify_msg just once! Will add that!

Do you have the redirection symbols inverted in sanity_check()? I think they should be 2>&-...

That's a nice way to do dependency check, but on this repo for example (and a few others) on this line, I've seen this form of checking dependency: command -v "$dep" 1>/dev/null || missing="$missing $dep". Is the shell built-in command a reliable method for this purpose while keeping POSIX compatibility in mind? Which method would you prefer?

Edit: Another thing I forgot to add. gsettings --help exits with a status of 1. 🤷

@newnix
Copy link

newnix commented Jul 8, 2021

2<&- closes stderr, >&- closes stdout.

Constructing a list like missing="${missing} ${dep}" definitely makes for a more flexible sanity check and message system. Both work just fine at this scale.

Since gsettings --help has an exit code of 1, something like this could be used instead:

sanity_check() {
  deps="gsettings notify-send"
  missing=""
  for dep in ${deps}
  do
    ${dep} --help 2<&- >/dev/null
    case $? in
      127) missing="${missing} ${dep}" ;;
      *) : ;; ## command exists, but returned an error code for some reason
    esac
    if [ -n "${missing}" ]
    then
      die "Missing necessary dependencies: ${missing}"
    fi
  unset missing
}

I prefer this method of probing for external commands because it's faster than manually scanning the contents of ${PATH} and I'm not sure how reliable the command builtin is. I'll have to pull up the POSIX sh(1) spec to be sure it's supposed to be available everywhere.

@rifazn
Copy link
Author

rifazn commented Jul 10, 2021

Hi @newnix!

It just occurred to me that not all programs include the --help flag. Some examples that come to mind are, dash, imv and gammastep. And executing --help might take some noticeable amount of time if the --help is particularly long. What might we do in this case?

Also, will it be better if the script dies with a relevant exit code? For example, in case of a dependency problem, instead of exiting with 1, how about exiting with 127? In that case die might be changed to

die () {
    printf "%s: %s\n" "${0##*/}" "${*}" >&2
    exit $2
}

Edit: Come to think of it... The default case in sanity_check() does handle that situation pretty well. But still, please let me know if that the intended behavior.

@newnix
Copy link

newnix commented Jul 10, 2021

Unfortunately, there's no universal way to verify if a command is usable without actually trying to invoke it (for example, docker may be installed, but if you're not in the docker group, then commands like docker info will return a nonzero status other than 127), so it really comes down to how much you want your script to be able to handle and which tools you're anticipating to work with.

Simply checking for their existence, seems to be well defined via POSIX with command -v ${utility} found a reference.

Your modification to die() makes sense, with the caveat that you probably don't (usually) want your script exiting with status 127, as something else could be watching for that then erroneously report to it's caller that your script doesn't exist. Also, you'd want to be sure it actually gets passed a value that can be accessed as $2, so something like ${2:-255}, or whatever the default exit code should be, would prevent it from potentially resolving to exit 0.

This is one problem that some people undoubtedly have some pretty strong opinions on, but the best solution is the one that works best for your needs.

@rifazn
Copy link
Author

rifazn commented Jul 11, 2021

Right. Exiting with 1 almost objectively makes more sense and we are also printing to stderr for removing ambiguity. Also, command -v $program itself exits with 1 and not 127 or something, so we can piggy-back off that.

About checking whether a theme exists, gsettings does not provide any API for that, so the best thing currently to do is to check whether a directory with the theme name exists in: /usr/share/themes/ and ~/.themes/.

Updated code with the dependency checks.

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