cheatsheet_to_shortcuts_json now recognizes a markdown link in a table's first column (e.g. [Admin & Account Setup](cheatsheet_admin.md) in a Command/Shortcut/Sheet table) and emits it as a navigable entry instead of a literal shortcut. cheatsheet_overlay renders such entries as clickable links; clicking one loads and displays that file in place of the current one (path resolved relative to the linking file, arbitrary depth, cycle-safe), with a "< Back" affordance to return. A new "Search all sheets" checkbox instead discovers and combines every sheet reachable from the root into one filterable view. Everything reloads fresh from disk at the moment it's needed (no caching across a click or toggle), so editing any of these .md files takes effect on the very next use, same as the existing per-invocation freshness for the root sheet. Adds cheatsheet_admin.md as the first real sub-sheet: one-time/occasional account and host setup tasks (personal dirs, gopass onboarding, PSI home-sync) pulled out of the main operational reference, linked from a new "Other cheat sheets" section in cheatsheet.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
548 lines
20 KiB
Python
Executable File
548 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Standalone floating overlay panel showing the Bernina cheat sheet,
|
||
generated fresh from cheatsheet.md every time it's shown (via
|
||
cheatsheet_to_shortcuts_json) -- no gnome-shell extension involved, so
|
||
there's no "brand-new extension not recognized until Shell restarts"
|
||
risk: this is just a normal process, like any other keybinding here.
|
||
|
||
Toggle behavior: running this while an instance is already showing closes
|
||
it instead of opening a second one (tracked via a PID file). That PID file
|
||
is verified against /proc before ever being trusted or signaled -- on a
|
||
host where its directory isn't cleared across reboots (confirmed: some
|
||
hosts' /tmp isn't, which the PID file falls back to when XDG_RUNTIME_DIR
|
||
isn't set), a stale PID number could otherwise get reused by a completely
|
||
unrelated process after reboot.
|
||
|
||
Dismiss: Escape, the close button, or pressing the trigger key again.
|
||
|
||
Sub-sheets: a shortcut entry with a "link" field (see
|
||
cheatsheet_to_shortcuts_json -- a markdown link in a table's first column,
|
||
e.g. `[Admin & Account Setup](cheatsheet_admin.md)`) renders as a clickable
|
||
link instead of a plain shortcut. Clicking it loads and displays that file
|
||
in place of the current one, with a "< Back" affordance to return; the
|
||
"Search all sheets" checkbox instead loads the whole reachable set (root
|
||
plus everything linked from it, however deep) into one combined, filterable
|
||
view. Both always re-read from disk at the moment they're needed -- nothing
|
||
is cached across a click or a checkbox toggle, matching the top-level
|
||
per-invocation freshness described in wait_for_source() below -- so editing
|
||
any of these .md files takes effect immediately, no restart of anything
|
||
required.
|
||
"""
|
||
import json
|
||
import os
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
|
||
import gi
|
||
|
||
gi.require_version("Gtk", "3.0")
|
||
gi.require_version("Gdk", "3.0")
|
||
from gi.repository import Gdk, GLib, Gtk # noqa: E402
|
||
|
||
MD_PATH = "/sf/bernina/bin/cheatsheet.md"
|
||
ROOT_TITLE = "Bernina Terminal Cheat Sheet"
|
||
GENERATOR = "/sf/bernina/bin/cheatsheet_to_shortcuts_json"
|
||
PID_FILE = os.path.join(
|
||
os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()),
|
||
"bernina-cheatsheet-overlay.pid",
|
||
)
|
||
MAX_COLUMNS = 3
|
||
|
||
CSS = b"""
|
||
window.cheatsheet-overlay {
|
||
background-color: rgba(20, 22, 26, 0.94);
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
}
|
||
.cs-titlebar {
|
||
margin-bottom: 10px;
|
||
}
|
||
.cs-title {
|
||
color: #ffffff;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
.cs-close-btn {
|
||
color: #e8eaed;
|
||
background: transparent;
|
||
border: none;
|
||
box-shadow: none;
|
||
padding: 2px 10px;
|
||
}
|
||
.cs-close-btn:hover {
|
||
color: #ffffff;
|
||
background-color: rgba(255, 255, 255, 0.15);
|
||
border-radius: 6px;
|
||
}
|
||
.cs-back-btn {
|
||
color: #8ab4f8;
|
||
background: transparent;
|
||
border: none;
|
||
box-shadow: none;
|
||
padding: 2px 10px;
|
||
}
|
||
.cs-back-btn:hover {
|
||
color: #aecbfa;
|
||
background-color: rgba(255, 255, 255, 0.1);
|
||
border-radius: 6px;
|
||
}
|
||
.cs-category {
|
||
color: #8ab4f8;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
margin-top: 10px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.cs-key {
|
||
color: #ffe082;
|
||
font-family: monospace;
|
||
font-size: 12px;
|
||
}
|
||
.cs-link-btn {
|
||
color: #8ab4f8;
|
||
background: transparent;
|
||
border: none;
|
||
box-shadow: none;
|
||
padding: 0;
|
||
}
|
||
.cs-link-btn:hover {
|
||
color: #aecbfa;
|
||
}
|
||
.cs-link-btn label {
|
||
text-decoration: underline;
|
||
font-size: 12px;
|
||
}
|
||
.cs-desc {
|
||
color: #e8eaed;
|
||
font-size: 12px;
|
||
}
|
||
.cs-hint {
|
||
color: #9aa0a6;
|
||
font-size: 11px;
|
||
margin-top: 10px;
|
||
}
|
||
.cs-search {
|
||
background-color: rgba(255, 255, 255, 0.08);
|
||
color: #ffffff;
|
||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||
}
|
||
.cs-search-all {
|
||
color: #9aa0a6;
|
||
font-size: 12px;
|
||
}
|
||
"""
|
||
|
||
|
||
def load_categories(path):
|
||
"""Runs the generator fresh against `path` every call -- no caching --
|
||
so any edit to the source .md is picked up the moment it's needed,
|
||
whether that's a fresh Super+F1 press or a link/checkbox click on an
|
||
already-open overlay."""
|
||
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
|
||
tmp_path = tmp.name
|
||
try:
|
||
subprocess.run([GENERATOR, path, tmp_path], check=True)
|
||
with open(tmp_path, encoding="utf-8") as f:
|
||
return json.load(f)
|
||
finally:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def discover_sheets(root_path, root_label):
|
||
"""BFS from root_path following every "link" target reachable from it
|
||
(however deep), re-reading each file fresh (see load_categories).
|
||
Returns [(path, label, categories), ...], root first; a link target
|
||
that fails to load (missing file, bad markdown) is skipped rather than
|
||
aborting the whole walk, and an already-visited path (a cycle, or two
|
||
sheets linking to the same target) is never re-queued.
|
||
Link paths are resolved relative to the *linking* file's own
|
||
directory, so a sub-sheet can itself link to siblings without needing
|
||
the full path spelled out."""
|
||
visited = set()
|
||
order = []
|
||
queue = [(os.path.normpath(root_path), root_label)]
|
||
while queue:
|
||
path, label = queue.pop(0)
|
||
if path in visited:
|
||
continue
|
||
visited.add(path)
|
||
try:
|
||
categories = load_categories(path)
|
||
except (subprocess.CalledProcessError, OSError, json.JSONDecodeError):
|
||
continue
|
||
order.append((path, label, categories))
|
||
base_dir = os.path.dirname(path)
|
||
for category in categories:
|
||
for shortcut in category["shortcuts"]:
|
||
link = shortcut.get("link")
|
||
if link:
|
||
target = os.path.normpath(os.path.join(base_dir, link))
|
||
if target not in visited:
|
||
queue.append((target, shortcut["key"]))
|
||
return order
|
||
|
||
|
||
def _is_our_process(pid):
|
||
"""True only if `pid` is actually still running *this* script -- not
|
||
just any process that happens to exist. Without this check, a PID
|
||
file surviving somewhere that isn't cleared across reboots (confirmed:
|
||
/tmp isn't, on at least one host here) could have its old PID number
|
||
reused by a completely unrelated process, which we'd otherwise
|
||
mistakenly SIGTERM and treat as "still showing"."""
|
||
try:
|
||
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||
return b"cheatsheet_overlay" in f.read()
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def already_showing():
|
||
"""If a live instance is already up, kill it and return True (caller
|
||
should then just exit, giving Super+F1 toggle-to-close behavior)."""
|
||
try:
|
||
with open(PID_FILE, encoding="utf-8") as f:
|
||
pid = int(f.read().strip())
|
||
if _is_our_process(pid):
|
||
os.kill(pid, signal.SIGTERM)
|
||
os.unlink(PID_FILE)
|
||
return True
|
||
os.unlink(PID_FILE)
|
||
return False
|
||
except (FileNotFoundError, ValueError, ProcessLookupError):
|
||
try:
|
||
os.unlink(PID_FILE)
|
||
except OSError:
|
||
pass
|
||
return False
|
||
|
||
|
||
def category_box(category, on_link):
|
||
"""Returns (box, rows) -- rows is [(key_widget, desc_label, search_text)],
|
||
handed to _apply_filter so it can show/hide individual shortcuts (and,
|
||
via the whole box, an entire category) without rebuilding anything.
|
||
key_widget is a plain label for an ordinary shortcut, or a clickable
|
||
link-styled button (calling on_link(target) when pressed) for an entry
|
||
with a "link" field."""
|
||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||
header = Gtk.Label(label=category["name"], xalign=0)
|
||
header.get_style_context().add_class("cs-category")
|
||
header.set_line_wrap(True)
|
||
box.pack_start(header, False, False, 0)
|
||
|
||
grid = Gtk.Grid(row_spacing=2, column_spacing=10)
|
||
rows = []
|
||
for row, shortcut in enumerate(category["shortcuts"]):
|
||
link = shortcut.get("link")
|
||
if link:
|
||
key_widget = Gtk.Button(label=shortcut["key"])
|
||
key_widget.get_style_context().add_class("cs-link-btn")
|
||
key_widget.set_relief(Gtk.ReliefStyle.NONE)
|
||
key_widget.connect(
|
||
"clicked",
|
||
lambda _b, target=link, label=shortcut["key"]: on_link(target, label),
|
||
)
|
||
else:
|
||
key_widget = Gtk.Label(label=shortcut["key"], xalign=0)
|
||
key_widget.get_style_context().add_class("cs-key")
|
||
key_widget.set_line_wrap(True)
|
||
desc_label = Gtk.Label(label=shortcut["description"], xalign=0)
|
||
desc_label.get_style_context().add_class("cs-desc")
|
||
desc_label.set_line_wrap(True)
|
||
desc_label.set_max_width_chars(40)
|
||
grid.attach(key_widget, 0, row, 1, 1)
|
||
grid.attach(desc_label, 1, row, 1, 1)
|
||
search_text = (shortcut["key"] + " " + shortcut["description"]).lower()
|
||
rows.append((key_widget, desc_label, search_text))
|
||
box.pack_start(grid, False, False, 0)
|
||
return box, rows
|
||
|
||
|
||
def _apply_filter(query, entries):
|
||
"""entries: [(category_box, category_name_lower, rows), ...] from
|
||
category_box() above. A category whose *name* matches shows all its
|
||
shortcuts regardless of their own text (e.g. searching "gtile" shows
|
||
the whole gTile category); otherwise each shortcut is matched
|
||
individually against its own key + description text."""
|
||
query = query.strip().lower()
|
||
for cat_box, cat_name_lower, rows in entries:
|
||
if not query:
|
||
for key_widget, desc_label, _search_text in rows:
|
||
key_widget.set_visible(True)
|
||
desc_label.set_visible(True)
|
||
cat_box.set_visible(True)
|
||
continue
|
||
cat_matches = query in cat_name_lower
|
||
any_visible = False
|
||
for key_widget, desc_label, search_text in rows:
|
||
match = cat_matches or query in search_text
|
||
key_widget.set_visible(match)
|
||
desc_label.set_visible(match)
|
||
any_visible = any_visible or match
|
||
cat_box.set_visible(any_visible)
|
||
|
||
|
||
class Overlay:
|
||
"""Holds the mutable pieces that change as the user navigates between
|
||
sheets or toggles "search all sheets", so link clicks and the checkbox
|
||
can rebuild just the content area in place rather than tearing down
|
||
and recreating the whole window."""
|
||
|
||
def __init__(self):
|
||
self.history = [] # stack of (path, label) visited via links
|
||
self.current = (MD_PATH, ROOT_TITLE)
|
||
self.search_all = False
|
||
self.entries = [] # current [(box, name_lower, rows), ...]
|
||
|
||
def build_chrome(self):
|
||
self.win = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
|
||
self.win.set_title(ROOT_TITLE)
|
||
self.win.get_style_context().add_class("cheatsheet-overlay")
|
||
self.win.set_decorated(False)
|
||
self.win.set_position(Gtk.WindowPosition.CENTER)
|
||
self.win.set_keep_above(True)
|
||
self.win.set_skip_taskbar_hint(True)
|
||
self.win.set_skip_pager_hint(True)
|
||
self.win.set_type_hint(Gdk.WindowTypeHint.DIALOG)
|
||
|
||
screen = self.win.get_screen()
|
||
visual = screen.get_rgba_visual()
|
||
if visual and screen.is_composited():
|
||
self.win.set_visual(visual)
|
||
|
||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||
outer.set_border_width(18)
|
||
outer.pack_start(self._title_bar(), False, False, 0)
|
||
|
||
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
self.back_btn = Gtk.Button(label="← Back")
|
||
self.back_btn.get_style_context().add_class("cs-back-btn")
|
||
self.back_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||
self.back_btn.connect("clicked", lambda *_: self.go_back())
|
||
toolbar.pack_start(self.back_btn, False, False, 0)
|
||
|
||
self.search_entry = Gtk.SearchEntry()
|
||
self.search_entry.get_style_context().add_class("cs-search")
|
||
self.search_entry.set_placeholder_text("Search shortcuts... (Ctrl+F)")
|
||
self.search_entry.connect(
|
||
"changed", lambda e: _apply_filter(e.get_text(), self.entries)
|
||
)
|
||
toolbar.pack_start(self.search_entry, True, True, 0)
|
||
|
||
self.search_all_check = Gtk.CheckButton(label="Search all sheets")
|
||
self.search_all_check.get_style_context().add_class("cs-search-all")
|
||
self.search_all_check.connect("toggled", self._on_search_all_toggled)
|
||
toolbar.pack_start(self.search_all_check, False, False, 0)
|
||
outer.pack_start(toolbar, False, False, 0)
|
||
|
||
self.scroller = Gtk.ScrolledWindow()
|
||
self.scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||
outer.pack_start(self.scroller, True, True, 0)
|
||
|
||
hint = Gtk.Label(
|
||
label="Esc or Super+F1 again to close · Ctrl+F to search",
|
||
xalign=0,
|
||
)
|
||
hint.get_style_context().add_class("cs-hint")
|
||
outer.pack_start(hint, False, False, 0)
|
||
|
||
self.win.add(outer)
|
||
|
||
geo = self.win.get_screen().get_monitor_geometry(
|
||
self.win.get_screen().get_primary_monitor()
|
||
)
|
||
self.win.set_default_size(
|
||
min(1100, int(geo.width * 0.8)), min(800, int(geo.height * 0.8))
|
||
)
|
||
|
||
self.win.connect("key-press-event", self._on_key_press)
|
||
self.win.connect("destroy", lambda *_: Gtk.main_quit())
|
||
|
||
def _title_bar(self):
|
||
"""No window decorations are used (they'd break the floating-overlay
|
||
look), so dragging and closing are reimplemented by hand here --
|
||
click-drag anywhere on the bar moves the window, the button on the
|
||
right closes it."""
|
||
bar = Gtk.EventBox()
|
||
bar.get_style_context().add_class("cs-titlebar")
|
||
|
||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||
self.title_label = Gtk.Label(label=ROOT_TITLE, xalign=0)
|
||
self.title_label.get_style_context().add_class("cs-title")
|
||
row.pack_start(self.title_label, True, True, 0)
|
||
|
||
close_btn = Gtk.Button(label="✕")
|
||
close_btn.get_style_context().add_class("cs-close-btn")
|
||
close_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||
close_btn.connect("clicked", lambda *_: Gtk.main_quit())
|
||
row.pack_start(close_btn, False, False, 0)
|
||
|
||
bar.add(row)
|
||
bar.connect(
|
||
"button-press-event",
|
||
lambda widget, event: self.win.begin_move_drag(
|
||
event.button, int(event.x_root), int(event.y_root), event.time
|
||
)
|
||
if event.button == 1
|
||
else False,
|
||
)
|
||
return bar
|
||
|
||
def _on_key_press(self, _widget, event):
|
||
ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
|
||
if ctrl and event.keyval in (Gdk.KEY_f, Gdk.KEY_F):
|
||
self.search_entry.grab_focus()
|
||
self.search_entry.select_region(0, -1)
|
||
return True
|
||
if event.keyval == Gdk.KEY_Escape:
|
||
# First Escape just clears an active search (typing Ctrl+F, then
|
||
# Escape to see everything again shouldn't also close the whole
|
||
# overlay) -- only close once there's no search text left to
|
||
# clear.
|
||
if self.search_entry.get_text():
|
||
self.search_entry.set_text("")
|
||
return True
|
||
Gtk.main_quit()
|
||
return False
|
||
|
||
def _on_search_all_toggled(self, check):
|
||
self.search_all = check.get_active()
|
||
self._render()
|
||
|
||
def navigate(self, target, label):
|
||
base_dir = os.path.dirname(self.current[0])
|
||
path = os.path.normpath(os.path.join(base_dir, target))
|
||
# A link clicked from within a combined "search all" view still
|
||
# means "go look at that one sheet" -- drop out of that mode first.
|
||
if self.search_all:
|
||
self.search_all = False
|
||
self.search_all_check.set_active(False) # fires _on_search_all_toggled itself
|
||
self.history.append(self.current)
|
||
self.current = (path, label)
|
||
self._render()
|
||
|
||
def go_back(self):
|
||
if not self.history:
|
||
return
|
||
self.current = self.history.pop()
|
||
if self.search_all:
|
||
self.search_all = False
|
||
self.search_all_check.set_active(False)
|
||
self._render()
|
||
|
||
def _render(self):
|
||
"""Rebuilds just the content area (and title/back-button state)
|
||
for whatever self.current / self.search_all currently say -- the
|
||
window chrome built in build_chrome() is untouched."""
|
||
if self.search_all:
|
||
# Always the full reachable set from the true root, regardless
|
||
# of which sheet is "current" -- "all sheets" should mean all,
|
||
# not just what's reachable from wherever you happen to be.
|
||
sheets = discover_sheets(MD_PATH, ROOT_TITLE)
|
||
categories = []
|
||
for path, label, sheet_categories in sheets:
|
||
is_root = path == os.path.normpath(MD_PATH)
|
||
for category in sheet_categories:
|
||
name = category["name"] if is_root else f"{label} — {category['name']}"
|
||
categories.append({"name": name, "shortcuts": category["shortcuts"]})
|
||
self.title_label.set_label(f"{ROOT_TITLE} — all sheets")
|
||
else:
|
||
try:
|
||
categories = load_categories(self.current[0])
|
||
except (subprocess.CalledProcessError, OSError, json.JSONDecodeError):
|
||
categories = []
|
||
if self.current[0] == os.path.normpath(MD_PATH):
|
||
self.title_label.set_label(ROOT_TITLE)
|
||
else:
|
||
self.title_label.set_label(f"{ROOT_TITLE} › {self.current[1]}")
|
||
|
||
self.back_btn.set_visible(bool(self.history) and not self.search_all)
|
||
|
||
columns = [
|
||
Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||
for _ in range(MAX_COLUMNS)
|
||
]
|
||
entries = []
|
||
for i, category in enumerate(categories):
|
||
box, rows = category_box(category, self.navigate)
|
||
columns[i % MAX_COLUMNS].pack_start(box, False, False, 0)
|
||
entries.append((box, category["name"].lower(), rows))
|
||
self.entries = entries
|
||
|
||
columns_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
|
||
for col in columns:
|
||
columns_row.pack_start(col, False, False, 0)
|
||
|
||
old_child = self.scroller.get_child()
|
||
if old_child:
|
||
self.scroller.remove(old_child)
|
||
self.scroller.add(columns_row)
|
||
columns_row.show_all()
|
||
|
||
_apply_filter(self.search_entry.get_text(), self.entries)
|
||
|
||
def show(self):
|
||
self.build_chrome()
|
||
self._render()
|
||
self.win.show_all()
|
||
self.back_btn.set_visible(False)
|
||
self.search_entry.grab_focus()
|
||
|
||
|
||
def wait_for_source(timeout=10):
|
||
"""Block until MD_PATH is actually readable, up to `timeout` seconds.
|
||
/sf/bernina is a real NFS mount (not autofs) so it's normally already
|
||
up by login, but this is cheap insurance against a race at boot, and
|
||
means the file always gets read fresh from the network filesystem on
|
||
every call -- no local copy to ever go stale."""
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
if os.access(MD_PATH, os.R_OK):
|
||
return True
|
||
time.sleep(0.2)
|
||
return os.access(MD_PATH, os.R_OK)
|
||
|
||
|
||
def main():
|
||
wait_for_source()
|
||
|
||
if already_showing():
|
||
return
|
||
|
||
with open(PID_FILE, "w", encoding="utf-8") as f:
|
||
f.write(str(os.getpid()))
|
||
|
||
def cleanup(*_args):
|
||
try:
|
||
os.unlink(PID_FILE)
|
||
except OSError:
|
||
pass
|
||
Gtk.main_quit()
|
||
|
||
signal.signal(signal.SIGTERM, lambda *_: GLib.idle_add(cleanup))
|
||
|
||
provider = Gtk.CssProvider()
|
||
provider.load_from_data(CSS)
|
||
Gtk.StyleContext.add_provider_for_screen(
|
||
Gdk.Screen.get_default(),
|
||
provider,
|
||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||
)
|
||
|
||
overlay = Overlay()
|
||
overlay.show()
|
||
Gtk.main()
|
||
try:
|
||
os.unlink(PID_FILE)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|