Skip to content

Commit 0e82f95

Browse files
committed
Add a "book mode"
1 parent 7d69172 commit 0e82f95

File tree

9 files changed

+265
-12
lines changed

9 files changed

+265
-12
lines changed

v2/bookmode.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/xyproto/env/v2"
11+
"github.com/xyproto/files"
12+
"github.com/xyproto/vt"
13+
)
14+
15+
// FindCurrentHeading returns the heading text of the nearest Markdown heading
16+
// at or above the cursor line, or "" if none is found.
17+
func (e *Editor) FindCurrentHeading() string {
18+
for i := e.DataY(); i >= 0; i-- {
19+
line := e.Line(i)
20+
if strings.HasPrefix(line, "#") {
21+
return strings.TrimSpace(strings.TrimLeft(line, "#"))
22+
}
23+
}
24+
return ""
25+
}
26+
27+
// NextHeadingLineIndex returns the line index of the next Markdown heading
28+
// below the current cursor position, and true if one was found.
29+
func (e *Editor) NextHeadingLineIndex() (LineIndex, bool) {
30+
for i := e.DataY() + 1; i < LineIndex(e.Len()); i++ {
31+
if strings.HasPrefix(e.Line(i), "#") {
32+
return i, true
33+
}
34+
}
35+
return 0, false
36+
}
37+
38+
// PrevHeadingLineIndex returns the line index of the previous Markdown heading
39+
// above the current cursor position, and true if one was found.
40+
func (e *Editor) PrevHeadingLineIndex() (LineIndex, bool) {
41+
for i := e.DataY() - 1; i >= 0; i-- {
42+
if strings.HasPrefix(e.Line(i), "#") {
43+
return i, true
44+
}
45+
}
46+
return 0, false
47+
}
48+
49+
// exportBookPDF renders the current Markdown file to a book-style PDF using
50+
// pandoc with documentclass=book, generous margins and no code-listings header.
51+
func (e *Editor) exportBookPDF(c *vt.Canvas, tty *vt.TTY, status *StatusBar, pandocPath, pdfFilename string) error {
52+
status.ClearAll(c, true)
53+
status.SetMessage("Rendering book PDF using Pandoc...")
54+
status.ShowNoTimeout(c, e)
55+
56+
f, err := os.CreateTemp(tempDir, "_o*.md")
57+
if err != nil {
58+
return err
59+
}
60+
f.Close()
61+
tempFilename := f.Name()
62+
defer os.Remove(tempFilename)
63+
64+
oldFilename := e.filename
65+
e.filename = tempFilename
66+
err = e.Save(c, tty)
67+
e.filename = oldFilename
68+
if err != nil {
69+
status.ClearAll(c, true)
70+
status.SetError(err)
71+
status.Show(c, e)
72+
return err
73+
}
74+
75+
papersize := env.Str("PAPERSIZE", "a4")
76+
77+
// Use documentclass=book for proper chapter headings, page numbering and
78+
// binding-friendly margins (wider on the spine side).
79+
pandocCommand := exec.Command(pandocPath,
80+
"-fmarkdown-implicit_figures",
81+
"--toc",
82+
"-Vdocumentclass=book",
83+
"-Vgeometry:left=3cm,top=2.5cm,right=2.5cm,bottom=3cm",
84+
"-Vpapersize:"+papersize,
85+
"-Vfontsize=12pt",
86+
"--pdf-engine=xelatex",
87+
"-o", pdfFilename,
88+
tempFilename,
89+
)
90+
91+
// Only add a TeX header file if a custom one already exists; skip the
92+
// code-listings boilerplate that is not useful for prose.
93+
expandedTexFilename := env.ExpandUser(pandocTexFilename)
94+
if files.Exists(expandedTexFilename) {
95+
pandocCommand.Args = append(pandocCommand.Args[:len(pandocCommand.Args)-1],
96+
"-H"+expandedTexFilename,
97+
pandocCommand.Args[len(pandocCommand.Args)-1],
98+
)
99+
}
100+
101+
saveCommand(pandocCommand)
102+
103+
if output, err := pandocCommand.CombinedOutput(); err != nil {
104+
status.ClearAll(c, false)
105+
outputByteLines := bytes.Split(bytes.TrimSpace(output), []byte{'\n'})
106+
errorMessage := string(outputByteLines[len(outputByteLines)-1])
107+
if len(errorMessage) == 0 {
108+
errorMessage = err.Error()
109+
}
110+
status.SetErrorMessage(errorMessage)
111+
status.Show(c, e)
112+
return err
113+
}
114+
115+
// Also generate the output filename from the first # heading if possible
116+
outName := filepath.Base(pdfFilename)
117+
status.ClearAll(c, true)
118+
status.SetMessage("Saved " + outName)
119+
status.ShowNoTimeout(c, e)
120+
return nil
121+
}

v2/cmenu.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,42 @@ func (e *Editor) CommandMenu(c *vt.Canvas, tty *vt.TTY, status *StatusBar, undo
520520
status.ShowNoTimeout(c, e)
521521
})
522522
}
523+
// Book mode
524+
if e.mode == mode.Markdown {
525+
if e.bookMode {
526+
actions.Add("Exit book mode", func() {
527+
e.bookMode = false
528+
e.targetWordCount = 0
529+
e.bookModeMargin = 0
530+
e.wrapWhenTyping = false
531+
e.drawFuncName.Store(true)
532+
e.redraw.Store(true)
533+
})
534+
actions.Add("Change target word count", func() {
535+
wordCountStr, ok := e.UserInput(c, tty, status, "Target word count (0 = disable)", "", []string{}, false, "0")
536+
if !ok {
537+
return
538+
}
539+
if n, err := strconv.Atoi(strings.TrimSpace(wordCountStr)); err == nil && n >= 0 {
540+
e.targetWordCount = n
541+
}
542+
e.redraw.Store(true)
543+
})
544+
} else {
545+
actions.Add("Book mode", func() {
546+
e.bookMode = true
547+
e.targetWordCount = 80000
548+
e.wrapWhenTyping = true
549+
e.wrapWidth = 66
550+
e.showColumnLimit = false
551+
if c != nil {
552+
e.bookModeMargin = max((int(c.Width())-e.wrapWidth)/2, 0)
553+
}
554+
e.drawFuncName.Store(true)
555+
e.redraw.Store(true)
556+
})
557+
}
558+
}
523559
}
524560

525561
if !vsCode && c != nil && height > menuHeightThreshold {

v2/editor.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ type Editor struct {
9696
fastInputMode bool // reduce input latency for real-time use
9797
pasteMode bool // insert incoming key data as raw text
9898
cycleFilenames bool
99+
bookMode bool // book/author mode: centered text, heading nav, heading in top-right
100+
targetWordCount int // 0 = disabled; shows a progress bar at the bottom when > 0
101+
bookModeMargin int // left margin (columns) used to center text in book mode
99102
}
100103

101104
// Copy makes a copy of an Editor struct, with most fields deep copied
@@ -164,6 +167,9 @@ func (e *Editor) Copy(withLines bool) *Editor {
164167
e2.highlightCurrentText = e.highlightCurrentText
165168
e2.fastInputMode = e.fastInputMode
166169
e2.pasteMode = e.pasteMode
170+
e2.bookMode = e.bookMode
171+
e2.targetWordCount = e.targetWordCount
172+
e2.bookModeMargin = e.bookModeMargin
167173
e2.nanoMode.Store(e.nanoMode.Load())
168174
e2.changed.Store(e.changed.Load())
169175
e2.redraw.Store(e.redraw.Load())
@@ -3071,7 +3077,7 @@ func (e *Editor) JoinLineWithNext(c *vt.Canvas) bool {
30713077
// EnableAndPlaceCursor first sets the cursor to shown and then places it at the right position
30723078
func (e *Editor) EnableAndPlaceCursor(c *vt.Canvas) {
30733079
//e.pos.mut.Lock()
3074-
x := uint(e.pos.ScreenX())
3080+
x := uint(e.pos.ScreenX()) + uint(e.bookModeMargin)
30753081
y := uint(e.pos.ScreenY())
30763082
//e.pos.mut.Unlock()
30773083
c.ShowCursor()

v2/funcname.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,14 @@ func (e *Editor) WriteCurrentFunctionName(c *vt.Canvas) {
356356
}
357357
return
358358
}
359+
if e.bookMode && e.mode == mode.Markdown {
360+
if heading := e.FindCurrentHeading(); heading != "" {
361+
s := " " + heading + " "
362+
x := c.Width() - uint(ulen(s)) - 2
363+
c.Write(x, 0, e.TopRightForeground, e.TopRightBackground, s)
364+
}
365+
return
366+
}
359367
if !ProgrammingLanguage(e.mode) && e.mode != mode.GoAssembly && e.mode != mode.Assembly {
360368
if ollama.Loaded() && hasBuildErrorExplanationThinking() {
361369
ellipsisX := c.Width() - 1

v2/highlight.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -920,7 +920,7 @@ func (e *Editor) WriteLines(c *vt.Canvas, fromline, toline LineIndex, cx, cy uin
920920
line = handleManPageEscape(line)
921921
}
922922
// Output a regular line, scrolled to the current e.pos.offsetX
923-
screenLine = e.ChopLine(line, int(cw))
923+
screenLine = e.ChopLine(line, int(cw)-int(cx))
924924
screenLine = asciiFallback(screenLine)
925925
c.Write(cx+lineRuneCount, cy+uint(y), e.Foreground, e.Background, screenLine)
926926
lineRuneCount += uint(utf8.RuneCountInString(screenLine)) // rune count
@@ -930,11 +930,14 @@ func (e *Editor) WriteLines(c *vt.Canvas, fromline, toline LineIndex, cx, cy uin
930930
// TODO: This may draw the wrong number of blanks, since lineRuneCount should really be the number of visible glyphs at this point. This is problematic for emojis.
931931
yp = cy + uint(y)
932932
xp = cx + lineRuneCount
933-
if int(cw-lineRuneCount) > 0 {
933+
if cx > 0 {
934+
c.WriteRunesB(0, yp, e.Foreground, e.Background, ' ', cx)
935+
}
936+
if xp < cw {
934937
if highlightCurrentLine && e.highlightCurrentLine {
935-
c.WriteRunesB(xp, yp, e.HighlightForeground, e.HighlightBackground, ' ', cw-lineRuneCount)
938+
c.WriteRunesB(xp, yp, e.HighlightForeground, e.HighlightBackground, ' ', cw-xp)
936939
} else {
937-
c.WriteRunesB(xp, yp, e.Foreground, bg, ' ', cw-lineRuneCount)
940+
c.WriteRunesB(xp, yp, e.Foreground, bg, ' ', cw-xp)
938941
}
939942
}
940943
// Draw a left-pointing arrow after the current debug line

v2/keyloop.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,16 @@ func Loop(tty *vt.TTY, fnord FilenameOrData, lineNumber LineNumber, colNumber Co
256256

257257
e.SetTheme(e.Theme)
258258

259+
// Auto-activate book mode when the executable name starts with 'b'
260+
if len(editorExecutable) > 0 && strings.ToLower(editorExecutable)[0] == 'b' && e.mode == mode.Markdown {
261+
e.bookMode = true
262+
e.targetWordCount = 80000
263+
e.wrapWhenTyping = true
264+
e.wrapWidth = 66
265+
e.showColumnLimit = false
266+
e.bookModeMargin = max((int(c.Width())-e.wrapWidth)/2, 0)
267+
}
268+
259269
// ctrl-c, USR1 and terminal resize handlers
260270
const onlyClearSignals = false
261271
e.SetUpSignalHandlers(c, tty, status, onlyClearSignals)
@@ -552,6 +562,20 @@ func Loop(tty *vt.TTY, fnord FilenameOrData, lineNumber LineNumber, colNumber Co
552562

553563
switch e.mode {
554564
case mode.Markdown:
565+
if e.bookMode && kh.DoubleTapped("c:0") {
566+
if pandocPath := files.WhichCached("pandoc"); pandocPath != "" {
567+
pdfFilename := strings.ReplaceAll(filepath.Base(e.filename), ".", "_") + ".pdf"
568+
go func() {
569+
pandocMutex.Lock()
570+
_ = e.exportBookPDF(c, tty, status, pandocPath, pdfFilename)
571+
pandocMutex.Unlock()
572+
}()
573+
} else {
574+
status.SetErrorMessage("Could not find pandoc")
575+
status.ShowNoTimeout(c, e)
576+
}
577+
break
578+
}
555579
if e.ToggleCheckboxCurrentLine() { // Toggle checkbox
556580
undo.Snapshot(e)
557581
break
@@ -981,6 +1005,16 @@ func Loop(tty *vt.TTY, fnord FilenameOrData, lineNumber LineNumber, colNumber Co
9811005

9821006
case "c:16": // ctrl-p, scroll up or jump to the previous match, using the sticky search term. In debug mode, change the pane layout.
9831007

1008+
if e.bookMode && e.mode == mode.Markdown && e.SearchTerm() == "" {
1009+
if i, ok := e.PrevHeadingLineIndex(); ok {
1010+
undo.Snapshot(e)
1011+
e.GoTo(i, c, status)
1012+
e.drawFuncName.Store(true)
1013+
e.redraw.Store(true)
1014+
}
1015+
break
1016+
}
1017+
9841018
if e.cycleFilenames && !e.changed.Load() && !e.moveLinesMode.Load() {
9851019
e.SaveLocation()
9861020
// go to the previous file, if launched from the file browser
@@ -1071,6 +1105,16 @@ func Loop(tty *vt.TTY, fnord FilenameOrData, lineNumber LineNumber, colNumber Co
10711105

10721106
case "c:14": // ctrl-n, scroll down or jump to next match, using the sticky search term
10731107

1108+
if e.bookMode && e.mode == mode.Markdown && e.SearchTerm() == "" {
1109+
if i, ok := e.NextHeadingLineIndex(); ok {
1110+
undo.Snapshot(e)
1111+
e.GoTo(i, c, status)
1112+
e.drawFuncName.Store(true)
1113+
e.redraw.Store(true)
1114+
}
1115+
break
1116+
}
1117+
10741118
if e.cycleFilenames && !e.changed.Load() && !e.moveLinesMode.Load() {
10751119
e.SaveLocation()
10761120
// go to the next file, if launched from the file browser

v2/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,8 @@ func main() {
517517
// Check if the executable starts with a specific letter ('f', 'g', 'p' and 'c' are already checked for)
518518
specificLetter = true
519519
switch firstLetterOfExecutable {
520-
case 'b', 'e': // bo, borland, ed, edit etc.
520+
case 'b': // bo, book etc. — book/author mode is activated in the editor loop; use the default theme
521+
case 'e': // ed, edit
521522
theme = NewDarkBlueEditTheme()
522523
// TODO: Later, when specificLetter is examined, use either NewEditLightTheme or NewEditDarkTheme
523524
editTheme = true

v2/redraw.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func (e *Editor) RepositionCursor(x, y uint) {
9595
func (e *Editor) PlaceAndEnableCursor(c *vt.Canvas) {
9696
// Redraw the cursor, if needed
9797
e.pos.mut.RLock()
98-
x := uint(e.pos.ScreenX())
98+
x := uint(e.pos.ScreenX()) + uint(e.bookModeMargin)
9999
y := uint(e.pos.ScreenY())
100100
e.pos.mut.RUnlock()
101101

@@ -110,7 +110,7 @@ func (e *Editor) PlaceAndEnableCursor(c *vt.Canvas) {
110110
func (e *Editor) RepositionCursorIfNeeded(c *vt.Canvas) {
111111
// Redraw the cursor, if needed
112112
e.pos.mut.RLock()
113-
x := e.pos.ScreenX()
113+
x := e.pos.ScreenX() + e.bookModeMargin
114114
y := e.pos.ScreenY()
115115
e.pos.mut.RUnlock()
116116

@@ -132,11 +132,15 @@ func (e *Editor) HideCursorDrawLines(c *vt.Canvas, respectOffset, redrawCanvas,
132132
// TODO: Use a channel for queuing up calls to the package to avoid race conditions
133133

134134
h := int(c.Height())
135+
if e.targetWordCount > 0 {
136+
h-- // leave the progress bar row untouched
137+
}
138+
cx := uint(e.bookModeMargin)
135139
if respectOffset {
136140
offsetY := e.pos.OffsetY()
137-
e.WriteLines(c, LineIndex(offsetY), LineIndex(h+offsetY), 0, 0, shouldHighlightCurrentLine, hideCursorWhenDrawing)
141+
e.WriteLines(c, LineIndex(offsetY), LineIndex(h+offsetY), cx, 0, shouldHighlightCurrentLine, hideCursorWhenDrawing)
138142
} else {
139-
e.WriteLines(c, LineIndex(0), LineIndex(h), 0, 0, shouldHighlightCurrentLine, hideCursorWhenDrawing)
143+
e.WriteLines(c, LineIndex(0), LineIndex(h), cx, 0, shouldHighlightCurrentLine, hideCursorWhenDrawing)
140144
}
141145
if redrawCanvas {
142146
c.HideCursorAndRedraw()
@@ -186,6 +190,7 @@ func (e *Editor) InitialRedraw(c *vt.Canvas, status *StatusBar) {
186190
e.DrawFunctionDescriptionContinuous(c, false)
187191
}
188192

193+
status.DrawWordCountProgress(c)
189194
c.HideCursorAndDraw() // drawing now
190195
}
191196

@@ -227,6 +232,7 @@ func (e *Editor) RedrawAtEndOfKeyLoop(c *vt.Canvas, status *StatusBar, shouldHig
227232
e.DrawFunctionDescriptionContinuous(c, false)
228233
}
229234

235+
status.DrawWordCountProgress(c)
230236
c.HideCursorAndDraw() // drawing now
231237
didDraw = true
232238
e.redraw.Store(false) // mark as redrawn

0 commit comments

Comments
 (0)