-
Notifications
You must be signed in to change notification settings - Fork 16
/
cmenu.go
550 lines (483 loc) · 15.7 KB
/
cmenu.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/xyproto/env/v2"
"github.com/xyproto/guessica"
"github.com/xyproto/mode"
"github.com/xyproto/vt100"
)
var (
lastCommandFile = filepath.Join(userCacheDir, "o", "last_command.sh")
changedTheme bool // has the theme been changed manually after the editor was started?
)
// Actions is a list of action titles and a list of action functions.
// The key is an int that is the same for both.
type Actions struct {
actionTitles map[int]string
actionFunctions map[int]func()
}
// NewActions will create a new Actions struct
func NewActions() *Actions {
var a Actions
a.actionTitles = make(map[int]string)
a.actionFunctions = make(map[int]func())
return &a
}
// NewActions2 will create a new Actions struct, while
// initializing it with the given slices of titles and functions
func NewActions2(actionTitles []string, actionFunctions []func()) (*Actions, error) {
a := NewActions()
if len(actionTitles) != len(actionFunctions) {
return nil, errors.New("length of action titles and action functions differ")
}
for i, title := range actionTitles {
a.actionTitles[i] = title
a.actionFunctions[i] = actionFunctions[i]
}
return a, nil
}
// UserSave saves the file and the location history
func (e *Editor) UserSave(c *vt100.Canvas, tty *vt100.TTY, status *StatusBar) {
// Save the file
if err := e.Save(c, tty); err != nil {
status.SetError(err)
status.Show(c, e)
return
}
// Save the current location in the location history and write it to file
if absFilename, err := e.AbsFilename(); err == nil { // no error
e.SaveLocation(absFilename, locationHistory)
}
// Status message
status.Clear(c)
status.SetMessage("Saved " + e.filename)
status.Show(c, e)
}
// Add will add an action title and an action function
func (a *Actions) Add(title string, f func()) {
i := len(a.actionTitles)
a.actionTitles[i] = title
a.actionFunctions[i] = f
}
// MenuChoices will return a string that lists the titles of
// the available actions.
func (a *Actions) MenuChoices() []string {
// Create a list of strings that are menu choices,
// while also creating a mapping from the menu index to a function.
menuChoices := make([]string, len(a.actionTitles))
for i, description := range a.actionTitles {
menuChoices[i] = fmt.Sprintf("[%d] %s", i, description)
}
return menuChoices
}
// Perform will call the given function index
func (a *Actions) Perform(index int) {
a.actionFunctions[index]()
}
// AddCommand will add a command to the action menu, if it can be looked up by e.CommandToFunction
func (a *Actions) AddCommand(e *Editor, c *vt100.Canvas, tty *vt100.TTY, status *StatusBar, bookmark *Position, undo *Undo, title string, args ...string) error {
f, err := e.CommandToFunction(c, tty, status, bookmark, undo, args...)
if err != nil {
//panic(err)
return err
}
a.Add(title, f)
return nil
}
// CommandMenu will display a menu with various commands that can be browsed with arrow up and arrow down.
// Also returns the selected menu index (can be -1), and if a space should be added to the text editor after the return.
// TODO: Figure out why this function needs an undo argument and can't use the regular one
func (e *Editor) CommandMenu(c *vt100.Canvas, tty *vt100.TTY, status *StatusBar, bookmark *Position, undo *Undo, lastMenuIndex int, forced bool, lk *LockKeeper) int {
const insertFilename = "include.txt"
wrapWidth := e.wrapWidth
if wrapWidth == 0 {
wrapWidth = 80
}
// Let the menu item for wrapping words suggest the minimum of e.wrapWidth and the terminal width
if c != nil {
w := int(c.Width())
if w < wrapWidth {
wrapWidth = w - int(0.05*float64(w))
}
}
var (
extraDashes bool
actions = NewActions()
)
// TODO: Create a string->[]string map from title to command, then add them
// TODO: Add the 6 first arguments to a context struct instead
actions.AddCommand(e, c, tty, status, bookmark, undo, "Save and quit", "savequitclear")
actions.AddCommand(e, c, tty, status, bookmark, undo, "Sort strings on the current line", "sortwords")
actions.AddCommand(e, c, tty, status, bookmark, undo, "Insert \""+insertFilename+"\" at the current line", "insertfile", insertFilename)
actions.AddCommand(e, c, tty, status, bookmark, undo, "Insert the current date", "insertdate") // in the RFC 3339 format
actions.AddCommand(e, c, tty, status, bookmark, undo, "Insert the current time", "inserttime")
// Word wrap at a custom width + enable word wrap when typing
actions.Add("Word wrap at...", func() {
if wordWrapString, ok := e.UserInput(c, tty, status, fmt.Sprintf("Word wrap at [%d]", wrapWidth), []string{}, false); ok {
if strings.TrimSpace(wordWrapString) == "" {
e.WrapNow(wrapWidth)
e.wrapWhenTyping = true
status.SetMessageAfterRedraw(fmt.Sprintf("Word wrap at %d", wrapWidth))
} else {
if ww, err := strconv.Atoi(wordWrapString); err != nil {
status.Clear(c)
status.SetError(err)
status.Show(c, e)
} else {
e.WrapNow(ww)
e.wrapWhenTyping = true
status.SetMessageAfterRedraw(fmt.Sprintf("Word wrap at %d", wrapWidth))
}
}
}
})
// Enter ChatGPT API key
actions.Add("Enter ChatGPT API key...", func() {
if enteredAPIKey, ok := e.UserInput(c, tty, status, "API key", []string{}, false); ok {
env.Set("CHATGPT_API_KEY", enteredAPIKey)
status.SetMessageAfterRedraw("Using API key " + enteredAPIKey)
}
})
// Disable or enable word wrap when typing
if e.wrapWhenTyping {
actions.Add("Disable word wrap when typing", func() {
e.wrapWhenTyping = false
if e.wrapWidth == 0 {
e.wrapWidth = wrapWidth
}
})
} else {
actions.Add("Enable word wrap when typing", func() {
e.wrapWhenTyping = true
if e.wrapWidth == 0 {
e.wrapWidth = wrapWidth
}
})
}
// Special menu option for PKGBUILD files
if strings.HasSuffix(e.filename, "PKGBUILD") {
actions.Add("Call Guessica", func() {
status.Clear(c)
status.SetMessage("Calling Guessica")
status.Show(c, e)
// Use the temporary directory defined in TMPDIR, with fallback to /tmp
tempdir := env.Str("TMPDIR", "/tmp")
tempFilename := ""
var (
f *os.File
err error
)
if f, err = os.CreateTemp(tempdir, "__o*"+"guessica"); err == nil {
// no error, everything is fine
tempFilename = f.Name()
// TODO: Implement e.SaveAs
oldFilename := e.filename
e.filename = tempFilename
err = e.Save(c, tty)
e.filename = oldFilename
}
if err != nil {
status.SetError(err)
status.Show(c, e)
return
}
if tempFilename == "" {
status.SetErrorMessage("Could not create a temporary file")
status.Show(c, e)
return
}
// Show the status message to the user right now
status.Draw(c, e.pos.offsetY)
// Call Guessica, which may take a little while
err = guessica.UpdateFile(tempFilename)
if err != nil {
status.SetErrorMessage("Failed to update PKGBUILD: " + err.Error())
status.Show(c, e)
} else {
if _, err := e.Load(c, tty, FilenameOrData{tempFilename, []byte{}, 0}); err != nil {
status.ClearAll(c)
status.SetMessage(err.Error())
status.Show(c, e)
}
// Mark the data as changed, despite just having loaded a file
e.changed = true
e.redrawCursor = true
}
})
}
actions.AddCommand(e, c, tty, status, bookmark, undo, "Copy all text to the clipboard", "copyall")
// Disable or enable the tag-expanding behavior when typing in HTML or XML
if e.mode == mode.HTML || e.mode == mode.XML {
if !e.noExpandTags {
actions.Add("Disable tag expansion when typing", func() {
e.noExpandTags = true
})
} else {
actions.Add("Enable tag expansion when typing", func() {
e.noExpandTags = false
})
}
}
// Find the path to either "rust-gdb" or "gdb", depending on the mode, then check if it's there
foundGDB := e.findGDB() != ""
// Debug mode on/off, if gdb is found and the mode is tested
if foundGDB && e.usingGDBMightWork() {
if e.debugMode {
actions.Add("Exit debug mode", func() {
status.Clear(c)
status.SetMessage("Debug mode disabled")
status.Show(c, e)
e.debugMode = false
// Also end the gdb session if there is one in progress
e.DebugEnd()
status.SetMessageAfterRedraw("Normal mode")
})
} else {
actions.Add("Debug mode", func() {
// Save the file when entering debug mode, since gdb may crash for some languages
// TODO: Identify which languages work poorly together with gdb
e.UserSave(c, tty, status)
status.SetMessageAfterRedraw("Debug mode enabled")
e.debugMode = true
})
}
}
if e.debugMode {
hasOutputData := len(strings.TrimSpace(gdbOutput.String())) > 0
if hasOutputData {
if e.debugHideOutput {
actions.Add("Show output pane", func() {
e.debugHideOutput = true
})
} else {
actions.Add("Hide output pane", func() {
e.debugHideOutput = true
})
}
}
}
// Add the syntax highlighting toggle menu item
if !envNoColor {
syntaxToggleText := "Disable syntax highlighting"
if !e.syntaxHighlight {
syntaxToggleText = "Enable syntax highlighting"
}
actions.Add(syntaxToggleText, func() {
e.ToggleSyntaxHighlight()
})
}
// Delete the rest of the file
actions.Add("Delete the rest of the file", func() { // copy file to clipboard
prepareFunction := func() {
// Prepare to delete all lines from this one and out
undo.Snapshot(e)
// Also close the portal, if any
ClosePortal(e)
// Mark the file as changed
e.changed = true
}
// Get the current index and remove the rest of the lines
currentLineIndex := int(e.DataY())
for y := range e.lines {
if y >= currentLineIndex {
// Run the prepareFunction, but only once, if there was changes to be made
if prepareFunction != nil {
prepareFunction()
prepareFunction = nil
}
delete(e.lines, y)
}
}
if e.changed {
e.MakeConsistent()
e.redraw = true
e.redrawCursor = true
}
})
// Add the unlock menu item
if forced {
// TODO: Detect if file is locked first
actions.Add("Unlock if locked", func() {
if absFilename, err := e.AbsFilename(); err == nil { // no issues
lk.Load()
lk.Unlock(absFilename)
lk.Save()
}
})
}
// Render to PDF using the gofpdf package
actions.Add("Render to PDF", func() {
// Write to PDF in a goroutine
pdfFilename := strings.ReplaceAll(filepath.Base(e.filename), ".", "_") + ".pdf"
// Show a status message while writing
status.SetMessage("Writing " + pdfFilename + "...")
status.ShowNoTimeout(c, e)
statusMessage := ""
// TODO: Only overwrite if the previous PDF file was also rendered by "o".
_ = os.Remove(pdfFilename)
// Write the file
if err := e.SavePDF(e.filename, pdfFilename); err != nil {
statusMessage = err.Error()
} else {
statusMessage = "Wrote " + pdfFilename
}
// Show a status message after writing
status.ClearAll(c)
status.SetMessage(statusMessage)
status.ShowNoTimeout(c, e)
})
// Render to PDF using pandoc
if (e.mode == mode.Markdown || e.mode == mode.Doc) && which("pandoc") != "" {
actions.Add("Render to PDF using pandoc", func() {
go func() {
pandocMutex.Lock()
// The last argument is if pandoc should run in the background or not
_, err := e.BuildOrExport(c, tty, status, e.filename, false)
// Could an action be performed for this file extension?
if err != nil {
status.SetError(err)
}
status.ShowNoTimeout(c, e)
pandocMutex.Unlock()
}()
})
}
if !envNoColor || changedTheme {
// Add an option for selecting a theme
actions.Add("Change theme", func() {
menuChoices := []string{"Default", "Red & black", "VS", "Synthwave", "Blue Edit", "Amber Mono", "Green Mono", "Blue Mono", "No color"}
useMenuIndex := 0
for i, menuChoiceText := range menuChoices {
if strings.HasPrefix(e.Theme.Name, menuChoiceText) {
useMenuIndex = i
}
}
changedTheme = true
switch e.Menu(status, tty, "Select color theme", menuChoices, e.Background, e.MenuTitleColor, e.MenuArrowColor, e.MenuTextColor, e.MenuHighlightColor, e.MenuSelectedColor, useMenuIndex, extraDashes) {
case 0: // Default
envNoColor = false
e.setDefaultTheme()
e.syntaxHighlight = true
case 1: // Red & black
envNoColor = false
e.setRedBlackTheme()
e.syntaxHighlight = true
case 2: // VS
envNoColor = false
e.setVSTheme()
e.syntaxHighlight = true
case 3: // Synthwave
envNoColor = false
e.setSynthwaveTheme()
e.syntaxHighlight = true
case 4: // Blue Edit
envNoColor = false
e.setBlueEditTheme()
e.syntaxHighlight = true
case 5: // Amber Mono
envNoColor = false
e.setAmberTheme()
e.syntaxHighlight = false
case 6: // Green Mono
envNoColor = false
e.setGreenTheme()
e.syntaxHighlight = false
case 7: // Blue Mono
envNoColor = false
e.setBlueTheme()
e.syntaxHighlight = false
case 8: // No color
envNoColor = true
e.setNoColorTheme()
e.syntaxHighlight = false
default:
changedTheme = false
return
}
drawLines := true
resized := false
e.FullResetRedraw(c, status, drawLines, resized)
})
}
// actions.Add("Syntax mode", func() {
// // Build the menu choices
// menuChoices := make([]string, 0)
// for modeCounter := 0; modeCounter < 4096; modeCounter++ {
// m := mode.Mode(modeCounter)
// name := m.String()
// if name == "-" { // Blank
// menuChoices = append(menuChoices, "None")
// } else if name != "?" && name != "" { // One of the other modes
// menuChoices = append(menuChoices, name)
// } else {
// break
// }
// }
// // Find which index to use
// useMenuIndex := int(e.mode)
// // Display the menu
// selectedMode := e.Menu(status, tty, "Syntax mode", menuChoices, e.Background, e.MenuTitleColor, e.MenuArrowColor, e.MenuTextColor, e.MenuHighlightColor, e.MenuSelectedColor, useMenuIndex, extraDashes)
// e.mode = mode.Mode(selectedMode)
// drawLines := true
// resized := false
// e.FullResetRedraw(c, status, drawLines, resized)
// })
actions.Add("Stop parent and quit without saving", func() {
e.stopParentOnQuit = true
e.clearOnQuit = true
e.quit = true // indicate that the user wishes to quit
e.clearOnQuit = true // clear the terminal after quitting
})
menuChoices := actions.MenuChoices()
// Launch a generic menu
useMenuIndex := 0
if lastMenuIndex > 0 {
useMenuIndex = lastMenuIndex
}
selected := e.Menu(status, tty, "Menu", menuChoices, e.Background, e.MenuTitleColor, e.MenuArrowColor, e.MenuTextColor, e.MenuHighlightColor, e.MenuSelectedColor, useMenuIndex, extraDashes)
// Redraw the editor contents
//e.DrawLines(c, true, false)
if selected < 0 {
// Esc was pressed, or an item was otherwise not selected.
// Trigger a redraw and return.
e.redraw = true
e.redrawCursor = true
return selected
}
// Perform the selected action by passing the function index
actions.Perform(selected)
// Redraw editor
e.redraw = true
e.redrawCursor = true
return selected
}
// getCommand takes an *exec.Cmd and returns the command
// it represents, but with "/usr/bin/sh -c " trimmed away.
func getCommand(cmd *exec.Cmd) string {
s := cmd.Path + " " + strings.Join(cmd.Args[1:], " ")
return strings.TrimPrefix(s, "/usr/bin/sh -c ")
}
// Save the command to a temporary file, given an exec.Cmd struct
func saveCommand(cmd *exec.Cmd) error {
p := lastCommandFile
// First create the folder for the lock file overview, if needed
folderPath := filepath.Dir(p)
os.MkdirAll(folderPath, os.ModePerm)
// Prepare the file
f, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0700)
if err != nil {
return err
}
defer f.Close()
// Strip the leading /usr/bin/sh -c command, if present
commandString := getCommand(cmd)
// Write the contents, ignore the number of written bytes
_, err = f.WriteString(fmt.Sprintf("#!/bin/sh\n%s\n", commandString))
return err
}