github.com/xyproto/orbiton/v2@v2.65.12-0.20240516144430-e10a419274ec/cmenu.go (about) 1 package main 2 3 import ( 4 "fmt" 5 "os" 6 "path/filepath" 7 "strconv" 8 "strings" 9 10 "github.com/xyproto/env/v2" 11 "github.com/xyproto/files" 12 "github.com/xyproto/guessica" 13 "github.com/xyproto/mode" 14 "github.com/xyproto/vt100" 15 ) 16 17 var ( 18 lastCommandMenuIndex int // for the command menu 19 changedTheme bool // has the theme been changed manually after the editor was started? 20 menuTitle string // used for displaying the program name and version at the top of the ctrl-o menu only the first time the menu is displayed 21 ) 22 23 // Actions is a list of action titles and a list of action functions. 24 // The key is an int that is the same for both. 25 type Actions struct { 26 actionTitles map[int]string 27 actionFunctions map[int]func() 28 } 29 30 // NewActions will create a new Actions struct 31 func NewActions() *Actions { 32 var a Actions 33 a.actionTitles = make(map[int]string) 34 a.actionFunctions = make(map[int]func()) 35 return &a 36 } 37 38 // UserSave saves the file and the location history 39 func (e *Editor) UserSave(c *vt100.Canvas, tty *vt100.TTY, status *StatusBar) { 40 // Save the file 41 if err := e.Save(c, tty); err != nil { 42 status.SetError(err) 43 status.Show(c, e) 44 return 45 } 46 47 // Save the current location in the location history and write it to file 48 if absFilename, err := e.AbsFilename(); err == nil { // no error 49 e.SaveLocation(absFilename, locationHistory) 50 } 51 52 // Status message 53 status.Clear(c) 54 status.SetMessage("Saved " + e.filename) 55 status.Show(c, e) 56 } 57 58 // Add will add an action title and an action function 59 func (a *Actions) Add(title string, f func()) { 60 i := len(a.actionTitles) 61 a.actionTitles[i] = title 62 a.actionFunctions[i] = f 63 } 64 65 // MenuChoices will return a string that lists the titles of 66 // the available actions. 67 func (a *Actions) MenuChoices() []string { 68 // Create a list of strings that are menu choices, 69 // while also creating a mapping from the menu index to a function. 70 menuChoices := make([]string, len(a.actionTitles)) 71 for i, description := range a.actionTitles { 72 menuChoices[i] = fmt.Sprintf("[%d] %s", i, description) 73 } 74 return menuChoices 75 } 76 77 // Perform will call the given function index 78 func (a *Actions) Perform(index int) { 79 a.actionFunctions[index]() 80 } 81 82 // AddCommand will add a command to the action menu, if it can be looked up by e.CommandToFunction 83 func (a *Actions) AddCommand(e *Editor, c *vt100.Canvas, tty *vt100.TTY, status *StatusBar, bookmark *Position, undo *Undo, title string, args ...string) error { 84 f, err := e.CommandToFunction(c, tty, status, bookmark, undo, args...) 85 if err != nil { 86 return err 87 } 88 a.Add(title, f) 89 return nil 90 } 91 92 // CommandMenu will display a menu with various commands that can be browsed with arrow up and arrow down. 93 // Also returns the selected menu index (can be -1), and if a space should be added to the text editor after the return. 94 // TODO: Figure out why this function needs an undo argument and can't use the regular one 95 func (e *Editor) CommandMenu(c *vt100.Canvas, tty *vt100.TTY, status *StatusBar, bookmark *Position, undo *Undo, lastMenuIndex int, forced bool, lk *LockKeeper) int { 96 const insertFilename = "include.txt" 97 98 if menuTitle == "" { 99 menuTitle = versionString 100 } else if menuTitle == versionString { 101 menuTitle = "Menu" 102 } 103 104 wrapWidth := e.wrapWidth 105 if wrapWidth == 0 { 106 wrapWidth = 80 107 } 108 109 // Let the menu item for wrapping words suggest the minimum of e.wrapWidth and the terminal width 110 if c != nil { 111 w := int(c.Width()) 112 if w < wrapWidth { 113 wrapWidth = w - int(0.05*float64(w)) 114 } 115 } 116 117 var ( 118 extraDashes bool 119 actions = NewActions() 120 ) 121 122 // TODO: Create a string->[]string map from title to command, then add them 123 // TODO: Add the 6 first arguments to a context struct instead 124 actions.AddCommand(e, c, tty, status, bookmark, undo, "Save and quit", "savequitclear") 125 126 actions.AddCommand(e, c, tty, status, bookmark, undo, "Sort strings on the current line", "sortwords") 127 actions.AddCommand(e, c, tty, status, bookmark, undo, "Sort the current block of lines", "sortblock") 128 129 actions.AddCommand(e, c, tty, status, bookmark, undo, "Insert \""+insertFilename+"\" at the current line", "insertfile", insertFilename) 130 actions.AddCommand(e, c, tty, status, bookmark, undo, "Insert the current date and time", "insertdateandtime") // in the RFC 3339 format 131 132 // Word wrap at a custom width + enable word wrap when typing 133 actions.Add("Word wrap at...", func() { 134 const tabInputText = "79" 135 if wordWrapString, ok := e.UserInput(c, tty, status, fmt.Sprintf("Word wrap at [%d]", wrapWidth), "", []string{}, false, tabInputText); ok { 136 if strings.TrimSpace(wordWrapString) == "" { 137 e.WrapNow(wrapWidth) 138 e.wrapWhenTyping = true 139 status.SetMessageAfterRedraw(fmt.Sprintf("Word wrap at %d", wrapWidth)) 140 } else { 141 if ww, err := strconv.Atoi(wordWrapString); err != nil { 142 status.Clear(c) 143 status.SetError(err) 144 status.Show(c, e) 145 } else { 146 e.WrapNow(ww) 147 e.wrapWhenTyping = true 148 status.SetMessageAfterRedraw(fmt.Sprintf("Word wrap at %d", wrapWidth)) 149 } 150 } 151 } 152 }) 153 154 // Enter ChatGPT API key, if it's not already set 155 if openAIKeyHolder == nil { 156 actions.Add("Enter ChatGPT API key...", func() { 157 if enteredAPIKey, ok := e.UserInput(c, tty, status, "API key from https://platform.openai.com/account/api-keys", "", []string{}, false, ""); ok { 158 openAIKeyHolder = NewKeyHolderWithKey(enteredAPIKey) 159 // env.Set("CHATGPT_API_KEY", enteredAPIKey) 160 status.SetMessageAfterRedraw("Using API key " + enteredAPIKey) 161 // Write the OpenAI API Key to a file in the cache directory as well, but ignore errors 162 _ = openAIKeyHolder.WriteAPIKey() 163 } 164 }) 165 } 166 167 // Build (for use on the terminal, since ctrl-space does not work on iTerm2 + macOS) 168 if !env.Bool("OG") && isDarwin() { 169 var alsoRun = false 170 var menuItemText = "Export" 171 if e.ProgrammingLanguage() { 172 if e.CanRun() { 173 alsoRun = true 174 menuItemText = "Build and run" 175 } else { 176 menuItemText = "Build" 177 } 178 } 179 actions.Add(menuItemText, func() { 180 const markdownDoubleSpacePrevention = false 181 e.Build(c, status, tty, alsoRun, markdownDoubleSpacePrevention) 182 }) 183 } 184 185 // Disable or enable word wrap when typing 186 if e.wrapWhenTyping { 187 actions.Add("Disable word wrap when typing", func() { 188 e.wrapWhenTyping = false 189 if e.wrapWidth == 0 { 190 e.wrapWidth = wrapWidth 191 } 192 }) 193 } else { 194 actions.Add("Enable word wrap when typing", func() { 195 e.wrapWhenTyping = true 196 if e.wrapWidth == 0 { 197 e.wrapWidth = wrapWidth 198 } 199 }) 200 } 201 202 actions.AddCommand(e, c, tty, status, bookmark, undo, "Copy all text to the clipboard", "copyall") 203 204 if bookmark != nil { 205 actions.AddCommand(e, c, tty, status, bookmark, undo, "Copy text from the bookmark to the cursor", "copymark") 206 } 207 208 // Special menu option for PKGBUILD and APKBUILD files 209 if strings.HasSuffix(e.filename, "PKGBUILD") || strings.HasSuffix(e.filename, "APKBUILD") { 210 actions.Add("Call Guessica", func() { 211 status.Clear(c) 212 status.SetMessage("Calling Guessica") 213 status.Show(c, e) 214 215 tempFilename := "" 216 217 var ( 218 f *os.File 219 err error 220 ) 221 if f, err = os.CreateTemp(tempDir, "__o*"+"guessica"); err == nil { 222 // no error, everything is fine 223 tempFilename = f.Name() 224 // TODO: Implement e.SaveAs 225 oldFilename := e.filename 226 e.filename = tempFilename 227 err = e.Save(c, tty) 228 e.filename = oldFilename 229 } 230 if err != nil { 231 status.SetError(err) 232 status.Show(c, e) 233 return 234 } 235 236 if tempFilename == "" { 237 status.SetErrorMessage("Could not create a temporary file") 238 status.Show(c, e) 239 return 240 } 241 242 // Show the status message to the user right now 243 status.Draw(c, e.pos.offsetY) 244 245 // Call Guessica, which may take a little while 246 err = guessica.UpdateFile(tempFilename) 247 248 if err != nil { 249 status.SetErrorMessage("Failed to update PKGBUILD: " + err.Error()) 250 status.Show(c, e) 251 } else { 252 if _, err := e.Load(c, tty, FilenameOrData{tempFilename, []byte{}, 0, false}); err != nil { 253 status.ClearAll(c) 254 status.SetMessage(err.Error()) 255 status.Show(c, e) 256 } 257 // Mark the data as changed, despite just having loaded a file 258 e.changed = true 259 e.redrawCursor = true 260 261 } 262 }) 263 } 264 265 // Fix as you type mode, on/off 266 if openAIKeyHolder != nil { // has AI 267 if e.fixAsYouType { 268 actions.Add("Fix as you type [turn off]", func() { 269 e.fixAsYouType = false 270 status.SetMessageAfterRedraw("Fix as you type turned off") 271 }) 272 } else { 273 actions.Add("Fix as you type", func() { 274 e.fixAsYouType = true 275 status.SetMessageAfterRedraw("Fix as you type turned on") 276 }) 277 } 278 } 279 280 if e.debugMode { 281 hasOutputData := len(strings.TrimSpace(gdbOutput.String())) > 0 282 if hasOutputData { 283 if e.debugHideOutput { 284 actions.Add("Show output pane", func() { 285 e.debugHideOutput = true 286 }) 287 } else { 288 actions.Add("Hide output pane", func() { 289 e.debugHideOutput = true 290 }) 291 } 292 } 293 } 294 295 // Delete the rest of the file 296 actions.Add("Delete the rest of the file", func() { // copy file to clipboard 297 298 prepareFunction := func() { 299 // Prepare to delete all lines from this one and out 300 undo.Snapshot(e) 301 // Also close the portal, if any 302 e.ClosePortal() 303 // Mark the file as changed 304 e.changed = true 305 } 306 307 // Get the current index and remove the rest of the lines 308 currentLineIndex := int(e.DataY()) 309 310 for y := range e.lines { 311 if y >= currentLineIndex { 312 // Run the prepareFunction, but only once, if there was changes to be made 313 if prepareFunction != nil { 314 prepareFunction() 315 prepareFunction = nil 316 } 317 delete(e.lines, y) 318 } 319 } 320 321 if e.changed { 322 e.MakeConsistent() 323 e.redraw = true 324 e.redrawCursor = true 325 } 326 }) 327 328 // Disable or enable the tag-expanding behavior when typing in HTML or XML 329 if e.mode == mode.HTML || e.mode == mode.XML { 330 if e.expandTags { 331 actions.Add("Disable tag expansion when typing", func() { 332 e.expandTags = false 333 }) 334 } else { 335 actions.Add("Enable tag expansion when typing", func() { 336 e.expandTags = true 337 }) 338 } 339 } 340 341 // Find the path to either "rust-gdb" or "gdb", depending on the mode, then check if it's there 342 foundGDB := e.findGDB() != "" 343 344 // Debug mode on/off, if gdb is found and the mode is tested 345 if foundGDB && e.usingGDBMightWork() { 346 if e.debugMode { 347 actions.Add("Exit debug mode", func() { 348 status.Clear(c) 349 status.SetMessage("Debug mode disabled") 350 status.Show(c, e) 351 e.debugMode = false 352 // Also end the gdb session if there is one in progress 353 e.DebugEnd() 354 status.SetMessageAfterRedraw("Normal mode") 355 }) 356 } else { 357 actions.Add("Debug mode", func() { 358 // Save the file when entering debug mode, since gdb may crash for some languages 359 // TODO: Identify which languages work poorly together with gdb 360 e.UserSave(c, tty, status) 361 status.SetMessageAfterRedraw("Debug mode enabled") 362 e.debugMode = true 363 }) 364 } 365 } 366 367 // Add the syntax highlighting toggle menu item 368 if !envNoColor { 369 syntaxToggleText := "Disable syntax highlighting" 370 if !e.syntaxHighlight { 371 syntaxToggleText = "Enable syntax highlighting" 372 } 373 actions.Add(syntaxToggleText, func() { 374 e.ToggleSyntaxHighlight() 375 }) 376 } 377 378 // Add the unlock menu item 379 if forced { 380 // TODO: Detect if file is locked first 381 actions.Add("Unlock if locked", func() { 382 if absFilename, err := e.AbsFilename(); err == nil { // no issues 383 lk.Load() 384 lk.Unlock(absFilename) 385 lk.Save() 386 } 387 }) 388 } 389 390 // Render to PDF using the gofpdf package 391 actions.Add("Render to PDF", func() { 392 // Write to PDF in a goroutine 393 pdfFilename := strings.ReplaceAll(filepath.Base(e.filename), ".", "_") + ".pdf" 394 395 // Show a status message while writing 396 status.SetMessage("Writing " + pdfFilename + "...") 397 status.ShowNoTimeout(c, e) 398 399 statusMessage := "" 400 401 // TODO: Only overwrite if the previous PDF file was also rendered by "o". 402 _ = os.Remove(pdfFilename) 403 // Write the file 404 if err := e.SavePDF(e.filename, pdfFilename); err != nil { 405 statusMessage = err.Error() 406 } else { 407 statusMessage = "Wrote " + pdfFilename 408 } 409 // Show a status message after writing 410 status.ClearAll(c) 411 status.SetMessage(statusMessage) 412 status.ShowNoTimeout(c, e) 413 }) 414 415 // Render to PDF using pandoc 416 if (e.mode == mode.Markdown || e.mode == mode.ASCIIDoc || e.mode == mode.SCDoc) && files.Which("pandoc") != "" { 417 actions.Add("Render to PDF using pandoc", func() { 418 // pandoc 419 if pandocPath := files.Which("pandoc"); pandocPath != "" { 420 pdfFilename := strings.ReplaceAll(filepath.Base(e.filename), ".", "_") + ".pdf" 421 go func() { 422 pandocMutex.Lock() 423 _ = e.exportPandocPDF(c, tty, status, pandocPath, pdfFilename) 424 pandocMutex.Unlock() 425 }() 426 // the exportPandoc function handles it's own status output 427 return 428 } 429 status.SetErrorMessage("Could not find pandoc") 430 status.ShowNoTimeout(c, e) 431 }) 432 } 433 434 // This is a bit odd, but useful when copying the file in 200 line chunks. 435 // actions.AddCommand(e, c, tty, status, bookmark, undo, "Copy the next 200 lines", "copy200") 436 437 if !envNoColor || changedTheme { 438 // Add an option for selecting a theme 439 actions.Add("Change theme", func() { 440 menuChoices := []string{ 441 "Default", 442 "Synthwave (O_THEME=synthwave)", 443 "Red & Black (O_THEME=redblack)", 444 "VS (O_THEME=vs)", 445 "Blue Edit (O_THEME=blueedit)", 446 "Litmus (O_THEME=litmus)", 447 "Teal (O_THEME=teal)", 448 "Gray Mono (O_THEME=graymono)", 449 "Amber Mono (O_THEME=ambermono)", 450 "Green Mono (O_THEME=greenmono)", 451 "Blue Mono (O_THEME=bluemono)", 452 "No colors (NO_COLOR=1)"} 453 useMenuIndex := 0 454 for i, menuChoiceText := range menuChoices { 455 themePrefix := menuChoiceText 456 if strings.Contains(themePrefix, "(") { 457 parts := strings.SplitN(themePrefix, "(", 2) 458 themePrefix = strings.TrimSpace(parts[0]) 459 } 460 if strings.HasPrefix(e.Theme.Name, themePrefix) { 461 useMenuIndex = i 462 } 463 } 464 if useMenuIndex == 0 && env.Bool("NO_COLOR") { 465 useMenuIndex = 10 // The "No colors" menu choice 466 } 467 changedTheme = true 468 switch e.Menu(status, tty, "Select color theme", menuChoices, e.Background, e.MenuTitleColor, e.MenuArrowColor, e.MenuTextColor, e.MenuHighlightColor, e.MenuSelectedColor, useMenuIndex, extraDashes) { 469 case 0: // Default 470 envNoColor = false 471 e.setDefaultTheme() 472 e.syntaxHighlight = true 473 case 1: // Synthwave 474 envNoColor = false 475 e.setSynthwaveTheme() 476 e.syntaxHighlight = true 477 case 2: // Red & Black 478 envNoColor = false 479 e.setRedBlackTheme() 480 e.syntaxHighlight = true 481 case 3: // VS 482 envNoColor = false 483 e.setVSTheme() 484 e.syntaxHighlight = true 485 case 4: // Blue Edit 486 envNoColor = false 487 e.setBlueEditTheme() 488 e.syntaxHighlight = true 489 case 5: // Litmus 490 envNoColor = false 491 e.setLitmusTheme() 492 e.syntaxHighlight = true 493 case 6: // Teal 494 envNoColor = false 495 e.setTealTheme() 496 e.syntaxHighlight = true 497 case 7: // Gray Mono 498 envNoColor = false 499 e.setGrayTheme() 500 e.syntaxHighlight = false 501 case 8: // Amber Mono 502 envNoColor = false 503 e.setAmberTheme() 504 e.syntaxHighlight = false 505 case 9: // Green Mono 506 envNoColor = false 507 e.setGreenTheme() 508 e.syntaxHighlight = false 509 case 10: // Blue Mono 510 envNoColor = false 511 e.setBlueTheme() 512 e.syntaxHighlight = false 513 case 11: // No color 514 envNoColor = true 515 e.setNoColorTheme() 516 e.syntaxHighlight = false 517 default: 518 changedTheme = false 519 return 520 } 521 drawLines := true 522 e.FullResetRedraw(c, status, drawLines) 523 }) 524 } 525 526 // Add a menu item to toggle primary/non-primary clipboard on Linux 527 if isLinux() { 528 primaryToggleText := "Use the secondary clipboard" 529 if !e.primaryClipboard { 530 primaryToggleText = "Use the primary clipboard" 531 } 532 actions.Add(primaryToggleText, func() { 533 e.primaryClipboard = !e.primaryClipboard 534 }) 535 } 536 537 if !e.EmptyLine() { 538 actions.AddCommand(e, c, tty, status, bookmark, undo, "Split line on blanks outside of (), [] or {}", "splitline") 539 } 540 541 // Only show the menu option for killing the parent process if the parent process is a known search command 542 searchProcessNames := []string{"ag", "find", "rg"} 543 if firstWordContainsOneOf(parentCommand(), searchProcessNames) { 544 actions.Add("Kill parent and exit without saving", func() { 545 e.stopParentOnQuit = true 546 e.clearOnQuit = true 547 e.quit = true // indicate that the user wishes to quit 548 e.clearOnQuit = true // clear the terminal after quitting 549 }) 550 } else { 551 actions.Add("Exit without saving", func() { 552 e.stopParentOnQuit = false 553 e.clearOnQuit = false 554 e.quit = true // indicate that the user wishes to quit 555 e.clearOnQuit = true // clear the terminal after quitting 556 }) 557 } 558 559 menuChoices := actions.MenuChoices() 560 561 // Launch a generic menu 562 useMenuIndex := 0 563 if lastMenuIndex > 0 { 564 useMenuIndex = lastMenuIndex 565 } 566 567 selected := e.Menu(status, tty, menuTitle, menuChoices, e.Background, e.MenuTitleColor, e.MenuArrowColor, e.MenuTextColor, e.MenuHighlightColor, e.MenuSelectedColor, useMenuIndex, extraDashes) 568 569 // Redraw the editor contents 570 // e.DrawLines(c, true, false) 571 572 if selected < 0 { 573 // Esc was pressed, or an item was otherwise not selected. 574 // Trigger a redraw and return. 575 e.redraw = true 576 e.redrawCursor = true 577 return selected 578 } 579 580 // Perform the selected action by passing the function index 581 actions.Perform(selected) 582 583 // Adjust the cursor placement 584 if e.AfterEndOfLine() { 585 e.End(c) 586 } 587 588 // Redraw editor 589 e.redraw = true 590 e.redrawCursor = true 591 592 return selected 593 }