Hardscrabble šŸ«

By Maxwell Jacobson

See also: the archives and an RSS feed

Using fish shell

January 21, 2025

Introduction

After ten years of using zsh as my shell, Iā€™ve recently switched to fish.

Iā€™d been aware of fish for a while, and had even kicked its tires once or twice before, but the impression Iā€™d gotten was that it was super opinionated and ā€œquirkyā€, and that rubbed me the wrong way. It turns out thatā€™s sort of true, and nowadays I think thatā€™s actually a great thing to be.

I was inspired by these recent blog posts to take another look:

Evansā€™ post is a good introduction to what makes fish an appealing shell from a longtime user. It covers some of the great functionality that exists outside of the box. Some of it has been ported to zsh, like zsh-users/zsh-syntax-highlighting which adds ā€œFish shell like syntax highlighting for Zshā€ and zsh-users/zsh-autosuggestions which adds ā€œFish-like autosuggestions for zshā€. Much of my zsh configuration, it turns out, was making zsh work more like fish works out of the box. Hmm!

The announcement post details their ambitious Rust rewrite which was recently completed. The writing in that post is kind of great. Itā€™s funny and clear. Thereā€™s compelling storytelling alongside a bunch of technical context. Reading it, I felt like I could happily read changelog updates from these maintainers. It also made me realize that Iā€™ve never read a single piece of writing from the zsh maintainers and donā€™t even know where Iā€™d go to find it.

Upon a bit of digging, I did find the zsh news page, which has not been updated since May 2022 and the zsh release notes page page which has plenty of detail about each release, but does not feel like it was written for a general audience. Overall the zsh web presence is fairly confusing. There is a zsh.org but it hardly has any information on it. Instead it refers the hapless reader to zsh.sourceforge.io/. Plenty of information exists there, but the whole thing feels to me like itā€™s covered in cobwebs. Some ambitious web designer looking for a cool project should absolutely volunteer to do some spring cleaning on that site.

Switching

I didnā€™t think I was in the market for a new shell, but I got such a good vibe from the writing in that announcement post that I decided to try it out.

Actually switching to fish is fairly straightforward on macOS:

  1. Install the shell brew install fish
    • As of this writing, that Rust rewrite is still in beta, so this will install the 3.x release. Honestly, donā€™t worry about it. Iā€™ve tried both and canā€™t tell the difference. If you do want to join me in running the beta you can instead run brew install fish-shell/fish-beta-4/fish
  2. Confirm where the fish binary is on the system: which fish, which prints out /opt/homebrew/bin/fish for me
  3. Add that binary to the list of allowed shells: sudo vim /etc/shells and then add /opt/homebrew/bin/fish to the list
  4. Change the default shell to fish: chsh -s /opt/homebrew/bin/fish
    • Note: no sudo needed on this one. If you do use sudo, youā€™ll change the default shell for your sudo user (super user?), not your regular user
  5. Open a new terminal, and hopefully youā€™ll see that your shell is now fish. It prints a greeting by default.
    • Note: sometimes I need to log out and log back in after changing shells with chsh, and other times not. Iā€™m not sure why. If the change doesnā€™t take effect, you can try that.

Next up was porting my zsh shell configuration to fish configuration. I was starting with a few files:

  • ~/.zshrc ā€“ 197 lines (my general user configuration)
  • ~/.zshenv ā€“ 1 line (setting up cargo)
  • ~/.zprofile ā€“ 1 line (setting up homebrew)

Porting this code meant answering a few questions:

  1. where does this go?
  2. how do I rewrite this from the zsh scripting language to the fish scripting language?

Thankfully, fishā€™s docs are fantastic. Let me count the ways:

  1. there is a search function
  2. each page has a table of contents to make it easily navigable
  3. there are lots of examples

Configuration

I found this doc that answered the question of ā€œwhere does this configuration go?ā€: https://fishshell.com/docs/current/index.html#configuration.

Some things I learned about where configuration goes:

  • I can put my general user configuration in ~/.config/fish/config.fish
  • I can also, optionally, organize my configuration into various files in ~/.config/fish/conf.d/ which fish will also load
  • The ~/.config/fish/conf.d files get loaded first, and they get loaded in alphabetical order, so if I want homebrewā€™s setup to happen earlier (so later configuration scripts can assume that homebrew-installed programs are available to use) I might want to put that in a file like ~/.config/fish/conf.d/0_homebrew.fish.
  • In zsh I needed to worry about what was the difference between ~/.zshrc and ~/.zprofile and ~/.zshenv and never quite could remember. Similarly when I used bash and couldnā€™t remember what was the difference between ~/.bashrc and ~/.bash_profile. With fish I donā€™t need to worry about this. All of the config files are always laoded. Within those files, I am encouraged to define conditional logic if something only pertains to login shells or interactive shells.
  • There is a way to manage configuration in a web UI but you donā€™t have to use that if you donā€™t want to
  • Instead of putting helper functions into ~/.config/fish/config.fish (which does work) you are encouraged to instead define them in ~/.config/fish/name_of_function.fish. That way, the function doesnā€™t get loaded until you actually call it the first time, which speeds up your shell initialization. It also means you can add a new function there and itā€™s immediately available to already-running fish sessions without needing to reload any configuration.
  • prompt configuration goes in ~/.config/fish/functions/fish_prompt.fish. To define a prompt you just define that function. A little more on this below.
  • Because Iā€™m a very cool minimalist, I can turn off the fish greeting by running set --universal fish_greeting.

Syntax

And I found this doc that answered the question of ā€œhow do I do all the basic programming stuff in fish?ā€: https://fishshell.com/docs/current/language.html

Some things I learned about the language:

  • the syntax feels more modern than zsh or bash. For example, the keyword to end a conditional, loop, function, or block is end, just like in Ruby. No need to remember if itā€™s done or fi or esac or }.
  • fish has a standard library of commands like path, math, string, and others. Their docs are great. It felt like theyā€™d thought of everything and had elegant, non-hacky solutions to whatever I might need. And I can always access a local version by running, say help math to pop open the docs
  • fish comes with a fish code formatter tool called fish_indent, which you can use to auto-format your fish code. It mostly does indenting, but it seems to do a little bit more than that, too. I enabled it as an ale fixer so it auto-formats on save when I edit fish files in vim. Thereā€™s also an ale fish linter which just checks for syntax errors as you go. Both very useful.
  • the only way to create variables is with the set command. You canā€™t just write foo=bar or FOO=bar or export FOO=bar. This felt weird at first, but I quickly got used to it.

fish_prompt

Iā€™m not a big fan of any of the prompts that fish offers out of the box. Since February 2021, Iā€™ve been happily using pure and I was a little bit loathe to lose it. I decided to configure a fish prompt that looks a lot like pure, but which doesnā€™t have all of pureā€™s cool functionality. Hereā€™s what I came up with in ~/.config/fish/functions/fish_prompt.fish:

function fish_prompt
    # Check if the last command succeeded so we can color the prompt red when it
    # did not succeed
    set --local last_status $status

    # Configure fish_git_prompt to show some more info
    set --global __fish_git_prompt_show_informative_status true
    set --global __fish_git_prompt_showdirtystate true
    set --global __fish_git_prompt_showuntrackedfiles true
    set --global __fish_git_prompt_showstashstate true
    set --global __fish_git_prompt_showcolorhints true

    # Configure fish_git_prompt symbols
    set --global __fish_git_prompt_char_stashstate "ā‰”"
    set --global __fish_git_prompt_char_cleanstate ""

    # blank line before each prompt
    echo ""

    # print some info
    string join '' -- \
        (set_color blue) (prompt_pwd --full-length-dirs 2) (set_color normal) \
        (fish_git_prompt)

    # print the actual prompt
    if test "$last_status" -ne 0
        string join '' -- (set_color red) 'āÆ '
    else
        string join '' -- (set_color magenta) 'āÆ '
    end
end

This looks a lot like pure, but it doesnā€™t try to match it 100%. Itā€™s just nice for it to feel a bit familiar.

The main ā€œcool functionalityā€ that pure has, which this doesnā€™t, is that pure will auto-fetch git repositories for you. I have become somewhat dependent on this. I often hit enter a few times in a git repo just to see if the prompt changes, which might suggest that I need to pull down the latest changes. I considered updating this function to run git fetch when it sees a git repository. Once I started thinking about actually writing that, I felt kind of like thatā€™s actually a crazy thing for a prompt to do, and maybe itā€™s been crazy that my prompt has been doing that for the last four years. I couldnā€™t bring myself to do it.

Howeverā€¦ I learned that itā€™s possible to define fish functions which respond to events, and that fish emits an event whenever it is about to call the fish_prompt function. I added this function to ~/.config/fish/conf.d/autofetch.fish:

function autofetch --on-event fish_prompt --description 'Fetch on prompt'
    if test -f ".git/FETCH_HEAD"
        set --local mtime (path mtime --relative .git/FETCH_HEAD)
        set --local duration (math '10 * 60')

        if test "$mtime" -gt "$duration"
            git fetch --prune --prune-tags --quiet
        end
    end
end

This will autofetch, synchronously, if it determines that we havenā€™t fetched the repo in the last ten minutes. Thatā€™s not exactly what pure did, but in practice itā€™s good enough. I no longer hit enter a few times as a weird way to say ā€œplease fetch for meā€. Occasionally my prompt hangs for a sec before rendering. Occasionally my prompt indicates that the remote has new commits I might want to pull down. When Iā€™m not connected to the internet, this autofetch fails. Itā€™s not perfect, but I think I overall do like it. And I like that itā€™s decoupled from the actual rendering of the prompt, so the prompt function can focus on the single responsibility of rendering a useful prompt.

And I love that this kind of thing is even possible and easy in fish to do. Callback functions! What is this, JavaScript?

(Additionally, pure achieves an impressive performance by rendering its git details asynchronously. In theory thatā€™s very cool but in practice it hasnā€™t felt like an issue for me to have a synchronous prompt.)

Other events

If you want to write some function which runs each time you change directories, thatā€™s also easy to do with events. In fact thatā€™s how tools like fnm implement their functionality to switch node version when changing directories into a project that uses a particular version of node (via):

function _fnm_autoload_hook --on-variable PWD --description 'Change Node version on directory change'
    status --is-command-substitution; and return
    # command that activates the correct version of node
end

_fnm_autoload_hook

This defines a function, tells fish to call it whenever $PWD changes, and then calls the function once, to ensure it runs on shell initialization too.

Thatā€™s so elegant. I donā€™t know if I have a need for doing something like that just yet, but I like knowing I can if I need to.

Universal variables

One gotcha that really confused me was universal variables, a feature I havenā€™t seen in other shells before.

If you run:

set --universal foo bar

Then the $foo variable is sort of sticky. It exists in every active fish session, including future fish sessions. The value is persisted on disk, and fish references that file on disk to get the value. Itā€™s possible to update the variable (set --universal foo baz) or remove it (set --erase foo). But just be aware, because it can be really confusing if you had a line like set -U foo bar in your ~/.config/fish/config.fish, and then you deleted that line, and the dang variable is still defined???

abbreviations

fish supports aliases just like zsh does. At first I ported over my various aliases and carried on happily. After continuing to poke around the fish docs, I came upon abbr, which manages fish abbreviations.

Previously I had this in my ~/.zshrc:

alias 'cat'='bat'

This is because I wanted to use the very nice sharkdp/bat instead of cat, but my muscle memory continued to keep typing cat anyway.

Now I have:

abbr --add cat bat

The behavior is pretty similar, in the sense that I type cat, and bat runs. The difference is that as soon as I type bat, and then hit space or enter, cat gets replaced with bat in my interactive shell. So I get to see what actually ran, instead of what I originally typed. And thatā€™s what goes into your command history, too. Itā€™s a subtle difference, but I appreciate it.

Itā€™s also possible to define command abbreviations. So, for example, I have a handful of git aliases like git co being an alias for git checkout. Up until now, Iā€™ve defined those aliases in my ~/.gitconfig. But now Iā€™ve migrated all of those to be fish command abbreviations. For example:

abbr --command git co checkout

Now when I type git co, that automatically expands to git checkout.

Itā€™s even possible to create abbreviations that call a function to programmatically determine what the abbreviation should expand into. For example, I have this:

function __fish_t_command
    set --local name (path basename $PWD)
    echo "tmux new-session -A -s $name"
end
abbr --add --function __fish_t_command t

Now when I type t in a project repo, it expands to tmux new-session -A -s seasoning, presuming Iā€™m in a directory called ā€œseasoningā€.

(Astute Hardscrabble readers might notice that Iā€™ve given up the over-engineered tmux helpers described in October 2023)

I think this expansion is just better than aliases. Once the command has expanded, tab completion works perfectly, for example.

custom completions

OK speaking of tab completions, one last cool thing before I ship this brain dump of a post.

One of the helper functions I ported from my old zsh configuration was this helper script:

# Clone repos from GitHub.
#
# Usage: clone maxjacobson/film_snob
#
# Inspired by https://github.com/pbrisbin/dotfiles/blob/632ab65643eac277c77c18a2587fec17fd1acac3/zshrc#L19-L28
function clone () {
  case "$1" in
    */*)
      target="$HOME/src/gh/$1"

      if [ -d "$target" ]; then
        echo "already exists"
        cd "$target"
      else
        mkdir -p "$target"
        gh repo clone "$1" "$target"
        cd "$target"
      fi

      ;;
    *)
      echo "Bad input"
      ;;
  esac
}

That became this fish function in ~/.config/fish/functions/clone.fish:

# Clone repos from GitHub.
#
# Usage: clone maxjacobson/film_snob
#
# Inspired by https://github.com/pbrisbin/dotfiles/blob/632ab65643eac277c77c18a2587fec17fd1acac3/zshrc#L19-L28
function clone --description "Clone a repository from GitHub"
    switch "$argv"
        case "*/*"
            set --local target "$HOME/src/gh/$argv"
            if test -d "$target"
                echo "already exists"
                cd "$target"
            else
                mkdir -p "$target"
                gh repo clone "$argv" "$target"
                cd "$target"
            end
        case '*'
            echo "Bad input"
    end
end

Very similar, although thereā€™s definitely less syntactical cruft.

I thought it might be fun to try adding a tab completion to this function that will tab complete the repositories that are available to clone. Everything in fish has great tab completion, but my helper function did not. By default it suggested the files in the current working directory as tab completion candidates, which was annoying.

I learned that itā€™s possible to configure a commandā€™s tab completions by adding a file in ~/.config/fish/completions/clone.fish

For example you can add this just to tell it not to tab complete filenames:

complete --command clone --no-files

The docs for the complete command have tons of examples. They also encourage you to poke around the completions that come with fish. I found a big trove of them in /opt/homebrew/Cellar/fish/4.0b1/share/fish/completions after checking the directoreis in fish_complete_path. Thereā€™s lots of inspiration there.

Ultimately hereā€™s what I came up with:

complete --command clone --no-files

function __fish_clone_repo_pattern
    set --local subpattern "[a-zA-Z0-9\-\_\.]+"
    echo "($subpattern)/?($subpattern)?"
end

# clone foo/<tab>
# clone foo/bar<tab>
# clone foo<tab>
function __fish_clone_should_search
    string match \
        --quiet \
        --regex (__fish_clone_repo_pattern) \
        (commandline --current-token)
end

# look up a particular owner's repos
function __fish_clone_search_results
    set --local groups (
      string match \
          --groups-only \
          --regex (__fish_clone_repo_pattern) \
          (commandline --current-token)
    )
    gh search repos \
        --owner="$groups[1]" \
        --json fullName \
        --jq '.[].fullName' \
        "$groups[2]"
end

# clone foo/<tab>
# will search GitHub for foo's repos and offer them as tab completions
complete \
    --command clone \
    --condition __fish_clone_should_search \
    --arguments '(__fish_clone_search_results)'

Thereā€™s a lot going on there, but the upshot is that when I type clone maxjacobson<tab>, fish will ask GitHub what repos are owned by maxjacobson and offer them as tab-completion suggestions.And if I type rails/action<tab>, it tab completes just the repos owned by rails that have action in the name.

Programming in fish is fun. I know itā€™s possible to do this kind of thing in zsh too, but I never would have attempted it.

Conclusion

I have been having a lot of fun with fish! Perhaps more posts to come as I continue poking at things.

generating opengraph social preview images for blog posts automatically in jekyll

December 3, 2024

Some time last year I thought it might be nice to have opengraph preview images on my blog posts, so if I posted them to Mastodon theyā€™d show a cool little preview image and more people would click on them.

Itā€™s easy enough, you just need something like this in the <head>:

<meta property="og:image" content="https://www.hardscrabble.net/img/preview/2024-10-03-colors.png">

Because I use Jekyll to generate my blog, it wasnā€™t too hard to add a little conditional logic that includes that meta element if a post specifies a preview image filename in its front matter.

But itā€™s sort of annoying to have to prepare an image whenever you want to blog something. I donā€™t necessarily have time for all that.

Then, yesterday, I was poking around on Bluesky and saw this post:

I'm starting my 2024 #blogvent series where I post a blog a day in December! Blogvent day 1 is about fighting spam in your open source repos: cassidoo.co/post/oss-int...

[image or embed]

— Cassidy (@cassidoo.co) December 1, 2024 at 4:07 PM

And I got so jealous of how nice and clean that preview image is, and I had a few thoughts:

  • Sigh I should blog more
  • Dang can you imagine blogging that much?
  • No, I mean, it would be so annoying to generate all those preview images
  • Wait, how did she manage to auto-generate the preview images with text for each blog post??? Thatā€™s so cool!!!

I saw that her blog post is open source and I tracked down the lines of code, which look sort of like this:

---
const { title, description, image = "/home-blog-card.png" } = Astro.props;
---

<head>
  <meta property="og:image" content={new URL(image, Astro.url)} />
</head>

I sat and stared at that for several minutes, trying to puzzle out how it works. Iā€™ve never used Astro, but it seems very powerful. I look at the docs.

Eventually I realized that actually itā€™s just a static image, and it isnā€™t dynamically changing the text for each blog post. I went back to her page and scrolled down past several links, and I saw that they all have the same text.

Lol.

But hey, I thought, maybe it is possible to generate custom images with text in them? And if it is, maybe it is possible to hook that into the jekyll build process?

And indeed it is. And Iā€™ve done a somewhat basic version of that. And hereā€™s how you can do that too.

First, create a plugin by creating a file called _plugins/opengraph.rb. Ruby files in the _plugins folder are loaded during the build process.

Within that plugin, register some jekyll hooks:

Jekyll::Hooks.register :posts, :pre_render do |post|
  unless post.data["preview_image"]
    preview = GeneratePreview.new(post)
    preview.write
    post.merge_data!({"preview_image" => preview.path }, source: "opengraph plugin")
  end
end

Jekyll::Hooks.register :site, :after_init do
  FileUtils.mkdir_p "tmp/img/preview"
end

Jekyll::Hooks.register :site, :post_write do
  Dir.glob("tmp/img/preview/*").each do |path|
    FileUtils.cp path, "_site/img/preview"
  end
end

The idea here is that for any post that doesnā€™t already have a preview image specified in the front matter, weā€™ll

  • generate a preview image and write it to a folder
  • modify the in-memory post object to know that it has a preview image with a particular filename. Because this is a :pre_render hook, the HTML for that post has not yet been written to disk, so itā€™s not too late to modify its metadata

And then, after the whole site has been written, copy all of those generated image files into place.

In this design, I donā€™t need to commit all of the generated files to the repository, I just generate them at build time. I need to regenerate them each time I deploy. This is a bit slow and ineffecient. I might decide to just commit them down the line.

The actual image generation code is sort of hacky. Itā€™s a bit of ruby glue code that shells out to the venerable imagemagick CLI tool. It executes commands like this:

magick \
    -background "#f2d8b2" \
    -font "Helvetica" \
    -fill "#248165" \
    -size 1200x630 \
    -gravity SouthWest \
    "caption:The\ easiest\ way\ to\ indent\ paragraphs\ online,\ not\ that\ you\ necessarily\ should" \
    tmp/img/preview/2012-03-21-indenting-paragraphs-online.png

Which produces images like this:

example preview image with green text on beige background saying "The easiest way to indent paragraphs online, not that you necessarily should"

Kind of obnoxious right? A little brat maybe? You tell me.

Getting this to run in GitHub Actions presented a few challenges. The ubuntu-latest machine where the builds run had imagemagick installed, but it was imagemagick 6, not 7. sudo apt-get install -y imagemagick just complained that hey, itā€™s already installed baby, nothing for me to do. But I had gotten all of this working with imagemagick 7 and I wasnā€™t about to redo anything.

Once I figured out how to install imagemagick 7, I had the next issue, which was that no fonts were installed on the machine, at least as far as imagemagick could tell. After installing the gsfonts package, it could see some fonts, but only the off brand, Linux versions of them. By running magick -list font in the CI context, I could see what those were. Eventually I worked out that something called Nimbus-Sans-L is basically knockoff Helvetica, which was what I had randomly picked locally. Great. Close enough. Perhaps you can tell but I have never really been a Font Guy. But I wanted this to work in both contexts, locally and in GitHub Actions. To work around that, I added some conditional logic to the ruby script to default the font to Helvetica, but allow overriding it via an environment variable.

And with thatā€¦ everything was working, and I went to bed, criminally late, having forgotten what I originally had been planning to blog about. I remembered over lunch today and finished that post. And now hereā€™s this.

Hereā€™s the commit with all of the code, if youā€™re curious. It kind of works.

How untrustworthy is that?

December 3, 2024

In Waitress, a musical I love, thereā€™s a song called ā€œWhen He Sees Meā€ that I love. But thereā€™s one line that gives me pause.

Hereā€™s the verse (emphasis mine):

Sorry girls

But he could be criminal, some sort of psychopath

Who escaped from an institution

Somewhere where they donā€™t have girls

He could have masterminded some way to find me

He could be colorblind

How untrustworthy is that?

He could be less than kind

The context here is that Dawn is single and hesitant about putting herself out there. Most of all sheā€™s afraid of being hurt, and the joke of the song is that she canā€™t quite admit that fact, and she chooses instead to enumerate all of her other excuses for not going on a date: minor infractions like calling the waiter by his first name or eating Oreos wrong, for example. Itā€™s a breathless and very funny stream of consciousness. Ha ha, right?

But hold on, whatā€™s her problem with colorblind people? How is it untrustworthy to be colorblind? I love this song, but I always trip over that line. In addition to being a bit ableist, itā€™s also such a non sequitor.

Iā€™m reminded of the classic 2008 parody of Deep Blue Somethingā€™s Breakfast at Tiffanyā€™s from the sketch comedy group Olde English:

Thatā€™s me right now.

Dawnā€™s characterization is probably strengthened by her being a little bit shitty about this. Sheā€™s such a sweet character that for her to be randomly biased against a protected group is funny, in a dark way. In the chorus, she asks, ā€œWhat if when he knows me, heā€™s only disappointed?ā€ The audience is inclined to love and root for Dawn, but in order for the song to resonate, she needs to show some human flaws.

The sloughing off of hacks

October 3, 2024

I was recently looking over a post of mine from a few years ago, Learning to love vim colorschemes, which goes over how I managed to get my favorite color scheme, Smyck, to take effect in several contexts:

  • as a vim colorscheme
  • as a bat / delta theme
  • as some accent colors in tmux

Honestly most of this has held up! I still use and love Smyck, mostly in exactly the same ways.1

In order to get all that to work, I mentioned this:

In my ~/.vimrc:

if $COLORTERM == 'truecolor'
  set termguicolors
  let &t_8f = "\<Esc>[38;2;%lu;%lu;%lum"
  let &t_8b = "\<Esc>[48;2;%lu;%lu;%lum"
  colorscheme smyck
endif

The conditional is there because this configuration only works in terminals that support true color. Iā€™m definitely sold on iTerm2 now, but I donā€™t want everything to look wacky if I did try and use vim in Terminal.app.

Those funky &t_8f and &t_8b things are there for tmux compatibility. I have no idea what they mean. I copied them from the internet.

It kind of bugs me having configuration that I donā€™t actually understand what itā€™s doing, so I tried to learn what it actually does. I didnā€™t quite figure it out. So then I tried removing it, and simplifying that whole passage to simply:

set termguicolors
colorscheme smyck

Forget the hack! Forget the conditional!

And to my pleasant surprise, everything seems to work fine?

When writing software we often accumulate piles of weird hacks, and sometimes if youā€™re lucky, they stop being necessary. Maybe tmux fixed something. Maybe vim did. I donā€™t know. They have updates all the time which I dutifully install. Why shouldnā€™t things get better?

Thereā€™s this idea called Chestertonā€™s Fence that people often invoke in the context of software. Hereā€™s a quote Iā€™m copying from Wikipedia:

In the matter of reforming things, as distinct from deforming them, there is one plain and simple principle; a principle which will probably be called a paradox. There exists in such a case a certain institution or law; let us say, for the sake of simplicity, a fence or gate erected across a road. The more modern type of reformer goes gaily up to it and says, ā€œI donā€™t see the use of this; let us clear it away.ā€ To which the more intelligent type of reformer will do well to answer: ā€œIf you donā€™t see the use of it, I certainly wonā€™t let you clear it away. Go away and think. Then, when you can come back and tell me that you do see the use of it, I may allow you to destroy it.ā€

This might as well have been written about my &t_8f and &t_8b and I might be the less intelligent type of reformer happily clearing it away. But hell, I put up the fence, and Iā€™ll live with the consequences.

I suspect many software teams are tolerating many such fences in their codebases. By all means, try to get to the bottom of the question. Make an effort. And then let the mystery be.

  1. The main difference is that Iā€™ve migrated from iTerm 2 to Alacritty. I had been using the Smyck.itermcolors theme file to use the Smyck colorscheme in my shell sessions ā€“ I want those sweet pastels to be used for my prompt and to color the output from the various command line programs Iā€™m running. BUT the official Smyck repository doesnā€™t offer any Alacritty theme. One kind soul has opened a pull request which might one day get merged. Before I saw that, I had already adapted it myself. Itā€™s not too bad.Ā 

Ten years of Hardscrabble

November 6, 2023

This website launched ten years ago today.

Itā€™s a little hard to define the exact birthday of the blog. There are a handful of posts in the archive that are older than ten years, but those ones were originally published on older iterations of my blog that werenā€™t called Hardscrabble, so I donā€™t count that. Thereā€™s the post announcing that the blog is now powered by Jekyll and open source, which is still a few weeks away from its ten year anniversary, but Iā€™ve done a little archaeology, and Iā€™ll point to this commit on November 6, 2013 at 10:41pm as the canonical moment. In that commit, I added the CNAME file that tells GitHub Pages that the site will be served from www.hardscrabble.net.

Incidentally, this site is still powered by Jekyll and open source and hosted by GitHub Pages. Jekyll and GitHub Pages have both iterated steadily over the years. I feel sort of like Iā€™ve been getting away with something to have this reliable, simple free hosting service. There have been times Iā€™ve been tempted to move the hosting elsewhere but itā€™s never become necessary.

Hereā€™s a screenshot of what the site looked like in February 2014, from the internet archive:

screenshot of a very plain homepage

(I tried to check out an old version of the source code and boot it up but it proved a bit challenging. If I were a more committed nostalgist Iā€™d probably try to make a docker image with the older dependencies, like Ruby 2.0.0, but this will have to do for now)

This has never been the most beautiful website and the design has not evolved very much. I kept the dashed lines and that shade of green for links. I changed the background color.

Before Hardscrabble, I bounced around between a bunch of different blogs and blogging tools, including WordPress, Tumblr, Octopress, Calepin, Livejournal, Facebook Notes. Maybe some others. I remember my friend Corey would poke gentle fun at me for this restlessness. I remember when I started Hardscrabble I wanted to stick with it if only to prove him wrong. Iā€™ll have to ask.

Thanks for reading.

Programming my macropad

November 3, 2023

I recently wrote about a gadget that Iā€™ve started using, a DOIO KB04-01 Macro Keyboard 4 Keys + 1 Knob Macro Pad. Weirdly enough, not long after I posted that, it stopped working. The buttons still worked, but the knob didnā€™t, and for me, the knob was basically the whole point. I had no idea how to debug this either. I had never figured out how to program the macropad at all and was using its out of the box settings. There was some discussion online about how it can be programmed using Via, the popular app, but you needed to install an old version of it and load in a special JSON file in order for that old version of Via to recognize the device. Reader, I tried.

After hitting some dead ends, distraught, I decided to shop around a bit, and to look for a more reputable brand. I ordered a Keychron Q0 Plus QMK Custom Number Pad, because Keychron is a good brand, and the product page has extensive details about how to program it with Via. I donā€™t really need a full numpad1 but I donā€™t mind having one, and more importantly, itā€™s got a beautiful knob and some keys that are reserved for doing whatever you want them to do.

When it came, I was thrilled to find that it worked out of the box. It controlled the volume when I turned the knob. And I was able to get Via to recognize the device by following the instructions on Keychronā€™s product page. Great success.

Itā€™s neat actually. Iā€™m simply using the web app in Chrome (it doesnā€™t work in Firefox or Safari) rather than the downloadable app. This is powered by the experimental WebHID API.

But, uh, now what?

I had five physical keys reserved for running macros but no immediate ideas what to have them do.

I spent the next several days happily turning the knob to adjust volume and keeping one eye open for ideas for things to turn into a macro. Eventually, inspiration struck.

I mostly use my Apple Studio Displayā€™s speakers, but every now and then I like to use headphones, if Iā€™m really zoning in and focusing. I keep my headphones permanently plugged in to my Mac Studio, and every now and then Iā€™ll click open Control Center and adjust the output device like so:

Gif showing me tapping on the macOS Sonoma Control Center menu bar icon and changing my audio output to my headphones

If I can find a way to script that, that would be a fine macro: a button that toggles the audio output between my headphones and my studio display.

I hoped that would be easy to script using Shortcuts but I couldnā€™t find any shortcut actions that update the audio output device.

I googled around a bit and came upon this open source project: switchaudio-osx which is easily installed with Homebrew with a command line interface. Sigh, okay, letā€™s try that.

Hereā€™s the script I came up with2, depending on that tool, to toggle the output device:

set -e

# N.B. this depends on https://github.com/deweller/switchaudio-osx

# Fully qualified path to SwitchAudioSource
SAS="/opt/homebrew/bin/SwitchAudioSource"

# Get the current audio output device
current_device=$($SAS -c)

# Check if the current device is "External Headphones"
if [ "$current_device" == "External Headphones" ]; then
    # Set the audio output to "Studio Display Speakers"
    $SAS -s "Studio Display Speakers"
else
    # Set the audio output to "External Headphones"
    $SAS -s "External Headphones"
fi

This worked reliably when invoked from the command-line. I also wrapped it up in a Shortcut and configured the shortcut to run when I type āŒƒāŒ„ā‡§āŒ˜H.

Screenshot showing the Shortcuts app using the Run Shell Script action to run that shell script

I had always wondered what does it actually mean to program a macropad to run a macro? Like, can we load a shell script onto the keyboard?? Does it remember things??

The main screen in Via is called ā€œConfigureā€, and within that is the Keymap section. There, you can map each physical key (including the knob) to type whatever character you want, or to invoke a macro. It seems that for this device, there are sixteen slots for macros, named named M0, M1, M2, ā€¦ M15.

After the Keymap section is the Macro section where you can define what those sixteen macros actually are. The way it works is that you click ā€œRecord keystrokesā€, and then you type some stuff, and then you click ā€œStop recordingā€ and then ā€œSave changesā€. Now, whenever you invoke that macro, it will type in exactly what you typed in during the recording. That could be your billing address or a keyboard shortcut or whatever else you want. In this gif, Iā€™m recording a macro that will perform the keyboard shortcut āŒƒāŒ„ā‡§āŒ˜H.

Recording a macro to invoke the shortcut

Once the macro exists, I just need to go back to the Keymap section and map that physical key to that macro:

Mapping the key to the macro

Iā€™m happy to learn that this is all nicely decoupled. The keyboard doesnā€™t need to know anything about my computer or any particular behavior I want my computer to have. Itā€™s simply an input device, and I can program it to send the inputs I want it to send. Then I can program my Mac to respond to those inputs how I want it to respond. The device does seem to remember how Iā€™ve programmed it.

And now I can happily jam that button to toggle my audio output.

There may be better or simpler ways to do some of this stuff, and Iā€™d be happy to hear about them if youā€™d like to share. Iā€™m pretty new to all this.

  1. the numbers are already on my regular keyboard and Iā€™ve never had any issue just using those, but if youā€™re a numpad person I wish you all the peace in the worldĀ 

  2. Iā€™ll be real with you, I used ChatGPT to actually write thisĀ 

My tmux aliases (2023 edition)

October 21, 2023

My tmux setup, with some panes showing a vim editor process, server logs, and test output

In 2015, I wrote a blog post called some helpful tmux aliases explaining a bit about how I use tmux in my development workflow. Iā€™ll confess itā€™s not my most coherent blog post Iā€™ve written, and Iā€™ve iterated a bit since then, so I thought Iā€™d take another run at it.

Wait what is tmux even all about?

When Iā€™m working on a project, I usually need a bunch of separate shells. Right now, as I work on this blog post, Iā€™m using three:

  1. One to run my text editor, vim, where Iā€™m writing these words
  2. Another to run my exe/serve helper script which runs the jekyll server, so I can preview the blog post in a browser and make sure it looks right
  3. Another for miscellaneous use, like running git operations to commit the changes or using ripgrep to search the project for references to things Iā€™ve blogged about already

It would be fine and reasonable to just open three separate windows or tabs in my terminal emulator to run those various things, but instead I use tmux. I basically never have more than one actual tab or window, even when Iā€™m bouncing between multiple projects, because my tmux workflow makes that unnecessary.

tmux is a ā€œmultiplexerā€, which is to say that it lets you run multiple shells in one shell.

It can split your screen vertically and horizontally to run as many shells as you want. In the screenshot at the top of this post, there are three shells1. In tmuxā€™s jargon, those are called ā€œpanesā€, like how a window may be made up of multiple panes of glass, this window is made up of multiple shells.

When I open too many panes, it can start to feel a little cramped. I often resize them by dragging the little border line2. I also will often maximize the current pane, hiding the others, if I just need to focus on one task for a bit:

Resizing tmux panes and zooming into one

You can also run multiple windows, and switch between those. This is nice because sometimes you want something running in the background and you donā€™t really want to look at it all the time. These are called ā€œwindowsā€ but I tend to think of them as ā€œtabsā€, because they function sort of like tabs in a GUI app. You can even rename windows to help you remember what that windowā€™s purpose is:

Switching between windows and renaming them

The last major bit of tmux terminology, after ā€œpaneā€ and ā€œwindowā€, is ā€œsessionā€. A session is a group of windows. You can have many sessions running at once, and switch between them. I have one session per project that Iā€™m working on. If I initiate a session in the directory for my project seasoning, then every new pane and window will start a new shell in that directory, which is really convenient.

So thatā€™s the overview.

Aliases

Creating a new session

You can start a new session by simply running tmux. If you do that, your session will automatically be given a name of 0. Your next session will be called 1, and so on. Because I run one session per project, and sometimes work on multiple projects at the same time, I like to name my sessions after the project. Thatā€™s easy enough to do. Instead of running simply tmux to start a session, I can run, for example:

tmux new-session -s seasoning

That will start a new session called seasoning in the current directory.

Back in 2015, I came up with a clever alias that would automatically infer the session name from the name of the current directory. It looked like this:

alias 't'='tmux new-session -A -s $(basename $PWD | tr -d .)'

Running basename $PWD | tr -d . when youā€™re in a directory like /Users/max/src/gh/maxjacobson/seasoning prints the text seasoning, which seems like a perfect session name for when Iā€™m working on that project.

The -A bit will attach to an existing session with that name, if one exists, and otherwise create it.

With this alias, I can happily run simply t in any directory and feel confident that Iā€™ll be in a nicely-named session.

This held up pretty well over the years, but it has one flaw: every now and then (and this is pretty rare) Iā€™ll have more than one ā€œprojectā€ with the same name. For example, Iā€™ll often clone other peopleā€™s dotfiles repos and rummage around for inspiration. Theyā€™re almost always called dotfiles. If I clone wfleming/dotfiles and start a new session by running t, Iā€™ll get a new session called dotfiles in Willā€™s dotfiles. If I then clone pbrisbin/dotfiles and run t, tmux will see that there is already a running session called dotfiles and attach to that instead of creating a new one.

This has only come up a very small handful of times but every time is a little papercut that has bugged me. So, recently, I revised my t alias for the first time in ages. It now looks like this:

alias 't'='tmux new-session -A -s "$(basename $PWD) $(echo $PWD | shasum -a 256 | cut -c1-4)"'

With this new version, the derived session name when Iā€™m in /Users/max/src/gh/maxjacobson/seasoning is seasoning 3f2c. That bit at the end generates a unique3 four character hash based on the absolute path to the project. It will always come up with the same hash, so it will be possible to run t in as many dotfiles repos as I want and start up independent sessions.

Listing sessions

You can run tmux list-sessions to print out a list of the running sessions, plus some interesting metadata about them:

$ tmux list-sessions
hardscrabble_github_io afd6: 4 windows (created Sat Oct 21 15:06:31 2023) (attached)
seasoning 3f2c: 1 windows (created Sat Oct 21 15:06:24 2023)

Iā€™ve had that aliased to tl for years. Today I learned you can format the output to include whatever information you want, and spent several minutes exploring various ideas.

Some ideasā€¦

If I want to correct the pluralization error of ā€œ1 windowsā€4:

$ tmux list-sessions -F '#{session_name} (#{session_windows} #{?#{==:#{session_windows},1},window,windows})'
hardscrabble_github_io afd6 (4 windows)
seasoning 3f2c (1 window)

That one is uh, pretty gnarly. In English, itā€™s saying ā€œIf the session_windows variable is equal to 1, say ā€˜windowā€™ otherwise say ā€˜windowsā€™ā€.

If I want to include the sessionā€™s directory:

$ tmux list-sessions -F '#{session_name} (#{session_path})'
hardscrabble_github_io afd6 (/Users/max/src/gh/hardscrabble/hardscrabble.github.io)
seasoning 3f2c (/Users/max/src/gh/maxjacobson/seasoning)

$ tmux list-sessions -F '#{session_name} (#{d:session_path})'
hardscrabble_github_io afd6 (/Users/max/src/gh/hardscrabble)
seasoning 3f2c (/Users/max/src/gh/maxjacobson)

$ tmux list-sessions -F '#{session_name} (#{b:session_path})'
hardscrabble_github_io afd6 (hardscrabble.github.io)
seasoning 3f2c (seasoning)

As you can see, there are modifiers to just say the parent directory, or just say the basename of the directory.

If I want to scrub out the unsightly trailing hash from the session name:

$ tmux list-sessions -F '#{s/ [a-f0-9][a-f0-9][a-f0-9][a-f0-9]$//:session_name}'
hardscrabble_github_io
seasoning

I feel like I should be able to simplify that regular expression to something like / [a-f0-9]{4}$/ but I canā€™t quite figure out how to escape it. So it goes.

I think Iā€™m going to keep it minimalist and use this last one when I run tl. That means my next recommended alias looks like this:

alias 'tl'="tmux list-sessions -F '#{s/ [a-f0-9][a-f0-9][a-f0-9][a-f0-9]$//:session_name}' 2>/dev/null || echo 'no sessions'"

Youā€™ll notice that thereā€™s a little bit of error handling in there. Thatā€™s because, by default, tmux list-sessions throws a kind of unsightly error when there are no sessions to list. We can make that a little nicer.

Attaching to an existing session

One of the benefits of tmux is that if you accidentally close your terminal emulator app (e.g. Terminal or iTerm 2 or Alacritty or whatever), your session is still running in the background, and you can reattach to it. Itā€™ll keep running until all of the shells within the session exit. I normally press Ctrl + d to exit shells, but you can also type exit and hit enter.

You can attach to a session by running tmux attach-session, which will attach to the most recently used session. I have this aliased to ta like so:

alias 'ta'='tmux attach-session'

This is usually what I want. From there, if I happen to have multiple sessions going, I might switch to another session like so:

switching between tmux sessions

(Eagle-eyed readers might notice that the hash has disappeared from the status bar in the lower right in that gif, because I realized I can use the same format string trick to scrub it from there too. And now you know that I am a bit too lazy to redo the earlier gifs for consistencyā€™s sake.)

Itā€™s also possible to attach to a specific session, rather than the most recently used one. You can do that by running a command like this:

$ tmux attach-session -t seas

Thankfully you donā€™t need to specify the full name. tmux can figure out that when I specify seas I mean seasoning 3f2c.

I have that aliased as to, so I can simply run:

$ to seas

That alias looks like this:

alias 'to'='tmux attach-session -t'

Wrapping it all up

Alright, thanks for coming on this journey. Hereā€™s the aliases all together:

alias 't'='tmux new-session -A -s "$(basename $PWD) $(echo $PWD | shasum -a 256 | cut -c1-4)"'
alias 'tl'="tmux list-sessions -F '#{s/ [a-f0-9][a-f0-9][a-f0-9][a-f0-9]$//:session_name}' 2>/dev/null || echo 'no sessions'"
alias 'ta'='tmux attach-session'
alias 'to'='tmux attach-session -t'

And hereā€™s the relevant bits of config in my ~/.tmux.conf

set -g status-interval 1
set -g status-left ""
set -g status-right "%b %d %H:%M:%S #{s/ [a-f0-9][a-f0-9][a-f0-9][a-f0-9]$//:session_name}"

Happy tmuxing. See you in another eight years.

  1. It sort of looks like four, but itā€™s really three. The one in the top left is running vim, which has its own splitting mechanism, and is showing two different files.

    It can get a bit fractal when you have vim inside tmux, but you get used to it.Ā 

  2. This requires you to enable mouse support in your tmux configuration, which I heartily recommend.

    set-option -g mouse on
    

  3. I guess itā€™s possible to have a hash conflict, and thatā€™s much more likely because Iā€™m truncating the hash to just four characters, but uh, fingers crossed.Ā 

  4. This is something that has apparently bothered me for a long time. I added the Rails/PluralizationGrammar rule to rubocop many years ago, and now itā€™s referenced in thousands of repos, something that genuinely delights me.Ā 

Multi-paragraph footnotes in Markdown

October 1, 2023

It can be challenging to write about Markdown in Markdown, but Iā€™m going to try. The hard part is showing examples of the syntax without that syntax getting converted into HTML. For example, did you know that if you want to bold some text, you do it like this? Shit, that got bolded. Let me try again. You can do it **like this**. OK, great.

So what about footnotes? Adding a simple footnote is fairly straightforward: you put [^1] in the main flow of your prose to indicate that there is a footnote. Then you can add your footnote like this:

[^1]: My great footnote

Hereā€™s how that looks1.

John Gruber, the inventor of Markdown, recently published a recap of Appleā€™s recent iPhone 15 event which contained three footnotes. When I saw that one of them is three whole paragraphs, my eyes widened. You can do that???

If we look at the generated HTML, it looks like this:

<li id="fn2-2023-09-15">
<p>On the eve...</p>

<blockquote>
  <p>Apple has also...</p>
</blockquote>

<p>Huaweiā€™s geopolitical travails...</p>
</li>

Nothing magical going on at all. Just some normal-looking HTML.

But, I wondered, how the hell do you represent that in Markdown? When I generate footnotes with Markdown, as soon as I finish the first paragraph, the footnote is done.

I happen to know that Daring Fireball has a trick, where you can append .text to any URL and see the Markdown source code for that article. So I took a look at that articleā€™s source Markdown, and hereā€™s what I saw:

<li id="fn2-2023-09-15">
<p>On the eve...</p>

<blockquote>
  <p>Apple has also...</p>
</blockquote>

<p>Huaweiā€™s geopolitical travails...</p>
</li>

Yep: the exact same thing. Heā€™s not using any special Markdown syntax to generate the footnotes, heā€™s doing it manually by writing HTML. And thatā€™s fair enough; itā€™s totally valid to include bits of HTML in your Markdown source.

Butā€¦ does that mean that itā€™s not possible to have multi-paragraph footnotes in HTML-free Markdown? Wellā€¦ unfortunately, itā€™s time that we start to get into some nuance (and a bit of drama).

I should note that Iā€™m referring to ā€œMarkdownā€ as though Markdown is one thing. Itā€™s not. Gruberā€™s original version of Markdown doesnā€™t support footnotes at all (so itā€™s not a surprise that his blog implements them without any non-HTML syntax). There are many, many different implementations of Markdown. The one that I tend to use is called GitHub Flavored Markdown, which is the version of Markdown used in GitHub text fields. Itā€™s also the version of Markdown that I use to build this blog. So thatā€™s the one Iā€™ll focus on today.

This diaspora of implementations can make it hard to find good information about what features you should expect to have access to. GitHub publishes a spec for GitHub flavored Markdown but it doesnā€™t describe their implementation of footnotes. Elsewhere, they publish a doc on ā€œBasic writing and formatting syntaxā€ and its section on footnotes includes these two examples:

Here is a simple footnote[^1].

A footnote can also have multiple lines[^2].

[^1]: My reference.
[^2]: To add line breaks within a footnote, prefix new lines with 2 spaces.
  This is a second line.

Oh God, is that the best we can do? That seems to generate one paragraph, with <br /> tags breaking it up into multiple lines. Thatā€™s not really what I want.

But, clicking around, I found some reason for hope. The 2021 changelog post that introduced footnotes to GitHub embeds a gif that, hooray, includes a multi-paragraph footnote example, which looks like this:

Some text.[^bignote]

[^bignote]: Here's one with multiple paragraphs and code.

    Indent paragraphs to include them in the footnote.

    `{ my code }`

    Add as many paragraphs as you like

As the gif looped and this little miracle flashed on the screen momentarily before flickering away again, I did my best to see what was there, and eventually the carousel looped around enough times that I got it. So thatā€™s easy enough: you can add more paragraphs to your footnote as long as you indent them (with four spaces). Easy.2 And hopefully this will actually continue to work, even though itā€™s barely documented.

I did tease a little drama, but Iā€™m actually not super invested in it. So, suffice it to say that Gruber is occasionally a bit salty about the various takes on Markdown that exist.

  1. My great footnoteĀ 

  2. Just to show it off here, Iā€™m doing another footnote, and this is the first paragraph of it.

    and wow hereā€™s a blockquote and itā€™s still in the same footnote

    And hereā€™s another paragraph thatā€™s still, magically, in the same footnote.

    The blank lines between the paragraphs can just be blank, they donā€™t need to have four spaces in them for no reason, donā€™t worry.Ā 

The Apple Studio Display's Missing Volume Knob

October 1, 2023

EDIT Nov 3, 2023: Iā€™ve replaced that gadget with a different, similar gadget that Iā€™ve written a new post about.

Iā€™ve been using the Apple Studio Display as a computer monitor for over a year now and hereā€™s my review: itā€™s pretty good, but itā€™s missing a volume knob.

I use the built-in speakers, which are pretty good. And, of course, itā€™s possible to adjust the volume. My goofy mechanical keyboard1 has a row of function keys, and maybe one of them is supposed to control the volume, but I canā€™t figure it out (Iā€™ve tried for like two whole minutes). So for the first year or so of using this monitor, any time I wanted to adjust the volume, I moused up to the little Control Center icon in the menu bar, clicked, and dragged the little slider.

Adjusting volume with Control Center

And reader, forgive me, maybe this is intuitively obvious to you, but it must be said: this experience sucks!

If Iā€™m ā€œfeeling myselfā€ and want to turn up the tunes, that should be as easy as possible to do. I donā€™t want to scan through a mess of tiny monochromatic icons and have to think. If Iā€™m in the middle of a tense chess position and I want to turn down the music and concentrate, again, that needs to be so easy to do without even taking my eyes off the chess.com chess board.

This sucky experience was kind of simmering below the threshold of conscious annoyance for a while, but a few months ago I finally put my finger on it and had the thought: damn, I wish I had a little volume knob I could turn right now, I wonder if that exists somewhere? So I started doing some google queries like ā€œstandalone volume knobā€ that yielded some very interesting products.

Some products are literally what I imagined: a standalone volume knob. For example this handmade walnut one from Etsy seller ZiddyMakes that I came very close to ordering:

Walnut knob

A bunch more of them were small knobs that are not usable standalone electronics, but meant to be somehow fastened to a mechanical keyboard. An appealing idea! If my keyboard had a little knob, Iā€™d be thrilled. But my keyboard doesnā€™t, and I do not dare attempt to give it one.

Eventually, in this research, I found my way to the term ā€œmacropadā€, which is basically like a little standalone keyboard with a few keys on it that you can program to do whatever you want, like kick off some automation, or act as simple media controls to pause your music or adjust your volume. Some of them even have knobs on them.

I ordered this one: DOIO KB04-01 Macro Keyboard 4 Keys + 1 Knob Macro Pad

DOIO macropad

Hot yellow! Nice.

When it arrived, it basically worked out of the box. The knob controlled the volume. The first button worked like a play/pause button. The second button like a previous track button. The third one like a next track button. The fourth one, uh, didnā€™t seem to do anything.

I spent a few minutes trying to figure out how to customize what those buttons would do, but nothing seemed to work. Then I remembered the classic Mitch Hedberg joke:

I write jokes for a living, I sit at my hotel at night, I think of something thatā€™s funny, then I go get a pen and I write it down. Or if the pen is too far away, I have to convince myself that what I thought of ainā€™t funny.

And so I convinced myself that actually having play/pause, previous track, next track, and a no-op button was what I wanted.

I did order some media keycaps from WASD to replace the blank keycaps that came with it, so I could remember what each key does. I was planning to leave the fourth key blank, but the WASD keycaps were a bit taller than the keycaps that came with the macropad, and it felt weird for them not to all be the same height, so I just put the square ā€œstopā€ keycap there, even though it doesnā€™t actually stop anything.

Iā€™ve had this little guy on my desk for a few months now and I really love it. I almost never press the keys, but I turn the volume knob all the time. It has a nice uh, knob feel. It isnā€™t an entirely smooth spin; it sort of turns in notches. As you turn it, you can feel exactly how many notches youā€™re turning it, and each notch is equivalent to pressing the ā€œvolume upā€ or ā€œvolume downā€ key on a keyboard that has those buttons.

Nice little gizmo.

  1. FWIW, Iā€™ve been very happily using the REALFORCE R2 KEYBOARD MID SIZE (IVORY) keyboard for almost two years and itā€™s my favorite keyboard Iā€™ve ever used. It has the excellent topre switches from the iconic Happy Hacker Keyboard, but in a normal keyboard layout that asks very little of you.Ā 

Streaming sites and their bad URLs

October 1, 2023

Why do streaming sites have such poor URLs?

Hereā€™s a quick survey of the field:

Site Example series URL
Amazon Prime Gen V https://www.amazon.com/gp/video/detail/B0CBFTRGPZ/
Apple TV+ Ted Lasso https://tv.apple.com/us/show/ted-lasso/umc.cmc.vtoh0mn0xn7t3c643xqonfzy
Disney+ Bluey https://www.disneyplus.com/series/bluey/1xy9TAOQ0M3r
Hulu Only Murders in the Building https://www.hulu.com/series/ef31c7e1-cd0f-4e07-848d-1cbfedb50ddf
Max Friends https://play.max.com/show/52dae4c7-2ab1-4bb9-ab1c-8100fd54e2f9
Netflix One Piece https://www.netflix.com/title/80217863
Paramount+ Star Trek: The Original Series (Remastered) https://www.paramountplus.com/shows/star_trek/
Peacock Killing It https://www.peacocktv.com/watch/asset/tv/killing-it/5156438808822262112
Tubi Hannibal https://tubitv.com/series/300000159/hannibal

Of these, the best one is clearly Paramount+, because itā€™s human-readable and free of any junk. If I were feeling uncharitable, I might ding it for using snake case rather than kebab case, but Iā€™m willing to concede thatā€™s a matter of taste.

In the second tier are Apple TV+, Disney+, Peacock, and Tubi which all contain the series name somewhere in their URL. They donā€™t make it easy for you to read them, because thereā€™s some amount of junk mixed in there too, but itā€™s possible.

When I see a URL like that, I want to test if they actually validate the human-readable bit. Letā€™s see:

Site Example series Fake URL
Apple TV+ Ted Lasso https://tv.apple.com/us/show/sad-soccer-show/umc.cmc.vtoh0mn0xn7t3c643xqonfzy
Disney+ Bluey https://www.disneyplus.com/series/sad-dog-show/1xy9TAOQ0M3r
Peacock Killing It https://www.peacocktv.com/watch/asset/tv/silly-snake-scenarios/5156438808822262112

And indeed, two out of those three work. Good for Peacock going that extra mile.

In a distant last place, of course, are Hulu, Netflix, Max, and Amazon Prime, which donā€™t make an effort to be human-readable at all. Boo.