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 }