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 }