prompt-command-center 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pcc/__init__.py +3 -0
- pcc/__main__.py +168 -0
- pcc/compose.py +51 -0
- pcc/context.py +198 -0
- pcc/ipc.py +109 -0
- pcc/journal.py +49 -0
- pcc/lint.py +102 -0
- pcc/model.py +365 -0
- pcc/placement.py +104 -0
- pcc/search.py +122 -0
- pcc/spell.py +348 -0
- pcc/store.py +338 -0
- pcc/ui/__init__.py +1 -0
- pcc/ui/editor.py +141 -0
- pcc/ui/fill.py +545 -0
- pcc/ui/flow.py +86 -0
- pcc/ui/grid.py +209 -0
- pcc/ui/palette.py +1233 -0
- pcc/ui/schemes.py +176 -0
- pcc/ui/settings_panel.py +341 -0
- pcc/ui/shortcuts.py +163 -0
- pcc/ui/spellcheck.py +383 -0
- pcc/ui/tabstrip.py +92 -0
- pcc/ui/textedit.py +44 -0
- pcc/ui/theme.py +87 -0
- pcc/ui/theme.qss +465 -0
- pcc/ui/tile.py +175 -0
- pcc/usage.py +200 -0
- pcc/winapi.py +420 -0
- prompt_command_center-1.0.0.dist-info/METADATA +581 -0
- prompt_command_center-1.0.0.dist-info/RECORD +35 -0
- prompt_command_center-1.0.0.dist-info/WHEEL +5 -0
- prompt_command_center-1.0.0.dist-info/entry_points.txt +2 -0
- prompt_command_center-1.0.0.dist-info/licenses/LICENSE +674 -0
- prompt_command_center-1.0.0.dist-info/top_level.txt +1 -0
pcc/__init__.py
ADDED
pcc/__main__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Entry point: single-instance guard, tray, IPC wiring, event loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from PySide6.QtCore import QObject, Qt, Signal
|
|
9
|
+
from PySide6.QtGui import QAction, QColor, QFont, QIcon, QPainter, QPainterPath, QPixmap
|
|
10
|
+
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon
|
|
11
|
+
|
|
12
|
+
from . import store, winapi
|
|
13
|
+
from .ipc import CMD_QUIT, CMD_SHOW, CMD_TOGGLE, HostWindow, TriggerFilter, find_existing_host
|
|
14
|
+
from .ui.palette import PaletteWindow, load_stylesheet
|
|
15
|
+
|
|
16
|
+
MUTEX_NAME = "Global\\PCC_PromptCommandCenter_SingleInstance"
|
|
17
|
+
ACCENT = QColor("#22D3EE")
|
|
18
|
+
BACKDROP = QColor("#0B0F14")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReloadBridge(QObject):
|
|
22
|
+
"""Marshals watchdog's worker-thread callback onto the Qt main thread.
|
|
23
|
+
|
|
24
|
+
Touching widgets from the watcher thread would be a crash waiting to happen;
|
|
25
|
+
a queued signal is the cheap, correct fix.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
triggered = Signal()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _acquire_single_instance() -> bool:
|
|
32
|
+
"""Claim the named mutex. ``False`` means another PCC already owns it.
|
|
33
|
+
|
|
34
|
+
A named mutex rather than a window search: process creation and window
|
|
35
|
+
creation are not atomic, so two near-simultaneous launches could both find
|
|
36
|
+
no window and both start.
|
|
37
|
+
"""
|
|
38
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
39
|
+
handle = kernel32.CreateMutexW(None, False, MUTEX_NAME)
|
|
40
|
+
if not handle:
|
|
41
|
+
return True # cannot tell; assume we are alone rather than refuse to run
|
|
42
|
+
ERROR_ALREADY_EXISTS = 183
|
|
43
|
+
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
|
|
44
|
+
return False
|
|
45
|
+
# Leaked deliberately: the mutex must outlive this function for the whole
|
|
46
|
+
# process lifetime, and Windows releases it on exit.
|
|
47
|
+
_acquire_single_instance._handle = handle # type: ignore[attr-defined]
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_icon() -> QIcon:
|
|
52
|
+
"""Draw the tray glyph rather than shipping a .ico file."""
|
|
53
|
+
pixmap = QPixmap(64, 64)
|
|
54
|
+
pixmap.fill(Qt.GlobalColor.transparent)
|
|
55
|
+
|
|
56
|
+
painter = QPainter(pixmap)
|
|
57
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
58
|
+
|
|
59
|
+
path = QPainterPath()
|
|
60
|
+
path.addRoundedRect(4, 4, 56, 56, 14, 14)
|
|
61
|
+
painter.fillPath(path, BACKDROP)
|
|
62
|
+
painter.setPen(ACCENT)
|
|
63
|
+
painter.drawPath(path)
|
|
64
|
+
|
|
65
|
+
font = QFont("Cascadia Code", 26, QFont.Weight.Bold)
|
|
66
|
+
painter.setFont(font)
|
|
67
|
+
painter.setPen(ACCENT)
|
|
68
|
+
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "›_")
|
|
69
|
+
painter.end()
|
|
70
|
+
|
|
71
|
+
return QIcon(pixmap)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
if not _acquire_single_instance():
|
|
76
|
+
# Second launch acts as a trigger for the instance already running --
|
|
77
|
+
# this is what makes the AHK hybrid fallback safe to fire twice.
|
|
78
|
+
existing = find_existing_host()
|
|
79
|
+
if existing:
|
|
80
|
+
ctypes.WinDLL("user32", use_last_error=True).PostMessageW(
|
|
81
|
+
ctypes.c_void_p(existing), 0x8000, CMD_SHOW, 0
|
|
82
|
+
)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
app = QApplication(sys.argv)
|
|
86
|
+
app.setApplicationName("PCC")
|
|
87
|
+
app.setQuitOnLastWindowClosed(False) # the palette hiding must not end the app
|
|
88
|
+
|
|
89
|
+
winapi.relax_foreground_lock()
|
|
90
|
+
|
|
91
|
+
settings = store.load_settings()
|
|
92
|
+
app.setStyleSheet(load_stylesheet(settings))
|
|
93
|
+
library = store.load_library(store.library_path(settings))
|
|
94
|
+
palette = PaletteWindow(library, settings)
|
|
95
|
+
palette.quit_requested.connect(app.quit)
|
|
96
|
+
palette.prewarm()
|
|
97
|
+
|
|
98
|
+
host = HostWindow()
|
|
99
|
+
trigger_filter = TriggerFilter(
|
|
100
|
+
host.hwnd,
|
|
101
|
+
{
|
|
102
|
+
CMD_SHOW: palette.trigger,
|
|
103
|
+
CMD_TOGGLE: palette.toggle,
|
|
104
|
+
CMD_QUIT: app.quit,
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
app.installNativeEventFilter(trigger_filter)
|
|
108
|
+
# Only now is the host discoverable by AHK; see HostWindow.arm().
|
|
109
|
+
host.arm()
|
|
110
|
+
|
|
111
|
+
bridge = ReloadBridge()
|
|
112
|
+
bridge.triggered.connect(palette.reload_library)
|
|
113
|
+
watcher = store.LibraryWatcher(store.library_path(settings), bridge.triggered.emit)
|
|
114
|
+
watcher.start()
|
|
115
|
+
|
|
116
|
+
# Every internal save must silence the watcher, or we reload our own writes.
|
|
117
|
+
palette.before_save = watcher.mark_self_write
|
|
118
|
+
|
|
119
|
+
icon = build_icon()
|
|
120
|
+
app.setWindowIcon(icon)
|
|
121
|
+
tray = QSystemTrayIcon(icon)
|
|
122
|
+
tray.setToolTip("PCC — CapsLock+Space")
|
|
123
|
+
|
|
124
|
+
menu = QMenu()
|
|
125
|
+
show_action = QAction("Show palette", menu)
|
|
126
|
+
show_action.triggered.connect(palette.trigger)
|
|
127
|
+
edit_action = QAction("Edit templates.json", menu)
|
|
128
|
+
edit_action.triggered.connect(palette.open_library_file)
|
|
129
|
+
prefs_action = QAction("Settings…\tCtrl+,", menu)
|
|
130
|
+
prefs_action.triggered.connect(lambda: (palette.trigger(), palette.open_settings()))
|
|
131
|
+
settings_action = QAction("Edit settings.json", menu)
|
|
132
|
+
settings_action.triggered.connect(palette.open_settings_file)
|
|
133
|
+
reload_action = QAction("Reload templates", menu)
|
|
134
|
+
reload_action.triggered.connect(palette.reload_library)
|
|
135
|
+
restyle_action = QAction("Reload settings", menu)
|
|
136
|
+
restyle_action.triggered.connect(palette.reload_settings)
|
|
137
|
+
quit_action = QAction("Quit PCC\tCtrl+Q", menu)
|
|
138
|
+
quit_action.triggered.connect(app.quit)
|
|
139
|
+
menu.addAction(show_action)
|
|
140
|
+
menu.addAction(prefs_action)
|
|
141
|
+
menu.addSeparator()
|
|
142
|
+
menu.addAction(edit_action)
|
|
143
|
+
menu.addAction(settings_action)
|
|
144
|
+
menu.addAction(reload_action)
|
|
145
|
+
menu.addAction(restyle_action)
|
|
146
|
+
menu.addSeparator()
|
|
147
|
+
menu.addAction(quit_action)
|
|
148
|
+
|
|
149
|
+
tray.setContextMenu(menu)
|
|
150
|
+
tray.activated.connect(
|
|
151
|
+
lambda reason: palette.toggle()
|
|
152
|
+
if reason == QSystemTrayIcon.ActivationReason.Trigger
|
|
153
|
+
else None
|
|
154
|
+
)
|
|
155
|
+
tray.show()
|
|
156
|
+
|
|
157
|
+
app.aboutToQuit.connect(watcher.stop)
|
|
158
|
+
# Flush the debounced usage tail so the last few pastes are not lost on exit.
|
|
159
|
+
app.aboutToQuit.connect(palette.usage.flush)
|
|
160
|
+
|
|
161
|
+
if "--show" in sys.argv:
|
|
162
|
+
palette.trigger()
|
|
163
|
+
|
|
164
|
+
return app.exec()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
sys.exit(main())
|
pcc/compose.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Composition: stack templates and layer modifiers into one prompt.
|
|
2
|
+
|
|
3
|
+
The trick that keeps this tiny and robust is to compose at the *body* level, not
|
|
4
|
+
with a new runtime object. Joining a few template bodies with blank lines yields
|
|
5
|
+
an ordinary body string, which then flows through the very same
|
|
6
|
+
:func:`pcc.model.render`, :func:`pcc.model.parse_slots` and fill panel as any
|
|
7
|
+
single template -- including the existing rule that a repeated ``{{name}}``
|
|
8
|
+
collapses to one field. So two stacked templates that both use ``{{code}}`` share
|
|
9
|
+
one input for free, with no code here to make that happen.
|
|
10
|
+
|
|
11
|
+
A *modifier* (a template tagged ``modifier``) is a fragment like "Be concise." or
|
|
12
|
+
"Answer as a markdown table." Marking a handful and applying them to a base is
|
|
13
|
+
what turns N templates into N x M prompts from the same small library.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .model import Template
|
|
19
|
+
|
|
20
|
+
#: Blank line between parts: it reads as paragraph breaks in the destination
|
|
21
|
+
#: chat box and never accidentally glues the end of one prompt to the next.
|
|
22
|
+
JOIN = "\n\n"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def combined_body(bases: list[Template], modifiers: list[Template]) -> str:
|
|
26
|
+
"""Join base bodies then modifier bodies into one prompt body.
|
|
27
|
+
|
|
28
|
+
Bases keep their given order (stacking), modifiers follow (so "be concise"
|
|
29
|
+
lands after the instruction it qualifies). Empty bodies are dropped so a
|
|
30
|
+
stray blank template cannot open a paste with whitespace.
|
|
31
|
+
"""
|
|
32
|
+
parts = [t.body.strip() for t in bases if t.body.strip()]
|
|
33
|
+
parts += [m.body.strip() for m in modifiers if m.body.strip()]
|
|
34
|
+
return JOIN.join(parts)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compose(bases: list[Template], modifiers: list[Template]) -> Template | None:
|
|
38
|
+
"""A transient :class:`Template` for the joined prompt, or ``None`` if empty.
|
|
39
|
+
|
|
40
|
+
Transient by design: it carries a sentinel id and is never stored, so it
|
|
41
|
+
reuses the whole fill/paste path without touching the library.
|
|
42
|
+
"""
|
|
43
|
+
body = combined_body(bases, modifiers)
|
|
44
|
+
if not body:
|
|
45
|
+
return None
|
|
46
|
+
if len(bases) == 1 and not modifiers:
|
|
47
|
+
# A single base with no modifiers is just that base -- keep its identity
|
|
48
|
+
# so usage recording and slot recall still key off the real template.
|
|
49
|
+
return bases[0]
|
|
50
|
+
title = bases[0].title if bases else "Composed"
|
|
51
|
+
return Template(title=title, body=body, id="__composed__")
|
pcc/context.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Context resolvers: the magic behind ``{{clipboard}}``, ``{{date}}`` and kin.
|
|
2
|
+
|
|
3
|
+
A *magic slot* is a placeholder whose name is a known context source rather than
|
|
4
|
+
a fill-in the user types. ``{{clipboard}}`` becomes whatever you last copied,
|
|
5
|
+
``{{app}}`` the executable you summoned PCC over, ``{{date}}`` today.
|
|
6
|
+
|
|
7
|
+
The whole feature is one function injected into :func:`pcc.model.render`: a
|
|
8
|
+
``resolve(name) -> str | None`` closure. ``model`` stays free of Qt and Win32 --
|
|
9
|
+
the promise that keeps it headlessly testable -- because the closure is built
|
|
10
|
+
*here* and passed in, and this module is the one place allowed to touch
|
|
11
|
+
:mod:`pcc.winapi`.
|
|
12
|
+
|
|
13
|
+
The slow-path contract is deliberately built in from day one, even though every
|
|
14
|
+
resolver today is instant. A :class:`Resolved` may report ``pending``; a
|
|
15
|
+
pending value renders to the literal token, exactly like an unavailable one, and
|
|
16
|
+
the paste path never blocks. An LLM-backed slot later is just a resolver that
|
|
17
|
+
stays pending for 900 ms -- no structural change needed then.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import datetime as _dt
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from . import winapi
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ResolveEnv:
|
|
31
|
+
"""Everything a resolver may read, captured at trigger time.
|
|
32
|
+
|
|
33
|
+
Captured rather than read live so a resolver is a pure function of this
|
|
34
|
+
snapshot: the target window has moved on by the time the user picks a
|
|
35
|
+
template, and tests can supply the whole world without a real desktop.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
target_hwnd: int = 0
|
|
39
|
+
#: Clipboard text at trigger time. ``None`` means "read it live"; a string
|
|
40
|
+
#: (including "") means "use exactly this", which is what lets tests and the
|
|
41
|
+
#: selection-capture path (wave 5) override it.
|
|
42
|
+
clipboard: str | None = None
|
|
43
|
+
#: Selection captured from the target app. Reserved for wave 5; until then it
|
|
44
|
+
#: stays ``None`` and ``{{selection}}`` resolves to the literal token.
|
|
45
|
+
selection: str | None = None
|
|
46
|
+
now: _dt.datetime | None = None
|
|
47
|
+
|
|
48
|
+
def _clipboard(self) -> str:
|
|
49
|
+
if self.clipboard is not None:
|
|
50
|
+
return self.clipboard
|
|
51
|
+
return winapi.clipboard_get_text() or ""
|
|
52
|
+
|
|
53
|
+
def _now(self) -> _dt.datetime:
|
|
54
|
+
return self.now or _dt.datetime.now()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Resolved:
|
|
59
|
+
"""A resolver's answer.
|
|
60
|
+
|
|
61
|
+
``value`` is ``None`` when the source has nothing (empty clipboard, unknown
|
|
62
|
+
window); ``pending`` marks a slow source still working. Both render to the
|
|
63
|
+
literal token, so an unresolved magic slot degrades to precisely what an
|
|
64
|
+
unfilled ordinary slot does today.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
value: str | None
|
|
68
|
+
pending: bool = False
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def available(self) -> bool:
|
|
72
|
+
return self.value is not None and not self.pending
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --- The registry -----------------------------------------------------------
|
|
76
|
+
#
|
|
77
|
+
# Each entry maps a magic name to a pure function of the env. Adding a source is
|
|
78
|
+
# one line here; nothing else in the app needs to know the name exists.
|
|
79
|
+
|
|
80
|
+
Resolver = Callable[[ResolveEnv], Resolved]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _some(text: str | None) -> Resolved:
|
|
84
|
+
"""Wrap a plain string: empty or ``None`` becomes an unavailable Resolved."""
|
|
85
|
+
return Resolved(text if text else None)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
RESOLVERS: dict[str, Resolver] = {
|
|
89
|
+
"clipboard": lambda env: _some(env._clipboard()),
|
|
90
|
+
"selection": lambda env: _some(env.selection),
|
|
91
|
+
"date": lambda env: _some(env._now().strftime("%Y-%m-%d")),
|
|
92
|
+
"time": lambda env: _some(env._now().strftime("%H:%M")),
|
|
93
|
+
"datetime": lambda env: _some(env._now().strftime("%Y-%m-%d %H:%M")),
|
|
94
|
+
"app": lambda env: _some(winapi.process_name(env.target_hwnd)),
|
|
95
|
+
"window": lambda env: _some(winapi.window_title(env.target_hwnd)),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#: Human labels for the fill panel's CONTEXT strip. A magic slot injects text the
|
|
99
|
+
#: user did not type, so it must be *shown*; silent injection is how this feature
|
|
100
|
+
#: would lose trust the first time it guessed wrong.
|
|
101
|
+
LABELS: dict[str, str] = {
|
|
102
|
+
"clipboard": "clipboard",
|
|
103
|
+
"selection": "selection",
|
|
104
|
+
"date": "today",
|
|
105
|
+
"time": "now",
|
|
106
|
+
"datetime": "now",
|
|
107
|
+
"app": "app",
|
|
108
|
+
"window": "window",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
#: Processes where synthesising Ctrl+C would do harm rather than copy: in a
|
|
113
|
+
#: console it is SIGINT and would kill the user's running command. Selection
|
|
114
|
+
#: capture always skips these, whatever the setting.
|
|
115
|
+
CONSOLE_DENYLIST = frozenset({
|
|
116
|
+
"windowsterminal.exe", "cmd.exe", "powershell.exe", "pwsh.exe",
|
|
117
|
+
"conhost.exe", "wt.exe", "mintty.exe", "bash.exe",
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def is_console(app: str | None) -> bool:
|
|
122
|
+
return bool(app) and app.strip().lower() in CONSOLE_DENYLIST
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_magic(name: str) -> bool:
|
|
126
|
+
return name.strip() in RESOLVERS
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve(name: str, env: ResolveEnv) -> Resolved:
|
|
130
|
+
"""Resolve one magic name against ``env``; unknown names are unavailable."""
|
|
131
|
+
resolver = RESOLVERS.get(name.strip())
|
|
132
|
+
if resolver is None:
|
|
133
|
+
return Resolved(None)
|
|
134
|
+
try:
|
|
135
|
+
return resolver(env)
|
|
136
|
+
except Exception:
|
|
137
|
+
# A resolver must never take down a paste. Treat any failure as "nothing
|
|
138
|
+
# here" and fall through to the literal token.
|
|
139
|
+
return Resolved(None)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def make_resolver(env: ResolveEnv) -> Callable[[str], str | None]:
|
|
143
|
+
"""Build the closure :func:`pcc.model.render` calls for each placeholder.
|
|
144
|
+
|
|
145
|
+
Returns the resolved text, or ``None`` to mean "not a magic slot / nothing
|
|
146
|
+
available", on which render keeps its literal-token fallback.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def _resolve(name: str) -> str | None:
|
|
150
|
+
result = resolve(name, env)
|
|
151
|
+
return result.value if result.available else None
|
|
152
|
+
|
|
153
|
+
return _resolve
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class ContextItem:
|
|
158
|
+
"""One resolved magic slot, ready to show in the CONTEXT strip."""
|
|
159
|
+
|
|
160
|
+
name: str
|
|
161
|
+
label: str
|
|
162
|
+
value: str | None
|
|
163
|
+
pending: bool = False
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def available(self) -> bool:
|
|
167
|
+
return self.value is not None and not self.pending
|
|
168
|
+
|
|
169
|
+
def summary(self) -> str:
|
|
170
|
+
"""A compact, safe one-liner: ``clipboard · 1.2k chars`` / ``app · Code.exe``."""
|
|
171
|
+
if self.pending:
|
|
172
|
+
return f"{self.label} · …"
|
|
173
|
+
if self.value is None:
|
|
174
|
+
return f"{self.label} · —"
|
|
175
|
+
text = self.value
|
|
176
|
+
# Long values (a pasted file, a whole selection) are summarised by size
|
|
177
|
+
# rather than dumped into the strip.
|
|
178
|
+
if len(text) > 42 or "\n" in text:
|
|
179
|
+
chars = len(text)
|
|
180
|
+
size = f"{chars / 1000:.1f}k" if chars >= 1000 else str(chars)
|
|
181
|
+
return f"{self.label} · {size} chars"
|
|
182
|
+
return f"{self.label} · {text}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def context_items(names: list[str], env: ResolveEnv) -> list[ContextItem]:
|
|
186
|
+
"""Resolve ``names`` (already known magic) into display items, order kept."""
|
|
187
|
+
items: list[ContextItem] = []
|
|
188
|
+
for name in names:
|
|
189
|
+
result = resolve(name, env)
|
|
190
|
+
items.append(
|
|
191
|
+
ContextItem(
|
|
192
|
+
name=name,
|
|
193
|
+
label=LABELS.get(name.strip(), name.strip()),
|
|
194
|
+
value=result.value,
|
|
195
|
+
pending=result.pending,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
return items
|
pcc/ipc.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Trigger channel: AutoHotkey -> PCC.
|
|
2
|
+
|
|
3
|
+
AHK posts ``WM_APP`` to a well-known, never-shown top-level window. That is
|
|
4
|
+
roughly a millisecond of work and needs no sockets, no ports, and no polling.
|
|
5
|
+
|
|
6
|
+
Two details matter and are easy to get wrong:
|
|
7
|
+
|
|
8
|
+
* The host must be a genuine **top-level** window, not a ``HWND_MESSAGE``-only
|
|
9
|
+
one -- message-only windows are invisible to AHK's ``WinExist``/``FindWindow``.
|
|
10
|
+
* ``winId()`` must be touched so Qt actually creates the HWND for a widget that
|
|
11
|
+
is never shown.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ctypes
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from ctypes import wintypes
|
|
19
|
+
|
|
20
|
+
from PySide6.QtCore import QAbstractNativeEventFilter, Qt
|
|
21
|
+
from PySide6.QtWidgets import QWidget
|
|
22
|
+
|
|
23
|
+
#: Window title AHK looks for. Must stay in sync with ``scripts/pcc.ahk``.
|
|
24
|
+
HOST_TITLE = "PCC_IPC_HOST"
|
|
25
|
+
|
|
26
|
+
WM_APP = 0x8000
|
|
27
|
+
#: wParam values, so one message type can carry several commands.
|
|
28
|
+
CMD_SHOW = 0
|
|
29
|
+
CMD_TOGGLE = 1
|
|
30
|
+
CMD_QUIT = 2
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _MSG(ctypes.Structure):
|
|
34
|
+
_fields_ = [
|
|
35
|
+
("hwnd", wintypes.HWND),
|
|
36
|
+
("message", wintypes.UINT),
|
|
37
|
+
("wParam", wintypes.WPARAM),
|
|
38
|
+
("lParam", wintypes.LPARAM),
|
|
39
|
+
("time", wintypes.DWORD),
|
|
40
|
+
("pt_x", wintypes.LONG),
|
|
41
|
+
("pt_y", wintypes.LONG),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class HostWindow(QWidget):
|
|
46
|
+
"""Invisible window whose only job is to own a findable HWND.
|
|
47
|
+
|
|
48
|
+
Created *unarmed*: the window exists (so its HWND can be handed to the event
|
|
49
|
+
filter) but carries a placeholder title, so AHK's ``WinWait`` cannot find it
|
|
50
|
+
yet. Call :meth:`arm` once the filter is installed. Without this, a trigger
|
|
51
|
+
arriving in the gap between window creation and filter installation is
|
|
52
|
+
dispatched to a window that is not yet listening and silently vanishes.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
UNARMED_TITLE = HOST_TITLE + "_STARTING"
|
|
56
|
+
|
|
57
|
+
def __init__(self) -> None:
|
|
58
|
+
super().__init__(None, Qt.WindowType.Tool)
|
|
59
|
+
self.setWindowTitle(self.UNARMED_TITLE)
|
|
60
|
+
self.setObjectName(HOST_TITLE)
|
|
61
|
+
self.setFixedSize(1, 1)
|
|
62
|
+
# Force native window creation without ever showing it.
|
|
63
|
+
self.hwnd = int(self.winId())
|
|
64
|
+
|
|
65
|
+
def arm(self) -> None:
|
|
66
|
+
"""Publish the well-known title; from here on AHK can reach us."""
|
|
67
|
+
self.setWindowTitle(HOST_TITLE)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TriggerFilter(QAbstractNativeEventFilter):
|
|
71
|
+
"""Routes ``WM_APP`` posted at the host window to Python callbacks.
|
|
72
|
+
|
|
73
|
+
Runs on Qt's main thread inside the event loop, so handlers may touch the
|
|
74
|
+
UI directly. Keep them fast -- this sits on the message pump.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, hwnd: int, handlers: dict[int, Callable[[], None]]) -> None:
|
|
78
|
+
super().__init__()
|
|
79
|
+
self._hwnd = hwnd
|
|
80
|
+
self._handlers = handlers
|
|
81
|
+
|
|
82
|
+
def nativeEventFilter(self, event_type, message): # noqa: N802 (Qt naming)
|
|
83
|
+
if event_type != b"windows_generic_MSG":
|
|
84
|
+
return False, 0
|
|
85
|
+
try:
|
|
86
|
+
msg = ctypes.cast(int(message), ctypes.POINTER(_MSG)).contents
|
|
87
|
+
except (TypeError, ValueError):
|
|
88
|
+
return False, 0
|
|
89
|
+
|
|
90
|
+
if msg.message != WM_APP or int(msg.hwnd or 0) != self._hwnd:
|
|
91
|
+
return False, 0
|
|
92
|
+
|
|
93
|
+
handler = self._handlers.get(int(msg.wParam))
|
|
94
|
+
if handler is not None:
|
|
95
|
+
handler()
|
|
96
|
+
# Swallow it: nothing else has any business seeing this message.
|
|
97
|
+
return True, 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def find_existing_host() -> int:
|
|
101
|
+
"""HWND of an already-running PCC, or 0.
|
|
102
|
+
|
|
103
|
+
Used only for diagnostics; the real single-instance guard is a named mutex,
|
|
104
|
+
which is race-free where a window search is not.
|
|
105
|
+
"""
|
|
106
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
107
|
+
user32.FindWindowW.argtypes = (wintypes.LPCWSTR, wintypes.LPCWSTR)
|
|
108
|
+
user32.FindWindowW.restype = wintypes.HWND
|
|
109
|
+
return int(user32.FindWindowW(None, HOST_TITLE) or 0)
|
pcc/journal.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""An in-session undo ring for destructive library edits.
|
|
2
|
+
|
|
3
|
+
Confirmation dialogs are a poor guard: they interrupt every delete to catch the
|
|
4
|
+
rare mistake, and people learn to dismiss them without reading. Undo is both
|
|
5
|
+
faster (no prompt on the common, correct case) and safer (it also recovers the
|
|
6
|
+
delete you *meant*, then regretted). So the palette deletes immediately and
|
|
7
|
+
banks a snapshot here.
|
|
8
|
+
|
|
9
|
+
Snapshots are whole-library ``to_dict`` dicts. That is coarse, but a library is
|
|
10
|
+
small and correctness beats cleverness: restoring a snapshot cannot leave a
|
|
11
|
+
half-applied edit the way replaying an inverse operation might.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class UndoJournal:
|
|
22
|
+
"""A bounded stack of ``(library_dict, label)`` snapshots, newest last."""
|
|
23
|
+
|
|
24
|
+
limit: int = 20
|
|
25
|
+
_stack: list[tuple[dict, str]] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
def record(self, library_dict: dict, label: str) -> None:
|
|
28
|
+
"""Snapshot the library *before* a destructive change, tagged ``label``."""
|
|
29
|
+
self._stack.append((copy.deepcopy(library_dict), label))
|
|
30
|
+
if len(self._stack) > self.limit:
|
|
31
|
+
# Drop the oldest; the deep past is not worth unbounded memory.
|
|
32
|
+
del self._stack[0 : len(self._stack) - self.limit]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def can_undo(self) -> bool:
|
|
36
|
+
return bool(self._stack)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def next_label(self) -> str | None:
|
|
40
|
+
return self._stack[-1][1] if self._stack else None
|
|
41
|
+
|
|
42
|
+
def undo(self) -> tuple[dict, str] | None:
|
|
43
|
+
"""Pop and return the most recent ``(library_dict, label)``, or ``None``."""
|
|
44
|
+
if not self._stack:
|
|
45
|
+
return None
|
|
46
|
+
return self._stack.pop()
|
|
47
|
+
|
|
48
|
+
def __len__(self) -> int:
|
|
49
|
+
return len(self._stack)
|
pcc/lint.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Library health checks: the only feature that makes a prompt library shrink.
|
|
2
|
+
|
|
3
|
+
Prompt collections rot silently -- they only ever grow, and nobody schedules a
|
|
4
|
+
cleanup. Surfacing the dead weight (never-used, duplicated, broken, empty) turns
|
|
5
|
+
"organise your library someday" into a concrete, finishable list. Loss aversion,
|
|
6
|
+
used honestly: "you have 14 templates you have never used" prunes what a tidy
|
|
7
|
+
"Manage" button never would.
|
|
8
|
+
|
|
9
|
+
Pure: a function of the library (and, optionally, usage), returning findings.
|
|
10
|
+
The UI just renders them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from .model import INCLUDE_PREFIX, PLACEHOLDER_RE, Library
|
|
19
|
+
|
|
20
|
+
#: Ordering/severity: lower sorts first in the health view.
|
|
21
|
+
SEVERITY = {"broken-include": 0, "empty": 1, "duplicate-title": 2, "untitled": 3, "unused": 4}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Finding:
|
|
26
|
+
kind: str
|
|
27
|
+
message: str
|
|
28
|
+
template_id: str
|
|
29
|
+
tab_name: str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def severity(self) -> int:
|
|
33
|
+
return SEVERITY.get(self.kind, 99)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _include_refs(body: str) -> list[str]:
|
|
37
|
+
refs = []
|
|
38
|
+
for match in PLACEHOLDER_RE.finditer(body):
|
|
39
|
+
name = match.group(1).strip()
|
|
40
|
+
if name.startswith(INCLUDE_PREFIX):
|
|
41
|
+
ref = name[len(INCLUDE_PREFIX):].strip()
|
|
42
|
+
if ref:
|
|
43
|
+
refs.append(ref)
|
|
44
|
+
return refs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def lint(library: Library, frecency: Callable[[str], float] | None = None) -> list[Finding]:
|
|
48
|
+
"""Return health findings, most severe first.
|
|
49
|
+
|
|
50
|
+
``frecency`` (from :class:`pcc.usage.UsageStore`) enables the "never used"
|
|
51
|
+
check; without it that check is skipped, so a fresh install is not scolded
|
|
52
|
+
for templates it has not had a chance to use yet.
|
|
53
|
+
"""
|
|
54
|
+
findings: list[Finding] = []
|
|
55
|
+
|
|
56
|
+
# Duplicate titles, compared case-insensitively across the whole library.
|
|
57
|
+
seen: dict[str, int] = {}
|
|
58
|
+
for _tab, template in library.iter_all():
|
|
59
|
+
seen[template.title.strip().lower()] = seen.get(template.title.strip().lower(), 0) + 1
|
|
60
|
+
|
|
61
|
+
# Valid include targets: any template id or title.
|
|
62
|
+
valid_refs = set()
|
|
63
|
+
for _tab, template in library.iter_all():
|
|
64
|
+
valid_refs.add(template.id)
|
|
65
|
+
valid_refs.add(template.title.strip().lower())
|
|
66
|
+
|
|
67
|
+
for tab, template in library.iter_all():
|
|
68
|
+
title = template.title.strip()
|
|
69
|
+
|
|
70
|
+
if not template.body.strip():
|
|
71
|
+
findings.append(Finding("empty", "empty body", template.id, tab.name))
|
|
72
|
+
|
|
73
|
+
if title.lower() == "untitled" or not title:
|
|
74
|
+
findings.append(Finding("untitled", "no title", template.id, tab.name))
|
|
75
|
+
|
|
76
|
+
if seen.get(title.lower(), 0) > 1:
|
|
77
|
+
findings.append(
|
|
78
|
+
Finding("duplicate-title", f"duplicate title “{title}”", template.id, tab.name)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
for ref in _include_refs(template.body):
|
|
82
|
+
if ref not in valid_refs and ref.lower() not in valid_refs:
|
|
83
|
+
findings.append(
|
|
84
|
+
Finding("broken-include", f"include “{ref}” not found", template.id, tab.name)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if frecency is not None and frecency(template.id) <= 0.0:
|
|
88
|
+
findings.append(Finding("unused", "never used", template.id, tab.name))
|
|
89
|
+
|
|
90
|
+
findings.sort(key=lambda f: (f.severity, f.tab_name))
|
|
91
|
+
return findings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def summary(findings: list[Finding]) -> str:
|
|
95
|
+
"""One-line rollup for a toast, e.g. ``2 broken · 3 unused``."""
|
|
96
|
+
if not findings:
|
|
97
|
+
return "library looks healthy"
|
|
98
|
+
counts: dict[str, int] = {}
|
|
99
|
+
for finding in findings:
|
|
100
|
+
counts[finding.kind] = counts.get(finding.kind, 0) + 1
|
|
101
|
+
order = sorted(counts, key=lambda k: SEVERITY.get(k, 99))
|
|
102
|
+
return " · ".join(f"{counts[k]} {k.replace('-', ' ')}" for k in order)
|