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  }