github.com/elves/elvish@v0.15.0/pkg/cli/listbox.go (about)

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