When you can’t remember where you put things to ensure you don’t forget them

I’ve often said that some of the things I write here are more for my benefit than for yours. I figure that going through the effort of writing out a procedure and posting it will help me remember it. And if I can’t remember the procedure itself, at least I’ll remember that I wrote about it once, and I’ll be able to search the blog for it.

So it really pisses me off to realize that I’ve solved a problem, written about it, and then had absolutely no memory of it whatsoever when the problem reappeared. I am talking, unfortunately, about SnapClip again. You’re tired of it, I’m tired of it, but I need to get this out because there’s a much better solution to the “path to optipng” problem than what I came up with earlier today. You can find the answer on the Keyboard Maestro Wiki and, damningly, here on this very blog in a post from only four years ago.

I was finally reminded that this was a solved problem by a tweet from Vítor Galvão. He suggested starting my Python script with a change to the PATH environment variable: adding both /opt/homebrew/bin and /usr/local/bin. Having extra directories in your PATH doesn’t hurt anything, even if some of those directories don’t exist. Vítor does this with Ruby scripts he runs from within Alfred.

But in Keyboard Maestro there’s a better way.

  1. Open up the Variables preference pane and add a variable called ENV_PATH.
  2. Set it to whatever you would like your PATH to be when you run shell scripts within Keyboard Maestro. I use1

    /Users/drdrang/Dropbox/bin:
    /Users/drdrang/opt/anaconda/bin:
    /opt/homebrew/bin:
    /usr/local/bin:
    /usr/bin:
    /bin:
    /usr/sbin:
    /sbin
    

    Note that I’ve put each directory on its own line to make it easy to read. The real value of ENV_PATH has all those lines mashed together:

    /Users/drdrang/Dropbox/bin:/Users/drdrang/opt/anaconda/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
    
  3. Never worry about your PATH again. Every executable in those directories will be available to you as if you were working at the Terminal. No need to figure out absolute paths.

So my final (I hope) iteration on the SnapClip script is this:

 1: #!/usr/bin/python3
 2: 
 3: import Pashua
 4: import tempfile
 5: from PIL import Image
 6: import sys, os
 7: import subprocess
 8: import shutil
 9: from datetime import datetime
10: 
11: # Local parameters
12: type = "png"
13: localdir = os.environ['HOME'] + "/Pictures/Screenshots"
14: tf, tfname = tempfile.mkstemp(suffix='.'+type, dir=localdir)
15: bgcolor = (85, 111, 137)
16: border = 16
17: desktop = os.environ['HOME'] + "/Desktop/"
18: fname = desktop + datetime.now().strftime("%Y%m%d-%H%M%S." + type)
19: 
20: # Dialog box configuration
21: conf = b'''
22: # Window properties
23: *.title = Snapshot
24: 
25: # Border checkbox properties
26: bd.type = checkbox
27: bd.label = Add background
28: bd.x = 10
29: bd.y = 60
30: 
31: # Save file checkbox properties
32: sf.type = checkbox
33: sf.label = Save file to Desktop
34: sf.x = 10
35: sf.y = 35
36: 
37: # Default button
38: db.type = defaultbutton
39: db.label = Clipboard
40: 
41: # Cancel button
42: cb.type = cancelbutton
43: '''
44: 
45: # Capture a portion of the screen and save it to a temporary file.
46: status = subprocess.call(["screencapture", "-io", "-t", type, tfname])
47: 
48: # Exit if the user canceled the screencapture.
49: if not status == 0:
50:   os.remove(tfname)
51:   sys.exit()
52: 
53: # Open the dialog box and get the input.
54: dialog = Pashua.run(conf)
55: if dialog['cb'] == '1':
56:   os.remove(tfname)
57:   sys.exit()
58: 
59: # Add a desktop background border if asked for.
60: snap = Image.open(tfname)
61: if dialog['bd'] == '1':
62:   # Make a solid-colored background bigger than the screenshot.
63:   snapsize = tuple([ x + 2*border for x in snap.size ])
64:   bg = Image.new('RGBA', snapsize, bgcolor)
65:   bg.alpha_composite(snap, dest=(border, border))
66:   bg.save(tfname)
67: 
68: # Optimize the file.
69: subprocess.call(['optipng', '-quiet', tfname])
70: 
71: # Put the image on the clipboard.
72: subprocess.call(['impbcopy', tfname])
73: 
74: # Save to Desktop if asked for.
75: if dialog['sf'] == '1':
76:   shutil.copyfile(tfname, fname)
77: 
78: # Delete the temporary file
79: os.remove(tfname)

As you can see, there’s no more need to provide absolute paths to impbcopy or optipng in the “local parameters” section. They appear as short, simple strings in Lines 69 and 72. Setting up the proper environment in Keyboard Maestro, which I have known how to do for four years, solves everything and works on every Mac.

I promise I will stop writing about SnapClip even if I find another improvement.


  1. No, my username is not “drdrang,” it’s my real name. 


SnapClip, Homebrew, and sysctl

In my last post, I said that I made a change the SnapClip script because Homebrew had changed where the OptiPNG utility was installed. That was partly true, but not entirely. Yesterday, I got bit by the part that wasn’t true and had to change the script again. In the process, I learned a couple of things that will be useful to me and might be useful to you.

First, Homebrew’s installation directory has changed, but only on Apple Silicon Macs. From the FAQ:

Homebrew’s pre-built binary packages (known as bottles) of many packages can only be used if you install in the default installation prefix, otherwise they have to be built from source. Building from source takes a long time, is prone to fail, and is not supported. Do yourself a favour and install to the default prefix so that you can use our pre-built binary packages. The default prefix is /usr/local for macOS on Intel, /opt/homebrew for macOS on ARM, and /home/linuxbrew/.linuxbrew for Linux. Pick another prefix at your peril!

I was doing the update to SnapClip on my new M1 MacBook Air (which I intend to write about soon) when I noticed that the optipng binary was in /opt/homebrew/bin instead of /usr/local/bin, where it was on my iMac. I thought this was a universal change, one that would propagate to my iMac after a Homebrew update (or reinstallation), so I changed the opting path. That led to an error the next time I tried to use SnapClip on my iMac. It was then that I learned how the Homebrew people have decided that Apple Silicon Macs are a different beast entirely and need their own directory distinct from that of Intel Macs.

My problem is that my Keyboard Maestro macros are shared among my computers. How was I going to set

optipng = '/opt/homebrew/bin/optipng'

on one computer and

optipng = '/usr/local/bin/optipng'

on another?

There are at least a few ways.

  1. I could access the HOMEBREW_PREFIX environment variable and extend it to get the full path to optipng. This would be the cleanest solution, but unfortunately, HOMEBREW_PREFIX isn’t available when a script is running under Keyboard Maestro. The environment there isn’t the same as it is when you’re working in a shell. And although I could set the environment variable in Keyboard Maestro’s Variables preference pane, I’d have to do that for every computer I have now and every one I get in the future. I’d prefer a solution that doesn’t force me to remember these kinds of setup details.
  2. I could use Keyboard Maestro’s %MacName% token to distinguish between computers. This turns out to be more complicated than it ought to be because although Keyboard Maestro’s variables can be accessed in shell/Perl/Python/Ruby scripts, Keyboard Maestro’s tokens cannot. I’d have to add a step to SnapClip that uses the token to set a variable and then access that variable in the Python script. Seemed too messy.
  3. I could get the computer’s name directly within the Python script by running scutil as a subprocess:

    cname = subprocess.check_output(['scutil', '--get', 'ComputerName']).strip()
    if cname == '2020Air':
      optipng = '/opt/homebrew/bin/optipng'
    else:
      optipng = '/usr/local/bin/optipng'
    

    This is fine, but the name of my computer reflects its architecture only by coincidence. Also, I’d have to adjust this the next time I buy an Apple Silicon Mac.

  4. I could get the CPU by running sysctl as a subprocess:

    optipng = {'Intel(R)': '/usr/local/bin/optipng', 'Apple': '/opt/homebrew/bin/optipng'}
    chipmaker = subprocess.check_output(['sysctl', '-n', 'machdep.cpu.brand_string']).decode().split()[0]
    

    Later in the script, when it was time to run optipng, I’d do it this way:

    subprocess.call([optipng[chipmaker], '-quiet', tfname])
    

    This was the one I liked. No extra macro steps, and the path to the optipng was set by the processor, not by the computer’s name. This logic will keep working on my next M1 Mac.1

I’m not going to pretend I knew about sysctl before yesterday. I found it by Googling around for a solution to my problem and hitting on this old OSXDaily page. On my 2020 MacBook Air, the output of

sysctl -n machdep.cpu.brand_string

is

Apple M1

which is short and sweet, but not very informative if you’re looking for seekret details on Apple Silicon.

On my 2017 iMac, the output is

Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz

which is filled with both technical and legal detail.

If you go to the man page, you’ll find that sysctl should have lots of goodies inside, but I found most of the variables empty. Oh, well.

The upshot of this newfound knowledge is this slightly changed script as the only step in SnapClip:

 1: #!/usr/bin/python3
 2: 
 3: import Pashua
 4: import tempfile
 5: from PIL import Image
 6: import sys, os
 7: import subprocess
 8: import shutil
 9: from datetime import datetime
10: 
11: # Local parameters
12: type = "png"
13: localdir = os.environ['HOME'] + "/Pictures/Screenshots"
14: tf, tfname = tempfile.mkstemp(suffix='.'+type, dir=localdir)
15: bgcolor = (85, 111, 137)
16: border = 16
17: desktop = os.environ['HOME'] + "/Desktop/"
18: fname = desktop + datetime.now().strftime("%Y%m%d-%H%M%S." + type)
19: impbcopy = os.environ['HOME'] + '/Dropbox/bin/impbcopy'
20: optipng = {'Intel(R)': '/usr/local/bin/optipng', 'Apple': '/opt/homebrew/bin/optipng'}
21: chipmaker = subprocess.check_output(['sysctl', '-n', 'machdep.cpu.brand_string']).decode().split()[0]
22: 
23: # Dialog box configuration
24: conf = b'''
25: # Window properties
26: *.title = Snapshot
27: 
28: # Border checkbox properties
29: bd.type = checkbox
30: bd.label = Add background
31: bd.x = 10
32: bd.y = 60
33: 
34: # Save file checkbox properties
35: sf.type = checkbox
36: sf.label = Save file to Desktop
37: sf.x = 10
38: sf.y = 35
39: 
40: # Default button
41: db.type = defaultbutton
42: db.label = Clipboard
43: 
44: # Cancel button
45: cb.type = cancelbutton
46: '''
47: 
48: # Capture a portion of the screen and save it to a temporary file.
49: status = subprocess.call(["screencapture", "-io", "-t", type, tfname])
50: 
51: # Exit if the user canceled the screencapture.
52: if not status == 0:
53:   os.remove(tfname)
54:   sys.exit()
55: 
56: # Open the dialog box and get the input.
57: dialog = Pashua.run(conf)
58: if dialog['cb'] == '1':
59:   os.remove(tfname)
60:   sys.exit()
61: 
62: # Add a desktop background border if asked for.
63: snap = Image.open(tfname)
64: if dialog['bd'] == '1':
65:   # Make a solid-colored background bigger than the screenshot.
66:   snapsize = tuple([ x + 2*border for x in snap.size ])
67:   bg = Image.new('RGBA', snapsize, bgcolor)
68:   bg.alpha_composite(snap, dest=(border, border))
69:   bg.save(tfname)
70: 
71: # Optimize the file.
72: subprocess.call([optipng[chipmaker], '-quiet', tfname])
73: 
74: # Put the image on the clipboard.
75: subprocess.call([impbcopy, tfname])
76: 
77: # Save to Desktop if asked for.
78: if dialog['sf'] == '1':
79:   shutil.copyfile(tfname, fname)
80: 
81: # Delete the temporary file
82: os.remove(tfname)

I suspect I’ll have to use the sysctl trick on several macros, as I’ve never been shy about using utilities from Homebrew.


  1. Assuming Homebrew doesn’t decide to change the location of its binaries in the meantime. 


A SnapClip update

Last night, I made some long-overdue updates to my Keyboard Maestro screenshot macro, SnapClip.

Running SnapClip (through ⌃⌥⌘4) starts out like taking a screenshot with ⇧⌘4. You can choose a rectangular portion of the screen or a particular window. It then asks if you want to save the file to the Desktop or put a border around the image (which improves the look of full-window screenshots).

SnapClip dialog box

Whatever you choose, it puts the image on the clipboard, which is convenient if the reason you took the screenshot was to paste it into an application.

You can get an overview of SnapClip’s construction and the nonstandard utilities it uses—Pashua, Pillow, impbcopy, and OptiPNG—at the link. Here, I’m just going to talk about the changes.

The biggest change is switching from Python 2, the only version of Python that came with OS X/macOS until Catalina, to Python 3. That meant installing the Pillow module for Python 3, which I did from the command line via

sudo /usr/bin/python3 -m pip install --upgrade Pillow

With that done, I edited the Python script that makes up the single step of my SnapClip macro:

 1:  #!/usr/bin/python3
 2:  
 3:  import Pashua
 4:  import tempfile
 5:  from PIL import Image
 6:  import sys, os
 7:  import subprocess
 8:  import shutil
 9:  from datetime import datetime
10:  
11:  # Local parameters
12:  type = "png"
13:  localdir = os.environ['HOME'] + "/Pictures/Screenshots"
14:  tf, tfname = tempfile.mkstemp(suffix='.'+type, dir=localdir)
15:  bgcolor = (85, 111, 137)
16:  border = 16
17:  desktop = os.environ['HOME'] + "/Desktop/"
18:  fname = desktop + datetime.now().strftime("%Y%m%d-%H%M%S." + type)
19:  impbcopy = os.environ['HOME'] + '/Dropbox/bin/impbcopy'
20:  optipng = '/opt/homebrew/bin/optipng'
21:  
22:  # Dialog box configuration
23:  conf = b'''
24:  # Window properties
25:  *.title = Snapshot
26:  
27:  # Border checkbox properties
28:  bd.type = checkbox
29:  bd.label = Add background
30:  bd.x = 10
31:  bd.y = 60
32:  
33:  # Save file checkbox properties
34:  sf.type = checkbox
35:  sf.label = Save file to Desktop
36:  sf.x = 10
37:  sf.y = 35
38:  
39:  # Default button
40:  db.type = defaultbutton
41:  db.label = Clipboard
42:  
43:  # Cancel button
44:  cb.type = cancelbutton
45:  '''
46:  
47:  # Capture a portion of the screen and save it to a temporary file.
48:  status = subprocess.call(["screencapture", "-io", "-t", type, tfname])
49:  
50:  # Exit if the user canceled the screencapture.
51:  if not status == 0:
52:    os.remove(tfname)
53:    sys.exit()
54:  
55:  # Open the dialog box and get the input.
56:  dialog = Pashua.run(conf)
57:  if dialog['cb'] == '1':
58:    os.remove(tfname)
59:    sys.exit()
60:  
61:  # Add a desktop background border if asked for.
62:  snap = Image.open(tfname)
63:  if dialog['bd'] == '1':
64:    # Make a solid-colored background bigger than the screenshot.
65:    snapsize = tuple([ x + 2*border for x in snap.size ])
66:    bg = Image.new('RGBA', snapsize, bgcolor)
67:    bg.alpha_composite(snap, dest=(border, border))
68:    bg.save(tfname)
69:  
70:  # Optimize the file.
71:  subprocess.call([optipng, '-quiet', tfname])
72:  
73:  # Put the image on the clipboard.
74:  subprocess.call([impbcopy, tfname])
75:  
76:  # Save to Desktop if asked for.
77:  if dialog['sf'] == '1':
78:    shutil.copyfile(tfname, fname)
79:  
80:  # Delete the temporary file
81:  os.remove(tfname)

There are five changes to the script:

  1. The obvious change is the switch to Python 3 in the shebang line.
  2. Because I revamped my Homebrew setup, the location of the OptiPNG binary has changed from /usr/local/bin/ to /opt/homebrew/bin/. That change is reflected in Line 20.
  3. Big Sur eliminated Solid Aqua Blue from its list of standard Desktop colors, so I made a small change to the color of my Desktop, and I wanted that change reflected in the background color SnapClip uses when creating a full-window screenshot. The new background color is set in Line 15.
  4. I changed the wording of one of the labels in the dialog box that appears after the screenshot is taken. That change is in Line 29.
  5. Recent versions of macOS have rounded the corners of windows, and SnapClip didn’t handle that properly in full-window screenshots. It used to use the Pillow paste function to overlay the window image onto the Desktop color. There was no accounting for transparency. That worked fine when Mac windows had square corners, but not with rounded corners, as you can see here:

    Corner without transparency

    The solution was to change the solid-color background image’s mode from RGB to RGBA (RGB with an alpha channel) in Line 66 and overlay the window image on Line 67 with the alpha_composite function instead of paste. That got rid of the little black triangles at every corner.

It’s been about four years since I last tweaked SnapClip. With luck, I’ll get the same amount of time out of this update.


Some bc stuff

When I sit down at my computer to do a longish series of calculations, I open a Jupyter console session. That gives me an interactive Python environment with all of NumPy and SciPy at my disposal. But when I just want to do a quick little calculation or two, the time it takes to start up Jupyter gets in the way. That’s when bc comes in handy.

I don’t know why bc is named bc. The c part is obviously “calculator,” but I’m not sure what the b is for—”basic,” maybe? It was originally a front end for the older dc (“desk calculator”), but the GNU version, which is what we have on our Macs, is a standalone program.

Update Feb 23, 2021 2:05 PM
According to this page, the b stands for “Bell,” which seems plausible.

Update Feb 22, 2021 2:24 PM
Kevin Likes remembers that where he went to school the b was thought to be “bignum.” Also plausible, since one of the bc’s main features is its ability to do math at arbitrarily large precision.

If anyone knows Lorinda Cherry, can you ask her?

I used to think bc was useless—I don’t trust a calculator that tells me 1/2 = 0—but I changed my mind after seeing what John Cook has done with it (here’s a good example). Although its defaults are weird, bc is quite nice if you start it up the right way, which is

bc -lq

where the -l option tells it to load in it small library of functions and sets the scale parameter (which controls the number of digits after the decimal point) to 20, and the -q option tells to start up without printing out the copyright information. Rather than remembering to type this every time, I have an alias in my .bashrc file:

alias bc='bc -lq'

I said bc has a small library of functions, and I wasn’t kidding. Here they are:

bc function math function
s(x) sine
c(x) cosine
a(x) arctangent
l(x) natural log
e(x) exponential
j(n, x) nth Bessel function

The more time you spend with Unix, the more you realize how much the folks from Bell Labs hated typing.

I like terse commands, too, but I think bc goes too far. Also, I prefer having a tangent function to constantly dividing sine by cosine. So I have a dotfile in my home directory, .bc, that defines the common names for the standard functions and adds a few more to make my life easier. Here it is:

 1:  # Trig
 2:  pi = 4*a(1)
 3:  define deg(x) { return 180*x/pi }
 4:  define rad(x) { return pi*x/180 }
 5:  
 6:  define t(x) { return s(x)/c(x) }
 7:  define sin(x) { return s(x) }
 8:  define sind(x) { return s(rad(x)) }
 9:  define cos(x) { return c(x) }
10:  define cosd(x) { return c(rad(x)) }
11:  define tan(x) { return t(x) }
12:  define tand(x) { return t(rad(x)) }
13:  define atan(x) { return a(x) }
14:  define atand(x) { return deg(a(x)) }
15:  
16:  define asin(x) {
17:   if (x == 1) {
18:     ans = pi/2
19:   } else {
20:      if (x == -1) {
21:       ans = -pi/2
22:      } else {
23:        ans = a(x/sqrt(1 - x^2))
24:      }
25:    }
26:    return ans
27:  }
28:  define asind(x) { return deg(asin(x)) }
29:  
30:  define acos(x) {
31:    if (x == 0 ) {
32:      ans = pi/2
33:    } else {
34:      if (x > 0) {
35:        ans = a(sqrt(1 - x^2)/x)
36:      } else {
37:        ans = a(sqrt(1 - x^2)/x) + pi
38:      }
39:    }
40:    return ans
41:  }
42:  define acosd(x) { return deg(acos(x)) }
43:  
44:  # Exponential
45:  define exp(x) { return e(x) }
46:  define log(x) { return l(x) }
47:  define ln(x) { return l(x) }
48:  define log10(x) { return l(x)/l(10) }
49:  define log2(x) { return l(x)/l(2) }

Most of this is pretty obvious, but a few comments are in order:

To get these definitions loaded into bc, I have this line in my bashrc:

export BC_ENV_ARGS=$HOME/.bc

BC_ENV_ARGS is a poorly named environment variable that tells bc to run the given file on startup. With this definition and the alias mentioned above, whenever I type bc at the command line, I get all of these functions and 20 decimal places of precision.

If I ever feel the need for more functions, this page has quite a few.

One last thing. When you don’t give it a file name argument, bc goes into interactive mode. This is nice when you have a few calculations to do, but it does force you to quit bc when you’re done (which you can do with either the quit command or by typing ⌃D). If you have just one calculation to do, it’s faster to give bc the command through a here string, like this:

bc <<< "5*cos(35) - 5*sin(35)"

Of course, like the Bell Labs people, I’m far too lazy to type out all those less than signs, and I can never remember to type the opening quotation mark before the calculation. So I made a TextExpander snippet that types out the boilerplate and puts the cursor between the quotation marks. All I have to type is the calculation itself.

bc snippet for single calculation