github.com/markusbkk/elvish@v0.0.0-20231204143114-91dc52438621/pkg/cli/tk/listbox.go (about)

     1  package tk
     2  
     3  import (
     4  	"strings"
     5  	"sync"
     6  
     7  	"github.com/markusbkk/elvish/pkg/cli/term"
     8  	"github.com/markusbkk/elvish/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  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 New, 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 := getHorizontalWindow(s, w.Padding, width, height)
    95  		return h
    96  	}
    97  	h := 0
    98  	for i := 0; i < s.Items.Len(); i++ {
    99  		h += s.Items.Show(i).CountLines()
   100  		if h >= height {
   101  			return height
   102  		}
   103  	}
   104  	return h
   105  }
   106  
   107  const listBoxColGap = 2
   108  
   109  func (w *listBox) renderHorizontal(width, height int) *term.Buffer {
   110  	var state ListBoxState
   111  	w.mutate(func(s *ListBoxState) {
   112  		if s.Items == nil || s.Items.Len() == 0 {
   113  			s.First = 0
   114  		} else {
   115  			s.First, s.Height = getHorizontalWindow(*s, w.Padding, width, height)
   116  			// Override height to the height required; we don't need the
   117  			// original height later.
   118  			height = s.Height
   119  		}
   120  		state = *s
   121  	})
   122  
   123  	if state.Items == nil || state.Items.Len() == 0 {
   124  		return Label{Content: w.Placeholder}.Render(width, height)
   125  	}
   126  
   127  	items, selected, first := state.Items, state.Selected, state.First
   128  	n := items.Len()
   129  
   130  	buf := term.NewBuffer(0)
   131  	remainedWidth := width
   132  	hasCropped := false
   133  	last := first
   134  	for i := first; i < n; i += height {
   135  		selectedRow := -1
   136  		// Render the column starting from i.
   137  		col := make([]ui.Text, 0, height)
   138  		for j := i; j < i+height && j < n; j++ {
   139  			last = j
   140  			item := items.Show(j)
   141  			if j == selected {
   142  				selectedRow = j - i
   143  			}
   144  			col = append(col, item)
   145  		}
   146  
   147  		colWidth := maxWidth(items, w.Padding, i, i+height)
   148  		if colWidth > remainedWidth {
   149  			colWidth = remainedWidth
   150  			hasCropped = true
   151  		}
   152  
   153  		colBuf := croppedLines{
   154  			lines: col, padding: w.Padding,
   155  			selectFrom: selectedRow, selectTo: selectedRow + 1,
   156  			extendStyle: w.ExtendStyle}.Render(colWidth, height)
   157  		buf.ExtendRight(colBuf)
   158  
   159  		remainedWidth -= colWidth
   160  		if remainedWidth <= listBoxColGap {
   161  			break
   162  		}
   163  		remainedWidth -= listBoxColGap
   164  		buf.Width += listBoxColGap
   165  	}
   166  	// We may not have used all the width required; force buffer width.
   167  	buf.Width = width
   168  	if first != 0 || last != n-1 || hasCropped {
   169  		scrollbar := HScrollbar{Total: n, Low: first, High: last + 1}
   170  		buf.Extend(scrollbar.Render(width, 1), false)
   171  	}
   172  	return buf
   173  }
   174  
   175  func (w *listBox) renderVertical(width, height int) *term.Buffer {
   176  	var state ListBoxState
   177  	var firstCrop int
   178  	w.mutate(func(s *ListBoxState) {
   179  		if s.Items == nil || s.Items.Len() == 0 {
   180  			s.First = 0
   181  		} else {
   182  			s.First, firstCrop = getVerticalWindow(*s, height)
   183  		}
   184  		s.Height = height
   185  		state = *s
   186  	})
   187  
   188  	if state.Items == nil || state.Items.Len() == 0 {
   189  		return Label{Content: w.Placeholder}.Render(width, height)
   190  	}
   191  
   192  	items, selected, first := state.Items, state.Selected, state.First
   193  	n := items.Len()
   194  	allLines := []ui.Text{}
   195  	hasCropped := firstCrop > 0
   196  
   197  	var i, selectFrom, selectTo int
   198  	for i = first; i < n && len(allLines) < height; i++ {
   199  		item := items.Show(i)
   200  		lines := item.SplitByRune('\n')
   201  		if i == first {
   202  			lines = lines[firstCrop:]
   203  		}
   204  		if i == selected {
   205  			selectFrom, selectTo = len(allLines), len(allLines)+len(lines)
   206  		}
   207  		// TODO: Optionally, add underlines to the last line as a visual
   208  		// separator between adjacent entries.
   209  
   210  		if len(allLines)+len(lines) > height {
   211  			lines = lines[:len(allLines)+len(lines)-height]
   212  			hasCropped = true
   213  		}
   214  		allLines = append(allLines, lines...)
   215  	}
   216  
   217  	var rd Renderer = croppedLines{
   218  		lines: allLines, padding: w.Padding,
   219  		selectFrom: selectFrom, selectTo: selectTo, extendStyle: w.ExtendStyle}
   220  	if first > 0 || i < n || hasCropped {
   221  		rd = VScrollbarContainer{
   222  			Content:   rd,
   223  			Scrollbar: VScrollbar{Total: n, Low: first, High: i},
   224  		}
   225  	}
   226  	return rd.Render(width, height)
   227  }
   228  
   229  type croppedLines struct {
   230  	lines       []ui.Text
   231  	padding     int
   232  	selectFrom  int
   233  	selectTo    int
   234  	extendStyle bool
   235  }
   236  
   237  func (c croppedLines) Render(width, height int) *term.Buffer {
   238  	bb := term.NewBufferBuilder(width)
   239  	leftSpacing := ui.T(strings.Repeat(" ", c.padding))
   240  	rightSpacing := ui.T(strings.Repeat(" ", width-c.padding))
   241  	for i, line := range c.lines {
   242  		if i > 0 {
   243  			bb.Newline()
   244  		}
   245  
   246  		selected := c.selectFrom <= i && i < c.selectTo
   247  		extendStyle := c.extendStyle && len(line) > 0
   248  
   249  		left := leftSpacing.Clone()
   250  		if extendStyle {
   251  			left[0].Style = line[0].Style
   252  		}
   253  		acc := ui.Concat(left, line.TrimWcwidth(width-2*c.padding))
   254  		if extendStyle || selected {
   255  			right := rightSpacing.Clone()
   256  			if extendStyle {
   257  				right[0].Style = line[len(line)-1].Style
   258  			}
   259  			acc = ui.Concat(acc, right).TrimWcwidth(width)
   260  		}
   261  		if selected {
   262  			acc = ui.StyleText(acc, stylingForSelected)
   263  		}
   264  
   265  		bb.WriteStyled(acc)
   266  	}
   267  	return bb.Buffer()
   268  }
   269  
   270  func (w *listBox) Handle(event term.Event) bool {
   271  	if w.Bindings.Handle(w, event) {
   272  		return true
   273  	}
   274  
   275  	switch event {
   276  	case term.K(ui.Up):
   277  		w.Select(Prev)
   278  		return true
   279  	case term.K(ui.Down):
   280  		w.Select(Next)
   281  		return true
   282  	case term.K(ui.Enter):
   283  		w.Accept()
   284  		return true
   285  	}
   286  	return false
   287  }
   288  
   289  func (w *listBox) CopyState() ListBoxState {
   290  	w.StateMutex.RLock()
   291  	defer w.StateMutex.RUnlock()
   292  	return w.State
   293  }
   294  
   295  func (w *listBox) Reset(it Items, selected int) {
   296  	w.mutate(func(s *ListBoxState) { *s = ListBoxState{Items: it, Selected: selected} })
   297  	if 0 <= selected && selected < it.Len() {
   298  		w.OnSelect(it, selected)
   299  	}
   300  }
   301  
   302  func (w *listBox) Select(f func(ListBoxState) int) {
   303  	var it Items
   304  	var oldSelected, selected int
   305  	w.mutate(func(s *ListBoxState) {
   306  		oldSelected, it = s.Selected, s.Items
   307  		selected = f(*s)
   308  		s.Selected = selected
   309  	})
   310  	if selected != oldSelected && 0 <= selected && selected < it.Len() {
   311  		w.OnSelect(it, selected)
   312  	}
   313  }
   314  
   315  // Prev moves the selection to the previous item, or does nothing if the
   316  // first item is currently selected. It is a suitable as an argument to
   317  // Widget.Select.
   318  func Prev(s ListBoxState) int {
   319  	return fixIndex(s.Selected-1, s.Items.Len())
   320  }
   321  
   322  // PrevPage moves the selection to the item one page before. It is only
   323  // meaningful in vertical layout and suitable as an argument to Widget.Select.
   324  //
   325  // TODO(xiaq): This does not correctly with multi-line items.
   326  func PrevPage(s ListBoxState) int {
   327  	return fixIndex(s.Selected-s.Height, s.Items.Len())
   328  }
   329  
   330  // Next moves the selection to the previous item, or does nothing if the
   331  // last item is currently selected. It is a suitable as an argument to
   332  // Widget.Select.
   333  func Next(s ListBoxState) int {
   334  	return fixIndex(s.Selected+1, s.Items.Len())
   335  }
   336  
   337  // NextPage moves the selection to the item one page after. It is only
   338  // meaningful in vertical layout and suitable as an argument to Widget.Select.
   339  //
   340  // TODO(xiaq): This does not correctly with multi-line items.
   341  func NextPage(s ListBoxState) int {
   342  	return fixIndex(s.Selected+s.Height, s.Items.Len())
   343  }
   344  
   345  // PrevWrap moves the selection to the previous item, or to the last item if
   346  // the first item is currently selected. It is a suitable as an argument to
   347  // Widget.Select.
   348  func PrevWrap(s ListBoxState) int {
   349  	selected, n := s.Selected, s.Items.Len()
   350  	switch {
   351  	case selected >= n:
   352  		return n - 1
   353  	case selected <= 0:
   354  		return n - 1
   355  	default:
   356  		return selected - 1
   357  	}
   358  }
   359  
   360  // NextWrap moves the selection to the previous item, or to the first item
   361  // if the last item is currently selected. It is a suitable as an argument to
   362  // Widget.Select.
   363  func NextWrap(s ListBoxState) int {
   364  	selected, n := s.Selected, s.Items.Len()
   365  	switch {
   366  	case selected >= n-1:
   367  		return 0
   368  	case selected < 0:
   369  		return 0
   370  	default:
   371  		return selected + 1
   372  	}
   373  }
   374  
   375  // Left moves the selection to the item to the left. It is only meaningful in
   376  // horizontal layout and suitable as an argument to Widget.Select.
   377  func Left(s ListBoxState) int {
   378  	return horizontal(s.Selected, s.Items.Len(), -s.Height)
   379  }
   380  
   381  // Right moves the selection to the item to the right. It is only meaningful in
   382  // horizontal layout and suitable as an argument to Widget.Select.
   383  func Right(s ListBoxState) int {
   384  	return horizontal(s.Selected, s.Items.Len(), s.Height)
   385  }
   386  
   387  func horizontal(selected, n, d int) int {
   388  	selected = fixIndex(selected, n)
   389  	newSelected := selected + d
   390  	if newSelected < 0 || newSelected >= n {
   391  		return selected
   392  	}
   393  	return newSelected
   394  }
   395  
   396  func fixIndex(i, n int) int {
   397  	switch {
   398  	case i < 0:
   399  		return 0
   400  	case i >= n:
   401  		return n - 1
   402  	default:
   403  		return i
   404  	}
   405  }
   406  
   407  func (w *listBox) Accept() {
   408  	state := w.CopyState()
   409  	if 0 <= state.Selected && state.Selected < state.Items.Len() {
   410  		w.OnAccept(state.Items, state.Selected)
   411  	}
   412  }
   413  
   414  func (w *listBox) mutate(f func(s *ListBoxState)) {
   415  	w.StateMutex.Lock()
   416  	defer w.StateMutex.Unlock()
   417  	f(&w.State)
   418  }