src.elv.sh@v0.21.0-dev.0.20240515223629-06979efb9a2a/pkg/cli/tk/listbox.go (about)

     1  package tk
     2  
     3  import (
     4  	"strings"
     5  	"sync"
     6  
     7  	"src.elv.sh/pkg/cli/term"
     8  	"src.elv.sh/pkg/ui"
     9  )
    10  
    11  // ListBox is a list for displaying and selecting from a list of items.
    12  type ListBox interface {
    13  	Widget
    14  	// CopyState returns a copy of the state.
    15  	CopyState() ListBoxState
    16  	// Reset resets the state of the widget with the given items and index of
    17  	// the selected item. It triggers the OnSelect callback if the index is
    18  	// valid.
    19  	Reset(it Items, selected int)
    20  	// Select changes the selection by calling f with the current state, and
    21  	// using the return value as the new selection index. It triggers the
    22  	// OnSelect callback if the selected index has changed and is valid.
    23  	Select(f func(ListBoxState) int)
    24  	// Accept accepts the currently selected item.
    25  	Accept()
    26  }
    27  
    28  // ListBoxSpec specifies the configuration and initial state for ListBox.
    29  type ListBoxSpec struct {
    30  	// Key bindings.
    31  	Bindings Bindings
    32  	// A placeholder to show when there are no items.
    33  	Placeholder ui.Text
    34  	// A function to call when the selected item has changed.
    35  	OnSelect func(it Items, i int)
    36  	// A function called on the accept event.
    37  	OnAccept func(it Items, i int)
    38  	// Whether the listbox should be rendered in a horizontal layout. Note that
    39  	// in the horizontal layout, items must have only one line.
    40  	Horizontal bool
    41  	// The minimal amount of space to reserve for left and right sides of each
    42  	// entry.
    43  	Padding int
    44  	// If true, the left padding of each item will be styled the same as the
    45  	// first segment of the item, and the right spacing and padding will be
    46  	// styled the same as the last segment of the item.
    47  	ExtendStyle bool
    48  
    49  	// State. When used in [NewListBox], this field specifies the initial state.
    50  	State ListBoxState
    51  }
    52  
    53  type listBox struct {
    54  	// Mutex for synchronizing access to the state.
    55  	StateMutex sync.RWMutex
    56  	// Configuration and state.
    57  	ListBoxSpec
    58  }
    59  
    60  // NewListBox creates a new ListBox from the given spec.
    61  func NewListBox(spec ListBoxSpec) ListBox {
    62  	if spec.Bindings == nil {
    63  		spec.Bindings = DummyBindings{}
    64  	}
    65  	if spec.OnAccept == nil {
    66  		spec.OnAccept = func(Items, int) {}
    67  	}
    68  	if spec.OnSelect == nil {
    69  		spec.OnSelect = func(Items, int) {}
    70  	} else {
    71  		s := spec.State
    72  		if s.Items != nil && 0 <= s.Selected && s.Selected < s.Items.Len() {
    73  			spec.OnSelect(s.Items, s.Selected)
    74  		}
    75  	}
    76  	return &listBox{ListBoxSpec: spec}
    77  }
    78  
    79  var stylingForSelected = ui.Inverse
    80  
    81  func (w *listBox) Render(width, height int) *term.Buffer {
    82  	if w.Horizontal {
    83  		return w.renderHorizontal(width, height)
    84  	}
    85  	return w.renderVertical(width, height)
    86  }
    87  
    88  func (w *listBox) MaxHeight(width, height int) int {
    89  	s := w.CopyState()
    90  	if s.Items == nil || s.Items.Len() == 0 {
    91  		return 0
    92  	}
    93  	if w.Horizontal {
    94  		_, h, scrollbar := getHorizontalWindow(s, w.Padding, width, height)
    95  		if scrollbar {
    96  			return h + 1
    97  		}
    98  		return h
    99  	}
   100  	h := 0
   101  	for i := 0; i < s.Items.Len(); i++ {
   102  		h += s.Items.Show(i).CountLines()
   103  		if h >= height {
   104  			return height
   105  		}
   106  	}
   107  	return h
   108  }
   109  
   110  const listBoxColGap = 2
   111  
   112  func (w *listBox) renderHorizontal(width, height int) *term.Buffer {
   113  	var state ListBoxState
   114  	var colHeight int
   115  	w.mutate(func(s *ListBoxState) {
   116  		if s.Items == nil || s.Items.Len() == 0 {
   117  			s.First = 0
   118  		} else {
   119  			s.First, s.ContentHeight, _ = getHorizontalWindow(*s, w.Padding, width, height)
   120  			colHeight = s.ContentHeight
   121  		}
   122  		state = *s
   123  	})
   124  
   125  	if state.Items == nil || state.Items.Len() == 0 {
   126  		return Label{Content: w.Placeholder}.Render(width, height)
   127  	}
   128  
   129  	items, selected, first := state.Items, state.Selected, state.First
   130  	n := items.Len()
   131  
   132  	buf := term.NewBuffer(0)
   133  	remainedWidth := width
   134  	hasCropped := false
   135  	last := first
   136  	for i := first; i < n; i += colHeight {
   137  		selectedRow := -1
   138  		// Render the column starting from i.
   139  		col := make([]ui.Text, 0, colHeight)
   140  		for j := i; j < i+colHeight && j < n; j++ {
   141  			last = j
   142  			item := items.Show(j)
   143  			if j == selected {
   144  				selectedRow = j - i
   145  			}
   146  			col = append(col, item)
   147  		}
   148  
   149  		colWidth := maxWidth(items, w.Padding, i, i+colHeight)
   150  		if colWidth > remainedWidth {
   151  			colWidth = remainedWidth
   152  			hasCropped = true
   153  		}
   154  
   155  		colBuf := croppedLines{
   156  			lines: col, padding: w.Padding,
   157  			selectFrom: selectedRow, selectTo: selectedRow + 1,
   158  			extendStyle: w.ExtendStyle}.Render(colWidth, colHeight)
   159  		buf.ExtendRight(colBuf)
   160  
   161  		remainedWidth -= colWidth
   162  		if remainedWidth <= listBoxColGap {
   163  			break
   164  		}
   165  		remainedWidth -= listBoxColGap
   166  		buf.Width += listBoxColGap
   167  	}
   168  	// We may not have used all the width required; force buffer width.
   169  	buf.Width = width
   170  	if colHeight < height && (first != 0 || last != n-1 || hasCropped) {
   171  		scrollbar := HScrollbar{Total: n, Low: first, High: last + 1}
   172  		buf.Extend(scrollbar.Render(width, 1), false)
   173  	}
   174  	return buf
   175  }
   176  
   177  func (w *listBox) renderVertical(width, height int) *term.Buffer {
   178  	var state ListBoxState
   179  	var firstCrop int
   180  	w.mutate(func(s *ListBoxState) {
   181  		if s.Items == nil || s.Items.Len() == 0 {
   182  			s.First = 0
   183  		} else {
   184  			s.First, firstCrop = getVerticalWindow(*s, height)
   185  		}
   186  		s.ContentHeight = height
   187  		state = *s
   188  	})
   189  
   190  	if state.Items == nil || state.Items.Len() == 0 {
   191  		return Label{Content: w.Placeholder}.Render(width, height)
   192  	}
   193  
   194  	items, selected, first := state.Items, state.Selected, state.First
   195  	n := items.Len()
   196  	allLines := []ui.Text{}
   197  	hasCropped := firstCrop > 0
   198  
   199  	var i, selectFrom, selectTo int
   200  	for i = first; i < n && len(allLines) < height; i++ {
   201  		item := items.Show(i)
   202  		lines := item.SplitByRune('\n')
   203  		if i == first {
   204  			lines = lines[firstCrop:]
   205  		}
   206  		if i == selected {
   207  			selectFrom, selectTo = len(allLines), len(allLines)+len(lines)
   208  		}
   209  		// TODO: Optionally, add underlines to the last line as a visual
   210  		// separator between adjacent entries.
   211  
   212  		if len(allLines)+len(lines) > height {
   213  			lines = lines[:len(allLines)+len(lines)-height]
   214  			hasCropped = true
   215  		}
   216  		allLines = append(allLines, lines...)
   217  	}
   218  
   219  	var rd Renderer = croppedLines{
   220  		lines: allLines, padding: w.Padding,
   221  		selectFrom: selectFrom, selectTo: selectTo, extendStyle: w.ExtendStyle}
   222  	if first > 0 || i < n || hasCropped {
   223  		rd = VScrollbarContainer{
   224  			Content:   rd,
   225  			Scrollbar: VScrollbar{Total: n, Low: first, High: i},
   226  		}
   227  	}
   228  	return rd.Render(width, height)
   229  }
   230  
   231  type croppedLines struct {
   232  	lines       []ui.Text
   233  	padding     int
   234  	selectFrom  int
   235  	selectTo    int
   236  	extendStyle bool
   237  }
   238  
   239  func (c croppedLines) Render(width, height int) *term.Buffer {
   240  	bb := term.NewBufferBuilder(width)
   241  	leftSpacing := ui.T(strings.Repeat(" ", c.padding))
   242  	rightSpacing := ui.T(strings.Repeat(" ", width-c.padding))
   243  	for i, line := range c.lines {
   244  		if i > 0 {
   245  			bb.Newline()
   246  		}
   247  
   248  		selected := c.selectFrom <= i && i < c.selectTo
   249  		extendStyle := c.extendStyle && len(line) > 0
   250  
   251  		left := leftSpacing.Clone()
   252  		if extendStyle && len(left) > 0 {
   253  			left[0].Style = line[0].Style
   254  		}
   255  		acc := ui.Concat(left, line.TrimWcwidth(width-2*c.padding))
   256  		if extendStyle || selected {
   257  			right := rightSpacing.Clone()
   258  			if extendStyle {
   259  				right[0].Style = line[len(line)-1].Style
   260  			}
   261  			acc = ui.Concat(acc, right).TrimWcwidth(width)
   262  		}
   263  		if selected {
   264  			acc = ui.StyleText(acc, stylingForSelected)
   265  		}
   266  
   267  		bb.WriteStyled(acc)
   268  	}
   269  	return bb.Buffer()
   270  }
   271  
   272  func (w *listBox) Handle(event term.Event) bool {
   273  	if w.Bindings.Handle(w, event) {
   274  		return true
   275  	}
   276  
   277  	switch event {
   278  	case term.K(ui.Up):
   279  		w.Select(Prev)
   280  		return true
   281  	case term.K(ui.Down):
   282  		w.Select(Next)
   283  		return true
   284  	case term.K(ui.Enter):
   285  		w.Accept()
   286  		return true
   287  	}
   288  	return false
   289  }
   290  
   291  func (w *listBox) CopyState() ListBoxState {
   292  	w.StateMutex.RLock()
   293  	defer w.StateMutex.RUnlock()
   294  	return w.State
   295  }
   296  
   297  func (w *listBox) Reset(it Items, selected int) {
   298  	w.mutate(func(s *ListBoxState) { *s = ListBoxState{Items: it, Selected: selected} })
   299  	if 0 <= selected && selected < it.Len() {
   300  		w.OnSelect(it, selected)
   301  	}
   302  }
   303  
   304  func (w *listBox) Select(f func(ListBoxState) int) {
   305  	var it Items
   306  	var oldSelected, selected int
   307  	w.mutate(func(s *ListBoxState) {
   308  		oldSelected, it = s.Selected, s.Items
   309  		selected = f(*s)
   310  		s.Selected = selected
   311  	})
   312  	if selected != oldSelected && 0 <= selected && selected < it.Len() {
   313  		w.OnSelect(it, selected)
   314  	}
   315  }
   316  
   317  // Prev moves the selection to the previous item, or does nothing if the
   318  // first item is currently selected. It is a suitable as an argument to
   319  // [ListBox.Select].
   320  func Prev(s ListBoxState) int {
   321  	return fixIndex(s.Selected-1, s.Items.Len())
   322  }
   323  
   324  // PrevPage moves the selection to the item one page before. It is only
   325  // meaningful in vertical layout and suitable as an argument to
   326  // [ListBox.Select].
   327  //
   328  // TODO(xiaq): This does not correctly with multi-line items.
   329  func PrevPage(s ListBoxState) int {
   330  	return fixIndex(s.Selected-s.ContentHeight, s.Items.Len())
   331  }
   332  
   333  // Next moves the selection to the previous item, or does nothing if the
   334  // last item is currently selected. It is a suitable as an argument to
   335  // [ListBox.Select].
   336  func Next(s ListBoxState) int {
   337  	return fixIndex(s.Selected+1, s.Items.Len())
   338  }
   339  
   340  // NextPage moves the selection to the item one page after. It is only
   341  // meaningful in vertical layout and suitable as an argument to
   342  // [ListBox.Select].
   343  //
   344  // TODO(xiaq): This does not correctly with multi-line items.
   345  func NextPage(s ListBoxState) int {
   346  	return fixIndex(s.Selected+s.ContentHeight, s.Items.Len())
   347  }
   348  
   349  // PrevWrap moves the selection to the previous item, or to the last item if
   350  // the first item is currently selected. It is a suitable as an argument to
   351  // [ListBox.Select].
   352  func PrevWrap(s ListBoxState) int {
   353  	selected, n := s.Selected, s.Items.Len()
   354  	switch {
   355  	case selected >= n:
   356  		return n - 1
   357  	case selected <= 0:
   358  		return n - 1
   359  	default:
   360  		return selected - 1
   361  	}
   362  }
   363  
   364  // NextWrap moves the selection to the previous item, or to the first item
   365  // if the last item is currently selected. It is a suitable as an argument to
   366  // [ListBox.Select].
   367  func NextWrap(s ListBoxState) int {
   368  	selected, n := s.Selected, s.Items.Len()
   369  	switch {
   370  	case selected >= n-1:
   371  		return 0
   372  	case selected < 0:
   373  		return 0
   374  	default:
   375  		return selected + 1
   376  	}
   377  }
   378  
   379  // Left moves the selection to the item to the left. It is only meaningful in
   380  // horizontal layout and suitable as an argument to [ListBox.Select].
   381  func Left(s ListBoxState) int {
   382  	return horizontal(s.Selected, s.Items.Len(), -s.ContentHeight)
   383  }
   384  
   385  // Right moves the selection to the item to the right. It is only meaningful in
   386  // horizontal layout and suitable as an argument to [ListBox.Select].
   387  func Right(s ListBoxState) int {
   388  	return horizontal(s.Selected, s.Items.Len(), s.ContentHeight)
   389  }
   390  
   391  func horizontal(selected, n, d int) int {
   392  	selected = fixIndex(selected, n)
   393  	newSelected := selected + d
   394  	if newSelected < 0 || newSelected >= n {
   395  		return selected
   396  	}
   397  	return newSelected
   398  }
   399  
   400  func fixIndex(i, n int) int {
   401  	switch {
   402  	case i < 0:
   403  		return 0
   404  	case i >= n:
   405  		return n - 1
   406  	default:
   407  		return i
   408  	}
   409  }
   410  
   411  func (w *listBox) Accept() {
   412  	state := w.CopyState()
   413  	if 0 <= state.Selected && state.Selected < state.Items.Len() {
   414  		w.OnAccept(state.Items, state.Selected)
   415  	}
   416  }
   417  
   418  func (w *listBox) mutate(f func(s *ListBoxState)) {
   419  	w.StateMutex.Lock()
   420  	defer w.StateMutex.Unlock()
   421  	f(&w.State)
   422  }