cjm-session-scratchpad-qt 0.0.1__tar.gz
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.
- cjm_session_scratchpad_qt-0.0.1/PKG-INFO +115 -0
- cjm_session_scratchpad_qt-0.0.1/README.md +101 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/__init__.py +1 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/app.py +436 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/appv2.py +682 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/cli.py +99 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/files.py +108 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/graph.py +238 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/manifest.py +89 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/render.py +251 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/textops.py +105 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/timeline.py +96 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/timeline_html.py +137 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/watcher.py +45 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/PKG-INFO +115 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/SOURCES.txt +28 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/dependency_links.txt +1 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/entry_points.txt +2 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/requires.txt +2 -0
- cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/top_level.txt +1 -0
- cjm_session_scratchpad_qt-0.0.1/pyproject.toml +23 -0
- cjm_session_scratchpad_qt-0.0.1/setup.cfg +4 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_app_probe.py +130 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_appv2_probe.py +331 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_files.py +107 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_manifest.py +51 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_render.py +107 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_textops.py +83 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_timeline.py +77 -0
- cjm_session_scratchpad_qt-0.0.1/tests/test_timeline_html.py +78 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cjm-session-scratchpad-qt
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: The session scratchpad: composition seat v0 of the seat family (DEC 2a062aff + ea85eab7). A model-agnostic Qt surface for composing LLM session messages in markdown — session-key file naming, one-key edit/rendered toggle with position stability, autosave with atomic writes, wrap-selection formatting on the KeymapRegistry lane. Designed for accretion; born on-graph.
|
|
5
|
+
Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Repository, https://github.com/cj-mills/cjm-session-scratchpad-qt
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: cjm-substrate-qt-kit>=0.0.2
|
|
13
|
+
Requires-Dist: PySide6
|
|
14
|
+
|
|
15
|
+
# cjm-session-scratchpad-qt
|
|
16
|
+
|
|
17
|
+
<!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
|
|
18
|
+
|
|
19
|
+
The session scratchpad — composition seat v0 of the seat family (DECs 2a062aff + ea85eab7). A model-agnostic Qt surface for composing LLM session messages in markdown: session-key file naming from .cjm/current-session (kills the copy-previous + rename rituals), a one-key edit/rendered toggle held position-stable in both directions by per-block source-line anchors in the owned markdown->HTML pipeline, autosave-only persistence (debounced idle + toggle/focus-loss/close, atomic writes, watcher guard — no dialogs), and wrap-selection formatting verbs on the KeymapRegistry lane. Designed for accretion: live-session feature filings land on-graph and build in later sittings.
|
|
20
|
+
|
|
21
|
+
## Modules
|
|
22
|
+
|
|
23
|
+
- **`cjm_session_scratchpad_qt.__init__`** — Session scratchpad — composition seat v0 (DEC 2a062aff + ea85eab7). Model-agnostic by design: any LLM session's message composition surface.
|
|
24
|
+
- **`cjm_session_scratchpad_qt.app`** — The scratchpad shell: composition seat v0 (DECs 2a062aff + ea85eab7).
|
|
25
|
+
- **`cjm_session_scratchpad_qt.appv2`** — The graph-rung scratchpad shell: composition seat v2 (DEC 6e76c120).
|
|
26
|
+
- **`cjm_session_scratchpad_qt.cli`** — CLI entry for the session scratchpad (console script `cjm-session-scratchpad-qt`).
|
|
27
|
+
- **`cjm_session_scratchpad_qt.files`** — Session-aware file resolution (DEC ea85eab7 pts 1-2).
|
|
28
|
+
- **`cjm_session_scratchpad_qt.graph`** — One open graph behind the scratchpad — the workbench GraphSession pattern
|
|
29
|
+
- **`cjm_session_scratchpad_qt.manifest`** — Pending-send manifests + pull-time reconciliation (DEC fc6a0cdc pt 5).
|
|
30
|
+
- **`cjm_session_scratchpad_qt.render`** — The owned markdown -> HTML pipeline (DEC ea85eab7 pt 4).
|
|
31
|
+
- **`cjm_session_scratchpad_qt.textops`** — Wrap-selection formatting operators (scope DEC 2a062aff pt 4) + the v2
|
|
32
|
+
- **`cjm_session_scratchpad_qt.timeline`** — Interleaved timeline derivation — the pure half of the v2 reading view.
|
|
33
|
+
- **`cjm_session_scratchpad_qt.timeline_html`** — Timeline HTML assembly — the pure presentation half of the v2 reading view.
|
|
34
|
+
- **`cjm_session_scratchpad_qt.watcher`** — Transcript-dir change detection — the sleep-first mtime poll's pure half.
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
### `cjm_session_scratchpad_qt.app`
|
|
39
|
+
|
|
40
|
+
- `Editor` _class_ — The source pane, with line-addressed viewport access for the toggle.
|
|
41
|
+
- `ScratchpadWindow` _class_ — One session file behind an edit/rendered toggle.
|
|
42
|
+
- `block_line_map` _function_ — Scan a rendered document for the renderer's Lxx anchors:
|
|
43
|
+
- `mono_family` _function_ — The rendered code font: the theme's mono token, else the system
|
|
44
|
+
|
|
45
|
+
### `cjm_session_scratchpad_qt.appv2`
|
|
46
|
+
|
|
47
|
+
- `GraphScratchpadWindow` _class_ — One session spine behind a timeline + part-composer.
|
|
48
|
+
- `LanguageBar` _class_ — The fence-language mini-bar: a completer-backed field; Enter applies,
|
|
49
|
+
- `SessionPickerDialog` _class_ — The open-session gesture's chooser: filterable Session list, newest
|
|
50
|
+
|
|
51
|
+
### `cjm_session_scratchpad_qt.cli`
|
|
52
|
+
|
|
53
|
+
- `build_parser` _function_
|
|
54
|
+
- `main` _function_
|
|
55
|
+
|
|
56
|
+
### `cjm_session_scratchpad_qt.files`
|
|
57
|
+
|
|
58
|
+
- `Target` _class_
|
|
59
|
+
- `atomic_write` _function_ — temp + rename in the target dir: a mid-session agent read never sees
|
|
60
|
+
- `ensure_file` _function_ — Create the file blank if missing (sessions are born blank now);
|
|
61
|
+
- `find_session_key` _function_ — Walk up from `start` for the nearest `.cjm/current-session` pointer.
|
|
62
|
+
- `find_session_root` _function_ — The nearest pointer AND the project directory that owns it:
|
|
63
|
+
- `most_recent` _function_ — Newest markdown file by mtime (the absent-pointer fallback).
|
|
64
|
+
- `parse_key_time` _function_ — A session key is its mint timestamp; None for legacy/free-form names.
|
|
65
|
+
- `resolve_target` _function_ — The launch contract: explicit path wins; otherwise the session
|
|
66
|
+
|
|
67
|
+
### `cjm_session_scratchpad_qt.graph`
|
|
68
|
+
|
|
69
|
+
- `ScratchpadSession` _class_ — Sync facade over the projection layer for ONE session's scratchpad:
|
|
70
|
+
- `default_transcript_dir` _function_ — The harness transcript dir for a project: `~/.claude/projects/<slug>`,
|
|
71
|
+
- `part_payload` _function_ — A composer part's mint/journal payload: composer-minted uuid (the
|
|
72
|
+
|
|
73
|
+
### `cjm_session_scratchpad_qt.manifest`
|
|
74
|
+
|
|
75
|
+
- `PendingSend` _class_ — One recorded compose-send awaiting its transcript echo.
|
|
76
|
+
- `compose_text` _function_ — The outgoing message: parts joined as paragraphs, outer whitespace trimmed.
|
|
77
|
+
- `load_pending` _function_ — Read manifests; a missing or unreadable sidecar is an empty list.
|
|
78
|
+
- `manifest_path` _function_ — The session's pending-send sidecar.
|
|
79
|
+
- `reconcile` _function_ — Match manifests against pulled user messages by EXACT text.
|
|
80
|
+
- `save_pending` _function_ — Persist manifests atomically (temp + rename; a concurrent read never
|
|
81
|
+
|
|
82
|
+
### `cjm_session_scratchpad_qt.render`
|
|
83
|
+
|
|
84
|
+
- `RenderResult` _class_
|
|
85
|
+
- `nearest_anchor` _function_ — The anchor line for a source position: greatest anchor <= line
|
|
86
|
+
- `render_html` _function_ — Render markdown to QTextBrowser-ready HTML with per-block source-line
|
|
87
|
+
|
|
88
|
+
### `cjm_session_scratchpad_qt.textops`
|
|
89
|
+
|
|
90
|
+
- `WrapResult` _class_
|
|
91
|
+
- `fence_selection` _function_ — Toggle a code fence around the selected lines. An already-fenced block
|
|
92
|
+
- `toggle_list` _function_ — Toggle list markers on the selected lines: strip when every non-blank
|
|
93
|
+
- `wrap_selection` _function_ — Toggle `marker` around [start, end) of `document`.
|
|
94
|
+
|
|
95
|
+
### `cjm_session_scratchpad_qt.timeline`
|
|
96
|
+
|
|
97
|
+
- `TimelineEntry` _class_ — One timeline row, presentation-ready.
|
|
98
|
+
- `build_timeline` _function_ — Derive the interleaved timeline: active-path status + birth-class flags,
|
|
99
|
+
- `timeline_blocks` _function_ — Group consecutive off-path entries for the superseded-branch chip:
|
|
100
|
+
|
|
101
|
+
### `cjm_session_scratchpad_qt.timeline_html`
|
|
102
|
+
|
|
103
|
+
- `build_timeline_html` _function_ — The whole timeline document: cards separated by rules, consecutive
|
|
104
|
+
- `card_html` _function_ — One message card: anchored header line (identity + affordances) +
|
|
105
|
+
- `lane_filter` _function_ — The lane views: composition = composer-born, transcript = pulled.
|
|
106
|
+
- `linkify_ids` _function_ — Wrap full node/source ids appearing as text in jump:// links —
|
|
107
|
+
- `test_clock_renders_local_time` _function_
|
|
108
|
+
|
|
109
|
+
### `cjm_session_scratchpad_qt.watcher`
|
|
110
|
+
|
|
111
|
+
- `DirWatch` _class_ — Cheap (mtime, size) fingerprint over a transcript dir's *.jsonl.
|
|
112
|
+
|
|
113
|
+
## Dependencies
|
|
114
|
+
|
|
115
|
+
**Depends on:** `PySide6`, `cjm-substrate-qt-kit`
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# cjm-session-scratchpad-qt
|
|
2
|
+
|
|
3
|
+
<!-- generated from the context graph by `cjm-context-graph readme` — do not edit by hand; edit the graph (the urge to hand-edit = move it on-graph) -->
|
|
4
|
+
|
|
5
|
+
The session scratchpad — composition seat v0 of the seat family (DECs 2a062aff + ea85eab7). A model-agnostic Qt surface for composing LLM session messages in markdown: session-key file naming from .cjm/current-session (kills the copy-previous + rename rituals), a one-key edit/rendered toggle held position-stable in both directions by per-block source-line anchors in the owned markdown->HTML pipeline, autosave-only persistence (debounced idle + toggle/focus-loss/close, atomic writes, watcher guard — no dialogs), and wrap-selection formatting verbs on the KeymapRegistry lane. Designed for accretion: live-session feature filings land on-graph and build in later sittings.
|
|
6
|
+
|
|
7
|
+
## Modules
|
|
8
|
+
|
|
9
|
+
- **`cjm_session_scratchpad_qt.__init__`** — Session scratchpad — composition seat v0 (DEC 2a062aff + ea85eab7). Model-agnostic by design: any LLM session's message composition surface.
|
|
10
|
+
- **`cjm_session_scratchpad_qt.app`** — The scratchpad shell: composition seat v0 (DECs 2a062aff + ea85eab7).
|
|
11
|
+
- **`cjm_session_scratchpad_qt.appv2`** — The graph-rung scratchpad shell: composition seat v2 (DEC 6e76c120).
|
|
12
|
+
- **`cjm_session_scratchpad_qt.cli`** — CLI entry for the session scratchpad (console script `cjm-session-scratchpad-qt`).
|
|
13
|
+
- **`cjm_session_scratchpad_qt.files`** — Session-aware file resolution (DEC ea85eab7 pts 1-2).
|
|
14
|
+
- **`cjm_session_scratchpad_qt.graph`** — One open graph behind the scratchpad — the workbench GraphSession pattern
|
|
15
|
+
- **`cjm_session_scratchpad_qt.manifest`** — Pending-send manifests + pull-time reconciliation (DEC fc6a0cdc pt 5).
|
|
16
|
+
- **`cjm_session_scratchpad_qt.render`** — The owned markdown -> HTML pipeline (DEC ea85eab7 pt 4).
|
|
17
|
+
- **`cjm_session_scratchpad_qt.textops`** — Wrap-selection formatting operators (scope DEC 2a062aff pt 4) + the v2
|
|
18
|
+
- **`cjm_session_scratchpad_qt.timeline`** — Interleaved timeline derivation — the pure half of the v2 reading view.
|
|
19
|
+
- **`cjm_session_scratchpad_qt.timeline_html`** — Timeline HTML assembly — the pure presentation half of the v2 reading view.
|
|
20
|
+
- **`cjm_session_scratchpad_qt.watcher`** — Transcript-dir change detection — the sleep-first mtime poll's pure half.
|
|
21
|
+
|
|
22
|
+
## API
|
|
23
|
+
|
|
24
|
+
### `cjm_session_scratchpad_qt.app`
|
|
25
|
+
|
|
26
|
+
- `Editor` _class_ — The source pane, with line-addressed viewport access for the toggle.
|
|
27
|
+
- `ScratchpadWindow` _class_ — One session file behind an edit/rendered toggle.
|
|
28
|
+
- `block_line_map` _function_ — Scan a rendered document for the renderer's Lxx anchors:
|
|
29
|
+
- `mono_family` _function_ — The rendered code font: the theme's mono token, else the system
|
|
30
|
+
|
|
31
|
+
### `cjm_session_scratchpad_qt.appv2`
|
|
32
|
+
|
|
33
|
+
- `GraphScratchpadWindow` _class_ — One session spine behind a timeline + part-composer.
|
|
34
|
+
- `LanguageBar` _class_ — The fence-language mini-bar: a completer-backed field; Enter applies,
|
|
35
|
+
- `SessionPickerDialog` _class_ — The open-session gesture's chooser: filterable Session list, newest
|
|
36
|
+
|
|
37
|
+
### `cjm_session_scratchpad_qt.cli`
|
|
38
|
+
|
|
39
|
+
- `build_parser` _function_
|
|
40
|
+
- `main` _function_
|
|
41
|
+
|
|
42
|
+
### `cjm_session_scratchpad_qt.files`
|
|
43
|
+
|
|
44
|
+
- `Target` _class_
|
|
45
|
+
- `atomic_write` _function_ — temp + rename in the target dir: a mid-session agent read never sees
|
|
46
|
+
- `ensure_file` _function_ — Create the file blank if missing (sessions are born blank now);
|
|
47
|
+
- `find_session_key` _function_ — Walk up from `start` for the nearest `.cjm/current-session` pointer.
|
|
48
|
+
- `find_session_root` _function_ — The nearest pointer AND the project directory that owns it:
|
|
49
|
+
- `most_recent` _function_ — Newest markdown file by mtime (the absent-pointer fallback).
|
|
50
|
+
- `parse_key_time` _function_ — A session key is its mint timestamp; None for legacy/free-form names.
|
|
51
|
+
- `resolve_target` _function_ — The launch contract: explicit path wins; otherwise the session
|
|
52
|
+
|
|
53
|
+
### `cjm_session_scratchpad_qt.graph`
|
|
54
|
+
|
|
55
|
+
- `ScratchpadSession` _class_ — Sync facade over the projection layer for ONE session's scratchpad:
|
|
56
|
+
- `default_transcript_dir` _function_ — The harness transcript dir for a project: `~/.claude/projects/<slug>`,
|
|
57
|
+
- `part_payload` _function_ — A composer part's mint/journal payload: composer-minted uuid (the
|
|
58
|
+
|
|
59
|
+
### `cjm_session_scratchpad_qt.manifest`
|
|
60
|
+
|
|
61
|
+
- `PendingSend` _class_ — One recorded compose-send awaiting its transcript echo.
|
|
62
|
+
- `compose_text` _function_ — The outgoing message: parts joined as paragraphs, outer whitespace trimmed.
|
|
63
|
+
- `load_pending` _function_ — Read manifests; a missing or unreadable sidecar is an empty list.
|
|
64
|
+
- `manifest_path` _function_ — The session's pending-send sidecar.
|
|
65
|
+
- `reconcile` _function_ — Match manifests against pulled user messages by EXACT text.
|
|
66
|
+
- `save_pending` _function_ — Persist manifests atomically (temp + rename; a concurrent read never
|
|
67
|
+
|
|
68
|
+
### `cjm_session_scratchpad_qt.render`
|
|
69
|
+
|
|
70
|
+
- `RenderResult` _class_
|
|
71
|
+
- `nearest_anchor` _function_ — The anchor line for a source position: greatest anchor <= line
|
|
72
|
+
- `render_html` _function_ — Render markdown to QTextBrowser-ready HTML with per-block source-line
|
|
73
|
+
|
|
74
|
+
### `cjm_session_scratchpad_qt.textops`
|
|
75
|
+
|
|
76
|
+
- `WrapResult` _class_
|
|
77
|
+
- `fence_selection` _function_ — Toggle a code fence around the selected lines. An already-fenced block
|
|
78
|
+
- `toggle_list` _function_ — Toggle list markers on the selected lines: strip when every non-blank
|
|
79
|
+
- `wrap_selection` _function_ — Toggle `marker` around [start, end) of `document`.
|
|
80
|
+
|
|
81
|
+
### `cjm_session_scratchpad_qt.timeline`
|
|
82
|
+
|
|
83
|
+
- `TimelineEntry` _class_ — One timeline row, presentation-ready.
|
|
84
|
+
- `build_timeline` _function_ — Derive the interleaved timeline: active-path status + birth-class flags,
|
|
85
|
+
- `timeline_blocks` _function_ — Group consecutive off-path entries for the superseded-branch chip:
|
|
86
|
+
|
|
87
|
+
### `cjm_session_scratchpad_qt.timeline_html`
|
|
88
|
+
|
|
89
|
+
- `build_timeline_html` _function_ — The whole timeline document: cards separated by rules, consecutive
|
|
90
|
+
- `card_html` _function_ — One message card: anchored header line (identity + affordances) +
|
|
91
|
+
- `lane_filter` _function_ — The lane views: composition = composer-born, transcript = pulled.
|
|
92
|
+
- `linkify_ids` _function_ — Wrap full node/source ids appearing as text in jump:// links —
|
|
93
|
+
- `test_clock_renders_local_time` _function_
|
|
94
|
+
|
|
95
|
+
### `cjm_session_scratchpad_qt.watcher`
|
|
96
|
+
|
|
97
|
+
- `DirWatch` _class_ — Cheap (mtime, size) fingerprint over a transcript dir's *.jsonl.
|
|
98
|
+
|
|
99
|
+
## Dependencies
|
|
100
|
+
|
|
101
|
+
**Depends on:** `PySide6`, `cjm-substrate-qt-kit`
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Session scratchpad — composition seat v0 (DEC 2a062aff + ea85eab7). Model-agnostic by design: any LLM session's message composition surface."""
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""The scratchpad shell: composition seat v0 (DECs 2a062aff + ea85eab7).
|
|
2
|
+
|
|
3
|
+
One window, one file. A QPlainTextEdit editor (theme body font + measure,
|
|
4
|
+
no line-height merges — undo integrity) and a QTextBrowser rendered view
|
|
5
|
+
(the lane's first real exerciser of document_css/heading-scale/measure on
|
|
6
|
+
rich text) behind a one-key toggle that holds the reading position in BOTH
|
|
7
|
+
directions: the renderer's per-block source-line anchors map top-visible
|
|
8
|
+
edit line -> rendered anchor, and rendered top block -> source line. Saving
|
|
9
|
+
is autosave-only — debounced idle, plus toggle / focus-loss / close — with
|
|
10
|
+
atomic writes and a file-watcher guard (clean buffer follows disk, dirty
|
|
11
|
+
buffer banners and holds; no dialogs, ever). Every gesture is a
|
|
12
|
+
KeymapRegistry verb, so the table is the discovery surface and new gestures
|
|
13
|
+
stay cheap (the accretion tenet)."""
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from cjm_substrate_qt_kit.findbar import FindBar
|
|
20
|
+
from cjm_substrate_qt_kit.keymap import KeymapRegistry
|
|
21
|
+
from cjm_substrate_qt_kit.theme import current_theme, make_font, style_text_pane
|
|
22
|
+
from PySide6.QtCore import QEvent, QFileSystemWatcher, QPoint, QSettings, QTimer
|
|
23
|
+
from PySide6.QtGui import QFontDatabase, QFontMetrics, QTextCursor
|
|
24
|
+
from PySide6.QtWidgets import (QFileDialog, QHBoxLayout, QLabel, QMainWindow, QPlainTextEdit,
|
|
25
|
+
QStackedWidget, QStatusBar, QTextBrowser, QVBoxLayout, QWidget)
|
|
26
|
+
|
|
27
|
+
from .files import atomic_write, ensure_file, SCRATCHPAD_DIR
|
|
28
|
+
from .render import nearest_anchor, render_html
|
|
29
|
+
from .textops import wrap_selection
|
|
30
|
+
|
|
31
|
+
AUTOSAVE_MS = 1000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def mono_family(theme: dict) -> str:
|
|
35
|
+
"""The rendered code font: the theme's mono token, else the system
|
|
36
|
+
fixed font (the mono token must render well — corpus is code-heavy)."""
|
|
37
|
+
family = str(theme.get("font-mono-family") or "")
|
|
38
|
+
if family:
|
|
39
|
+
return family
|
|
40
|
+
return QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def block_line_map(doc) -> List[Tuple[int, int]]:
|
|
44
|
+
"""Scan a rendered document for the renderer's Lxx anchors:
|
|
45
|
+
[(block_number, source_line)] ascending — the render->edit half of the
|
|
46
|
+
position mapping."""
|
|
47
|
+
pairs: List[Tuple[int, int]] = []
|
|
48
|
+
block = doc.begin()
|
|
49
|
+
while block.isValid():
|
|
50
|
+
found: Optional[int] = None
|
|
51
|
+
it = block.begin()
|
|
52
|
+
while not it.atEnd() and found is None:
|
|
53
|
+
for name in it.fragment().charFormat().anchorNames():
|
|
54
|
+
if name.startswith("L") and name[1:].isdigit():
|
|
55
|
+
found = int(name[1:])
|
|
56
|
+
break
|
|
57
|
+
it += 1
|
|
58
|
+
if found is not None:
|
|
59
|
+
pairs.append((block.blockNumber(), found))
|
|
60
|
+
block = block.next()
|
|
61
|
+
return pairs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Editor(QPlainTextEdit):
|
|
65
|
+
"""The source pane, with line-addressed viewport access for the toggle."""
|
|
66
|
+
|
|
67
|
+
def top_line(self) -> int:
|
|
68
|
+
return self.firstVisibleBlock().blockNumber()
|
|
69
|
+
|
|
70
|
+
def visible_lines(self) -> int:
|
|
71
|
+
spacing = self.fontMetrics().lineSpacing() or 1
|
|
72
|
+
return max(1, self.viewport().height() // spacing)
|
|
73
|
+
|
|
74
|
+
def scroll_to_line(self, line: int) -> None:
|
|
75
|
+
"""Put a source line at (approximately) the viewport top; the
|
|
76
|
+
scrollbar counts VISUAL lines, so wrapped blocks are summed."""
|
|
77
|
+
doc = self.document()
|
|
78
|
+
line = max(0, min(line, doc.blockCount() - 1))
|
|
79
|
+
visual = 0
|
|
80
|
+
block = doc.firstBlock()
|
|
81
|
+
for _ in range(line):
|
|
82
|
+
layout = block.layout()
|
|
83
|
+
visual += max(1, layout.lineCount() if layout else 1)
|
|
84
|
+
block = block.next()
|
|
85
|
+
self.verticalScrollBar().setValue(visual)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ScratchpadWindow(QMainWindow):
|
|
89
|
+
"""One session file behind an edit/rendered toggle."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, path: Path, banner: Optional[str] = None,
|
|
92
|
+
directory: Optional[Path] = None):
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.path = Path(path)
|
|
95
|
+
self.directory = directory or SCRATCHPAD_DIR
|
|
96
|
+
self._dirty = False
|
|
97
|
+
self._hold = False
|
|
98
|
+
self._loading = False
|
|
99
|
+
self._bound = False # no save-before-load until a file is truly bound
|
|
100
|
+
self._measure_capped = True # the width option, never a lock
|
|
101
|
+
self._anchor_lines: List[int] = []
|
|
102
|
+
self._block_lines: List[Tuple[int, int]] = []
|
|
103
|
+
|
|
104
|
+
self.banner = QLabel()
|
|
105
|
+
self.banner.setWordWrap(True)
|
|
106
|
+
self.banner.hide()
|
|
107
|
+
self.editor = Editor()
|
|
108
|
+
editor_row = QWidget()
|
|
109
|
+
row = QHBoxLayout(editor_row)
|
|
110
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
111
|
+
row.addStretch(1)
|
|
112
|
+
row.addWidget(self.editor, 100) # out-stretch the sides: grow to the measure cap, then center
|
|
113
|
+
row.addStretch(1)
|
|
114
|
+
self.editor_container = editor_row
|
|
115
|
+
self.browser = QTextBrowser()
|
|
116
|
+
self.browser.setOpenExternalLinks(True)
|
|
117
|
+
self.stack = QStackedWidget()
|
|
118
|
+
self.stack.addWidget(self.editor_container)
|
|
119
|
+
self.stack.addWidget(self.browser)
|
|
120
|
+
self.findbar = FindBar(self.editor)
|
|
121
|
+
central = QWidget()
|
|
122
|
+
column = QVBoxLayout(central)
|
|
123
|
+
column.setContentsMargins(0, 0, 0, 0)
|
|
124
|
+
column.setSpacing(0)
|
|
125
|
+
column.addWidget(self.banner)
|
|
126
|
+
column.addWidget(self.stack, 1)
|
|
127
|
+
column.addWidget(self.findbar)
|
|
128
|
+
self.setCentralWidget(central)
|
|
129
|
+
|
|
130
|
+
self.file_label = QLabel()
|
|
131
|
+
self.file_label.setProperty("role", "content-dim")
|
|
132
|
+
self.mode_label = QLabel("EDIT")
|
|
133
|
+
self.save_label = QLabel("")
|
|
134
|
+
self.save_label.setProperty("role", "content-dim")
|
|
135
|
+
bar = QStatusBar()
|
|
136
|
+
bar.addWidget(self.file_label)
|
|
137
|
+
bar.addPermanentWidget(self.mode_label)
|
|
138
|
+
bar.addPermanentWidget(self.save_label)
|
|
139
|
+
self.setStatusBar(bar)
|
|
140
|
+
|
|
141
|
+
self._save_timer = QTimer(self)
|
|
142
|
+
self._save_timer.setSingleShot(True)
|
|
143
|
+
self._save_timer.setInterval(AUTOSAVE_MS)
|
|
144
|
+
self._save_timer.timeout.connect(self.save_now)
|
|
145
|
+
self.editor.textChanged.connect(self._on_edited)
|
|
146
|
+
self.watcher = QFileSystemWatcher(self)
|
|
147
|
+
self.watcher.fileChanged.connect(self._on_file_changed)
|
|
148
|
+
|
|
149
|
+
self.keymap = KeymapRegistry(self)
|
|
150
|
+
self._register_verbs()
|
|
151
|
+
self._build_menus()
|
|
152
|
+
self.refresh_theme()
|
|
153
|
+
app = self._application()
|
|
154
|
+
if app is not None:
|
|
155
|
+
hints = app.styleHints()
|
|
156
|
+
if hasattr(hints, "colorSchemeChanged"):
|
|
157
|
+
hints.colorSchemeChanged.connect(
|
|
158
|
+
lambda _s: QTimer.singleShot(0, self.refresh_theme))
|
|
159
|
+
|
|
160
|
+
self._settings = QSettings("cjm", "cjm-session-scratchpad-qt")
|
|
161
|
+
geometry = self._settings.value("geometry")
|
|
162
|
+
if geometry is not None:
|
|
163
|
+
self.restoreGeometry(geometry)
|
|
164
|
+
else:
|
|
165
|
+
self.resize(980, 760)
|
|
166
|
+
|
|
167
|
+
self.bind_path(self.path)
|
|
168
|
+
if banner:
|
|
169
|
+
self.show_banner(banner, role="warn")
|
|
170
|
+
|
|
171
|
+
# ----- verbs -------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def _register_verbs(self) -> None:
|
|
174
|
+
add = self.keymap.add
|
|
175
|
+
add("toggle-view", "Toggle edit/rendered view", "Ctrl+/", self.toggle_view)
|
|
176
|
+
add("find", "Find in file", "Ctrl+F", self.open_find)
|
|
177
|
+
add("find-next", "Find next", "F3", self.findbar.next)
|
|
178
|
+
add("find-previous", "Find previous", "Shift+F3", self.findbar.previous)
|
|
179
|
+
add("save-now", "Save now (keeps yours on disk conflict)", "Ctrl+S",
|
|
180
|
+
lambda: self.save_now(force=True))
|
|
181
|
+
add("reload-disk", "Reload from disk (takes disk version)", "Ctrl+Shift+R",
|
|
182
|
+
self.reload_from_disk)
|
|
183
|
+
add("wrap-bold", "Bold selection", "Ctrl+B", lambda: self.wrap("**"))
|
|
184
|
+
add("wrap-italic", "Italic selection", "Ctrl+I", lambda: self.wrap("*"))
|
|
185
|
+
add("wrap-code", "Inline-code selection", "Ctrl+`", lambda: self.wrap("`"))
|
|
186
|
+
add("toggle-measure", "Toggle edit measure cap (narrow/wide)", "Ctrl+Shift+M",
|
|
187
|
+
self.toggle_measure)
|
|
188
|
+
add("open-file", "Open file…", "Ctrl+O", self.open_dialog)
|
|
189
|
+
add("quit", "Quit", "Ctrl+Q", self.close)
|
|
190
|
+
|
|
191
|
+
def _build_menus(self) -> None:
|
|
192
|
+
menus = {"File": ("open-file", "save-now", "reload-disk", "quit"),
|
|
193
|
+
"View": ("toggle-view", "toggle-measure", "find", "find-next",
|
|
194
|
+
"find-previous"),
|
|
195
|
+
"Format": ("wrap-bold", "wrap-italic", "wrap-code")}
|
|
196
|
+
for title, verbs in menus.items():
|
|
197
|
+
menu = self.menuBar().addMenu(title)
|
|
198
|
+
for verb in verbs:
|
|
199
|
+
menu.addAction(self.keymap.action(verb))
|
|
200
|
+
|
|
201
|
+
# ----- file binding ------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def bind_path(self, path: Path) -> None:
|
|
204
|
+
"""Bind the window to a file (created blank when new), loading its
|
|
205
|
+
content without waking the autosave. The flush-first only applies to
|
|
206
|
+
a REBOUND window — at construction nothing is loaded yet, and saving
|
|
207
|
+
then would clobber the target with an empty buffer."""
|
|
208
|
+
if self._bound:
|
|
209
|
+
self.save_now()
|
|
210
|
+
self.path = Path(path)
|
|
211
|
+
created = ensure_file(self.path)
|
|
212
|
+
text = self.path.read_text(encoding="utf-8")
|
|
213
|
+
self._loading = True
|
|
214
|
+
try:
|
|
215
|
+
self.editor.setPlainText(text)
|
|
216
|
+
finally:
|
|
217
|
+
self._loading = False
|
|
218
|
+
self._dirty = False
|
|
219
|
+
self._hold = False
|
|
220
|
+
self._bound = True
|
|
221
|
+
self._rewatch()
|
|
222
|
+
self.setWindowTitle(f"{self.path.name} — session scratchpad")
|
|
223
|
+
self.file_label.setText(str(self.path))
|
|
224
|
+
self.save_label.setText("new file" if created else "")
|
|
225
|
+
if self.stack.currentWidget() is self.browser:
|
|
226
|
+
self._to_edit()
|
|
227
|
+
|
|
228
|
+
def open_dialog(self) -> None:
|
|
229
|
+
name, _filter = QFileDialog.getOpenFileName(
|
|
230
|
+
self, "Open scratchpad", str(self.directory), "Markdown (*.md);;All files (*)")
|
|
231
|
+
if name:
|
|
232
|
+
self.clear_banner()
|
|
233
|
+
self.bind_path(Path(name))
|
|
234
|
+
|
|
235
|
+
# ----- autosave + watcher ------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _on_edited(self) -> None:
|
|
238
|
+
if self._loading:
|
|
239
|
+
return
|
|
240
|
+
self._dirty = True
|
|
241
|
+
self._save_timer.start()
|
|
242
|
+
self.save_label.setText("editing…")
|
|
243
|
+
|
|
244
|
+
def save_now(self, force: bool = False) -> None:
|
|
245
|
+
"""Flush the buffer to disk (atomic). Held after an external change
|
|
246
|
+
until Ctrl+S (force: keep yours) or Ctrl+Shift+R (take disk)."""
|
|
247
|
+
if self._hold and not force:
|
|
248
|
+
return
|
|
249
|
+
if not self._dirty and not force:
|
|
250
|
+
return
|
|
251
|
+
atomic_write(self.path, self.editor.toPlainText())
|
|
252
|
+
self._dirty = False
|
|
253
|
+
self._hold = False
|
|
254
|
+
self._rewatch()
|
|
255
|
+
self.clear_banner()
|
|
256
|
+
self.save_label.setText(f"saved {datetime.now():%H:%M:%S}")
|
|
257
|
+
|
|
258
|
+
def reload_from_disk(self) -> None:
|
|
259
|
+
"""Take the disk version (the other half of conflict resolution)."""
|
|
260
|
+
try:
|
|
261
|
+
disk = self.path.read_text(encoding="utf-8")
|
|
262
|
+
except OSError:
|
|
263
|
+
return
|
|
264
|
+
top = self.editor.top_line()
|
|
265
|
+
self._loading = True
|
|
266
|
+
try:
|
|
267
|
+
self.editor.setPlainText(disk)
|
|
268
|
+
finally:
|
|
269
|
+
self._loading = False
|
|
270
|
+
self.editor.scroll_to_line(top)
|
|
271
|
+
self._dirty = False
|
|
272
|
+
self._hold = False
|
|
273
|
+
self.clear_banner()
|
|
274
|
+
self.save_label.setText("reloaded from disk")
|
|
275
|
+
if self.stack.currentWidget() is self.browser:
|
|
276
|
+
self._to_render() # a READ view must show the reloaded content, not the stale render
|
|
277
|
+
|
|
278
|
+
def _rewatch(self) -> None:
|
|
279
|
+
"""os.replace swaps the inode, which can drop the watch — re-arm."""
|
|
280
|
+
path = str(self.path)
|
|
281
|
+
if self.path.exists() and path not in self.watcher.files():
|
|
282
|
+
self.watcher.addPath(path)
|
|
283
|
+
|
|
284
|
+
def _on_file_changed(self, _path: str) -> None:
|
|
285
|
+
self._rewatch()
|
|
286
|
+
try:
|
|
287
|
+
disk = self.path.read_text(encoding="utf-8")
|
|
288
|
+
except OSError:
|
|
289
|
+
return
|
|
290
|
+
if disk == self.editor.toPlainText():
|
|
291
|
+
return # our own save (or an identical write) — nothing to do
|
|
292
|
+
if not self._dirty:
|
|
293
|
+
self.reload_from_disk()
|
|
294
|
+
self.show_banner("reloaded — file changed on disk", role="info")
|
|
295
|
+
else:
|
|
296
|
+
self._hold = True
|
|
297
|
+
self.show_banner("changed on disk while editing — autosave held · "
|
|
298
|
+
"Ctrl+S keeps yours · Ctrl+Shift+R takes disk",
|
|
299
|
+
role="warn")
|
|
300
|
+
|
|
301
|
+
# ----- toggle + position mapping -----------------------------------
|
|
302
|
+
|
|
303
|
+
def toggle_view(self) -> None:
|
|
304
|
+
if self.stack.currentWidget() is self.editor_container:
|
|
305
|
+
self._to_render()
|
|
306
|
+
else:
|
|
307
|
+
self._to_edit()
|
|
308
|
+
|
|
309
|
+
def _to_render(self) -> None:
|
|
310
|
+
self.save_now()
|
|
311
|
+
top = self.editor.top_line()
|
|
312
|
+
theme = current_theme()
|
|
313
|
+
result = render_html(self.editor.toPlainText(), theme, mono_family(theme))
|
|
314
|
+
self._anchor_lines = result.anchor_lines
|
|
315
|
+
self.browser.setHtml(result.html)
|
|
316
|
+
style_text_pane(self.browser, theme, live=True)
|
|
317
|
+
self._block_lines = block_line_map(self.browser.document())
|
|
318
|
+
anchor = nearest_anchor(self._anchor_lines, top)
|
|
319
|
+
if anchor is not None:
|
|
320
|
+
self.browser.scrollToAnchor(f"L{anchor}")
|
|
321
|
+
self.stack.setCurrentWidget(self.browser)
|
|
322
|
+
self.findbar.attach(self.browser)
|
|
323
|
+
self.browser.setFocus()
|
|
324
|
+
self.mode_label.setText("READ")
|
|
325
|
+
|
|
326
|
+
def _to_edit(self) -> None:
|
|
327
|
+
cursor = self.browser.cursorForPosition(QPoint(0, 0))
|
|
328
|
+
top_block = cursor.block().blockNumber()
|
|
329
|
+
line = 0
|
|
330
|
+
for block_number, source_line in self._block_lines:
|
|
331
|
+
if block_number > top_block:
|
|
332
|
+
break
|
|
333
|
+
line = source_line
|
|
334
|
+
self.editor.scroll_to_line(line)
|
|
335
|
+
first = self.editor.top_line()
|
|
336
|
+
span = self.editor.visible_lines()
|
|
337
|
+
if not (first <= self.editor.textCursor().blockNumber() <= first + span):
|
|
338
|
+
moved = self.editor.textCursor()
|
|
339
|
+
moved.setPosition(self.editor.document().findBlockByNumber(line).position())
|
|
340
|
+
self.editor.setTextCursor(moved)
|
|
341
|
+
self.stack.setCurrentWidget(self.editor_container)
|
|
342
|
+
self.findbar.attach(self.editor)
|
|
343
|
+
self.editor.setFocus()
|
|
344
|
+
self.mode_label.setText("EDIT")
|
|
345
|
+
|
|
346
|
+
# ----- formatting ---------------------------------------------------
|
|
347
|
+
|
|
348
|
+
def wrap(self, marker: str) -> None:
|
|
349
|
+
"""Apply a wrap-selection operator through one edit block (single
|
|
350
|
+
undo step)."""
|
|
351
|
+
if self.stack.currentWidget() is not self.editor_container:
|
|
352
|
+
return
|
|
353
|
+
cursor = self.editor.textCursor()
|
|
354
|
+
result = wrap_selection(self.editor.toPlainText(),
|
|
355
|
+
cursor.selectionStart(), cursor.selectionEnd(),
|
|
356
|
+
marker)
|
|
357
|
+
edit = self.editor.textCursor()
|
|
358
|
+
edit.beginEditBlock()
|
|
359
|
+
edit.setPosition(result.start)
|
|
360
|
+
edit.setPosition(result.end, QTextCursor.MoveMode.KeepAnchor)
|
|
361
|
+
edit.insertText(result.text)
|
|
362
|
+
edit.endEditBlock()
|
|
363
|
+
after = self.editor.textCursor()
|
|
364
|
+
after.setPosition(result.anchor)
|
|
365
|
+
after.setPosition(result.cursor, QTextCursor.MoveMode.KeepAnchor)
|
|
366
|
+
self.editor.setTextCursor(after)
|
|
367
|
+
|
|
368
|
+
def toggle_measure(self) -> None:
|
|
369
|
+
"""The width option, not a lock: flip the edit pane between the
|
|
370
|
+
measure cap and full width (first live-session filing, 2026-08-19)."""
|
|
371
|
+
self._measure_capped = not self._measure_capped
|
|
372
|
+
self.refresh_theme()
|
|
373
|
+
|
|
374
|
+
# ----- find ----------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def open_find(self) -> None:
|
|
377
|
+
pane = (self.browser if self.stack.currentWidget() is self.browser
|
|
378
|
+
else self.editor)
|
|
379
|
+
self.findbar.attach(pane)
|
|
380
|
+
self.findbar.open()
|
|
381
|
+
|
|
382
|
+
# ----- banner + theme ------------------------------------------------
|
|
383
|
+
|
|
384
|
+
def show_banner(self, text: str, role: str = "warn") -> None:
|
|
385
|
+
self.banner.setProperty("role", role)
|
|
386
|
+
self.banner.style().unpolish(self.banner)
|
|
387
|
+
self.banner.style().polish(self.banner)
|
|
388
|
+
self.banner.setText(" " + text)
|
|
389
|
+
self.banner.show()
|
|
390
|
+
|
|
391
|
+
def clear_banner(self) -> None:
|
|
392
|
+
self.banner.hide()
|
|
393
|
+
self.banner.setText("")
|
|
394
|
+
|
|
395
|
+
def refresh_theme(self) -> None:
|
|
396
|
+
"""Land typography on both panes: editor gets body font + measure
|
|
397
|
+
width (NO line-height merges — undo integrity), browser gets the
|
|
398
|
+
full reading treatment; a rendered view re-renders for the new
|
|
399
|
+
inline colors."""
|
|
400
|
+
theme = current_theme()
|
|
401
|
+
font = make_font(theme, "body")
|
|
402
|
+
self.editor.setFont(font)
|
|
403
|
+
metrics = QFontMetrics(font)
|
|
404
|
+
was_loading = self._loading
|
|
405
|
+
self._loading = True # margin change fires textChanged; not a user edit
|
|
406
|
+
try:
|
|
407
|
+
self.editor.document().setDocumentMargin(12.0)
|
|
408
|
+
finally:
|
|
409
|
+
self._loading = was_loading
|
|
410
|
+
width = metrics.averageCharWidth() * int(theme.get("measure") or 68)
|
|
411
|
+
chrome = (2 * int(self.editor.document().documentMargin())
|
|
412
|
+
+ self.editor.verticalScrollBar().sizeHint().width() + 8)
|
|
413
|
+
self.editor.setMaximumWidth(width + chrome if self._measure_capped
|
|
414
|
+
else 16777215)
|
|
415
|
+
self.editor.setTabStopDistance(4 * metrics.horizontalAdvance(" "))
|
|
416
|
+
if self.stack.currentWidget() is self.browser:
|
|
417
|
+
self._to_render()
|
|
418
|
+
else:
|
|
419
|
+
style_text_pane(self.browser, theme, live=True)
|
|
420
|
+
|
|
421
|
+
# ----- lifecycle ------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def _application():
|
|
425
|
+
from PySide6.QtWidgets import QApplication
|
|
426
|
+
return QApplication.instance()
|
|
427
|
+
|
|
428
|
+
def changeEvent(self, event) -> None:
|
|
429
|
+
if event.type() == QEvent.Type.ActivationChange and not self.isActiveWindow():
|
|
430
|
+
self.save_now()
|
|
431
|
+
super().changeEvent(event)
|
|
432
|
+
|
|
433
|
+
def closeEvent(self, event) -> None:
|
|
434
|
+
self.save_now()
|
|
435
|
+
self._settings.setValue("geometry", self.saveGeometry())
|
|
436
|
+
super().closeEvent(event)
|