Files
operation-bin/cheatsheet_overlay
T

367 lines
12 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.
"""
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"
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-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-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);
margin-bottom: 8px;
}
"""
def load_categories():
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
tmp_path = tmp.name
try:
subprocess.run([GENERATOR, MD_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 _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):
"""Returns (box, rows) -- rows is [(key_label, desc_label, search_text)],
handed to _apply_filter below so it can show/hide individual shortcuts
(and, via the whole box, an entire category) without rebuilding
anything."""
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"]):
key_label = Gtk.Label(label=shortcut["key"], xalign=0)
key_label.get_style_context().add_class("cs-key")
key_label.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_label, 0, row, 1, 1)
grid.attach(desc_label, 1, row, 1, 1)
search_text = (shortcut["key"] + " " + shortcut["description"]).lower()
rows.append((key_label, 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_label, desc_label, _search_text in rows:
key_label.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_label, desc_label, search_text in rows:
match = cat_matches or query in search_text
key_label.set_visible(match)
desc_label.set_visible(match)
any_visible = any_visible or match
cat_box.set_visible(any_visible)
def search_bar(entries):
entry = Gtk.SearchEntry()
entry.get_style_context().add_class("cs-search")
entry.set_placeholder_text("Search shortcuts... (Ctrl+F)")
entry.connect("changed", lambda e: _apply_filter(e.get_text(), entries))
return entry
def title_bar(win):
"""Custom title bar: 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)
title = Gtk.Label(label="Bernina Terminal Cheat Sheet", xalign=0)
title.get_style_context().add_class("cs-title")
row.pack_start(title, 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: 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 build_window(categories):
win = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
win.set_title("Bernina Cheat Sheet")
win.get_style_context().add_class("cheatsheet-overlay")
win.set_decorated(False)
win.set_position(Gtk.WindowPosition.CENTER)
win.set_keep_above(True)
win.set_skip_taskbar_hint(True)
win.set_skip_pager_hint(True)
win.set_type_hint(Gdk.WindowTypeHint.DIALOG)
screen = win.get_screen()
visual = screen.get_rgba_visual()
if visual and screen.is_composited():
win.set_visual(visual)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
outer.set_border_width(18)
outer.pack_start(title_bar(win), False, False, 0)
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)
columns[i % MAX_COLUMNS].pack_start(box, False, False, 0)
entries.append((box, category["name"].lower(), rows))
entry = search_bar(entries)
outer.pack_start(entry, False, False, 0)
columns_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=28)
for col in columns:
columns_row.pack_start(col, False, False, 0)
scroller = Gtk.ScrolledWindow()
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroller.add(columns_row)
outer.pack_start(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)
win.add(outer)
geo = win.get_screen().get_monitor_geometry(
win.get_screen().get_primary_monitor()
)
win.set_default_size(min(1100, int(geo.width * 0.8)), min(800, int(geo.height * 0.8)))
win.connect("key-press-event", lambda w, e: _on_key_press(w, e, entry))
win.connect("destroy", lambda *_: Gtk.main_quit())
return win, entry
def _on_key_press(widget, event, search_entry):
ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
if ctrl and event.keyval in (Gdk.KEY_f, Gdk.KEY_F):
search_entry.grab_focus()
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 search_entry.get_text():
search_entry.set_text("")
return True
Gtk.main_quit()
return False
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))
categories = load_categories()
provider = Gtk.CssProvider()
provider.load_from_data(CSS)
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
win, search_entry = build_window(categories)
win.show_all()
search_entry.grab_focus()
Gtk.main()
try:
os.unlink(PID_FILE)
except OSError:
pass
if __name__ == "__main__":
main()