package tui import ( "context" "errors" "fmt" "strings" "sync/atomic" "time" "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-lens/internal/schema/common" "github.com/davecgh/go-spew/spew" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "go.etcd.io/bbolt" ) type Config struct { LoadBufferSize int SearchHistorySize int LoadingIndicatorLag time.Duration } var DefaultConfig = Config{ LoadBufferSize: 100, SearchHistorySize: 100, LoadingIndicatorLag: 500 * time.Millisecond, } type Primitive interface { tview.Primitive Mount(ctx context.Context) error Update(ctx context.Context) error Unmount() } type UI struct { *tview.Box // Need to use context while updating pages those read data from a database. // Context should be shared among all mount and updates. Current TUI library // doesn't use contexts at all, so I do that feature by myself. //nolint:containedctx ctx context.Context onStop func() app *tview.Application db *bbolt.DB pageHistory []Primitive mountedPage Primitive pageToMount Primitive pageStub tview.Primitive infoBar *tview.TextView searchBar *InputFieldWithHistory loadingBar *LoadingBar helpBar *tview.TextView helpPage *HelpPage searchErrorBar *tview.TextView isSearching bool isLoading atomic.Bool isShowingError bool isShowingHelp bool loadBufferSize int rootParser common.Parser loadingIndicatorLag time.Duration cancelLoading func() filters map[string]func(string) (any, error) compositeFilters map[string]func(string) (map[string]any, error) filterHints map[string]string } func NewUI( ctx context.Context, app *tview.Application, db *bbolt.DB, rootParser common.Parser, cfg *Config, ) *UI { spew.Config.DisableMethods = true if cfg == nil { cfg = &DefaultConfig } ui := &UI{ Box: tview.NewBox(), app: app, db: db, rootParser: rootParser, filters: make(map[string]func(string) (any, error)), compositeFilters: make(map[string]func(string) (map[string]any, error)), filterHints: make(map[string]string), loadBufferSize: cfg.LoadBufferSize, loadingIndicatorLag: cfg.LoadingIndicatorLag, } ui.ctx, ui.onStop = context.WithCancel(ctx) backgroundColor := ui.GetBackgroundColor() textColor := tview.Styles.PrimaryTextColor inverseBackgroundColor := textColor inverseTextColor := backgroundColor alertTextColor := tcell.ColorRed ui.pageStub = tview.NewBox() ui.infoBar = tview.NewTextView() ui.infoBar.SetBackgroundColor(inverseBackgroundColor) ui.infoBar.SetTextColor(inverseTextColor) ui.infoBar.SetText( fmt.Sprintf(" %s (press h for help, q to quit) ", db.Path()), ) ui.searchBar = NewInputFieldWithHistory(cfg.SearchHistorySize) ui.searchBar.SetFieldBackgroundColor(backgroundColor) ui.searchBar.SetFieldTextColor(textColor) ui.searchBar.SetLabelColor(textColor) ui.searchBar.Focus(nil) ui.searchBar.SetLabel("/") ui.searchErrorBar = tview.NewTextView() ui.searchErrorBar.SetBackgroundColor(backgroundColor) ui.searchErrorBar.SetTextColor(alertTextColor) ui.helpBar = tview.NewTextView() ui.helpBar.SetBackgroundColor(inverseBackgroundColor) ui.helpBar.SetTextColor(inverseTextColor) ui.helpBar.SetText(" Press Enter for next page or Escape to exit help ") ui.loadingBar = NewLoadingBar(ui.triggerDraw) ui.pageToMount = NewBucketsView(ui, NewFilter(nil)) return ui } func (ui *UI) checkFilterExists(typ string) bool { if _, ok := ui.filters[typ]; ok { return true } if _, ok := ui.compositeFilters[typ]; ok { return true } return false } func (ui *UI) AddFilter( typ string, parser func(string) (any, error), helpHint string, ) error { if ui.checkFilterExists(typ) { return fmt.Errorf("filter %s already exists", typ) } ui.filters[typ] = parser ui.filterHints[typ] = helpHint return nil } func (ui *UI) AddCompositeFilter( typ string, parser func(string) (map[string]any, error), helpHint string, ) error { if ui.checkFilterExists(typ) { return fmt.Errorf("filter %s already exists", typ) } ui.compositeFilters[typ] = parser ui.filterHints[typ] = helpHint return nil } func (ui *UI) stopOnError(err error) { if err != nil { ui.onStop() ui.app.QueueEvent(tcell.NewEventError(err)) } } func (ui *UI) stop() { ui.onStop() ui.app.Stop() } func (ui *UI) movePrevPage() { if len(ui.pageHistory) != 0 { ui.mountedPage.Unmount() ui.mountedPage = ui.pageHistory[len(ui.pageHistory)-1] ui.pageHistory = ui.pageHistory[:len(ui.pageHistory)-1] ui.triggerDraw() } } func (ui *UI) moveNextPage(page Primitive) { ui.pageToMount = page ui.triggerDraw() } func (ui *UI) triggerDraw() { go ui.app.QueueUpdateDraw(func() {}) } func (ui *UI) Draw(screen tcell.Screen) { if ui.isLoading.Load() { ui.draw(screen) return } ui.isLoading.Store(true) ctx, cancel := context.WithCancel(ui.ctx) ready := make(chan struct{}) go func() { ui.load(ctx) cancel() close(ready) ui.isLoading.Store(false) }() select { case <-ready: case <-time.After(ui.loadingIndicatorLag): ui.loadingBar.Start(ui.ctx) ui.cancelLoading = cancel go func() { <-ready ui.loadingBar.Stop() ui.triggerDraw() }() } ui.draw(screen) } func (ui *UI) load(ctx context.Context) { if ui.mountedPage == nil && ui.pageToMount == nil { ui.stop() return } if ui.pageToMount != nil { ui.mountAndUpdate(ctx) } else { ui.update(ctx) } } func (ui *UI) draw(screen tcell.Screen) { ui.DrawForSubclass(screen, ui) x, y, width, height := ui.GetInnerRect() var ( pageToDraw tview.Primitive barToDraw tview.Primitive ) switch { case ui.isShowingHelp: if ui.helpPage == nil { var filters []string for f := range ui.filters { filters = append(filters, f) } for f := range ui.compositeFilters { filters = append(filters, f) } ui.helpPage = NewHelpPage(filters, ui.filterHints) } pageToDraw = ui.helpPage case ui.mountedPage != nil: pageToDraw = ui.mountedPage default: pageToDraw = ui.pageStub } pageToDraw.SetRect(x, y, width, height-1) pageToDraw.Draw(screen) // Search bar uses cursor and we need to hide it when another bar is drawn. screen.HideCursor() switch { case ui.isLoading.Load(): barToDraw = ui.loadingBar case ui.isSearching: barToDraw = ui.searchBar case ui.isShowingError: barToDraw = ui.searchErrorBar case ui.isShowingHelp: barToDraw = ui.helpBar default: barToDraw = ui.infoBar } barToDraw.SetRect(x, y+height-1, width, 1) barToDraw.Draw(screen) } func (ui *UI) mountAndUpdate(ctx context.Context) { defer func() { // Operation succeeded or was canceled, either way reset page to mount. ui.pageToMount = nil }() // Mount should use app global context. //nolint:contextcheck err := ui.pageToMount.Mount(ui.ctx) if err != nil { ui.stopOnError(err) return } x, y, width, height := ui.GetInnerRect() ui.pageToMount.SetRect(x, y, width, height-1) s := loadOp(ctx, ui.pageToMount.Update) if s.err != nil { ui.pageToMount.Unmount() ui.stopOnError(s.err) return } // Update was canceled. if !s.done { ui.pageToMount.Unmount() return } if ui.mountedPage != nil { ui.pageHistory = append(ui.pageHistory, ui.mountedPage) } ui.mountedPage = ui.pageToMount } func (ui *UI) update(ctx context.Context) { x, y, width, height := ui.GetInnerRect() ui.mountedPage.SetRect(x, y, width, height-1) s := loadOp(ctx, ui.mountedPage.Update) if s.err != nil { ui.stopOnError(s.err) return } } type status struct { done bool err error } func loadOp(ctx context.Context, op func(ctx context.Context) error) status { errCh := make(chan error) go func() { errCh <- op(ctx) }() select { case <-ctx.Done(): return status{done: false, err: nil} case err := <-errCh: return status{done: true, err: err} } } func (ui *UI) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { return ui.WrapInputHandler(func(event *tcell.EventKey, _ func(tview.Primitive)) { switch { case ui.isLoading.Load(): ui.handleInputOnLoading(event) case ui.isShowingHelp: ui.handleInputOnShowingHelp(event) case ui.isShowingError: ui.handleInputOnShowingError() case ui.isSearching: ui.handleInputOnSearching(event) default: ui.handleInput(event) } }) } func (ui *UI) handleInput(event *tcell.EventKey) { m, k, r := event.Modifiers(), event.Key(), event.Rune() switch { case k == tcell.KeyEsc: ui.movePrevPage() case m == 0 && k == tcell.KeyRune && r == 'h': ui.isShowingHelp = true case m == 0 && k == tcell.KeyRune && r == '/': ui.isSearching = true case m == 0 && k == tcell.KeyRune && r == 'q': ui.stop() default: if ui.mountedPage != nil { ui.mountedPage.InputHandler()(event, func(tview.Primitive) {}) } } } func (ui *UI) handleInputOnLoading(event *tcell.EventKey) { switch k, r := event.Key(), event.Rune(); { case k == tcell.KeyEsc: ui.cancelLoading() case k == tcell.KeyRune && r == 'q': ui.stop() } } func (ui *UI) handleInputOnShowingError() { ui.isShowingError = false ui.isSearching = true } func (ui *UI) handleInputOnShowingHelp(event *tcell.EventKey) { k, r := event.Key(), event.Rune() switch { case k == tcell.KeyEsc: ui.isShowingHelp = false case k == tcell.KeyRune && r == 'q': ui.stop() default: ui.helpPage.InputHandler()(event, func(tview.Primitive) {}) } } func (ui *UI) handleInputOnSearching(event *tcell.EventKey) { m, k := event.Modifiers(), event.Key() switch { case k == tcell.KeyEnter: prompt := ui.searchBar.GetText() res, err := ui.processPrompt(prompt) if err != nil { ui.isShowingError = true ui.isSearching = false ui.searchErrorBar.SetText(err.Error() + " (press any key to continue)") return } switch ui.mountedPage.(type) { case *BucketsView: ui.moveNextPage(NewBucketsView(ui, res)) case *RecordsView: bucket := ui.mountedPage.(*RecordsView).bucket ui.moveNextPage(NewRecordsView(ui, bucket, res)) } if ui.searchBar.GetText() != "" { ui.searchBar.AddToHistory(ui.searchBar.GetText()) } ui.searchBar.SetText("") ui.isSearching = false case k == tcell.KeyEsc: ui.isSearching = false case (k == tcell.KeyBackspace2 || m&tcell.ModCtrl != 0 && k == tcell.KeyETB) && len(ui.searchBar.GetText()) == 0: ui.isSearching = false default: ui.searchBar.InputHandler()(event, func(tview.Primitive) {}) } ui.Box.MouseHandler() } func (ui *UI) WithPrompt(prompt string) error { filter, err := ui.processPrompt(prompt) if err != nil { return err } ui.pageToMount = NewBucketsView(ui, filter) if prompt != "" { ui.searchBar.AddToHistory(prompt) } return nil } func (ui *UI) processPrompt(prompt string) (filter *Filter, err error) { if prompt == "" { return NewFilter(nil), nil } filterMap := make(map[string]any) for _, filterString := range strings.Split(prompt, "+") { parts := strings.Split(filterString, ":") if len(parts) != 2 { return nil, errors.New("expected 'tag:value [+ tag:value]...'") } filterTag := strings.TrimSpace(parts[0]) filterValueString := strings.TrimSpace(parts[1]) if _, exists := filterMap[filterTag]; exists { return nil, fmt.Errorf("duplicate filter tag '%s'", filterTag) } parser, ok := ui.filters[filterTag] if ok { filterValue, err := parser(filterValueString) if err != nil { return nil, fmt.Errorf("can't parse '%s' filter value: %w", filterTag, err) } filterMap[filterTag] = filterValue continue } compositeParser, ok := ui.compositeFilters[filterTag] if ok { compositeFilterValue, err := compositeParser(filterValueString) if err != nil { return nil, fmt.Errorf( "can't parse '%s' filter value '%s': %w", filterTag, filterValueString, err, ) } for tag, value := range compositeFilterValue { if _, exists := filterMap[tag]; exists { return nil, fmt.Errorf( "found duplicate filter tag '%s' while processing composite filter with tag '%s'", tag, filterTag, ) } filterMap[tag] = value } continue } return nil, fmt.Errorf("unknown filter tag '%s'", filterTag) } return NewFilter(filterMap), nil }