367 lines
12 KiB
Python
Executable File
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()
|