Skip to content

Instantly share code, notes, and snippets.

@rnmhdn
Created May 12, 2026 09:11
Show Gist options
  • Select an option

  • Save rnmhdn/95dbf360587bbc0ea91c4592ecfecb30 to your computer and use it in GitHub Desktop.

Select an option

Save rnmhdn/95dbf360587bbc0ea91c4592ecfecb30 to your computer and use it in GitHub Desktop.
A lightweight dictionary lookup tool for Linux that displays word definitions in a beautiful popup window and automatically adds them to your Anki flashcard deck. Perfect for language learners who want a seamless look-up-to-flashcard workflow.

📚 Lookup — Dictionary Popup with Automatic Anki Card Creation

A lightweight dictionary lookup tool for Linux that displays word definitions in a beautiful popup window and automatically adds them to your Anki flashcard deck. Perfect for language learners who want a seamless look-up-to-flashcard workflow.

What It Does

  1. Looks up a word using the dict command with the WordNet dictionary
  2. Displays the definition in a stylish AwesomeWM popup (with scroll support)
  3. Automatically adds the word and definition to an Anki-compatible TSV file
  4. Avoids duplicates — tells you if a word is already in your deck

Files

  • lookup — The Zsh script that performs the lookup and Anki card management
  • awesomewm-config.lua — AwesomeWM keybinding configuration for the popup interface

Requirements

System Dependencies

  • zsh — Shell interpreter
  • dict and dictd — Dictionary client and server (with dict-wn for WordNet)
  • xsel — X11 selection tool (for clipboard integration)
  • flock — File locking utility (usually part of util-linux)

Optional (for AwesomeWM integration)

  • AwesomeWM window manager
  • Adwaita Sans font (or modify the font in the Lua config)

Installation

# Ubuntu/Debian
sudo apt install zsh dict dictd dict-wn xsel

# Arch Linux
sudo pacman -S zsh dictd xsel

# Fedora
sudo dnf install zsh dictd xsel

Setup

  1. Clone the files into place:

    # Make the script executable and move it to your PATH
    chmod +x lookup
    cp lookup ~/.local/bin/lookup
  2. Configure the Anki file path in the lookup script:

    ANKI_FILE="$HOME/Projects/Learning/Vocab/anki_import.tsv"

    Change this to wherever you want your Anki import file stored.

  3. Add the AwesomeWM keybinding: Merge the Lua code snippet into your rc.lua configuration file (typically ~/.config/awesome/rc.lua).

Usage

From AwesomeWM

Press Mod + d to look up the currently selected text (X11 primary selection).

From the Terminal

# Look up a specific word
lookup serendipity

# Look up currently selected text (uses X11 primary selection)
lookup

How It Works

  1. The script grabs the word (from argument or X11 selection)
  2. Runs dict -d wn to get the WordNet definition
  3. Displays the full output immediately to stdout
  4. Extracts clean definition lines (removes headers, metadata)
  5. Checks if the word already exists in your Anki TSV file
  6. If new, atomically appends it using file locking (prevents corruption with concurrent lookups)
  7. Returns status: "Added" or "Already in Anki"

The AwesomeWM config catches this output and displays it in a scrollable popup that can be dismissed with a click or Escape key.

The flow from "I wonder what this means" to "it's in my Anki deck" becomes: select text → Mod+d → review definition → start learning.

Anki Integration

The script writes to a TSV file compatible with Anki's import feature:

  • Field 1: Word (tab-separated)
  • Field 2: Definition (properly quoted for CSV/TSV safety)

Importing into Anki

  1. Open Anki
  2. File → Import
  3. Select your anki_import.tsv file
  4. Set field separator to Tab
  5. Make sure "Allow HTML in fields" is checked if your definitions contain formatting
  6. Map fields to your note type

Customization

  • Change the dictionary: Replace wn in dict -d wn with any other dict server database
  • Modify the popup appearance: Edit colors, fonts, and dimensions in the Lua config
  • Change the Anki file format: Adjust the printf statement and field extraction logic

License

Do whatever you want with it. Learning should be free. 📖

#!/usr/bin/env zsh
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# lookup — Dictionary + Anki‑card adder (optimal single‑call version)
#
# Requirements: zsh, dict, xsel, flock
# Usage: lookup [word]
# If word is omitted, uses the X11 primary selection (xsel -o).
#
# Anki TSV: $HOME/anki_import.tsv (tab‑separated, quotes safe)
#~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
set -euo pipefail
# ── Configuration ──────────────────────────────────────────────────────────
ANKI_FILE="$HOME/Projects/Learning/Vocab/anki_import.tsv"
LOCKFILE="${ANKI_FILE}.lock"
# ── Cleanup on any exit ────────────────────────────────────────────────────
cleanup() { rm -f "$LOCKFILE" ; }
trap cleanup EXIT
# ── Dependency checks ──────────────────────────────────────────────────────
command -v dict >/dev/null 2>&1 || { echo >&2 "Error: dict not installed."; exit 1; }
command -v xsel >/dev/null 2>&1 || { echo >&2 "Error: xsel not installed."; exit 1; }
# ── Word source: argument > X11 primary selection ──────────────────────────
if [[ $# -gt 0 ]]; then
WORD="$1"
else
WORD=$(xsel -o 2>/dev/null | head -n1 | tr -d '\n' || true)
fi
WORD="${WORD##[[:space:]]##}"
WORD="${WORD%%[[:space:]]##}"
[[ -z "$WORD" ]] && { echo >&2 "Error: no word given or primary selection empty."; exit 1; }
# ── Single dictionary lookup ───────────────────────────────────────────────
FULL_DEF=$(dict -d wn "$WORD" 2>&1) || { echo >&2 "Error: dict failed for '$WORD'."; exit 1; }
[[ -z "$FULL_DEF" ]] && { echo >&2 "Error: dict returned no output for '$WORD'."; exit 1; }
# Show the user the complete output immediately
printf '%s\n' "$FULL_DEF"
# ── Prepare clean definition for the flashcard ────────────────────────────
# Keep only indented meaning lines from each dictionary block,
# skip headword‑title line, "N definitions found", and "From …" headers.
DEFINITION=$(printf '%s\n' "$FULL_DEF" | awk -v w="$WORD" '
/^[0-9]+ definitions found/ { next } # discard count line
/^From / { block=1; head=1; next } # start of a dict block
block && /^[^[:space:]]/ { block=0; next } # end of indented block
block && NF {
# Remove leading/trailing whitespace for clean storage
sub(/^[[:space:]]+/, "")
sub(/[[:space:]]+$/, "")
# Skip exactly the headword line inside this block
if (head && tolower($0) == tolower(w)) { head=0; next }
head = 0
# Append line to definition, separated by newline
def = def (def ? "\n" : "") $0
}
END {
if (def == "") exit 1
# Trim leading/trailing blank lines from the whole block
sub(/^\n+/, "", def)
sub(/\n+$/, "", def)
print def
}
') || { echo >&2 "Error: no definition lines extracted for '$WORD'."; exit 1; }
# ── Lock, duplicate‑check, and atomically append ───────────────────────────
exec {lock_fd}<> "$LOCKFILE"
flock -x $lock_fd
# Exact match on first field (tab-delimited)
if awk -F'\t' -v w="$WORD" '$1 == w { found=1; exit } END { exit !found }' "$ANKI_FILE" 2>/dev/null; then
STATUS="Already in Anki."
else
# TSV‑safe quoting: double every double quote, then wrap in quotes
ESCAPED="${DEFINITION//\"/\"\"}"
printf '%s\t"%s"\n' "$WORD" "$ESCAPED" >> "$ANKI_FILE"
COUNT=$(grep -cP '^[^\t]+\t"' "$ANKI_FILE")
STATUS="Added (total: $COUNT)"
fi
exec {lock_fd}>&- # release the lock
rm -f "$LOCKFILE" # remove lockfile (trap also cleans, safe to do early)
# ── Final status to the user ───────────────────────────────────────────────
printf '%s\n' "$STATUS"
-- Dictionary lookup popup
awful.key({ modkey }, "d", function()
awful.spawn.easy_async_with_shell("zsh ~/.local/bin/lookup 2>&1", function(stdout, stderr, _, exit_code)
if exit_code ~= 0 or not stdout or stdout == "" then
naughty.notify {
title = "Lookup failed",
text = stderr ~= "" and stderr or "No definition found",
preset = naughty.config.presets.critical
}
return
end
local s = awful.screen.focused()
if not s then return end
local wa = s.workarea
local w = math.min(700, wa.width - 4)
local h = math.min(math.floor(wa.height * 0.7), wa.height - 4)
local x = wa.x + math.floor((wa.width - w) / 2)
local y = wa.y + math.floor((wa.height - h) / 2)
-- No escaping needed for your dict output – but we safely turn \n into <br/>
local markup = '<span font="Adwaita Sans 13" color="'
.. (beautiful.fg_normal or "#cdd6f4") .. '">'
.. stdout .. '</span>'
local popup = wibox {
x = x,
y = y,
width = w,
height = h,
bg = beautiful.bg_normal or "#1e1e2e",
border_width = beautiful.border_width or 2,
border_color = beautiful.border_color or "#89b4fa",
type = "utility",
ontop = true,
visible = true,
widget = wibox.container.margin(
wibox.container.scroll.vertical(
wibox.widget {
markup = markup,
widget = wibox.widget.textbox,
wrap = "word",
valign = "top",
},
{ step = 30 }
), 20, 20, 15, 15),
}
local grabber
local function close()
if grabber then awful.keygrabber.stop(grabber); grabber = nil end
if popup then popup.visible = false; popup = nil end
end
popup:connect_signal("button::press", close)
grabber = awful.keygrabber.run(function(_, key, event)
if key == "Escape" and event == "press" then close(); return true end
return false
end)
popup:connect_signal("property::visible", function()
if not popup.visible then close() end
end)
end)
end, { description = "Dictionary lookup popup", group = "custom" })
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment