Layers

2022-04-01

The problem

Pressing (not holding) ⌘-tab is useful for swapping between apps. If you ⌘-tab from your editor to your browser, you can then ⌘-tab back to your editor. You can jump back and forth between your editor and browser with just a quick ⌘-tab keystroke. It is a great workflow as long as you're using exactly two apps.

Then you get a Slack message and after giving Slack focus, ⌘-tab takes you back to your browser, the next ⌘-tab sends you back to Slack.

Sigh. OK, so we hold ⌘ and push tab until we're back to our editor and then hold ⌘ and push tab until we're back to our browser and now we can hit ⌘-tab to quickly jump between the two again. Order is restored.

This is mostly a minor nuisance except that this can happen dozens of times throughout the day. Little frictions add up and break flow.

The journey

I tried using Spaces a few times before but the animation was honestly slightly nauseating. If you turn on reduce motion then Spaces changes from a dizzying sliding animation to a more subtle fade animation. The fade animation is pretty tolerable and you can drag whatever applications you want to the proper Spaces and jump around with the keyboard. Success!

Except that somehow the reduce motion setting stopped working for me after a reboot -- it is still checked, it just doesn't do anything and I'm back to the abrasive sliding animation.

Gross. I started looking at other options. yabai looks powerful but requires you to disable System Integrity Protection (permanently, not just during installation) and that's a non-starter for me.

The solution

Taking a step back, what am I really trying to accomplish? I want to be able to press a key and jump to an app (or group of apps). Ideally I don't have to wrangle app the apps into the right workspace like I did with Spaces after every reboot.

Fine. I'll write some AppleScript. After lots of Googling and some optimization, I ended up with a script I'm calling Layers.

How does it work? Create a file in your home directory named .layers. Each line of the file should include a comma-separated list of applications in that layer. Running the layers script will generate compiled AppleScripts to focus applications and spit out a command to bind to a keyboard shortcut.

My .layers file looks like this:

Alacritty
Firefox, Google Chrome, Safari
Slack
Messages, Music, Stream Deck
zoom.us

The layers output is

Add keyboard shortcuts for the following:
ls /Users/ship/.layers_scripts/layer_0*_compiled | xargs -L 1 -P 8 osascript
ls /Users/ship/.layers_scripts/layer_1*_compiled | xargs -L 1 -P 8 osascript
ls /Users/ship/.layers_scripts/layer_2*_compiled | xargs -L 1 -P 8 osascript
ls /Users/ship/.layers_scripts/layer_3*_compiled | xargs -L 1 -P 8 osascript
ls /Users/ship/.layers_scripts/layer_4*_compiled | xargs -L 1 -P 8 osascript

I assign each line starting with ls to a shortcut via an Alfred workflow but you can use any tool that lets you map bash commands to keyboard shortcuts. I'm using ctrl-1 through ctrl-5 since those worked well enough for me with Spaces.

The ls ... command finds all the compiled scripts for a layer and passes them to xargs to run in parallel (up to 8 at a time).

The compilation + parallelization felt like overengineering at first, but it took little time to implement and has a real impact on responsiveness.

Pressing ctrl-2 will bring Firefox, Google Chrome, and Safari to the forefront of my screen. It skips any apps that aren't running. I can press ctrl-1 to get back to Alacritty where my editor is running.

This is better for me than ⌘-tab because it is predictable -- the positions of things never change. This is better for me than Spaces because there's no distracting animations.

There's one downside I can think of when compared to Spaces -- you can't have different windows of an Application mapped to different Layers. I didn't use this functionality in Spaces so I don't miss it.

Why "Layers"? Spaces are effectively distinct virtual screens. Layers don't have this same isolation and apps overlap. This overlap allows for composability and use-cases not allowed by space.

I've been using it a few days now and it feels fantastic. Take it for a spin and let me know how it works for you.


Ruby Custom Code Actions Plugin

2021-12-31

I've packaged up the code action from the last post into a neovim plugin: ruby-code-actions.nvim. Since publishing the plugin, I've added new code actions for correcting the current line, selected lines, or entire file with RuboCop.

I wrote it using plenary for unit testing and found the experience lovely. It has good mocking support for vim.api and your custom code. I couldn't out the correct way to mock things like vim.cmd and such but I worked around this by using vim.api equivalents or wrapping calls and mocking the wrappers. I can't say enough nice things about plenary or TJ DeVries' work on telescope and neovim itself.

In the end, I have a spec file I can run via make and the development process feels more like the comfortable TDD I'm used to.

spec results

null-ls.nvim custom code actions

2021-12-31

NOTE: since this was published, I've released a plugin with this code action and more: ruby-code-actions.nvim.

Language servers are a great tool for empowering your editor, but your language server might not support every feature you want. So what can you do? Bring your own functionality!

What are my options for custom LSP features?

So far, I've looked into efm-langserver and null-ls.nvim.

efm-langserver is an editor-agnostic language server meant for helping you wire up external commands as diagnostics, code actions, etc. It looks promising but I've had trouble getting it working with neovim. YMMV.

Fortunately, for neovim we have null-ls.nvim. It allows you to "Use Neovim as a language server to inject LSP diagnostics, code actions, and more via Lua."

You might worry that writing custom actions in Lua means you're building something less portable than what you'd get with a script wired up to efm-langserver. Maybe. But you could always throw your Lua code in a shell script and call that from null-ls or from efm-langserver. The benefit of null-ls running Lua directly in neovim is that rather than communicating by shelling out processes, you're keeping everything in-process.

My null-ls setup

Here's my current packer.nvim config for null-ls:

use {
    'jose-elias-alvarez/null-ls.nvim',
    requires = {{'neovim/nvim-lspconfig'}, {'nvim-lua/plenary.nvim'}},
    config = function()
        local null_ls = require("null-ls")
        local sources = {
            null_ls.builtins.code_actions.gitsigns,
            null_ls.builtins.formatting.prettier,
            null_ls.builtins.formatting.mix,
            null_ls.builtins.formatting.rubocop,
            null_ls.builtins.diagnostics.rubocop,
            null_ls.builtins.diagnostics.shellcheck
        }
        require("custom_code_actions")
        null_ls.setup({sources = sources, debug = true})
    end
}

You'll note that null-ls provides some nice builtins for formatting, diagnostics, and code actions. Unfortunately there's no builtins for ruby code actions.

I keep my custom code actions in custom_code_actions.lua and register them with the require("custom_code_actions") line.

Let's write a simple code action to see how it works. We'll write a code action to insert the frozen_string_literal directive in our ruby file if it isn't already present.

How do we define a custom action?

Let's look at the skeleton of a code action:

local null_ls = require("null-ls")

local frozen_string_actions = {
    method = null_ls.methods.CODE_ACTION,
    filetypes = {"ruby"},
    generator = {
        fn = function(context)
          -- ...
        end
    }
}
null_ls.register(frozen_string_actions)

What's going on here?

  • We require null_ls to reference.
  • We define a table named frozen_string_actions.
  • It has a method key to specify that we're defining a code action.
  • It has a filetypes array that specifies which file types we want this to apply to.
  • It has a generator function that takes a param (here named context) describing the current context of the file in your editor (current selection, etc).
  • Finally, we register our code action with null-ls

The generator function

The generator function is invoked when the editor is considering which code actions to present to the user. The function should consider the context of the current file and return a table of relevant actions. It could return nothing if no actions are relevant.

Context params

Let's see what the context argument passed to our generator contains.

We create test.rb with the content

puts "Hello world"

If we change the body of our generator body to be print(vim.inspect(context)), open our test.rb in neovim, and request available code actions (I'm using :Telescope lsp_code_actions), our generator will be invoked, and we'll see the table passed in as context:

{
  bufname = "/private/tmp/out.rb",
  bufnr = 1,
  client_id = 1,
  col = 16,
  content = { 'puts "Hello world"', "" },
  ft = "ruby",
  lsp_method = "textDocument/codeAction",
  method = "NULL_LS_CODE_ACTION",
  range = {
    col = 17,
    end_col = 17,
    end_row = 1,
    row = 1
  },
  row = 1
}

For our needs, the important part is content. If the first line of the file, context.content[1], is something other than the frozen_string_literal directive, we should return an action to allow the user to insert the directive. If it does match the directive, our action isn't applicable so we return nothing.

The implementation

Here's an implementation of the generator function to conditionally insert the directive:

fn = function(context)
    frozen_string_literal_comment = "# frozen_string_literal: true"

    first_line = context.content[1]

    if first_line ~= frozen_string_literal_comment then
        return {
            {
                title = "🥶Add frozen string literal comment",
                action = function()
                    lines = {frozen_string_literal_comment, "", first_line}

                    vim.api
                        .nvim_buf_set_lines(context.bufnr, 0, 1, false, lines)
                end
            }
        }
    end
end

Walking through, we see that:

  1. We declare a variable with the directive content
  2. We get the first line of the file
  3. We check to see if they differ
  4. If they do, we return a table with a title that shows up as a prompt for the user and an action that is executed if the user selects our code action.

Here's what the title looks like in :Telescope lsp_code_actions:

our title in Telescope lsp_code_actions

After selecting our code action, we see our file has been updated with the directive:

the result of our code action

There's not much more to say here that you can't learn with :help (e.g. :help nvim_buf_set_lines).

Here's a gist with the entire implementation.

What's next?

Since we determine in our generator if the code action should apply, we might also want to consider the user's selection. If they've selected multiple lines of text, they're probably not intending to invoke a code action to add the frozen_string_literal directive. We could return nothing if we saw that the context.range.row differs from the context.range.end_row.

In a standalone ruby script, the frozen_string_literal directive can also appear on the second line. We could update our generator to insert the directive on the second line if there's a shebang line on line 1. We could return no action if the directive is on either the first or second line.

Have fun!

My goofy face

Hi, I'm Jeffrey Chupp.
I solve problems, often with code.