src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/cli/app.go (about) 1 // Package cli implements a generic interactive line editor. 2 package cli 3 4 import ( 5 "io" 6 "os" 7 "sort" 8 "sync" 9 "syscall" 10 11 "src.elv.sh/pkg/cli/term" 12 "src.elv.sh/pkg/cli/tk" 13 "src.elv.sh/pkg/sys" 14 "src.elv.sh/pkg/ui" 15 ) 16 17 // App represents a CLI app. 18 type App interface { 19 // ReadCode requests the App to read code from the terminal by running an 20 // event loop. This function is not re-entrant. 21 ReadCode() (string, error) 22 23 // MutateState mutates the state of the app. 24 MutateState(f func(*State)) 25 // CopyState returns a copy of the a state. 26 CopyState() State 27 28 // PushAddon pushes a widget to the addon stack. 29 PushAddon(w tk.Widget) 30 // PopAddon pops the last widget from the addon stack. If the widget 31 // implements interface{ Dismiss() }, the Dismiss method is called 32 // first. This method does nothing if the addon stack is empty. 33 PopAddon() 34 35 // ActiveWidget returns the currently active widget. If the addon stack is 36 // non-empty, it returns the last addon. Otherwise it returns the main code 37 // area widget. 38 ActiveWidget() tk.Widget 39 // FocusedWidget returns the currently focused widget. It is searched like 40 // ActiveWidget, but skips widgets that implement interface{ Focus() bool } 41 // and return false when .Focus() is called. 42 FocusedWidget() tk.Widget 43 44 // CommitEOF causes the main loop to exit with EOF. If this method is called 45 // when an event is being handled, the main loop will exit after the handler 46 // returns. 47 CommitEOF() 48 // CommitCode causes the main loop to exit with the current code content. If 49 // this method is called when an event is being handled, the main loop will 50 // exit after the handler returns. 51 CommitCode() 52 53 // Redraw requests a redraw. It never blocks and can be called regardless of 54 // whether the App is active or not. 55 Redraw() 56 // RedrawFull requests a full redraw. It never blocks and can be called 57 // regardless of whether the App is active or not. 58 RedrawFull() 59 // Notify adds a note and requests a redraw. 60 Notify(note ui.Text) 61 } 62 63 type app struct { 64 loop *loop 65 reqRead chan struct{} 66 67 TTY TTY 68 MaxHeight func() int 69 RPromptPersistent func() bool 70 BeforeReadline []func() 71 AfterReadline []func(string) 72 Highlighter Highlighter 73 Prompt Prompt 74 RPrompt Prompt 75 GlobalBindings tk.Bindings 76 77 StateMutex sync.RWMutex 78 State State 79 80 codeArea tk.CodeArea 81 } 82 83 // State represents mutable state of an App. 84 type State struct { 85 // Notes that have been added since the last redraw. 86 Notes []ui.Text 87 // The addon stack. All widgets are shown under the codearea widget. The 88 // last widget handles terminal events. 89 Addons []tk.Widget 90 } 91 92 // NewApp creates a new App from the given specification. 93 func NewApp(spec AppSpec) App { 94 lp := newLoop() 95 a := app{ 96 loop: lp, 97 TTY: spec.TTY, 98 MaxHeight: spec.MaxHeight, 99 RPromptPersistent: spec.RPromptPersistent, 100 BeforeReadline: spec.BeforeReadline, 101 AfterReadline: spec.AfterReadline, 102 Highlighter: spec.Highlighter, 103 Prompt: spec.Prompt, 104 RPrompt: spec.RPrompt, 105 GlobalBindings: spec.GlobalBindings, 106 State: spec.State, 107 } 108 if a.TTY == nil { 109 a.TTY = NewTTY(os.Stdin, os.Stderr) 110 } 111 if a.MaxHeight == nil { 112 a.MaxHeight = func() int { return -1 } 113 } 114 if a.RPromptPersistent == nil { 115 a.RPromptPersistent = func() bool { return false } 116 } 117 if a.Highlighter == nil { 118 a.Highlighter = dummyHighlighter{} 119 } 120 if a.Prompt == nil { 121 a.Prompt = NewConstPrompt(nil) 122 } 123 if a.RPrompt == nil { 124 a.RPrompt = NewConstPrompt(nil) 125 } 126 if a.GlobalBindings == nil { 127 a.GlobalBindings = tk.DummyBindings{} 128 } 129 lp.HandleCb(a.handle) 130 lp.RedrawCb(a.redraw) 131 132 a.codeArea = tk.NewCodeArea(tk.CodeAreaSpec{ 133 Bindings: spec.CodeAreaBindings, 134 Highlighter: a.Highlighter.Get, 135 Prompt: a.Prompt.Get, 136 RPrompt: a.RPrompt.Get, 137 QuotePaste: spec.QuotePaste, 138 OnSubmit: a.CommitCode, 139 State: spec.CodeAreaState, 140 141 SimpleAbbreviations: spec.SimpleAbbreviations, 142 CommandAbbreviations: spec.CommandAbbreviations, 143 SmallWordAbbreviations: spec.SmallWordAbbreviations, 144 }) 145 146 return &a 147 } 148 149 func (a *app) MutateState(f func(*State)) { 150 a.StateMutex.Lock() 151 defer a.StateMutex.Unlock() 152 f(&a.State) 153 } 154 155 func (a *app) CopyState() State { 156 a.StateMutex.RLock() 157 defer a.StateMutex.RUnlock() 158 return State{ 159 append([]ui.Text(nil), a.State.Notes...), 160 append([]tk.Widget(nil), a.State.Addons...), 161 } 162 } 163 164 type dismisser interface { 165 Dismiss() 166 } 167 168 func (a *app) PushAddon(w tk.Widget) { 169 a.StateMutex.Lock() 170 defer a.StateMutex.Unlock() 171 a.State.Addons = append(a.State.Addons, w) 172 } 173 174 func (a *app) PopAddon() { 175 a.StateMutex.Lock() 176 defer a.StateMutex.Unlock() 177 if len(a.State.Addons) == 0 { 178 return 179 } 180 if d, ok := a.State.Addons[len(a.State.Addons)-1].(dismisser); ok { 181 d.Dismiss() 182 } 183 a.State.Addons = a.State.Addons[:len(a.State.Addons)-1] 184 } 185 186 func (a *app) ActiveWidget() tk.Widget { 187 a.StateMutex.Lock() 188 defer a.StateMutex.Unlock() 189 if len(a.State.Addons) > 0 { 190 return a.State.Addons[len(a.State.Addons)-1] 191 } 192 return a.codeArea 193 } 194 195 func (a *app) FocusedWidget() tk.Widget { 196 a.StateMutex.Lock() 197 defer a.StateMutex.Unlock() 198 addons := a.State.Addons 199 for i := len(addons) - 1; i >= 0; i-- { 200 if hasFocus(addons[i]) { 201 return addons[i] 202 } 203 } 204 return a.codeArea 205 } 206 207 func (a *app) resetAllStates() { 208 a.MutateState(func(s *State) { *s = State{} }) 209 a.codeArea.MutateState( 210 func(s *tk.CodeAreaState) { *s = tk.CodeAreaState{} }) 211 } 212 213 func (a *app) handle(e event) { 214 switch e := e.(type) { 215 case os.Signal: 216 switch e { 217 case syscall.SIGHUP: 218 a.loop.Return("", io.EOF) 219 case syscall.SIGINT: 220 a.resetAllStates() 221 a.triggerPrompts(true) 222 case sys.SIGWINCH: 223 a.RedrawFull() 224 } 225 case term.Event: 226 target := a.ActiveWidget() 227 handled := target.Handle(e) 228 if !handled { 229 handled = a.GlobalBindings.Handle(target, e) 230 } 231 if !handled { 232 if k, ok := e.(term.KeyEvent); ok { 233 a.Notify(ui.T("Unbound key: " + ui.Key(k).String())) 234 } 235 } 236 if !a.loop.HasReturned() { 237 a.triggerPrompts(false) 238 a.reqRead <- struct{}{} 239 } 240 } 241 } 242 243 func (a *app) triggerPrompts(force bool) { 244 a.Prompt.Trigger(force) 245 a.RPrompt.Trigger(force) 246 } 247 248 func (a *app) redraw(flag redrawFlag) { 249 // Get the dimensions available. 250 height, width := a.TTY.Size() 251 if maxHeight := a.MaxHeight(); maxHeight > 0 && maxHeight < height { 252 height = maxHeight 253 } 254 255 var notes []ui.Text 256 var addons []tk.Widget 257 a.MutateState(func(s *State) { 258 notes = s.Notes 259 s.Notes = nil 260 addons = append([]tk.Widget(nil), s.Addons...) 261 }) 262 263 bufNotes := renderNotes(notes, width) 264 isFinalRedraw := flag&finalRedraw != 0 265 if isFinalRedraw { 266 hideRPrompt := !a.RPromptPersistent() 267 a.codeArea.MutateState(func(s *tk.CodeAreaState) { 268 s.HideTips = true 269 s.HideRPrompt = hideRPrompt 270 }) 271 bufMain := renderApp([]tk.Widget{a.codeArea /* no addon */}, width, height) 272 a.codeArea.MutateState(func(s *tk.CodeAreaState) { 273 s.HideTips = false 274 s.HideRPrompt = false 275 }) 276 // Insert a newline after the buffer and position the cursor there. 277 bufMain.Extend(term.NewBuffer(width), true) 278 279 a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0) 280 a.TTY.ResetBuffer() 281 } else { 282 bufMain := renderApp(append([]tk.Widget{a.codeArea}, addons...), width, height) 283 a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0) 284 } 285 } 286 287 // Renders notes. This does not respect height so that overflow notes end up in 288 // the scrollback buffer. 289 func renderNotes(notes []ui.Text, width int) *term.Buffer { 290 if len(notes) == 0 { 291 return nil 292 } 293 bb := term.NewBufferBuilder(width) 294 for i, note := range notes { 295 if i > 0 { 296 bb.Newline() 297 } 298 bb.WriteStyled(note) 299 } 300 return bb.Buffer() 301 } 302 303 // Renders the codearea, and uses the rest of the height for the listing. 304 func renderApp(widgets []tk.Widget, width, height int) *term.Buffer { 305 heights, focus := distributeHeight(widgets, width, height) 306 var buf *term.Buffer 307 for i, w := range widgets { 308 if heights[i] == 0 { 309 continue 310 } 311 buf2 := w.Render(width, heights[i]) 312 if buf == nil { 313 buf = buf2 314 } else { 315 buf.Extend(buf2, i == focus) 316 } 317 } 318 return buf 319 } 320 321 // Distributes the height among all the widgets. Returns the height for each 322 // widget, and the index of the widget currently focused. 323 func distributeHeight(widgets []tk.Widget, width, height int) ([]int, int) { 324 var focus int 325 for i, w := range widgets { 326 if hasFocus(w) { 327 focus = i 328 } 329 } 330 n := len(widgets) 331 heights := make([]int, n) 332 if height <= n { 333 // Not enough (or just enough) height to render every widget with a 334 // height of 1. 335 remain := height 336 // Start from the focused widget, and extend downwards as much as 337 // possible. 338 for i := focus; i < n && remain > 0; i++ { 339 heights[i] = 1 340 remain-- 341 } 342 // If there is still space remaining, start from the focused widget 343 // again, and extend upwards as much as possible. 344 for i := focus - 1; i >= 0 && remain > 0; i-- { 345 heights[i] = 1 346 remain-- 347 } 348 return heights, focus 349 } 350 351 maxHeights := make([]int, n) 352 for i, w := range widgets { 353 maxHeights[i] = w.MaxHeight(width, height) 354 } 355 356 // The algorithm below achieves the following goals: 357 // 358 // 1. If maxHeights[u] > maxHeights[v], heights[u] >= heights[v]; 359 // 360 // 2. While achieving goal 1, have as many widgets s.t. heights[u] == 361 // maxHeights[u]. 362 // 363 // This is done by allocating the height among the widgets following an 364 // non-decreasing order of maxHeights. At each step: 365 // 366 // - If it's possible to allocate maxHeights[u] to all remaining widgets, 367 // then allocate maxHeights[u] to widget u; 368 // 369 // - If not, allocate the remaining budget evenly - rounding down at each 370 // step, so the widgets with smaller maxHeights gets smaller heights. 371 372 // TODO: Add a test for this. 373 374 indices := make([]int, n) 375 for i := range indices { 376 indices[i] = i 377 } 378 sort.Slice(indices, func(i, j int) bool { 379 return maxHeights[indices[i]] < maxHeights[indices[j]] 380 }) 381 382 remain := height 383 for rank, idx := range indices { 384 if remain >= maxHeights[idx]*(n-rank) { 385 heights[idx] = maxHeights[idx] 386 } else { 387 heights[idx] = remain / (n - rank) 388 } 389 remain -= heights[idx] 390 } 391 392 return heights, focus 393 } 394 395 func hasFocus(w any) bool { 396 if f, ok := w.(interface{ Focus() bool }); ok { 397 return f.Focus() 398 } 399 return true 400 } 401 402 func (a *app) ReadCode() (string, error) { 403 for _, f := range a.BeforeReadline { 404 f() 405 } 406 defer func() { 407 content := a.codeArea.CopyState().Buffer.Content 408 for _, f := range a.AfterReadline { 409 f(content) 410 } 411 a.resetAllStates() 412 }() 413 414 restore, err := a.TTY.Setup() 415 if err != nil { 416 return "", err 417 } 418 defer restore() 419 420 var wg sync.WaitGroup 421 defer wg.Wait() 422 423 // Relay input events. 424 a.reqRead = make(chan struct{}, 1) 425 a.reqRead <- struct{}{} 426 defer close(a.reqRead) 427 defer a.TTY.CloseReader() 428 wg.Add(1) 429 go func() { 430 defer wg.Done() 431 for range a.reqRead { 432 event, err := a.TTY.ReadEvent() 433 if err == nil { 434 a.loop.Input(event) 435 } else if err == term.ErrStopped { 436 return 437 } else if term.IsReadErrorRecoverable(err) { 438 a.loop.Input(term.NonfatalErrorEvent{Err: err}) 439 } else { 440 a.loop.Input(term.FatalErrorEvent{Err: err}) 441 return 442 } 443 } 444 }() 445 446 // Relay signals. 447 sigCh := a.TTY.NotifySignals() 448 defer a.TTY.StopSignals() 449 wg.Add(1) 450 go func() { 451 for sig := range sigCh { 452 a.loop.Input(sig) 453 } 454 wg.Done() 455 }() 456 457 // Relay late updates from prompt, rprompt and highlighter. 458 stopRelayLateUpdates := make(chan struct{}) 459 defer close(stopRelayLateUpdates) 460 relayLateUpdates := func(ch <-chan struct{}) { 461 if ch == nil { 462 return 463 } 464 wg.Add(1) 465 go func() { 466 defer wg.Done() 467 for { 468 select { 469 case <-ch: 470 a.Redraw() 471 case <-stopRelayLateUpdates: 472 return 473 } 474 } 475 }() 476 } 477 478 relayLateUpdates(a.Prompt.LateUpdates()) 479 relayLateUpdates(a.RPrompt.LateUpdates()) 480 relayLateUpdates(a.Highlighter.LateUpdates()) 481 482 // Trigger an initial prompt update. 483 a.triggerPrompts(true) 484 485 return a.loop.Run() 486 } 487 488 func (a *app) Redraw() { 489 a.loop.Redraw(false) 490 } 491 492 func (a *app) RedrawFull() { 493 a.loop.Redraw(true) 494 } 495 496 func (a *app) CommitEOF() { 497 a.loop.Return("", io.EOF) 498 } 499 500 func (a *app) CommitCode() { 501 code := a.codeArea.CopyState().Buffer.Content 502 a.loop.Return(code, nil) 503 } 504 505 func (a *app) Notify(note ui.Text) { 506 a.MutateState(func(s *State) { s.Notes = append(s.Notes, note) }) 507 a.Redraw() 508 }