github.com/aretext/aretext@v1.3.0/state/menu.go (about)

     1  package state
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"log"
     7  	"os"
     8  	"path/filepath"
     9  	"sort"
    10  
    11  	"github.com/aretext/aretext/file"
    12  	"github.com/aretext/aretext/menu"
    13  	"github.com/aretext/aretext/text"
    14  )
    15  
    16  type MenuStyle int
    17  
    18  const (
    19  	MenuStyleCommand = MenuStyle(iota)
    20  	MenuStyleFilePath
    21  	MenuStyleFileLocation
    22  	MenuStyleChildDir
    23  	MenuStyleParentDir
    24  	MenuStyleInsertChoice
    25  	MenuStyleWorkingDir
    26  )
    27  
    28  // EmptyQueryShowAll returns whether an empty query should show all items.
    29  func (s MenuStyle) EmptyQueryShowAll() bool {
    30  	switch s {
    31  	case MenuStyleFilePath, MenuStyleFileLocation, MenuStyleChildDir, MenuStyleParentDir, MenuStyleInsertChoice, MenuStyleWorkingDir:
    32  		return true
    33  	default:
    34  		return false
    35  	}
    36  }
    37  
    38  // MenuState represents the menu for searching and selecting items.
    39  type MenuState struct {
    40  	// style controls how the menu is displayed.
    41  	style MenuStyle
    42  
    43  	// query is the text input by the user to search for a menu item.
    44  	query text.RuneStack
    45  
    46  	// search controls which items are visible based on the user's current search query.
    47  	search *menu.Search
    48  
    49  	// selectedResultIdx is the index of the currently selected search result.
    50  	// If there are no results, this is set to zero.
    51  	// If there are results, this must be less than the number of results.
    52  	selectedResultIdx int
    53  
    54  	// prevInputMode is the input mode to set after exiting menu mode.
    55  	prevInputMode InputMode
    56  }
    57  
    58  func (m *MenuState) Style() MenuStyle {
    59  	return m.style
    60  }
    61  
    62  func (m *MenuState) SearchQuery() string {
    63  	return m.query.String()
    64  }
    65  
    66  func (m *MenuState) SearchResults() (results []menu.Item, selectedResultIdx int) {
    67  	if m.search == nil {
    68  		return nil, 0
    69  	}
    70  	return m.search.Results(), m.selectedResultIdx
    71  }
    72  
    73  // ShowMenu displays the menu with the specified style and items.
    74  func ShowMenu(state *EditorState, style MenuStyle, items []menu.Item) {
    75  	if style == MenuStyleCommand {
    76  		items = append(items, state.customMenuItems...)
    77  	}
    78  
    79  	switch style {
    80  	case MenuStyleParentDir:
    81  		// Sort lexicographic order descending.
    82  		// This ensures that longer paths appear first when listing parent directory paths.
    83  		sort.SliceStable(items, func(i, j int) bool { return items[i].Name > items[j].Name })
    84  
    85  	case MenuStyleCommand, MenuStyleFilePath, MenuStyleChildDir:
    86  		// Sort lexicographic order ascending.
    87  		sort.SliceStable(items, func(i, j int) bool { return items[i].Name < items[j].Name })
    88  	}
    89  
    90  	search := menu.NewSearch(items, style.EmptyQueryShowAll())
    91  	state.menu = &MenuState{
    92  		style:             style,
    93  		search:            search,
    94  		selectedResultIdx: 0,
    95  		prevInputMode:     state.inputMode,
    96  	}
    97  	setInputMode(state, InputModeMenu)
    98  }
    99  
   100  // ShowFileMenu displays a menu for finding and loading files in the current working directory.
   101  // The files are loaded asynchronously as a task that the user can cancel.
   102  func ShowFileMenu(s *EditorState, dirPatternsToHide []string) {
   103  	log.Printf("Scheduling task to load file menu items...\n")
   104  	StartTask(s, func(ctx context.Context) func(*EditorState) {
   105  		log.Printf("Starting to load file menu items...\n")
   106  		items := loadFileMenuItems(ctx, dirPatternsToHide)
   107  		log.Printf("Successfully loaded %d file menu items\n", len(items))
   108  		return func(s *EditorState) {
   109  			ShowMenu(s, MenuStyleFilePath, items)
   110  		}
   111  	})
   112  }
   113  
   114  func loadFileMenuItems(ctx context.Context, dirPatternsToHide []string) []menu.Item {
   115  	dir, err := os.Getwd()
   116  	if err != nil {
   117  		log.Printf("Error loading menu items: %v\n", fmt.Errorf("os.GetCwd: %w", err))
   118  		return nil
   119  	}
   120  
   121  	paths := file.ListDir(ctx, dir, file.ListDirOptions{
   122  		DirPatternsToHide: dirPatternsToHide,
   123  	})
   124  	log.Printf("Listed %d paths for dir %q\n", len(paths), dir)
   125  
   126  	items := make([]menu.Item, 0, len(paths))
   127  	for _, p := range paths {
   128  		menuPath := p // reference path in this iteration of the loop
   129  		items = append(items, menu.Item{
   130  			Name: file.RelativePath(menuPath, dir),
   131  			Action: func(s *EditorState) {
   132  				LoadDocument(s, menuPath, true, func(LocatorParams) uint64 {
   133  					return 0
   134  				})
   135  			},
   136  		})
   137  	}
   138  	return items
   139  }
   140  
   141  // ShowChildDirsMenu displays a menu for changing the working directory to a child directory.
   142  func ShowChildDirsMenu(s *EditorState, dirPatternsToHide []string) {
   143  	log.Printf("Scheduling task to load child dir menu items...\n")
   144  	StartTask(s, func(ctx context.Context) func(*EditorState) {
   145  		log.Printf("Starting to load child dir menu items...\n")
   146  		items := loadChildDirMenuItems(ctx, dirPatternsToHide)
   147  		log.Printf("Successfully loaded %d child dir menu items\n", len(items))
   148  		return func(s *EditorState) {
   149  			ShowMenu(s, MenuStyleChildDir, items)
   150  		}
   151  	})
   152  }
   153  
   154  func loadChildDirMenuItems(ctx context.Context, dirPatternsToHide []string) []menu.Item {
   155  	dir, err := os.Getwd()
   156  	if err != nil {
   157  		log.Printf("Error loading menu items: %v\n", fmt.Errorf("os.GetCwd: %w", err))
   158  		return nil
   159  	}
   160  
   161  	paths := file.ListDir(ctx, dir, file.ListDirOptions{
   162  		DirectoriesOnly:   true,
   163  		DirPatternsToHide: dirPatternsToHide,
   164  	})
   165  	log.Printf("Listed %d subdirectory paths for dir %q\n", len(paths), dir)
   166  
   167  	items := make([]menu.Item, 0, len(paths))
   168  	for _, p := range paths {
   169  		menuPath := p // reference path in this iteration of the loop
   170  		items = append(items, menu.Item{
   171  			Name: fmt.Sprintf("./%s", file.RelativePath(menuPath, dir)),
   172  			Action: func(s *EditorState) {
   173  				SetWorkingDirectory(s, menuPath)
   174  			},
   175  		})
   176  	}
   177  	return items
   178  }
   179  
   180  // ShowParentDirsMenu displays a menu for changing the working directory to a parent directory.
   181  func ShowParentDirsMenu(s *EditorState) {
   182  	ShowMenu(s, MenuStyleParentDir, parentDirMenuItems())
   183  }
   184  
   185  func parentDirMenuItems() []menu.Item {
   186  	dir, err := os.Getwd()
   187  	if err != nil {
   188  		log.Printf("Error loading menu items: %v\n", fmt.Errorf("os.GetCwd: %w", err))
   189  		return nil
   190  	}
   191  
   192  	dir = filepath.Clean(dir)
   193  
   194  	// Create an item for each parent directory.
   195  	// We can detect when we've reached the root directory by checking the last character
   196  	// of the path because both filepath.Clean and filepath.Dir
   197  	// guarantee that only the root directory ends in a separator.
   198  	var items []menu.Item
   199  	for len(dir) > 0 && dir[len(dir)-1] != os.PathSeparator {
   200  		dir = filepath.Dir(dir)
   201  		menuDir := dir // reference path in this iteration of the loop
   202  		items = append(items, menu.Item{
   203  			Name: dir,
   204  			Action: func(s *EditorState) {
   205  				SetWorkingDirectory(s, menuDir)
   206  			},
   207  		})
   208  	}
   209  	return items
   210  }
   211  
   212  // HideMenu hides the menu.
   213  func HideMenu(state *EditorState) {
   214  	prevInputMode := state.menu.prevInputMode
   215  	state.menu = &MenuState{}
   216  	setInputMode(state, prevInputMode)
   217  }
   218  
   219  // ExecuteSelectedMenuItem executes the action of the selected menu item and closes the menu.
   220  func ExecuteSelectedMenuItem(state *EditorState) {
   221  	search := state.menu.search
   222  	results := search.Results()
   223  	if len(results) == 0 {
   224  		// If there are no results, then there is no action to perform.
   225  		SetStatusMsg(state, StatusMsg{
   226  			Style: StatusMsgStyleError,
   227  			Text:  "No menu item selected",
   228  		})
   229  		HideMenu(state)
   230  		return
   231  	}
   232  
   233  	idx := state.menu.selectedResultIdx
   234  	selectedItem := results[idx]
   235  
   236  	// Some menu commands enter a different input mode (like task mode for shell commands),
   237  	// then return to whatever the input mode was at the start of the action.
   238  	// Hide the menu first so that these actions return to normal/visual mode, not menu mode.
   239  	HideMenu(state)
   240  
   241  	executeMenuItemAction(state, selectedItem)
   242  	ScrollViewToCursor(state)
   243  }
   244  
   245  func executeMenuItemAction(state *EditorState, item menu.Item) {
   246  	log.Printf("Executing menu item %q\n", item.Name)
   247  	actionFunc, ok := item.Action.(func(*EditorState))
   248  	if !ok {
   249  		log.Printf("Invalid action for menu item %q\n", item.Name)
   250  		return
   251  	}
   252  	actionFunc(state)
   253  }
   254  
   255  // MoveMenuSelection moves the menu selection up or down with wraparound.
   256  func MoveMenuSelection(state *EditorState, delta int) {
   257  	numResults := len(state.menu.search.Results())
   258  	if numResults == 0 {
   259  		return
   260  	}
   261  
   262  	newIdx := (state.menu.selectedResultIdx + delta) % numResults
   263  	if newIdx < 0 {
   264  		newIdx = numResults + newIdx
   265  	}
   266  
   267  	state.menu.selectedResultIdx = newIdx
   268  }
   269  
   270  // AppendMenuSearch appends a rune to the menu search query.
   271  func AppendRuneToMenuSearch(state *EditorState, r rune) {
   272  	menu := state.menu
   273  	menu.query.Push(r)
   274  	menu.search.Execute(menu.query.String())
   275  	menu.selectedResultIdx = 0
   276  }
   277  
   278  // DeleteMenuSearch deletes a rune from the menu search query.
   279  func DeleteRuneFromMenuSearch(state *EditorState) {
   280  	menu := state.menu
   281  	if menu.query.Len() > 0 {
   282  		menu.query.Pop()
   283  		menu.search.Execute(menu.query.String())
   284  		menu.selectedResultIdx = 0
   285  	}
   286  }