github.com/Benchkram/bob@v0.0.0-20220321080157-7c8f3876e225/tui/model.go (about)

     1  package tui
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"fmt"
     7  	"github.com/Benchkram/bob/pkg/usererror"
     8  	"github.com/Benchkram/errz"
     9  	"github.com/pkg/errors"
    10  	"io"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/Benchkram/bob/pkg/boblog"
    15  	"github.com/Benchkram/bob/pkg/ctl"
    16  
    17  	"github.com/charmbracelet/bubbles/help"
    18  	"github.com/charmbracelet/bubbles/key"
    19  	"github.com/charmbracelet/bubbles/viewport"
    20  	tea "github.com/charmbracelet/bubbletea"
    21  	"github.com/charmbracelet/lipgloss"
    22  	"github.com/logrusorgru/aurora"
    23  	"github.com/xlab/treeprint"
    24  )
    25  
    26  func init() {
    27  	treeprint.EdgeTypeLink = "โ”‚"
    28  	treeprint.EdgeTypeMid = "โ”œ"
    29  	treeprint.EdgeTypeEnd = "โ””"
    30  	treeprint.IndentSize = 2
    31  }
    32  
    33  type keyMap struct {
    34  	NextTab      key.Binding
    35  	FollowOutput key.Binding
    36  	Restart      key.Binding
    37  	Quit         key.Binding
    38  	SelectScroll key.Binding
    39  	Up           key.Binding
    40  	Down         key.Binding
    41  }
    42  
    43  func (k keyMap) ShortHelp() []key.Binding {
    44  	return []key.Binding{k.Restart, k.NextTab, k.FollowOutput, k.SelectScroll, k.Quit}
    45  }
    46  
    47  func (k keyMap) FullHelp() [][]key.Binding {
    48  	return [][]key.Binding{
    49  		{k.Restart, k.NextTab, k.FollowOutput, k.SelectScroll, k.Quit},
    50  	}
    51  }
    52  
    53  var keys = keyMap{
    54  	NextTab: key.NewBinding(
    55  		key.WithKeys("tab"),
    56  		key.WithHelp("[TAB]", "next tab"),
    57  	),
    58  	FollowOutput: key.NewBinding(
    59  		key.WithKeys("esc"),
    60  		key.WithHelp("[ESC]", "follow output"),
    61  	),
    62  	Restart: key.NewBinding(
    63  		key.WithKeys("ctrl+r"),
    64  		key.WithHelp("[^R]", "restart task"),
    65  	),
    66  	Quit: key.NewBinding(
    67  		key.WithKeys("ctrl+c"),
    68  		key.WithHelp("[^C]", "quit"),
    69  	),
    70  	SelectScroll: key.NewBinding(
    71  		key.WithKeys("ctrl+s"),
    72  		key.WithHelp("[^S]", "select text"),
    73  	),
    74  	Up: key.NewBinding(
    75  		key.WithKeys("up", "pgup", "wheel up"),
    76  	),
    77  	Down: key.NewBinding(
    78  		key.WithKeys("down", "pgdown", "wheel down"),
    79  	),
    80  }
    81  
    82  type model struct {
    83  	keys          keyMap
    84  	scroll        bool
    85  	events        chan interface{}
    86  	programEvents chan interface{}
    87  	cmder         ctl.Commander
    88  	tabs          []*tab
    89  	currentTab    int
    90  	starting      bool
    91  	restarting    bool
    92  	stopping      bool
    93  	width         int
    94  	height        int
    95  	content       viewport.Model
    96  	header        viewport.Model
    97  	footer        help.Model
    98  	follow        bool
    99  	scrollOffset  int
   100  	ready         bool
   101  	error error
   102  }
   103  
   104  type tab struct {
   105  	name   string
   106  	output *LineBuffer
   107  }
   108  
   109  func newModel(cmder ctl.Commander, evts, programEvts chan interface{}, buffer *LineBuffer) *model {
   110  	tabs := []*tab{}
   111  
   112  	tabs = append(tabs, &tab{
   113  		name:   "status",
   114  		output: buffer,
   115  	})
   116  
   117  	for i, cmd := range cmder.Subcommands() {
   118  		buf, err := multiScanner(i+1, evts, cmd.Stdout(), cmd.Stderr())
   119  		if err != nil {
   120  			errz.Log(err)
   121  		}
   122  
   123  		tabs = append(tabs, &tab{
   124  			name:   cmd.Name(),
   125  			output: buf,
   126  		})
   127  	}
   128  
   129  	return &model{
   130  		cmder:         cmder,
   131  		currentTab:    0,
   132  		scroll:        true,
   133  		tabs:          tabs,
   134  		events:        evts,
   135  		programEvents: programEvts,
   136  		keys:          keys,
   137  		follow:        true,
   138  		footer: help.Model{
   139  			ShowAll:        false,
   140  			ShortSeparator: " ยท ",
   141  			FullSeparator:  "",
   142  			Ellipsis:       "...",
   143  			Styles: help.Styles{
   144  				ShortKey:  lipgloss.NewStyle().Foreground(lipgloss.Color("#bbb")),
   145  				ShortDesc: lipgloss.NewStyle().Foreground(lipgloss.Color("#999")),
   146  			},
   147  		},
   148  	}
   149  }
   150  
   151  func multiScanner(tabId int, events chan interface{}, rs ...io.Reader) (*LineBuffer, error) {
   152  	buf := NewLineBuffer(120) // use some default width
   153  
   154  	for _, r := range rs {
   155  		s := bufio.NewScanner(r)
   156  		s.Split(bufio.ScanLines)
   157  
   158  		go func() {
   159  			i := 0
   160  			for s.Scan() {
   161  				err := s.Err()
   162  				if err != nil {
   163  					return
   164  				}
   165  
   166  				_, _ = buf.Write(s.Bytes())
   167  
   168  				i++
   169  
   170  				events <- Update{tab: tabId}
   171  			}
   172  		}()
   173  	}
   174  
   175  	return buf, nil
   176  }
   177  
   178  func (m *model) Init() tea.Cmd {
   179  	return tea.Batch(
   180  		start(m),
   181  		nextEvent(m.events),
   182  		tick(),
   183  	)
   184  }
   185  
   186  func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   187  	var cmds []tea.Cmd
   188  
   189  	updateHeader := false
   190  
   191  	switch msg := msg.(type) {
   192  
   193  	case tea.KeyMsg:
   194  		//for _, r := range msg.Runes {
   195  		//	print(fmt.Sprintf("%s\n", strconv.QuoteRuneToASCII(r)))
   196  		//}
   197  
   198  		switch {
   199  		//case string(msg.Runes[0]) == "[":
   200  		//	errz.Log(fmt.Errorf("%#v", msg.Runes[0]))
   201  
   202  		case key.Matches(msg, m.keys.NextTab):
   203  			m.currentTab = (m.currentTab + 1) % len(m.tabs)
   204  
   205  			m.setOffset(m.tabs[m.currentTab].output.Len())
   206  			m.updateContent()
   207  			updateHeader = true
   208  
   209  		case key.Matches(msg, m.keys.FollowOutput):
   210  			m.follow = true
   211  			m.setOffset(m.tabs[m.currentTab].output.Len())
   212  			m.updateContent()
   213  
   214  		case key.Matches(msg, m.keys.Restart):
   215  			status := fmt.Sprintf("\n%-*s\n", 10, "restarting")
   216  			status = aurora.Colorize(status, aurora.CyanFg|aurora.BoldFm).String()
   217  
   218  			for i, t := range m.tabs {
   219  				_, err := t.output.Write([]byte(status))
   220  				errz.Log(err)
   221  
   222  				m.events <- Update{tab: i}
   223  			}
   224  
   225  			m.follow = true
   226  			m.setOffset(m.tabs[m.currentTab].output.Len())
   227  			m.updateContent()
   228  			updateHeader = true
   229  
   230  			cmds = append(cmds, restart(m))
   231  
   232  		case key.Matches(msg, m.keys.Quit):
   233  			status := fmt.Sprintf("\n%-*s\n", 10, "stopping")
   234  			status = aurora.Colorize(status, aurora.RedFg|aurora.BoldFm).String()
   235  
   236  			for i, t := range m.tabs {
   237  				_, err := t.output.Write([]byte(status))
   238  				errz.Log(err)
   239  
   240  				m.events <- Update{tab: i}
   241  			}
   242  
   243  			m.follow = true
   244  			m.setOffset(m.tabs[m.currentTab].output.Len())
   245  			m.updateContent()
   246  			updateHeader = true
   247  
   248  			cmds = append(cmds, stop(m))
   249  
   250  		case key.Matches(msg, m.keys.SelectScroll):
   251  			scroll := !m.scroll
   252  			if scroll {
   253  				m.programEvents <- EnableScroll{}
   254  				m.keys.SelectScroll.SetHelp("[^S]", "select text")
   255  			} else {
   256  				m.programEvents <- DisableScroll{}
   257  				m.keys.SelectScroll.SetHelp("[^S]", "scroll text")
   258  			}
   259  
   260  			m.scroll = scroll
   261  
   262  		case key.Matches(msg, m.keys.Up):
   263  			m.follow = false
   264  			m.updateOffset(-1)
   265  			m.updateContent()
   266  
   267  		case key.Matches(msg, m.keys.Down):
   268  			m.updateOffset(1)
   269  			m.updateContent()
   270  		}
   271  
   272  	case tea.MouseMsg:
   273  		switch {
   274  		case msg.Type == tea.MouseWheelUp:
   275  			m.follow = false
   276  			m.updateOffset(-1)
   277  			m.updateContent()
   278  
   279  		case msg.Type == tea.MouseWheelDown:
   280  			m.updateOffset(1)
   281  			m.updateContent()
   282  		}
   283  
   284  	case time.Time:
   285  		cmds = append(cmds, tick())
   286  
   287  	case tea.WindowSizeMsg:
   288  		if !m.ready {
   289  			// initialize viewports
   290  			m.header.SetContent("\n")
   291  			m.updateOffset(0)
   292  			if m.follow {
   293  				m.updateOffset(m.tabs[m.currentTab].output.Len())
   294  			}
   295  			m.updateContent()
   296  			updateHeader = true
   297  			m.ready = true
   298  		}
   299  
   300  		m.width = msg.Width
   301  		m.height = msg.Height
   302  
   303  		m.header.Width = m.width
   304  		m.header.Height = 2
   305  
   306  		m.content.Width = m.width
   307  		m.content.Height = m.height - 4
   308  
   309  		m.footer.Width = m.width
   310  
   311  		for _, t := range m.tabs {
   312  			// update all lines in the buffers so that soft wrapping works nicely
   313  			t.output.SetWidth(m.width)
   314  		}
   315  
   316  		if m.follow {
   317  			m.updateOffset(m.tabs[m.currentTab].output.Len())
   318  		}
   319  		// re-render content after resize
   320  		m.updateContent()
   321  
   322  	case Quit:
   323  		cmds = append(cmds, tea.Quit)
   324  
   325  	case Started:
   326  		m.starting = false
   327  		updateHeader = true
   328  
   329  	case Restarted:
   330  		m.restarting = false
   331  		updateHeader = true
   332  
   333  	case Update:
   334  		// ignore updates for tabs that are not currently in view
   335  		if msg.tab == m.currentTab {
   336  			// scroll to end for the current tab if following output
   337  			if m.follow {
   338  				m.updateOffset(m.tabs[m.currentTab].output.Len())
   339  			}
   340  
   341  			// always re-render content if a new message is received
   342  			m.updateContent()
   343  		}
   344  
   345  		// listen for the next update event
   346  		cmds = append(cmds, nextEvent(m.events))
   347  	}
   348  
   349  	// only re-render the header if necessary
   350  	if updateHeader {
   351  		// calculate header status
   352  		var status string
   353  		if m.starting {
   354  			status = fmt.Sprintf("%-*s", 10, "starting")
   355  			status = aurora.Colorize(status, aurora.BlueFg|aurora.BoldFm).String()
   356  		} else if m.restarting {
   357  			status = fmt.Sprintf("%-*s", 10, "restarting")
   358  			status = aurora.Colorize(status, aurora.CyanFg|aurora.BoldFm).String()
   359  		} else if m.stopping {
   360  			status = fmt.Sprintf("%-*s", 10, "stopping")
   361  			status = aurora.Colorize(status, aurora.RedFg|aurora.BoldFm).String()
   362  		} else {
   363  			status = fmt.Sprintf("%-*s", 10, "running")
   364  			status = aurora.Colorize(status, aurora.GreenFg|aurora.BoldFm).String()
   365  		}
   366  
   367  		// create tabs
   368  		tabs := make([]string, len(m.tabs))
   369  		for i, tab := range m.tabs {
   370  			var name string
   371  			if i == m.currentTab {
   372  				name = aurora.Colorize(tab.name, aurora.BoldFm).String()
   373  			} else {
   374  				name = aurora.Colorize(tab.name, aurora.WhiteFg).String()
   375  			}
   376  
   377  			tabs[i] = fmt.Sprintf("[%s]", name)
   378  		}
   379  
   380  		tabsView := strings.Join(tabs, " ")
   381  
   382  		m.header.SetContent(fmt.Sprintf("%s  %s", status, tabsView))
   383  		m.header, _ = m.header.Update(msg)
   384  	}
   385  
   386  	var updateCmd tea.Cmd
   387  	m.content, updateCmd = m.content.Update(msg)
   388  	cmds = append(cmds, updateCmd)
   389  
   390  	//if m.error != nil {
   391  	//	//cmds = append(cmds, stop(m))
   392  	//}
   393  
   394  	return m, tea.Batch(cmds...)
   395  }
   396  
   397  func (m *model) updateContent() {
   398  	buf := m.tabs[m.currentTab].output
   399  	viewportHeight := m.height - 4
   400  	bufLen := buf.Len()
   401  
   402  	offset := m.scrollOffset
   403  
   404  	maxOffset := bufLen
   405  
   406  	from := min(offset, maxOffset)
   407  	to := max(offset+viewportHeight, 0)
   408  
   409  	lines := buf.Lines(from, to)
   410  
   411  	m.content.SetContent(strings.Join(lines, "\n"))
   412  }
   413  
   414  func max(a, b int) int {
   415  	if a > b {
   416  		return a
   417  	}
   418  
   419  	return b
   420  }
   421  
   422  func min(a, b int) int {
   423  	if a < b {
   424  		return a
   425  	}
   426  
   427  	return b
   428  }
   429  
   430  func (m *model) View() string {
   431  	var view strings.Builder
   432  
   433  	view.WriteString(m.header.View())
   434  	view.WriteString("\n")
   435  	view.WriteString(m.content.View())
   436  	view.WriteString("\n\n")
   437  	view.WriteString(m.footer.View(m.keys))
   438  
   439  	return view.String()
   440  }
   441  
   442  func (m *model) updateOffset(delta int) {
   443  	m.setOffset(m.scrollOffset + delta*3)
   444  }
   445  
   446  func (m *model) setOffset(offset int) {
   447  	viewportHeight := m.height - 4
   448  	buf := m.tabs[m.currentTab].output
   449  	bufLen := buf.Len()
   450  
   451  	maxOffset := bufLen
   452  
   453  	if maxOffset > viewportHeight {
   454  		maxOffset -= viewportHeight
   455  
   456  		if offset == bufLen-viewportHeight {
   457  			m.follow = true
   458  		}
   459  
   460  	} else {
   461  		maxOffset = 0 // do not allow scrolling if there is nothing else to see
   462  		m.follow = true
   463  	}
   464  
   465  	offset = max(min(offset, maxOffset), 0)
   466  
   467  	m.scrollOffset = offset
   468  }
   469  
   470  func tick() tea.Cmd {
   471  	return tea.Tick(
   472  		1000*time.Millisecond, func(t time.Time) tea.Msg {
   473  			return t
   474  		},
   475  	)
   476  }
   477  
   478  func start(m *model) tea.Cmd {
   479  	m.starting = true
   480  
   481  	return func() tea.Msg {
   482  		err := m.cmder.Start()
   483  		if errors.As(err, &usererror.Err) {
   484  			boblog.Log.UserError(err)
   485  		} else if err != nil && err != context.Canceled {
   486  			boblog.Log.Error(err, "Error during commander execution")
   487  		}
   488  
   489  		m.error = err
   490  
   491  		return Started{}
   492  	}
   493  }
   494  
   495  func restart(m *model) tea.Cmd {
   496  	m.restarting = true
   497  
   498  	return func() tea.Msg {
   499  		err := m.cmder.Restart()
   500  		errz.Log(err)
   501  
   502  		return Restarted{}
   503  	}
   504  }
   505  
   506  func stop(m *model) tea.Cmd {
   507  	m.stopping = true
   508  	m.programEvents <- DisableScroll{}
   509  
   510  	return func() tea.Msg {
   511  		err := m.cmder.Stop()
   512  		errz.Log(err)
   513  
   514  		return Quit{}
   515  	}
   516  }
   517  
   518  func nextEvent(evts chan interface{}) tea.Cmd {
   519  	return func() tea.Msg {
   520  		return <-evts
   521  	}
   522  }