Download shortcut_counter_tab.py from smlflg/ClaudeCodePanel: direct link, hf CLI and curl.
- Browser
- Download file 16.3 kB
-
https://huggingface.co/smlflg/ClaudeCodePanel/resolve/main/shortcut_counter_tab.py
- Command line
-
hf download hf://smlflg/ClaudeCodePanel/shortcut_counter_tab.py
-
curl -L -o shortcut_counter_tab.py https://huggingface.co/smlflg/ClaudeCodePanel/resolve/main/shortcut_counter_tab.py
16.3 kB
| #!/usr/bin/env python3 | |
| """Shortcut Counter Tab — GTK3 widget for ClaudeCodePanel. | |
| Reads directly from ~/.local/share/shortcut-counter/shortcuts.db (read-only). | |
| Refreshes every 30s via GLib timer registered in panel.py. | |
| Provides: | |
| build_shortcut_counter_tab() -> Gtk.ScrolledWindow | |
| refresh_shortcut_counter(tab) -> bool | |
| """ | |
| import sqlite3 | |
| import tomllib | |
| from datetime import datetime | |
| from pathlib import Path | |
| import gi | |
| gi.require_version("Gtk", "3.0") | |
| from gi.repository import GLib, Gtk, Pango | |
| from theme import get_palette, hex_to_pango_rgb | |
| # --------------------------------------------------------------------------- | |
| # DB path (XDG) | |
| # --------------------------------------------------------------------------- | |
| _DB_PATH = Path.home() / ".local" / "share" / "shortcut-counter" / "shortcuts.db" | |
| # Config path for "unused" recommendations | |
| _CONFIG_PATH = Path.home() / "Projekte" / "ShortcutCounter" / "config.toml" | |
| # Thresholds | |
| _MASTERED = 50 | |
| _LEARNING_MIN = 1 | |
| # --------------------------------------------------------------------------- | |
| # Module-level widget refs (stable outer structure) | |
| # --------------------------------------------------------------------------- | |
| _header_label: Gtk.Label | None = None | |
| _progress_bar: Gtk.ProgressBar | None = None | |
| _cards_container: Gtk.Box | None = None # replaces TreeView | |
| _learn_box: Gtk.Box | None = None | |
| # --------------------------------------------------------------------------- | |
| # Data loading | |
| # --------------------------------------------------------------------------- | |
| def _load_rows() -> list[dict]: | |
| """Read all rows from shortcuts.db. Returns [] if DB missing.""" | |
| if not _DB_PATH.exists(): | |
| return [] | |
| try: | |
| # Read raw values instead of sqlite3's TIMESTAMP converter. The | |
| # shortcut counter stores ISO timestamps with a "T" separator, which | |
| # sqlite3's default converter rejects. | |
| conn = sqlite3.connect(f"file:{_DB_PATH}?mode=ro", uri=True) | |
| conn.row_factory = sqlite3.Row | |
| rows = conn.execute( | |
| "SELECT combo, count, last_used, category FROM shortcuts ORDER BY count DESC" | |
| ).fetchall() | |
| conn.close() | |
| return [dict(r) for r in rows] | |
| except (sqlite3.OperationalError, sqlite3.DatabaseError): | |
| return [] | |
| def _load_config() -> dict[str, list[dict]]: | |
| """Load shortcut config with descriptions. Returns {} on any error.""" | |
| if not _CONFIG_PATH.exists(): | |
| return {} | |
| try: | |
| with open(_CONFIG_PATH, "rb") as f: | |
| cfg = tomllib.load(f) | |
| result = {} | |
| for cat_name, cat_data in cfg.get("shortcuts", {}).items(): | |
| items = cat_data.get("items", []) | |
| result[cat_name] = [{"combo": it["combo"], "desc": it.get("desc", "")} for it in items] | |
| return result | |
| except Exception: | |
| return {} | |
| # --------------------------------------------------------------------------- | |
| # Status helpers | |
| # --------------------------------------------------------------------------- | |
| def _status_label(count: int) -> str: | |
| if count >= _MASTERED: | |
| return "Mastered" | |
| elif count >= _LEARNING_MIN: | |
| return "Learning" | |
| else: | |
| return "Not Used" | |
| def _status_css_class(count: int) -> str: | |
| if count >= _MASTERED: | |
| return "sc-badge-mastered" | |
| elif count >= _LEARNING_MIN: | |
| return "sc-badge-learning" | |
| else: | |
| return "sc-badge-unused" | |
| def _fmt_last_used(last_used) -> str: | |
| if last_used is None: | |
| return "—" | |
| if isinstance(last_used, datetime): | |
| return last_used.strftime("%d.%m %H:%M") | |
| try: | |
| dt = datetime.fromisoformat(str(last_used)) | |
| return dt.strftime("%d.%m %H:%M") | |
| except (ValueError, TypeError): | |
| return str(last_used)[:10] | |
| def _find_category(combo: str, config: dict[str, list[dict]]) -> str: | |
| """Try to find category for a combo via config dict.""" | |
| for cat_name, items in config.items(): | |
| if any(it["combo"].lower() == combo.lower() for it in items): | |
| return cat_name | |
| return "Sonstige" | |
| # --------------------------------------------------------------------------- | |
| # Card builders | |
| # --------------------------------------------------------------------------- | |
| def _build_shortcut_row(r: dict, p: dict) -> Gtk.Box: | |
| """Build one horizontal shortcut row inside a category card.""" | |
| row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) | |
| row.set_margin_top(2) | |
| row.set_margin_bottom(2) | |
| # Combo label (monospace bold) | |
| combo_lbl = Gtk.Label(label=r["combo"]) | |
| combo_lbl.set_halign(Gtk.Align.START) | |
| combo_lbl.set_markup( | |
| f'<tt><b>{GLib.markup_escape_text(r["combo"])}</b></tt>' | |
| ) | |
| combo_lbl.set_width_chars(18) | |
| combo_lbl.set_xalign(0.0) | |
| row.pack_start(combo_lbl, False, False, 0) | |
| # Description label (dim, between combo and count) | |
| desc = r.get("desc", "") | |
| desc_lbl = Gtk.Label() | |
| desc_lbl.set_markup( | |
| f'<span foreground="{p["dim"]}" size="small">{GLib.markup_escape_text(desc)}</span>' | |
| ) | |
| desc_lbl.set_width_chars(26) | |
| desc_lbl.set_xalign(0.0) | |
| desc_lbl.set_halign(Gtk.Align.START) | |
| row.pack_start(desc_lbl, False, False, 0) | |
| # Count badge (dim text) | |
| count_lbl = Gtk.Label(label=str(r["count"])) | |
| count_lbl.set_markup( | |
| f'<span foreground="{p["overlay"]}" size="small">{r["count"]}×</span>' | |
| ) | |
| row.pack_start(count_lbl, False, False, 0) | |
| # Status badge | |
| status_lbl = Gtk.Label(label=_status_label(r["count"])) | |
| status_lbl.get_style_context().add_class(_status_css_class(r["count"])) | |
| row.pack_start(status_lbl, False, False, 0) | |
| # Last-used (right-aligned, dim) | |
| last_lbl = Gtk.Label() | |
| last_lbl.set_markup( | |
| f'<span foreground="{p["dim"]}" size="small">{_fmt_last_used(r.get("last_used"))}</span>' | |
| ) | |
| last_lbl.set_halign(Gtk.Align.END) | |
| row.pack_end(last_lbl, False, False, 0) | |
| return row | |
| def _build_category_card(cat_name: str, cat_rows: list[dict], p: dict) -> Gtk.Box: | |
| """Build a full category card widget.""" | |
| card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) | |
| card.get_style_context().add_class("sc-card") | |
| # Category header: bold name + progress "X/Y" | |
| mastered = sum(1 for r in cat_rows if r["count"] >= _MASTERED) | |
| used = sum(1 for r in cat_rows if r["count"] >= _LEARNING_MIN) | |
| total = len(cat_rows) | |
| header = Gtk.Label() | |
| header.set_markup( | |
| f'<b>{GLib.markup_escape_text(cat_name)}</b> ' | |
| f'<span foreground="{p["overlay"]}" size="small">' | |
| f'{mastered} mastered · {used}/{total} used</span>' | |
| ) | |
| header.set_halign(Gtk.Align.START) | |
| header.set_margin_bottom(4) | |
| card.pack_start(header, False, False, 0) | |
| # Shortcut rows sorted by count desc | |
| for r in sorted(cat_rows, key=lambda x: -x["count"]): | |
| card.pack_start(_build_shortcut_row(r, p), False, False, 0) | |
| return card | |
| def _build_learn_card(combo: str, desc: str, cat_name: str, p: dict) -> Gtk.Box: | |
| """Build a single Learn Next card.""" | |
| card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) | |
| card.get_style_context().add_class("sc-learn-card") | |
| combo_lbl = Gtk.Label() | |
| combo_lbl.set_markup( | |
| f'<tt><b><span foreground="{p["text"]}">' | |
| f'{GLib.markup_escape_text(combo)}</span></b></tt>' | |
| ) | |
| combo_lbl.set_halign(Gtk.Align.START) | |
| combo_lbl.get_style_context().add_class("section-title") | |
| card.pack_start(combo_lbl, False, False, 0) | |
| if desc: | |
| desc_lbl = Gtk.Label() | |
| desc_lbl.set_markup( | |
| f'<span foreground="{p["text"]}" size="small">' | |
| f'{GLib.markup_escape_text(desc)}</span>' | |
| ) | |
| desc_lbl.set_halign(Gtk.Align.START) | |
| card.pack_start(desc_lbl, False, False, 0) | |
| cat_lbl = Gtk.Label() | |
| cat_lbl.set_markup( | |
| f'<span foreground="{p["dim"]}" size="small">' | |
| f'{GLib.markup_escape_text(cat_name)}</span>' | |
| ) | |
| cat_lbl.set_halign(Gtk.Align.START) | |
| card.pack_start(cat_lbl, False, False, 0) | |
| return card | |
| # --------------------------------------------------------------------------- | |
| # UI update (always called on GTK main thread via idle_add) | |
| # --------------------------------------------------------------------------- | |
| def _do_refresh() -> bool: | |
| """Actual UI update — must run on GTK thread. Returns False (one-shot).""" | |
| rows = _load_rows() | |
| config = _load_config() | |
| p = get_palette() | |
| # Flat list of all configured items for counting and lookup | |
| all_config_items = [it for items in config.values() for it in items] | |
| total_config = len(all_config_items) | |
| used_count = sum(1 for r in rows if r["count"] >= _LEARNING_MIN) | |
| mastered_count = sum(1 for r in rows if r["count"] >= _MASTERED) | |
| # --- Header --- | |
| if _header_label is not None: | |
| if total_config > 0: | |
| _header_label.set_markup( | |
| f'<b>Shortcut Counter</b> ' | |
| f'<span foreground="{p["green"]}">{mastered_count} Mastered</span> ' | |
| f'<span foreground="{p["yellow"]}">{used_count} Learning</span> ' | |
| f'<span foreground="{p["overlay"]}">/ {total_config} konfiguriert</span>' | |
| ) | |
| else: | |
| _header_label.set_markup( | |
| f'<b>Shortcut Counter</b> ' | |
| f'<span foreground="{p["overlay"]}">{len(rows)} gespeichert</span>' | |
| ) | |
| # --- Progress bar --- | |
| if _progress_bar is not None: | |
| if total_config > 0: | |
| frac = min(used_count / total_config, 1.0) | |
| _progress_bar.set_fraction(frac) | |
| _progress_bar.set_text( | |
| f"{used_count} / {total_config} mindestens 1x benutzt ({frac*100:.0f}%)" | |
| ) | |
| else: | |
| _progress_bar.set_fraction(0.0) | |
| _progress_bar.set_text("Keine Config-Datei gefunden") | |
| # --- Category cards --- | |
| if _cards_container is not None: | |
| for child in _cards_container.get_children(): | |
| _cards_container.remove(child) | |
| # Build desc lookup: combo -> desc from config (case-insensitive) | |
| desc_lookup: dict[str, str] = { | |
| it["combo"].lower(): it["desc"] for it in all_config_items | |
| } | |
| # Group DB rows by category, enriching with desc from config | |
| categories: dict[str, list[dict]] = {} | |
| for r in rows: | |
| cat = r.get("category") or "Sonstige" | |
| enriched = dict(r) | |
| enriched["desc"] = desc_lookup.get(r["combo"].lower(), "") | |
| categories.setdefault(cat, []).append(enriched) | |
| # Add config items never used (not in DB) | |
| db_combos = {r["combo"].lower() for r in rows} | |
| for cat_name, items in config.items(): | |
| for it in items: | |
| if it["combo"].lower() not in db_combos: | |
| categories.setdefault(cat_name, []).append({ | |
| "combo": it["combo"], | |
| "desc": it["desc"], | |
| "count": 0, | |
| "last_used": None, | |
| "category": cat_name, | |
| }) | |
| mastered_items: list[dict] = [] # collect individually mastered shortcuts | |
| for cat_name, cat_rows in sorted(categories.items()): | |
| learning = [r for r in cat_rows if r["count"] < _MASTERED] | |
| mastered = [r for r in cat_rows if r["count"] >= _MASTERED] | |
| # Tag mastered items with their category for the expander | |
| for m in mastered: | |
| m["_cat"] = cat_name | |
| mastered_items.append(m) | |
| # Only build a card if there are non-mastered items | |
| if learning: | |
| card = _build_category_card(cat_name, learning, p) | |
| _cards_container.pack_start(card, False, False, 0) | |
| if mastered_items: | |
| expander = Gtk.Expander() | |
| expander.set_label(f"Mastered ({len(mastered_items)} Shortcuts)") | |
| expander.set_expanded(False) | |
| exp_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) | |
| # Group mastered items by category inside the expander | |
| from itertools import groupby | |
| for cat, items in groupby( | |
| sorted(mastered_items, key=lambda r: r.get("_cat", "")), | |
| key=lambda r: r.get("_cat", ""), | |
| ): | |
| cat_card = _build_category_card(cat, list(items), p) | |
| exp_box.pack_start(cat_card, False, False, 0) | |
| expander.add(exp_box) | |
| _cards_container.pack_start(expander, False, False, 0) | |
| _cards_container.show_all() | |
| # --- Learn Next section --- | |
| if _learn_box is not None: | |
| for child in _learn_box.get_children(): | |
| _learn_box.remove(child) | |
| lbl = Gtk.Label() | |
| lbl.set_markup( | |
| f'<b>Learn Next</b> ' | |
| f'<span foreground="{p["overlay"]}">Top 3 ungenutzte Shortcuts</span>' | |
| ) | |
| lbl.set_halign(Gtk.Align.START) | |
| lbl.set_margin_bottom(4) | |
| _learn_box.pack_start(lbl, False, False, 0) | |
| db_combos = {r["combo"].lower() for r in rows} | |
| unused = [ | |
| it for it in all_config_items | |
| if it["combo"].lower() not in db_combos | |
| or next((r["count"] for r in rows if r["combo"].lower() == it["combo"].lower()), 0) == 0 | |
| ] | |
| if unused: | |
| for it in unused[:3]: | |
| cat = _find_category(it["combo"], config) | |
| card = _build_learn_card(it["combo"], it["desc"], cat, p) | |
| _learn_box.pack_start(card, False, False, 0) | |
| else: | |
| done = Gtk.Label() | |
| done.set_markup( | |
| f'<span foreground="{p["green"]}">' | |
| f'Alle konfigurierten Shortcuts mindestens 1x benutzt!</span>' | |
| ) | |
| done.set_halign(Gtk.Align.START) | |
| _learn_box.pack_start(done, False, False, 0) | |
| _learn_box.show_all() | |
| return False # one-shot | |
| # --------------------------------------------------------------------------- | |
| # Public API | |
| # --------------------------------------------------------------------------- | |
| def refresh_shortcut_counter(_tab=None) -> bool: | |
| """Called by GLib timer from panel.py. Schedules UI update via idle_add.""" | |
| GLib.idle_add(_do_refresh) | |
| return True # keep timer alive | |
| def build_shortcut_counter_tab() -> Gtk.ScrolledWindow: | |
| """Build and return the Shortcut Counter tab widget.""" | |
| global _header_label, _progress_bar, _cards_container, _learn_box | |
| scrolled = Gtk.ScrolledWindow() | |
| scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) | |
| scrolled.set_hexpand(True) | |
| scrolled.set_vexpand(True) | |
| vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) | |
| vbox.set_margin_top(10) | |
| vbox.set_margin_bottom(10) | |
| vbox.set_margin_start(10) | |
| vbox.set_margin_end(10) | |
| scrolled.add(vbox) | |
| p = get_palette() | |
| # --- Header --- | |
| _header_label = Gtk.Label() | |
| _header_label.set_markup( | |
| f'<b>Shortcut Counter</b> <span foreground="{p["overlay"]}">Lade...</span>' | |
| ) | |
| _header_label.set_halign(Gtk.Align.START) | |
| _header_label.set_margin_bottom(6) | |
| header_attrs = Pango.AttrList() | |
| header_attrs.insert(Pango.attr_scale_new(1.1)) | |
| _header_label.set_attributes(header_attrs) | |
| vbox.pack_start(_header_label, False, False, 0) | |
| # --- Progress bar --- | |
| _progress_bar = Gtk.ProgressBar() | |
| _progress_bar.set_show_text(True) | |
| _progress_bar.set_fraction(0.0) | |
| _progress_bar.set_text("Lade...") | |
| _progress_bar.set_margin_bottom(10) | |
| vbox.pack_start(_progress_bar, False, False, 0) | |
| # --- Category cards container --- | |
| _cards_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) | |
| _cards_container.set_margin_bottom(8) | |
| vbox.pack_start(_cards_container, False, False, 0) | |
| # --- Learn Next section --- | |
| sep = Gtk.Separator() | |
| sep.set_margin_top(4) | |
| sep.set_margin_bottom(8) | |
| vbox.pack_start(sep, False, False, 0) | |
| _learn_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) | |
| vbox.pack_start(_learn_box, False, False, 0) | |
| # Initial load | |
| GLib.idle_add(_do_refresh) | |
| scrolled.show_all() | |
| return scrolled | |