Sep 07,
2023

Mind the Gap: Replacing Whitespace with Dots in Zsh

Hello folks!

In the shell I spend too much time renaming files to replace spaces with dots or dashes. Without that, I'm stuck quoting filenames or escaping spaces everywhere. Really tedious.

Why not automate it? Zsh has all the machinery to customize the line editor (ZLE). Shell scripting is awfully inconsistent, but after renaming files manually one too many times, I decided to wade into the mess.

My initial thought: when using mv, have the shell parse the second argument and automatically replace all spaces. Type mv 'foo bar' <duplicate><transform> and get 'foo.bar'. Simple, right?

Maximum effort

This approach has quite a bit of hidden complexity — I was thinking the move command has two arguments: the original file name and the new name. I can duplicate the first argument with Zsh's `copy-earlier-word`` function (which copies the previous word to cursor position), then I only need a way to parse that duplicated string and replace spaces, but only if the cursor is right after a quote symbol.

To duplicate arguments efficiently, I suggest to the interested reader that they bind copy-earlier-word to a chord — I chose Alt-, because it's near Alt-. and both perfom a similar function1:

autoload -Uz copy-earlier-word
zle -N copy-earlier-word
bindkey '^[,' copy-earlier-word  # Alt+,

Usage: mv foo → press Alt+, → mv foo foo

Back to the main argument. This is the result of a couple of hours. When the cursor is right after a quoted string, it will parse the string and replace spaces with dots, handling escaped single quotes in the filename. (I don't check for double quotes — who in their right mind would use double quotes in a filename?)

# Function to replace spaces with dots in quoted string at cursor
replace-spaces-with-dots() {
  # Get position right before cursor
  local pos=$((CURSOR - 1))

  # Check if cursor is right after a quote
  if [[ $pos -ge 0 ]]; then
    local char=${BUFFER:$pos:1}

    # Check if it's a quote character
    if [[ $char == "'" || $char == '"' ]]; then
      local quote_char=$char
      local -a segments
      local search_pos=$pos

      # Find all concatenated quoted segments (handles '\'' pattern)
      while [[ $search_pos -ge 0 ]]; do
        local end_pos=$search_pos
        local start_pos=-1

        # Search backward for matching opening quote
        local k=$((search_pos - 1))
        while [[ $k -ge 0 ]]; do
          if [[ ${BUFFER:$k:1} == $quote_char ]]; then
            # Count preceding backslashes
            local backslash_count=0
            local m=$((k - 1))
            while [[ $m -ge 0 && ${BUFFER:$m:1} == '\' ]]; do
              ((backslash_count++))
              ((m--))
            done
            # If even number of backslashes (including 0), quote is not escaped
            if [[ $((backslash_count % 2)) -eq 0 ]]; then
              start_pos=$k
              break
            fi
          fi
          ((k--))
        done

        # If we found a matching quote, record this segment
        if [[ $start_pos -ge 0 ]]; then
          segments=("$start_pos:$end_pos" "${segments[@]}")

          # Check if there's a '\'' pattern before this segment (for single quotes)
          # Pattern is: ' \ ' '  (4 characters: close quote, backslash, quote, open quote)
          if [[ $quote_char == "'" && $start_pos -ge 3 ]]; then
            local pattern=${BUFFER:$((start_pos-3)):4}
            if [[ $pattern == "'\\''" ]]; then
              # Continue searching before the '\'' pattern (before the closing quote)
              search_pos=$((start_pos - 4))
              continue
            fi
          fi
        fi
        break
      done

      # Process all segments (from left to right)
      if [[ ${#segments[@]} -gt 0 ]]; then
        local total_offset=0
        local seg
        for seg in "${segments[@]}"; do
          local seg_start=${seg%:*}
          local seg_end=${seg#*:}

          # Adjust for previous offsets
          seg_start=$((seg_start + total_offset))
          seg_end=$((seg_end + total_offset))

          # Extract content between quotes
          local content_start=$((seg_start + 1))
          local content_length=$((seg_end - seg_start - 1))
          local quoted_content=${BUFFER:$content_start:$content_length}

          # Replace spaces with dots
          local replacement=${quoted_content// /.}
          local diff=$((${#replacement} - content_length))

          # Update buffer
          BUFFER="${BUFFER:0:$content_start}${replacement}${BUFFER:$seg_end}"

          # Track cumulative offset
          total_offset=$((total_offset + diff))
        done

        # Adjust cursor position
        CURSOR=$((CURSOR + total_offset))
      fi
    fi
  fi
}

# Register the function as a ZLE widget
zle -N replace-spaces-with-dots

# Bind it to Alt+/
bindkey '^[/' replace-spaces-with-dots

What's going on here?

This monstrosity operates on Zsh's line editor buffer ($BUFFER). When triggered, it checks if the cursor sits right after a quote character. If so, it searches backward through the buffer to find the matching opening quote, carefully counting backslashes to distinguish escaped quotes from real delimiters.

But wait, there's more! It handles filenames like 'foo'\''s bar' — the shell's awkward way of embedding a single quote inside single-quoted strings. The function detects these '\'' patterns and treats them as concatenated segments, collecting all pieces that belong together.

Once it maps out all the segments, it extracts the content between quotes, replaces spaces with dots using Zsh's ${var// /.} substitution, then reconstructs the buffer. It even tracks offset changes to keep the cursor position correct as string lengths change.

All this complexity for what? So I could type mv 'foo bar' <Alt+,><Alt+/> and have the second argument magically transformed to 'foo.bar'.

Awesome!

Doh!

Only after debugging the horrible mess above did it occur to me that I was approaching it wrong. Why replace the spaces in the argument when I could simply create a function that takes the file name and renames the file without spaces?

To be fair, the first approach has merit — it works during command construction. It could theoretically be used with cp, ln, or any command where one want to transform a duplicated argument on the fly. But honestly? Beyond mv, I can't think of many practical use cases where this inline string manipulation would be needed.

So, here is the sensibly simpler function spc2dot in all its glory:

# Rename files by replacing spaces with dots
spc2dot() {
    if [[ $# -eq 0 ]]; then
        print "Usage: spc2dot <filename>"
        return 1
    fi

    # Join all arguments (handles unquoted filenames)
    local original="$*"

    # Replace spaces with dots
    local newname="${original// /.}"

    if [[ ! -e "$original" ]]; then
        print "Error: File '$original' not found"
        return 1
    fi

    if [[ "$original" == "$newname" ]]; then
        print "No spaces to replace"
        return 0
    fi

    if [[ -e "$newname" ]]; then
        print "Error: '$newname' already exists"
        return 1
    fi

    mv -v "$original" "$newname"
}

The lesson? Sometimes the clever solution is the wrong solution. A simple wrapper function beats 100 lines of buffer manipulation any day. In the process I learned way too much I needed to know about Zsh.


  1. on default Z_sh it's bound to insert-last-word 

zsh
 
 

Aug 17,
2023

Installing scrcpy with Stow

Greetings terminal dwellers!

Scrcpy lets you display and control Android devices from your desktop over USB or TCP/IP. No root required, minimal latency, works over adb.

Debian ships it, but packages lag behind releases. I build from source and manage with Stow.

Getting the prebuilt server

Scrcpy has two parts: desktop client (what you build) and Android server (prebuilt APK). Download the server binary first:

wget https://github.com/Genymobile/scrcpy/releases/download/v2.2.1/scrcpy-server-v2.2.1

Building with Meson

Extract source, then configure with Meson. Key detail: --prefix=/ not --prefix=/usr/local. Stow handles the prefix via DESTDIR:

meson setup build --buildtype release --strip -Db_lto=true \
    -Dprebuilt_server=scrcpy-server-v2.2.1 --prefix=/

Build flags: release mode, stripped binaries, LTO for smaller size. Point to downloaded server binary.

Compile:

ninja -C build

Installing to Stow directory

Install to version-specific Stow directory instead of system paths:

DESTDIR=/usr/local/stow/scrcpy-2.2.1 ninja -C build install

This creates /usr/local/stow/scrcpy-2.2.1/bin/scrcpy, /usr/local/stow/scrcpy-2.2.1/share/scrcpy/*, etc.

Activate with Stow:

cd /usr/local/stow
sudo stow scrcpy-2.2.1

Symlinks created in /usr/local/bin, /usr/local/share, pointing to Stow directory.

Uninstalling

Remove symlinks:

cd /usr/local/stow
sudo stow -D scrcpy-2.2.1

Delete directory when done:

sudo rm -rf /usr/local/stow/scrcpy-2.2.1

If server path isn't found

Sometimes scrcpy can't locate the server binary. Set the path manually:

export SCRCPY_SERVER_PATH=/usr/local/share/scrcpy/scrcpy-server
scrcpy

Or add to shell rc file for persistence.

Why bother

Distro packages work fine. But building from source means latest features and bug fixes. Stow keeps /usr/local clean—multiple versions coexist peacefully. Upgrades are symmetric: build new version, stow it, stow -D the old one.

Is it necessary? No. Is compiling software more satisfying than apt install? Absolutely. Even when it doesn't matter.

 
 

Dec 03,
2022

Wayland and Dual Role Keys

Welcome Interweb traveler.

In the last few weeks I decided to tackle the lack of options in Wayland/Weston1 to configure a keyboard's keys as "dual role".

Probably I should do a quick recap to explain why I need this kind of machinery. In the last few years I grew annoyed with standard staggered keyboards based on an ancient design that has no right to exist in this day and age.

What I can't stand is the horizontal stagger itself, as if they were still using mechanical levers from the steam age, but also the lack of easily reachable modifiers, as Ctrl and Alt keys in a default layout are probably in the most inconvenient place, at the side of the uselessly humongous space bar.

(ノಠ益ಠ)ノ彡┻━┻

When in the past I've been stuck with a non replaceable keyboard I settled on using a little utility called Xcape to configure the space bar to act as a space when tapped and as a Ctrl modifier when pressed with another key, what's called a "dual role" or "tap and hold" key. Pressing two keys at the same time is called chording, like in music.

Well, I also added left paren to left Shift, right paren to right Shift, and Escape to Capslock, as in Emacs complex combinations like Meta-Ctrl-somethingelse could be simplified by tapping Escape and then doing a plain Ctrl chord instead of doing a more awkward three-key chord2.

Anywho, Xcape is a little cumbersome to configure and to use as there's a little delay that's used to discriminate between one of the two roles, also it's more evident when one types fast enough, but the biggest showstopper lies in its underlying operations as it taps directly into X11 and that's a big no-no under Wayland.

So doing a documentation survey on evdev to tackle the problem myself, I stumbled on a little and relatively unknown utility called evdoublebind that allegedly already did what I was looking for.

“ヽ(´▽`)ノ”

Well, it needs a proper reading of the documentation and because Gnome lacks support for customized XKB rules it also requires modifying the system XKB file directly — /usr/share/X11/xkb/rules/evdev. On Debian I suggest using dpkg-divert to preserve it in case of upgrade — but at least now I've learned how to build a keyboard description file using XKB.

After a few weeks of usage I could say I'm a happy camper, it's extremely reliable as I've never found the keyboard unable to type space characters after resuming the notebook from sleep — as it sometimes happened previously with Xcape — and I have to say that I haven't mistyped a single space, nor have I changed the way I type like when I was using Xcape.

In conclusion, to the reader with my same requirements who followed along this far, I can give my two thumbs up to evdoublebind wholeheartedly.


  1. the first is a protocol, the second is the reference implementation of a Wayland compositor 

  2. the first rule of touch type is pressing a modifier with one hand and the key with the other