github.com/aretext/aretext@v1.3.0/app/editor.go (about) 1 package app 2 3 import ( 4 "fmt" 5 "log" 6 "path/filepath" 7 "time" 8 9 "github.com/gdamore/tcell/v2" 10 11 "github.com/aretext/aretext/config" 12 "github.com/aretext/aretext/display" 13 "github.com/aretext/aretext/input" 14 "github.com/aretext/aretext/locate" 15 "github.com/aretext/aretext/state" 16 ) 17 18 // Editor is a terminal-based text editing program. 19 type Editor struct { 20 inputInterpreter *input.Interpreter 21 editorState *state.EditorState 22 screen tcell.Screen 23 palette *display.Palette 24 documentLoadCount int 25 termEventChan chan tcell.Event 26 } 27 28 // NewEditor instantiates a new editor that uses the provided screen. 29 func NewEditor(screen tcell.Screen, path string, lineNum uint64, configRuleSet config.RuleSet) *Editor { 30 screenWidth, screenHeight := screen.Size() 31 editorState := state.NewEditorState( 32 uint64(screenWidth), 33 uint64(screenHeight), 34 configRuleSet, 35 suspendScreenFunc(screen), 36 ) 37 inputInterpreter := input.NewInterpreter() 38 palette := display.NewPalette() 39 documentLoadCount := editorState.DocumentLoadCount() 40 termEventChan := make(chan tcell.Event, 1) 41 editor := &Editor{ 42 inputInterpreter, 43 editorState, 44 screen, 45 palette, 46 documentLoadCount, 47 termEventChan, 48 } 49 50 // Attempt to load the file. 51 // If it doesn't exist, this will start with an empty document 52 // that the user can edit and save to the specified path. 53 state.LoadDocument( 54 editorState, 55 effectivePath(path), 56 false, 57 func(p state.LocatorParams) uint64 { 58 return locate.StartOfLineNum(p.TextTree, lineNum) 59 }, 60 ) 61 62 return editor 63 } 64 65 func effectivePath(path string) string { 66 if path == "" { 67 // If no path is specified, set a default that is probably unique. 68 // The user can treat this as a scratchpad or discard it and open another file. 69 path = fmt.Sprintf("untitled-%d.txt", time.Now().Unix()) 70 } 71 72 absPath, err := filepath.Abs(path) 73 if err != nil { 74 log.Printf("Error converting %q to absolute path: %v", path, fmt.Errorf("filepath.Abs: %w", err)) 75 return path 76 } 77 78 return absPath 79 } 80 81 // RunEventLoop processes events and draws to the screen, blocking until the user exits the program. 82 func (e *Editor) RunEventLoop() { 83 e.redraw(true) 84 go e.pollTermEvents() 85 e.runMainEventLoop() 86 e.shutdown() 87 } 88 89 func (e *Editor) pollTermEvents() { 90 for { 91 event := e.screen.PollEvent() 92 e.termEventChan <- event 93 } 94 } 95 96 func (e *Editor) runMainEventLoop() { 97 var inBracketedPaste bool 98 for { 99 select { 100 case event := <-e.termEventChan: 101 e.handleTermEvent(event) 102 if pasteEvent, ok := event.(*tcell.EventPaste); ok { 103 inBracketedPaste = pasteEvent.Start() 104 } 105 106 case actionFunc := <-e.editorState.TaskResultChan(): 107 log.Printf("Task completed, executing resulting action...\n") 108 actionFunc(e.editorState) 109 110 case <-e.editorState.FileWatcher().ChangedChan(): 111 e.handleFileChanged() 112 } 113 114 e.handleIfDocumentLoaded() 115 116 if e.editorState.QuitFlag() { 117 log.Printf("Quit flag set, exiting event loop...\n") 118 return 119 } 120 121 // Redraw unless there are pending terminal events to process first 122 // or we're in the middle of a bracketed paste. 123 // This helps avoid the overhead of redrawing after every keypress 124 // if the user pastes a lot of text into the terminal emulator. 125 if len(e.termEventChan) == 0 && !inBracketedPaste { 126 e.redraw(false) 127 } 128 } 129 } 130 131 func (e *Editor) handleTermEvent(event tcell.Event) { 132 inputCtx := input.ContextFromEditorState(e.editorState) 133 actionFunc := e.inputInterpreter.ProcessEvent(event, inputCtx) 134 actionFunc(e.editorState) 135 } 136 137 func (e *Editor) handleFileChanged() { 138 log.Printf("File change detected, reloading file...\n") 139 state.AbortIfUnsavedChanges(e.editorState, "", state.ReloadDocument) 140 } 141 142 func (e *Editor) handleIfDocumentLoaded() { 143 documentLoadCount := e.editorState.DocumentLoadCount() 144 if documentLoadCount != e.documentLoadCount { 145 log.Printf("Detected document loaded, updating editor") 146 147 // Reset the input interpreter, which may have state from the prev document. 148 e.inputInterpreter = input.NewInterpreter() 149 150 // Update palette, since the configuration might have changed. 151 styles := e.editorState.Styles() 152 e.palette = display.NewPaletteFromConfigStyles(styles) 153 154 // Store the new document load count so we know when the next document loads. 155 e.documentLoadCount = documentLoadCount 156 } 157 } 158 159 func (e *Editor) shutdown() { 160 e.editorState.FileWatcher().Stop() 161 } 162 163 func (e *Editor) redraw(sync bool) { 164 inputMode := e.editorState.InputMode() 165 inputBufferString := e.inputInterpreter.InputBufferString(inputMode) 166 display.DrawEditor(e.screen, e.palette, e.editorState, inputBufferString) 167 if sync { 168 e.screen.Sync() 169 } else { 170 e.screen.Show() 171 } 172 } 173 174 func suspendScreenFunc(screen tcell.Screen) state.SuspendScreenFunc { 175 return func(f func() error) error { 176 // Suspend input processing and reset the terminal to its original state. 177 if err := screen.Suspend(); err != nil { 178 return fmt.Errorf("screen.Suspend: %w", err) 179 } 180 181 // Ensure screen is resumed after executing the function. 182 defer func() { 183 if err := screen.Resume(); err != nil { 184 log.Printf("Error resuming screen: %v\n", err) 185 } 186 }() 187 188 // Execute the function. 189 return f() 190 } 191 }