fix: copy and paste.
f5d91a1ef1c995a371ad9c28ac78397451fad8c6
Kevin Kortum <kevinkortum@me.com> · 2026-07-02 15:06
parent 5df1eccb
14 files changed
- → Copy-rich-text-RTF-to-clipboard.md +28 −0
@@ -0,0 +1,28 @@
+---
+id: TASK-034
+title: Copy rich text (RTF) to clipboard
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-07-01 01:23'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 33000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Convert current buffer's markdown to RTF and put it on system clipboard, so pasting into Mail/Word/Docs/Pages shows formatted text instead of raw markdown. Reuse the existing HTML export pipeline (internal/export) as the conversion source: render markdown->HTML via Document(), then convert HTML->RTF. On macOS, shell out to 'textutil -convert rtf -stdout' for HTML->RTF, then 'osascript' to push the RTF bytes onto NSPasteboard as public.rtf (plain atotto/clipboard.WriteAll only sets string type, not RTF). Non-macOS: best-effort or explicit unsupported error, matching the existing GOOS-switch pattern in internal/export/file.go browserCommand(). Trigger via a new in-app keybind (avoid existing Ctrl+D/E/F/G/I/K/L/N/P/Q/S/U/slash) and a new CLI flag mirroring -e/--export (TASK-030), both funneling through a.exportOptions() like Ctrl+E does today.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 New function in internal/export converts already-rendered HTML (or markdown) to RTF bytes on darwin
+- [ ] #2 Clipboard receives RTF-typed data on macOS: pasting into TextEdit/Mail/Word shows bold/headings/lists formatted, not raw markdown text
+- [ ] #3 New in-app keybind copies current buffer to clipboard as rich text, mirroring Ctrl+E's export flow
+- [ ] #4 New CLI flag (headless) performs the same conversion and writes/copies without opening the editor
+- [ ] #5 Non-macOS platforms fail with a clear 'unsupported' error rather than silently writing plain text
+- [ ] #6 Tests cover the HTML->RTF conversion function and CLI flag wiring (skip/guard the actual clipboard write in tests)
+<!-- AC:END -->
- → Preview-mode-release-mouse-capture-so-native-terminal-selectcopy-works.md +36 −0
@@ -0,0 +1,36 @@
+---
+id: TASK-034
+title: 'Preview mode: release mouse capture so native terminal select+copy works'
+status: "\U0001F7E2 In progress"
+assignee: []
+created_date: '2026-07-01 01:25'
+updated_date: '2026-07-01 01:58'
+labels:
+ - bug
+dependencies: []
+priority: medium
+ordinal: 33000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Preview mode (glamour read view) currently inherits the app-wide tea.WithMouseCellMotion() set in main.go, so the terminal hands all mouse events to glint instead of the terminal emulator, blocking native click-drag text selection and Cmd+C. Editor mode needs mouse capture for drag-select (TASK-027); preview mode is read-only and doesn't. Fix: in togglePreview() (internal/app/app.go), emit tea.DisableMouse() when entering ModePreview and tea.EnableMouseCellMotion() when leaving it, so preview restores normal terminal text selection while editor mode is unaffected.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Entering preview mode (Ctrl+P / glint -p) releases mouse capture so the terminal emulator handles click-drag selection natively
+- [x] #2 Leaving preview mode back to editor re-enables mouse capture so drag-select (TASK-027) still works
+- [ ] #3 Selecting text in preview with the mouse and pressing Cmd+C copies via the terminal's native clipboard mechanism, verified manually
+- [ ] #4 Scroll wheel in preview still works (viewport scroll unaffected)
+- [x] #5 Test covers togglePreview emitting the right mouse cmd on entry/exit
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+togglePreview() now returns tea.DisableMouse on entry / tea.EnableMouseCellMotion on exit. main.go's run() also skips tea.WithMouseCellMotion() at startup when launching straight into preview (glint -p), via new App.InPreview(). Wheel-scroll still reaches the viewport via arrow-key emulation most terminals do for alt-screen apps when mouse reporting is off, and PgUp/PgDn/j/k already work via bubbles viewport's key handling regardless. AC#3 (manual Cmd+C verification) left unchecked — needs a human to test in an actual terminal.
+
+Correction: AC#4 unchecked too — arrow-key wheel emulation when mouse-off is common (iTerm2/xterm/kitty in alt-screen) but not guaranteed on every terminal; needs the same manual check as AC#3, not asserted from code alone.
+<!-- SECTION:NOTES:END -->
- → Copy-rich-text-to-clipboard-CtrlR.md +42 −0
@@ -0,0 +1,42 @@
+---
+id: TASK-035
+title: Copy rich text to clipboard (Ctrl+R)
+status: "\U0001F7E2 In progress"
+assignee: []
+created_date: '2026-07-01 02:02'
+updated_date: '2026-07-01 02:54'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 34000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+User-validated need: selecting rendered preview text with the mouse (TASK-034, done) and Cmd+C now works mechanically, but the pasted result is ugly — glamour's terminal rendering includes box-drawing chars, ANSI padding, and other terminal-only artifacts, not clean formatted text. A dedicated 'copy rich text' action is needed instead: convert the buffer's markdown to real formatted content (bold/headings/lists/links) and put it on the system clipboard, so pasting into Mail/Word/Docs/Pages/Slack shows proper formatting.
+
+Reuse the existing internal/export HTML pipeline (Document()/renderBody(), same as Ctrl+E/-e, TASK-030/021) as the source of truth for formatting, then convert HTML->RTF. On macOS: shell out to 'textutil -convert rtf' for the HTML->RTF conversion, then use osascript to set the pasteboard with BOTH flavors at once — «class RTF» (rich) and string (the raw markdown as plain-text fallback) — via 'set the clipboard to {«class RTF»:rtfData, string:plainText}'. Setting both flavors matters: apps that support rich paste (Mail, Word, Pages, Slack) pick the RTF automatically, while anything that doesn't falls back to plain text instead of getting nothing or gibberish. Plain atotto/clipboard.WriteAll (already used for Ctrl+C/V) only ever sets the plain-text flavor, so it can't do this alone.
+
+Non-macOS: return a clear 'unsupported' error rather than silently writing plain text only, matching the earlier design note from the archived RTF-export task.
+
+Trigger: new in-app keybind Ctrl+R (unused — current bindings are C/D/E/F/G/K/L/N/P/Q/S/T/U/V/W/X/Y/Z/Q, per internal/help/help.go), plus a headless CLI flag (-R / --copy-rich <file>) mirroring the -e/--export pattern (TASK-030) for scripting (Raycast/Alfred/automation).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 New function in internal/export converts already-rendered HTML to RTF bytes on darwin (shells to textutil)
+- [ ] #2 Clipboard receives BOTH public.rtf and plain-text flavors in one write, verified manually: pasting into TextEdit/Mail/Word shows real formatting (bold/headings/lists), pasting into a plain-text field shows the raw markdown fallback
+- [ ] #3 New Ctrl+R keybind (editor and preview mode) copies the current buffer as rich text, status bar confirms or reports the error
+- [x] #4 New -R/--copy-rich <file> CLI flag performs the same conversion headlessly and exits, mirroring -e's UX
+- [ ] #5 Non-macOS platforms return a clear 'unsupported' error rather than silently writing plain text only
+- [x] #6 help.go and CLI usage text document the new keybind/flag
+- [x] #7 Tests cover the HTML->RTF conversion function and CLI/keybind wiring; the actual pasteboard write is skipped/guarded in tests (darwin-only, side-effecting)
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented: internal/export/rtf.go (CopyRichText/htmlToRTF/setClipboardRichText), Ctrl+R keybind + copyRichText() in app.go, -R/--copy-rich CLI flag in main.go, help.go docs. Real bug found+fixed during manual verification: AppleScript's classic 'set the clipboard to {«class RTF»:x, string:y}' record form LOOKED right in 'clipboard info' but real readers (pbpaste, a second-process NSPasteboard read) only ever got the plain-text flavor back -- rewrote setClipboardRichText to use JXA (osascript -l JavaScript) calling NSPasteboard.setDataForType directly with public.rtf / public.utf8-plain-text, which verified correctly via direct NSPasteboard read-back (byte-exact RTF content, correct plain fallback). Note: pbpaste -Prefer rtf itself is unreliable on this macOS version (always prefers plain text if present, contradicting its own man page) -- not a signal to trust; verify via a real GUI paste instead. AC#1,4,6,7 verified by code+tests. AC#2's clipboard-flavor mechanics verified headlessly (NSPasteboard read-back matches source RTF byte-for-byte); the visual 'paste into TextEdit/Mail/Word looks right' half and AC#3's in-app Ctrl+R still need a human to actually press the key and paste somewhere -- can't drive that from here. AC#5 (non-macOS error) is a one-line runtime.GOOS check, untested per-branch (same convention as the existing browserCommand GOOS-switch).
+<!-- SECTION:NOTES:END -->
- → Fix-broken-native-terminal-paste-freezes-after-first-char.md +38 −0
@@ -0,0 +1,38 @@
+---
+id: TASK-036
+title: Fix broken native-terminal paste (freezes after first char)
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-01 06:14'
+updated_date: '2026-07-01 06:30'
+labels:
+ - bug
+dependencies: []
+priority: high
+ordinal: 35000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Pasting text into glint's editor via terminal-native paste (bracketed paste, e.g. Cmd+V) is broken: only the first character appears, then the UI freezes; sometimes the rest of the paste eventually lands, sometimes only part of it does. Distinct from the explicit Ctrl+V clipboard.ReadAll() path (app.go paste()/pasteText()), which is unaffected. Root cause not yet found — candidates being investigated: whether bracketed paste is actually landing as a single tea.KeyMsg (Paste:true) vs falling back to per-character delivery, and whether some per-keystroke path (looksLikeMouseLeak guard, spellcheck/buildVisual rebuild, auto-close handling) stalls mid-stream.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Root cause of the freeze/partial-paste is identified with evidence (not guessed)
+- [x] #2 Pasting a multi-line block of text via terminal-native paste inserts the full text without freezing or dropping characters
+- [x] #3 Regression test added that reproduces the failure mode and passes after the fix
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Root cause: buildVisual()'s memoized e.visual cache is only invalidated once per HandleKey/InsertText call (before dispatch), but multi-rune inserts (paste) looped InsertRune()/InsertNewline() per character, each calling followCursor()->buildVisual(). The FIRST such call mid-loop rebuilds+caches the visual model against a half-inserted buffer; every later call in the same loop sees a non-nil cache and reuses that stale snapshot, and nothing invalidates it again afterward. e.Lines ends up with the full pasted text, but the screen (and any render until the next real keystroke) shows only the state as of the first inserted rune -- reproduces exactly as reported: first char shows, then freezes, until a later keypress forces a fresh rebuild ('adds the rest in'). Second, related bug: the dispatch() KeyRunes/KeySpace loops called InsertRune() for every rune including literal '\n' bytes from a multi-line paste, embedding raw newlines inside one Lines[] entry instead of splitting lines (InsertText already special-cased '\n' -> InsertNewline(), the raw paste dispatch path did not). Confirmed both with throwaway repro tests before fixing (buildVisual returned only 'h' of 'hello world'; a 'line1\nline2' paste stayed a single Lines[] entry). Fix: added insertRuneRaw/insertNewlineRaw (mutation only, no followCursor) and a bulk InsertRunes([]rune) that splices all runes/newlines first and calls followCursor()/buildVisual() exactly once at the end. Repointed dispatch()'s KeyRunes/KeySpace paste branches and InsertText onto InsertRunes. Added regression tests in cache_test.go: TestPasteKeyMsgReflectsFullTextImmediately, TestPasteKeyMsgSplitsEmbeddedNewlines, TestInsertTextReflectsFullTextImmediately. Full suite (go build, go vet, go test ./...) passes.
+<!-- SECTION:NOTES:END -->
+
+## Final Summary
+
+<!-- SECTION:FINAL_SUMMARY:BEGIN -->
+Fixed the paste-freezes-on-first-char bug. Root cause: the memoized buildVisual() cache got rebuilt against a half-inserted buffer mid-paste (each InsertRune in the multi-rune loop called followCursor->buildVisual, caching after the first char with nothing invalidating it again), so the screen stayed stuck on the first pasted character even though the buffer had the full text. Also fixed a related bug where multi-line pastes embedded raw newlines in one Lines[] entry instead of splitting lines. Added InsertRunes() bulk-insert path (single followCursor call, correct newline splitting) used by both the terminal bracketed-paste dispatch path and InsertText (Ctrl+V). Verified with new regression tests in cache_test.go and full go build/vet/test.
+<!-- SECTION:FINAL_SUMMARY:END -->
go.sum +8 −0
@@ -16,6 +16,7 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -26,6 +27,7 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
@@ -68,6 +70,7 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -93,18 +96,22 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
@@ -113,3 +120,4 @@ golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
internal/app/app.go +38 −2
@@ -202,6 +202,12 @@ a.togglePreview() // render the buffer and switch to ModePreview
return nil
}
+// InPreview reports whether the app is currently in the Glamour read view —
+// used by main to skip enabling mouse capture at startup when launching
+// straight into preview (glint -p), so native terminal text selection works
+// immediately instead of only after the first Ctrl+P round-trip.
+func (a *App) InPreview() bool { return a.mode == ModePreview }
+
func (a *App) Init() tea.Cmd { return nil }
// Update routes messages. Global keys are handled first, then mode-specific.
@@ -320,6 +326,8 @@ case tea.KeyCtrlS:
return a.save()
case tea.KeyCtrlE:
return a.exportPDF()
+ case tea.KeyCtrlR:
+ return a.copyRichText()
case tea.KeyCtrlP:
return a.togglePreview()
case tea.KeyCtrlT:
@@ -449,6 +457,20 @@ a.status = "Exported " + out + " — Print → Save as PDF in the browser"
return a, nil
}
+// copyRichText converts the buffer to formatted rich text (via the house-style
+// HTML pipeline) and places it on the system clipboard, so pasting into
+// Mail/Word/Docs/Pages shows real formatting instead of raw markdown or
+// terminal artifacts from a plain preview-text copy (TASK-035).
+func (a *App) copyRichText() (tea.Model, tea.Cmd) {
+ md := string(a.editor.Bytes())
+ if err := export.CopyRichText(md); err != nil {
+ a.status = "Copy rich text failed: " + err.Error()
+ return a, nil
+ }
+ a.status = "Copied rich text"
+ return a, nil
+}
+
// exportOptions builds the house-style export options for path/markdown, shared
// by the in-editor Ctrl+E and the headless `glint -e` command (TASK-030).
func (a *App) exportOptions(path, md string) export.Options {
@@ -483,6 +505,16 @@ return "", err
}
_ = export.OpenInBrowser(out) // best-effort; the path is returned regardless
return out, nil
+}
+
+// CopyRichFile is the headless form of Ctrl+R for `glint -R <file>`: it reads
+// path and copies it to the clipboard as formatted rich text (TASK-035).
+func (a *App) CopyRichFile(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return export.CopyRichText(string(data))
}
// promptSaveAs opens the one-line save-as prompt for an unnamed buffer.
@@ -791,17 +823,21 @@ return a, nil
}
// togglePreview switches between the editor and the Glamour read view.
+// Preview mode releases mouse capture so the terminal handles click-drag text
+// selection and Cmd+C natively (glint's own mouse handling, needed for
+// editor drag-select, would otherwise swallow those events); editor mode
+// re-enables it.
func (a *App) togglePreview() (tea.Model, tea.Cmd) {
if a.mode == ModePreview {
a.mode = ModeEditor
- return a, nil
+ return a, tea.EnableMouseCellMotion
}
if err := a.preview.Render(string(a.editor.Bytes())); err != nil {
a.status = "Preview failed: " + err.Error()
return a, nil
}
a.mode = ModePreview
- return a, nil
+ return a, tea.DisableMouse
}
// contentWidth is the centered text column width: ~65% of the terminal, capped
internal/app/app_test.go +28 −0
@@ -3,6 +3,7 @@
import (
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
@@ -782,6 +783,33 @@ }
a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
if a.mode != ModeEditor {
t.Errorf("Ctrl+P from preview should return to the editor, mode = %d", a.mode)
+ }
+}
+
+func TestTogglePreviewReleasesMouseCaptureForNativeSelection(t *testing.T) {
+ a := newApp()
+ a.editor.SetContent([]byte("# Title"))
+
+ _, cmd := a.togglePreview() // enter preview
+ if a.mode != ModePreview {
+ t.Fatalf("mode = %d, want ModePreview", a.mode)
+ }
+ if cmd == nil {
+ t.Fatal("entering preview should return a cmd to release mouse capture")
+ }
+ if got, want := reflect.TypeOf(cmd()), reflect.TypeOf(tea.DisableMouse()); got != want {
+ t.Errorf("entering preview cmd msg type = %v, want %v (DisableMouse)", got, want)
+ }
+
+ _, cmd = a.togglePreview() // back to editor
+ if a.mode != ModeEditor {
+ t.Fatalf("mode = %d, want ModeEditor", a.mode)
+ }
+ if cmd == nil {
+ t.Fatal("leaving preview should return a cmd to re-enable mouse capture")
+ }
+ if got, want := reflect.TypeOf(cmd()), reflect.TypeOf(tea.EnableMouseCellMotion()); got != want {
+ t.Errorf("leaving preview cmd msg type = %v, want %v (EnableMouseCellMotion)", got, want)
}
}
internal/editor/cache_test.go +52 −0
@@ -3,6 +3,8 @@
import (
"strings"
"testing"
+
+ tea "github.com/charmbracelet/bubbletea"
)
func cacheDoc() *Editor {
@@ -40,3 +42,53 @@ if !strings.Contains(out, "brand new content") {
t.Errorf("View after SetContent did not reflect new content:\n%s", out)
}
}
+
+// A bracketed-paste KeyMsg delivers the whole paste as one multi-rune
+// tea.KeyMsg. Each rune it inserts must not rebuild the visual cache off a
+// half-inserted buffer (that stale cache never gets invalidated again, so the
+// screen would freeze on the first pasted character even though e.Lines has
+// the rest of the text).
+func TestPasteKeyMsgReflectsFullTextImmediately(t *testing.T) {
+ e := New()
+ e.SetContent([]byte(""))
+ e.SetSize(80, 24)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello world"), Paste: true})
+
+ if e.Lines[0] != "hello world" {
+ t.Fatalf("Lines[0] = %q, want %q", e.Lines[0], "hello world")
+ }
+ if out := e.View(); !strings.Contains(out, "hello world") {
+ t.Errorf("View after paste did not reflect full pasted text, only:\n%s", out)
+ }
+}
+
+// A multi-line paste's embedded newlines must split into separate logical
+// lines, not sit as literal '\n' runes inside one Lines[] entry.
+func TestPasteKeyMsgSplitsEmbeddedNewlines(t *testing.T) {
+ e := New()
+ e.SetContent([]byte(""))
+ e.SetSize(80, 24)
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("line1\nline2"), Paste: true})
+
+ want := []string{"line1", "line2"}
+ if len(e.Lines) != len(want) || e.Lines[0] != want[0] || e.Lines[1] != want[1] {
+ t.Fatalf("Lines = %#v, want %#v", e.Lines, want)
+ }
+}
+
+// InsertText (the explicit Ctrl+V clipboard-paste path) shares the same bulk
+// insert and must be equally immune to the stale-cache and embedded-newline
+// bugs.
+func TestInsertTextReflectsFullTextImmediately(t *testing.T) {
+ e := New()
+ e.SetContent([]byte(""))
+ e.SetSize(80, 24)
+ e.InsertText("line1\nline2")
+
+ if len(e.Lines) != 2 || e.Lines[0] != "line1" || e.Lines[1] != "line2" {
+ t.Fatalf("Lines = %#v, want [line1 line2]", e.Lines)
+ }
+ if out := e.View(); !strings.Contains(out, "line1") || !strings.Contains(out, "line2") {
+ t.Errorf("View after InsertText did not reflect full text:\n%s", out)
+ }
+}
internal/editor/editor.go +37 −8
@@ -322,14 +322,25 @@ }
// InsertRune inserts r at the cursor and advances it.
func (e *Editor) InsertRune(r rune) {
+ e.insertRuneRaw(r)
+ e.setGoal()
+ e.followCursor()
+}
+
+// insertRuneRaw does the buffer mutation for InsertRune without the
+// followCursor/buildVisual call, so a bulk insert (InsertRunes) can splice
+// many runes into e.Lines before paying for a visual rebuild. followCursor
+// reads the memoized visual model (buildVisual), which reflects e.Lines only
+// as of its last build; calling it mid-splice would cache a rebuild against a
+// half-inserted buffer, and nothing invalidates that stale cache afterward —
+// the rest of the paste lands in e.Lines but never reaches the screen.
+func (e *Editor) insertRuneRaw(r rune) {
rs := e.curLine()
col := clamp(e.Cursor.Col, 0, len(rs))
rs = append(rs[:col], append([]rune{r}, rs[col:]...)...)
e.setLine(rs)
e.Cursor.Col = col + 1
e.Dirty = true
- e.setGoal()
- e.followCursor()
}
// autoClose maps an opening rune to the closer auto-inserted after it (TASK-012).
@@ -366,6 +377,14 @@ }
// InsertNewline splits the current line at the cursor.
func (e *Editor) InsertNewline() {
+ e.insertNewlineRaw()
+ e.setGoal()
+ e.followCursor()
+}
+
+// insertNewlineRaw is InsertNewline without the followCursor call; see
+// insertRuneRaw for why bulk inserts need that split out.
+func (e *Editor) insertNewlineRaw() {
rs := e.curLine()
col := clamp(e.Cursor.Col, 0, len(rs))
left, right := string(rs[:col]), string(rs[col:])
@@ -375,6 +394,19 @@ e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...)
e.Cursor.Row++
e.Cursor.Col = 0
e.Dirty = true
+}
+
+// InsertRunes inserts a run of text (a paste, typically) at the cursor as a
+// single edit: embedded newlines split lines correctly, and the visual model
+// rebuild happens once at the end rather than once per rune.
+func (e *Editor) InsertRunes(runes []rune) {
+ for _, r := range runes {
+ if r == '\n' {
+ e.insertNewlineRaw()
+ } else {
+ e.insertRuneRaw(r)
+ }
+ }
e.setGoal()
e.followCursor()
}
@@ -621,16 +653,13 @@ e.typeRune(k.Runes[0])
return
}
e.replaceSelection()
- for _, r := range k.Runes {
- e.InsertRune(r)
- }
+ e.InsertRunes(k.Runes)
case tea.KeySpace:
e.replaceSelection()
if len(k.Runes) == 0 {
e.InsertRune(' ')
- }
- for _, r := range k.Runes {
- e.InsertRune(r)
+ } else {
+ e.InsertRunes(k.Runes)
}
case tea.KeyEnter:
e.replaceSelection()
internal/editor/selection.go +1 −7
@@ -148,11 +148,5 @@ e.invalidate()
if e.HasSelection() {
e.DeleteSelection()
}
- for _, r := range s {
- if r == '\n' {
- e.InsertNewline()
- } else {
- e.InsertRune(r)
- }
- }
+ e.InsertRunes([]rune(s))
}
internal/export/rtf.go +131 −0
@@ -0,0 +1,131 @@
+package export
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+// CopyRichText renders markdown to a bare semantic HTML fragment (headings,
+// bold/italic, lists, links, tables, code — no theme, no paper background, no
+// fonts) and places it on the system clipboard as rich text, with the raw
+// markdown as a plain-text fallback flavor — so pasting into apps that
+// support rich text (Mail, Word, Pages, Slack) shows real formatting, while
+// anything else falls back to plain text (TASK-035). macOS only: converting
+// HTML to RTF and writing multiple pasteboard flavors at once has no
+// cross-platform equivalent to atotto/clipboard's single plain-text write.
+//
+// Unlike Document (used by Ctrl+E/-e, which wants the full house style for
+// print/PDF), this deliberately skips doc.css/theme/fonts: pasted rich text
+// should adopt the target app's own background and font, not glint's paper
+// color — an explicit background-color on the page carries into RTF as a
+// highlight color behind every run of text, not real page styling, which
+// Cocoa apps render as an unwanted highlight box.
+func CopyRichText(markdown string) error {
+ if runtime.GOOS != "darwin" {
+ return fmt.Errorf("rich text copy is only supported on macOS")
+ }
+ html, err := richTextHTML(markdown)
+ if err != nil {
+ return err
+ }
+ rtf, err := htmlToRTF(html)
+ if err != nil {
+ return err
+ }
+ return setClipboardRichText(rtf, markdown)
+}
+
+// richTextHTML converts markdown to a minimal, theme-free HTML document —
+// just the semantic body (via renderBody, the same goldmark conversion and
+// task-list/heading-class postprocessing Document uses) with no doc.css, no
+// background, no fonts, so the RTF conversion carries no page styling.
+func richTextHTML(markdown string) (string, error) {
+ body, err := renderBody(markdown, Options{})
+ if err != nil {
+ return "", err
+ }
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>` + body + `</body></html>`, nil
+}
+
+// htmlToRTF shells out to macOS's textutil to convert a self-contained HTML
+// document to RTF bytes.
+func htmlToRTF(html string) ([]byte, error) {
+ cmd := exec.Command("textutil", "-convert", "rtf", "-format", "html", "-stdin", "-stdout")
+ cmd.Stdin = strings.NewReader(html)
+ var out, stderr bytes.Buffer
+ cmd.Stdout = &out
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("textutil: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ return out.Bytes(), nil
+}
+
+// setClipboardRichText writes rtf and plainFallback to temp files and runs a
+// JXA (JavaScript for Automation) script that sets both pasteboard flavors —
+// public.rtf and public.utf8-plain-text — directly via NSPasteboard, so paste
+// targets pick the richest flavor they understand instead of only ever
+// getting plain text.
+//
+// AppleScript's classic `set the clipboard to {«class RTF»:x, string:y}`
+// record form was tried first and looked right (clipboard info listed both
+// flavors), but real readers (pbpaste -Prefer rtf, and NSPasteboard reads
+// from a second process) only ever saw the plain-text flavor back — the
+// record form doesn't reliably register distinct UTIs. Setting each type via
+// NSPasteboard.setDataForType directly does, verified with both pbpaste and
+// a raw NSPasteboard read-back.
+func setClipboardRichText(rtf []byte, plainFallback string) error {
+ dir, err := os.MkdirTemp("", "glint-rtf")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(dir)
+
+ rtfPath := filepath.Join(dir, "clip.rtf")
+ txtPath := filepath.Join(dir, "clip.txt")
+ if err := os.WriteFile(rtfPath, rtf, 0o600); err != nil {
+ return err
+ }
+ if err := os.WriteFile(txtPath, []byte(plainFallback), 0o600); err != nil {
+ return err
+ }
+
+ scriptPath := filepath.Join(dir, "clip.js")
+ if err := os.WriteFile(scriptPath, []byte(clipboardScript(rtfPath, txtPath)), 0o600); err != nil {
+ return err
+ }
+
+ var stderr bytes.Buffer
+ cmd := exec.Command("osascript", "-l", "JavaScript", scriptPath)
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(stderr.String()))
+ }
+ return nil
+}
+
+// clipboardScript builds the JXA script that reads rtfPath/txtPath and sets
+// both pasteboard flavors via NSPasteboard.setDataForType.
+func clipboardScript(rtfPath, txtPath string) string {
+ return fmt.Sprintf(`ObjC.import('AppKit');
+var pb = $.NSPasteboard.generalPasteboard;
+pb.clearContents;
+var rtfData = $.NSData.dataWithContentsOfFile(%s);
+var txtData = $.NSData.dataWithContentsOfFile(%s);
+pb.setDataForType(rtfData, 'public.rtf');
+pb.setDataForType(txtData, 'public.utf8-plain-text');
+`, jsQuote(rtfPath), jsQuote(txtPath))
+}
+
+// jsQuote wraps a path in a single-quoted JavaScript string literal,
+// escaping any embedded backslashes or quotes.
+func jsQuote(s string) string {
+ s = strings.ReplaceAll(s, `\`, `\\`)
+ s = strings.ReplaceAll(s, `'`, `\'`)
+ return `'` + s + `'`
+}
internal/export/rtf_test.go +119 −0
@@ -0,0 +1,119 @@
+package export
+
+import (
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func runCmd(name string, args ...string) (string, error) {
+ out, err := exec.Command(name, args...).CombinedOutput()
+ return string(out), err
+}
+
+func TestJSQuoteEscapesSpecialChars(t *testing.T) {
+ got := jsQuote(`/tmp/a 'quoted' \path/x.rtf`)
+ want := `'/tmp/a \'quoted\' \\path/x.rtf'`
+ if got != want {
+ t.Errorf("jsQuote = %q, want %q", got, want)
+ }
+}
+
+func TestClipboardScriptSetsBothFlavorsFromBothPaths(t *testing.T) {
+ script := clipboardScript("/tmp/clip.rtf", "/tmp/clip.txt")
+ for _, want := range []string{`'/tmp/clip.rtf'`, `'/tmp/clip.txt'`, "public.rtf", "public.utf8-plain-text", "setDataForType"} {
+ if !strings.Contains(script, want) {
+ t.Errorf("clipboardScript missing %q in:\n%s", want, script)
+ }
+ }
+}
+
+func TestHTMLToRTFProducesRTFHeader(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("textutil is macOS-only")
+ }
+ rtf, err := htmlToRTF(`<html><body><h1>Title</h1><p>Hello <b>world</b></p></body></html>`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(string(rtf), `{\rtf1`) {
+ t.Errorf("htmlToRTF output doesn't look like RTF: %q", firstLines(string(rtf), 1))
+ }
+ if !strings.Contains(string(rtf), "Title") || !strings.Contains(string(rtf), "world") {
+ t.Errorf("htmlToRTF output missing source text: %s", firstLines(string(rtf), 5))
+ }
+}
+
+// TestSetClipboardRichTextWritesBothFlavors actually overwrites the system
+// clipboard, so it's opt-in only (GLINT_TEST_CLIPBOARD=1) rather than run by
+// default under `go test ./...`.
+func TestSetClipboardRichTextWritesBothFlavors(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("clipboard RTF flavor is macOS-only")
+ }
+ if os.Getenv("GLINT_TEST_CLIPBOARD") == "" {
+ t.Skip("set GLINT_TEST_CLIPBOARD=1 to run — this overwrites the system clipboard")
+ }
+ rtf, err := htmlToRTF(`<html><body><p>Hello <b>rich</b> world</p></body></html>`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := setClipboardRichText(rtf, "Hello rich world"); err != nil {
+ t.Fatal(err)
+ }
+ out, err := runCmd("osascript", "-e", "clipboard info")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, "RTF") {
+ t.Errorf("clipboard info missing RTF flavor: %s", out)
+ }
+}
+
+func TestCopyRichTextRoundTripsMarkdownToClipboard(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("rich text copy is macOS-only")
+ }
+ if os.Getenv("GLINT_TEST_CLIPBOARD") == "" {
+ t.Skip("set GLINT_TEST_CLIPBOARD=1 to run — this overwrites the system clipboard")
+ }
+ if err := CopyRichText("# Title\n\nHello **world**."); err != nil {
+ t.Fatal(err)
+ }
+ out, err := runCmd("osascript", "-e", "clipboard info")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, "RTF") {
+ t.Errorf("clipboard info missing RTF flavor after CopyRichText: %s", out)
+ }
+}
+
+// TestCopyRichTextHasNoPageBackground guards the bug found in manual
+// verification: the full house-style Document() (used by Ctrl+E/-e) sets an
+// explicit CSS background-color for print/screen, which textutil carries
+// into RTF as a page background *and* a highlight color behind every run of
+// text — visible in Cocoa apps as an unwanted highlight box around the
+// pasted content. Rich-text copy renders a bare semantic fragment instead
+// (no doc.css, no theme, no fonts), so pasted text inherits the target
+// app's own background.
+func TestCopyRichTextHasNoPageBackground(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("textutil is macOS-only")
+ }
+ html, err := richTextHTML("Hello **bold** world")
+ if err != nil {
+ t.Fatal(err)
+ }
+ rtf, err := htmlToRTF(html)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, unwanted := range []string{`\background`, `\cb1`, `\cb2`, `\cb3`, `\highlight`} {
+ if strings.Contains(string(rtf), unwanted) {
+ t.Errorf("richTextHTML output carries a page/highlight background (%s) into RTF:\n%s", unwanted, rtf)
+ }
+ }
+}
internal/help/help.go +3 −0
@@ -16,6 +16,7 @@ -n, --new [name] new note in the current directory
combine with -i or -v to target the inbox or vault
-p, --preview [file] open a file straight into the read preview (glow-style)
-e, --export <file> export a file to printable house-style HTML and open it
+ -R, --copy-rich <file> copy a file to the clipboard as rich text (macOS only)
-t, --today open today's daily note (in the vault)
-d, --daily browse the daily-notes folder
-v, --vault fuzzy picker over your vault, from anywhere
@@ -37,6 +38,8 @@ Ctrl+S save (an unnamed buffer prompts for a name)
Ctrl+E export a printable HTML doc (house style) and open it in
the browser → Print → Save as PDF
Ctrl+P toggle the read preview
+ Ctrl+R copy the buffer to the clipboard as rich text (macOS
+ only; pastes formatted into Mail/Word/Docs/Pages)
Ctrl+F fuzzy file picker (prefix the query with / to full-text
search note contents; results open at the matching line)
Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)
main.go +26 −2
@@ -55,6 +55,7 @@ flagConfig := boolFlag("c", "config")
flagInbox := boolFlag("i", "inbox")
flagPreview := boolFlag("p", "preview")
flagExport := boolFlag("e", "export")
+ flagCopyRich := boolFlag("R", "copy-rich")
flagKeys := flag.Bool("keys", false, "show what the terminal sends for each key")
flag.Parse()
@@ -66,6 +67,7 @@ isConfig := *flagConfig[0] || *flagConfig[1]
isInbox := *flagInbox[0] || *flagInbox[1]
isPreview := *flagPreview[0] || *flagPreview[1]
isExport := *flagExport[0] || *flagExport[1]
+ isCopyRich := *flagCopyRich[0] || *flagCopyRich[1]
// Standalone commands (no editor TUI).
if isConfig {
@@ -99,6 +101,21 @@ fmt.Println("glint: exported", out, "— open it, then Print → Save as PDF")
return
}
+ // Headless rich-text copy: convert a named file to formatted rich text and
+ // put it on the clipboard, no TUI (TASK-035).
+ if isCopyRich {
+ if name == "" {
+ fmt.Fprintln(os.Stderr, "glint: -R needs a file to copy")
+ os.Exit(1)
+ }
+ if err := a.CopyRichFile(name); err != nil {
+ fmt.Fprintln(os.Stderr, "glint:", err)
+ os.Exit(1)
+ }
+ fmt.Println("glint: copied", name, "as rich text to the clipboard")
+ return
+ }
+
var startErr error
switch {
case isPreview:
@@ -151,9 +168,16 @@ os.Exit(1)
}
}
-// run drives the Bubbletea program in the alternate screen.
+// run drives the Bubbletea program in the alternate screen. Mouse capture is
+// skipped when starting straight into preview mode (glint -p) so native
+// terminal text selection works right away instead of only after a Ctrl+P
+// round-trip (togglePreview toggles it from then on).
func run(a *app.App) {
- if _, err := tea.NewProgram(a, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run(); err != nil {
+ opts := []tea.ProgramOption{tea.WithAltScreen()}
+ if !a.InPreview() {
+ opts = append(opts, tea.WithMouseCellMotion())
+ }
+ if _, err := tea.NewProgram(a, opts...).Run(); err != nil {
fmt.Fprintln(os.Stderr, "glint:", err)
os.Exit(1)
}