github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/hud/renderer.go (about) 1 package hud 2 3 import ( 4 "fmt" 5 "os" 6 "sync" 7 "time" 8 9 "github.com/gdamore/tcell" 10 11 "github.com/tilt-dev/tilt/internal/hud/view" 12 "github.com/tilt-dev/tilt/internal/rty" 13 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 14 ) 15 16 const defaultLogPaneHeight = 8 17 18 type Renderer struct { 19 rty rty.RTY 20 screen tcell.Screen 21 mu *sync.RWMutex 22 clock func() time.Time 23 } 24 25 func NewRenderer(clock func() time.Time) *Renderer { 26 return &Renderer{ 27 mu: new(sync.RWMutex), 28 clock: clock, 29 } 30 } 31 32 func (r *Renderer) Render(v view.View, vs view.ViewState) { 33 r.mu.RLock() 34 defer r.mu.RUnlock() 35 rty := r.rty 36 if rty != nil { 37 layout := r.layout(v, vs) 38 rty.Render(layout) 39 } 40 } 41 42 var cText = tcell.Color232 43 var cLightText = tcell.Color243 44 var cGood = tcell.ColorGreen 45 var cBad = tcell.ColorRed 46 var cPending = tcell.Color243 47 48 func (r *Renderer) layout(v view.View, vs view.ViewState) rty.Component { 49 l := rty.NewFlexLayout(rty.DirVert) 50 if vs.ShowNarration { 51 l.Add(renderNarration(vs.NarrationMessage)) 52 l.Add(rty.NewLine()) 53 } 54 55 l.Add(r.renderResourceHeader(v)) 56 l.Add(r.renderResources(v, vs)) 57 l.Add(r.renderLogPane(v, vs)) 58 l.Add(r.renderFooter(v, keyLegend(v, vs))) 59 60 var ret rty.Component = l 61 62 ret = r.maybeAddFullScreenLog(v, vs, ret) 63 64 ret = r.maybeAddAlertModal(v, vs, ret) 65 66 return ret 67 } 68 69 func (r *Renderer) maybeAddFullScreenLog(v view.View, vs view.ViewState, layout rty.Component) rty.Component { 70 if vs.TiltLogState == view.TiltLogFullScreen { 71 tabView := NewTabView(v, vs) 72 73 l := rty.NewConcatLayout(rty.DirVert) 74 sl := rty.NewTextScrollLayout("log") 75 l.Add(tabView.buildTabs(true)) 76 sl.Add(rty.TextString(tabView.log())) 77 l.AddDynamic(sl) 78 l.Add(r.renderFooter(v, keyLegend(v, vs))) 79 80 layout = rty.NewModalLayout(layout, l, 1, true) 81 } 82 return layout 83 } 84 85 func (r *Renderer) maybeAddAlertModal(v view.View, vs view.ViewState, layout rty.Component) rty.Component { 86 alertMsg := "" 87 if v.FatalError != nil { 88 alertMsg = fmt.Sprintf("Tilt has encountered a fatal error: %s\nOnce you fix this issue you'll need to restart Tilt. In the meantime feel free to browse through the UI.", v.FatalError.Error()) 89 } else if vs.AlertMessage != "" { 90 alertMsg = vs.AlertMessage 91 } 92 93 if alertMsg != "" { 94 l := rty.NewLines() 95 l.Add(rty.TextString("")) 96 97 msg := " " + alertMsg + " " 98 l.Add(rty.Fg(rty.TextString(msg), tcell.ColorDefault)) 99 l.Add(rty.TextString("")) 100 101 w := rty.NewWindow(l) 102 w.SetTitle("! Alert !") 103 layout = r.renderModal(rty.Fg(w, tcell.ColorRed), layout, false) 104 } 105 return layout 106 } 107 108 func (r *Renderer) renderLogPane(v view.View, vs view.ViewState) rty.Component { 109 tabView := NewTabView(v, vs) 110 var height int 111 switch vs.TiltLogState { 112 case view.TiltLogShort: 113 height = defaultLogPaneHeight 114 case view.TiltLogHalfScreen: 115 height = rty.GROW 116 case view.TiltLogFullScreen: 117 height = 1 118 // FullScreen is handled elsewhere, since it's no longer a pane 119 // but we have to set height to something non-0 or rty will blow up 120 } 121 return rty.NewFixedSize(tabView.Build(), rty.GROW, height) 122 } 123 124 func renderPaneHeader(isMax bool) rty.Component { 125 var verb string 126 if isMax { 127 verb = "contract" 128 } else { 129 verb = "expand" 130 } 131 s := fmt.Sprintf("X: %s", verb) 132 l := rty.NewLine() 133 l.Add(rty.NewFillerString(' ')) 134 l.Add(rty.TextString(fmt.Sprintf(" %s ", s))) 135 l.Add(rty.TextString("| ")) 136 l.Add(rty.TextString("t: trigger update")) 137 return l 138 } 139 140 func (r *Renderer) renderStatusMessage(v view.View) rty.Component { 141 errorCount := 0 142 for _, res := range v.Resources { 143 if isInError(res) { 144 errorCount++ 145 } 146 } 147 148 sb := rty.NewStringBuilder() 149 if errorCount == 0 && v.TiltfileErrorMessage() == "" { 150 sb.Fg(cGood).Text("✓").Fg(cText).Text(" OK") 151 } else { 152 var errorCountMessage string 153 s := "error" 154 if errorCount > 1 { 155 s = "errors" 156 } 157 158 if errorCount > 0 { 159 errorCountMessage = fmt.Sprintf(" %d %s", errorCount, s) 160 } 161 162 sb.Fg(cBad).Text(xMark()). 163 Fg(cText).Textf("%s", errorCountMessage) 164 } 165 return sb.Build() 166 } 167 168 func (r *Renderer) renderStatusBar(v view.View) rty.Component { 169 l := rty.NewConcatLayout(rty.DirHor) 170 l.Add(rty.TextString(" ")) 171 l.Add(r.renderStatusMessage(v)) 172 l.Add(rty.TextString(" ")) 173 l.AddDynamic(rty.NewFillerString(' ')) 174 175 msg := " To explore, open web view (enter) • terminal is limited " 176 l.Add(rty.ColoredString(msg, cText)) 177 return rty.Bg(rty.OneLine(l), tcell.ColorWhiteSmoke) 178 } 179 180 func (r *Renderer) renderFooter(v view.View, keys string) rty.Component { 181 footer := rty.NewConcatLayout(rty.DirVert) 182 footer.Add(r.renderStatusBar(v)) 183 l := rty.NewConcatLayout(rty.DirHor) 184 sbRight := rty.NewStringBuilder() 185 sbRight.Text(keys) 186 l.AddDynamic(rty.NewFillerString(' ')) 187 l.Add(sbRight.Build()) 188 footer.Add(l) 189 return rty.NewFixedSize(footer, rty.GROW, 2) 190 } 191 192 func keyLegend(v view.View, vs view.ViewState) string { 193 defaultKeys := "Browse (↓ ↑), Expand (→) ┊ (enter) log ┊ (ctrl-C) quit " 194 if vs.AlertMessage != "" { 195 return "Tilt (l)og ┊ (esc) close alert " 196 } 197 return defaultKeys 198 } 199 200 func isInError(res view.Resource) bool { 201 return combinedStatus(res).color == cBad 202 } 203 204 func isCrashing(res view.Resource) bool { 205 return (res.IsK8s() && res.K8sInfo().PodRestarts > 0) || 206 res.IsDC() && res.DockerComposeTarget().RuntimeStatus() == v1alpha1.RuntimeStatusError 207 } 208 209 func (r *Renderer) renderModal(fg rty.Component, bg rty.Component, fixed bool) rty.Component { 210 return rty.NewModalLayout(bg, fg, .9, fixed) 211 } 212 213 func renderNarration(msg string) rty.Component { 214 lines := rty.NewLines() 215 l := rty.NewLine() 216 l.Add(rty.TextString(msg)) 217 lines.Add(rty.NewLine()) 218 lines.Add(l) 219 lines.Add(rty.NewLine()) 220 221 box := rty.Fg(rty.Bg(lines, tcell.ColorLightGrey), cText) 222 return rty.NewFixedSize(box, rty.GROW, 3) 223 } 224 225 func (r *Renderer) renderResourceHeader(v view.View) rty.Component { 226 l := rty.NewConcatLayout(rty.DirHor) 227 l.Add(rty.ColoredString(" RESOURCE NAME ", cLightText)) 228 l.AddDynamic(rty.NewFillerString(' ')) 229 230 k8sCell := rty.ColoredString(" CONTAINER", cLightText) 231 l.Add(k8sCell) 232 l.Add(middotText()) 233 234 buildCell := rty.NewMinLengthLayout(BuildDurCellMinWidth+BuildStatusCellMinWidth, rty.DirHor). 235 SetAlign(rty.AlignEnd). 236 Add(rty.ColoredString("UPDATE STATUS ", cLightText)) 237 l.Add(buildCell) 238 l.Add(middotText()) 239 deployCell := rty.NewMinLengthLayout(DeployCellMinWidth+1, rty.DirHor). 240 SetAlign(rty.AlignEnd). 241 Add(rty.ColoredString("AS OF ", cLightText)) 242 l.Add(deployCell) 243 return rty.OneLine(l) 244 } 245 246 func (r *Renderer) renderResources(v view.View, vs view.ViewState) rty.Component { 247 rs := v.Resources 248 249 cl := rty.NewConcatLayout(rty.DirVert) 250 251 childNames := make([]string, len(rs)) 252 for i, r := range rs { 253 childNames[i] = r.Name.String() 254 } 255 // the items added to `l` below must be kept in sync with `childNames` above 256 l, selectedResource := r.rty.RegisterElementScroll(resourcesScollerName, childNames) 257 258 if len(rs) > 0 { 259 for i, res := range rs { 260 resView := NewResourceView(v.LogReader, res, vs.Resources[i], res.TriggerMode, selectedResource == res.Name.String(), r.clock) 261 l.Add(resView.Build()) 262 } 263 } 264 265 cl.Add(l) 266 return cl 267 } 268 269 func (r *Renderer) SetUp() (chan tcell.Event, error) { 270 r.mu.Lock() 271 defer r.mu.Unlock() 272 273 screen, err := tcell.NewScreen() 274 if err != nil { 275 if err == tcell.ErrTermNotFound { 276 // The statically-compiled tcell only supports the most common TERM configs. 277 // The dynamically-compiled tcell supports more, but has distribution problems. 278 // See: https://github.com/gdamore/tcell/issues/252 279 term := os.Getenv("TERM") 280 return nil, fmt.Errorf("Tilt does not support TERM=%q. "+ 281 "This is not a common Terminal config. "+ 282 "If you expect that you're using a common terminal, "+ 283 "you might have misconfigured $TERM in your .profile.", term) 284 } 285 return nil, err 286 } 287 if err = screen.Init(); err != nil { 288 return nil, err 289 } 290 screenEvents := make(chan tcell.Event) 291 go func() { 292 for { 293 screenEvents <- screen.PollEvent() 294 } 295 }() 296 297 r.rty = rty.NewRTY(screen, rty.SkipErrorHandler{}) 298 299 r.screen = screen 300 301 return screenEvents, nil 302 } 303 304 func (r *Renderer) RTY() rty.RTY { 305 r.mu.RLock() 306 defer r.mu.RUnlock() 307 308 return r.rty 309 } 310 311 func (r *Renderer) Reset() { 312 r.mu.Lock() 313 defer r.mu.Unlock() 314 315 if r.screen != nil { 316 r.screen.Fini() 317 } 318 319 r.screen = nil 320 }