github.com/kastenhq/syft@v0.0.0-20230821225854-0710af25cdbe/cmd/syft/internal/ui/ui.go (about) 1 package ui 2 3 import ( 4 "os" 5 "sync" 6 7 tea "github.com/charmbracelet/bubbletea" 8 "github.com/wagoodman/go-partybus" 9 10 "github.com/anchore/bubbly/bubbles/frame" 11 "github.com/anchore/clio" 12 "github.com/anchore/go-logger" 13 handler "github.com/kastenhq/syft/cmd/syft/cli/ui" 14 "github.com/kastenhq/syft/internal/bus" 15 "github.com/kastenhq/syft/internal/log" 16 "github.com/kastenhq/syft/syft/event" 17 ) 18 19 var _ interface { 20 tea.Model 21 partybus.Responder 22 clio.UI 23 } = (*UI)(nil) 24 25 type UI struct { 26 program *tea.Program 27 running *sync.WaitGroup 28 quiet bool 29 subscription partybus.Unsubscribable 30 finalizeEvents []partybus.Event 31 32 handler *handler.Handler 33 frame tea.Model 34 } 35 36 func New(h *handler.Handler, _, quiet bool) *UI { 37 return &UI{ 38 handler: h, 39 frame: frame.New(), 40 running: &sync.WaitGroup{}, 41 quiet: quiet, 42 } 43 } 44 45 func (m *UI) Setup(subscription partybus.Unsubscribable) error { 46 // we still want to collect log messages, however, we also the logger shouldn't write to the screen directly 47 if logWrapper, ok := log.Get().(logger.Controller); ok { 48 logWrapper.SetOutput(m.frame.(*frame.Frame).Footer()) 49 } 50 51 m.subscription = subscription 52 m.program = tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithInput(os.Stdin)) 53 m.running.Add(1) 54 55 go func() { 56 defer m.running.Done() 57 if _, err := m.program.Run(); err != nil { 58 log.Errorf("unable to start UI: %+v", err) 59 m.exit() 60 } 61 }() 62 63 return nil 64 } 65 66 func (m *UI) exit() { 67 // stop the event loop 68 bus.Exit() 69 } 70 71 func (m *UI) Handle(e partybus.Event) error { 72 if m.program != nil { 73 m.program.Send(e) 74 if e.Type == event.CLIExit { 75 return m.subscription.Unsubscribe() 76 } 77 } 78 return nil 79 } 80 81 func (m *UI) Teardown(force bool) error { 82 if !force { 83 m.handler.Running.Wait() 84 m.program.Quit() 85 // typically in all cases we would want to wait for the UI to finish. However there are still error cases 86 // that are not accounted for, resulting in hangs. For now, we'll just wait for the UI to finish in the 87 // happy path only. There will always be an indication of the problem to the user via reporting the error 88 // string from the worker (outside of the UI after teardown). 89 m.running.Wait() 90 } else { 91 m.program.Kill() 92 } 93 94 // TODO: allow for writing out the full log output to the screen (only a partial log is shown currently) 95 // this needs coordination to know what the last frame event is to change the state accordingly (which isn't possible now) 96 97 return newPostUIEventWriter(os.Stdout, os.Stderr).write(m.quiet, m.finalizeEvents...) 98 } 99 100 // bubbletea.Model functions 101 102 func (m UI) Init() tea.Cmd { 103 return m.frame.Init() 104 } 105 106 func (m UI) RespondsTo() []partybus.EventType { 107 return append([]partybus.EventType{ 108 event.CLIReport, 109 event.CLINotification, 110 event.CLIExit, 111 event.CLIAppUpdateAvailable, 112 }, m.handler.RespondsTo()...) 113 } 114 115 func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 116 // note: we need a pointer receiver such that the same instance of UI used in Teardown is referenced (to keep finalize events) 117 118 var cmds []tea.Cmd 119 120 // allow for non-partybus UI updates (such as window size events). Note: these must not affect existing models, 121 // that is the responsibility of the frame object on this UI object. The handler is a factory of models 122 // which the frame is responsible for the lifecycle of. This update allows for injecting the initial state 123 // of the world when creating those models. 124 m.handler.OnMessage(msg) 125 126 switch msg := msg.(type) { 127 case tea.KeyMsg: 128 switch msg.String() { 129 case "esc", "ctrl+c": 130 m.exit() 131 return m, tea.Quit 132 } 133 134 case partybus.Event: 135 log.WithFields("component", "ui").Tracef("event: %q", msg.Type) 136 137 switch msg.Type { 138 case event.CLIReport, event.CLINotification, event.CLIExit, event.CLIAppUpdateAvailable: 139 // keep these for when the UI is terminated to show to the screen (or perform other events) 140 m.finalizeEvents = append(m.finalizeEvents, msg) 141 142 // why not return tea.Quit here for exit events? because there may be UI components that still need the update-render loop. 143 // for this reason we'll let the syft event loop call Teardown() which will explicitly wait for these components 144 return m, nil 145 } 146 147 for _, newModel := range m.handler.Handle(msg) { 148 if newModel == nil { 149 continue 150 } 151 cmds = append(cmds, newModel.Init()) 152 m.frame.(*frame.Frame).AppendModel(newModel) 153 } 154 // intentionally fallthrough to update the frame model 155 } 156 157 frameModel, cmd := m.frame.Update(msg) 158 m.frame = frameModel 159 cmds = append(cmds, cmd) 160 161 return m, tea.Batch(cmds...) 162 } 163 164 func (m UI) View() string { 165 return m.frame.View() 166 }