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  }