File size: 16,264 Bytes
bbc65cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
#!/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