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.
Files changed (30) hide show
  1. cjm_session_scratchpad_qt-0.0.1/PKG-INFO +115 -0
  2. cjm_session_scratchpad_qt-0.0.1/README.md +101 -0
  3. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/__init__.py +1 -0
  4. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/app.py +436 -0
  5. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/appv2.py +682 -0
  6. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/cli.py +99 -0
  7. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/files.py +108 -0
  8. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/graph.py +238 -0
  9. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/manifest.py +89 -0
  10. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/render.py +251 -0
  11. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/textops.py +105 -0
  12. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/timeline.py +96 -0
  13. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/timeline_html.py +137 -0
  14. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt/watcher.py +45 -0
  15. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/PKG-INFO +115 -0
  16. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/SOURCES.txt +28 -0
  17. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/dependency_links.txt +1 -0
  18. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/entry_points.txt +2 -0
  19. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/requires.txt +2 -0
  20. cjm_session_scratchpad_qt-0.0.1/cjm_session_scratchpad_qt.egg-info/top_level.txt +1 -0
  21. cjm_session_scratchpad_qt-0.0.1/pyproject.toml +23 -0
  22. cjm_session_scratchpad_qt-0.0.1/setup.cfg +4 -0
  23. cjm_session_scratchpad_qt-0.0.1/tests/test_app_probe.py +130 -0
  24. cjm_session_scratchpad_qt-0.0.1/tests/test_appv2_probe.py +331 -0
  25. cjm_session_scratchpad_qt-0.0.1/tests/test_files.py +107 -0
  26. cjm_session_scratchpad_qt-0.0.1/tests/test_manifest.py +51 -0
  27. cjm_session_scratchpad_qt-0.0.1/tests/test_render.py +107 -0
  28. cjm_session_scratchpad_qt-0.0.1/tests/test_textops.py +83 -0
  29. cjm_session_scratchpad_qt-0.0.1/tests/test_timeline.py +77 -0
  30. 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)