chore: initial implementation

This commit is contained in:
Kujtim Hoxha
2025-07-19 21:06:38 +02:00
parent 4509fe77d6
commit 99bbcce224
20 changed files with 1022 additions and 17 deletions

View File

@@ -1,41 +1,60 @@
package list
import (
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
)
type Item interface {
util.Model
layout.Sizeable
ID() string
}
type List interface {
util.Model
layout.Sizeable
layout.Focusable
SetItems(items []Item) tea.Cmd
}
type direction int
const (
Forward direction = iota
Backward
)
const (
NotFound = -1
)
type renderedItem struct {
id string
view string
height int
}
type list struct {
width, height int
offset int
gap int
direction direction
selectedItem string
focused bool
items []Item
// Filter options
filterable bool
filterPlaceholder string
items []Item
renderedItems []renderedItem
rendered string
isReady bool
}
type listOption func(*list)
// WithFilterable enables filtering on the list.
func WithFilterable(placeholder string) listOption {
return func(l *list) {
l.filterable = true
l.filterPlaceholder = placeholder
}
}
// WithItems sets the initial items for the list.
func WithItems(items ...Item) listOption {
return func(l *list) {
@@ -58,9 +77,24 @@ func WithGap(gap int) listOption {
}
}
// WithDirection sets the direction of the list.
func WithDirection(dir direction) listOption {
return func(l *list) {
l.direction = dir
}
}
// WithSelectedItem sets the initially selected item in the list.
func WithSelectedItem(id string) listOption {
return func(l *list) {
l.selectedItem = id
}
}
func New(opts ...listOption) List {
list := &list{
items: make([]Item, 0),
items: make([]Item, 0),
direction: Forward,
}
for _, opt := range opts {
opt(list)
@@ -73,15 +107,422 @@ func (l *list) Init() tea.Cmd {
if l.height <= 0 || l.width <= 0 {
return nil
}
return nil
if len(l.items) == 0 {
return nil
}
var cmds []tea.Cmd
for _, item := range l.items {
cmd := item.Init()
cmds = append(cmds, cmd)
}
cmds = append(cmds, l.renderItems())
return tea.Batch(cmds...)
}
// Update implements List.
func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
panic("unimplemented")
return l, nil
}
// View implements List.
func (l *list) View() string {
panic("unimplemented")
if l.height <= 0 || l.width <= 0 {
return ""
}
view := l.rendered
lines := strings.Split(view, "\n")
start, end := l.viewPosition(len(lines))
lines = lines[start:end]
return strings.Join(lines, "\n")
}
func (l *list) viewPosition(total int) (int, int) {
start, end := 0, 0
if l.direction == Forward {
start = max(0, l.offset)
end = min(l.offset+l.listHeight(), total)
} else {
start = max(0, total-l.offset-l.listHeight())
end = max(0, total-l.offset)
}
return start, end
}
func (l *list) renderItem(item Item) renderedItem {
view := item.View()
return renderedItem{
id: item.ID(),
view: view,
height: lipgloss.Height(view),
}
}
func (l *list) renderView() {
var sb strings.Builder
for i, rendered := range l.renderedItems {
sb.WriteString(rendered.view)
if i < len(l.renderedItems)-1 {
sb.WriteString(strings.Repeat("\n", l.gap+1))
}
}
l.rendered = sb.String()
}
func (l *list) incrementOffset(n int) {
if !l.isReady {
return
}
renderedHeight := lipgloss.Height(l.rendered)
// no need for offset
if renderedHeight <= l.listHeight() {
return
}
maxOffset := renderedHeight - l.listHeight()
n = min(n, maxOffset-l.offset)
if n <= 0 {
return
}
l.offset += n
}
func (l *list) decrementOffset(n int) {
if !l.isReady {
return
}
n = min(n, l.offset)
if n <= 0 {
return
}
l.offset -= n
if l.offset < 0 {
l.offset = 0
}
}
func (l *list) MoveUp(n int) {
if l.direction == Forward {
l.decrementOffset(n)
} else {
l.incrementOffset(n)
}
}
func (l *list) MoveDown(n int) {
if l.direction == Forward {
l.incrementOffset(n)
} else {
l.decrementOffset(n)
}
}
func (l *list) firstSelectableItemBefore(inx int) int {
for i := inx - 1; i >= 0; i-- {
if _, ok := l.items[i].(layout.Focusable); ok {
return i
}
}
return NotFound
}
func (l *list) firstSelectableItemAfter(inx int) int {
for i := inx + 1; i < len(l.items); i++ {
if _, ok := l.items[i].(layout.Focusable); ok {
return i
}
}
return NotFound
}
func (l *list) moveToSelected() {
if l.selectedItem == "" || !l.isReady {
return
}
currentPosition := 0
start, end := l.viewPosition(lipgloss.Height(l.rendered))
for _, item := range l.renderedItems {
if item.id == l.selectedItem {
if start <= currentPosition && currentPosition <= end {
return
}
// we need to go up
if currentPosition < start {
l.MoveUp(start - currentPosition)
}
// we need to go down
if currentPosition > end {
l.MoveDown(currentPosition - end)
}
}
currentPosition += item.height + l.gap
}
}
func (l *list) SelectItemAbove() tea.Cmd {
if !l.isReady {
return nil
}
var cmds []tea.Cmd
for i, item := range l.items {
if l.selectedItem == item.ID() {
inx := l.firstSelectableItemBefore(i)
if inx == NotFound {
// no item above
return nil
}
// blur the current item
if focusable, ok := item.(layout.Focusable); ok {
cmds = append(cmds, focusable.Blur())
}
// rerender the item
l.renderedItems[i] = l.renderItem(item)
// focus the item above
above := l.items[inx]
if focusable, ok := above.(layout.Focusable); ok {
cmds = append(cmds, focusable.Focus())
}
// rerender the item
l.renderedItems[inx] = l.renderItem(above)
l.selectedItem = above.ID()
break
}
}
l.renderView()
l.moveToSelected()
return tea.Batch(cmds...)
}
func (l *list) SelectItemBelow() tea.Cmd {
if !l.isReady {
return nil
}
var cmds []tea.Cmd
for i, item := range l.items {
if l.selectedItem == item.ID() {
inx := l.firstSelectableItemAfter(i)
if inx == NotFound {
// no item below
return nil
}
// blur the current item
if focusable, ok := item.(layout.Focusable); ok {
cmds = append(cmds, focusable.Blur())
}
// rerender the item
l.renderedItems[i] = l.renderItem(item)
// focus the item below
below := l.items[inx]
if focusable, ok := below.(layout.Focusable); ok {
cmds = append(cmds, focusable.Focus())
}
// rerender the item
l.renderedItems[inx] = l.renderItem(below)
l.selectedItem = below.ID()
break
}
}
l.renderView()
l.moveToSelected()
return tea.Batch(cmds...)
}
func (l *list) GoToTop() tea.Cmd {
if !l.isReady {
return nil
}
l.offset = 0
l.direction = Forward
return tea.Batch(l.selectFirstItem(), l.renderForward())
}
func (l *list) GoToBottom() tea.Cmd {
if !l.isReady {
return nil
}
l.offset = 0
l.direction = Backward
return tea.Batch(l.selectLastItem(), l.renderBackward())
}
func (l *list) renderForward() tea.Cmd {
// TODO: figure out a way to preserve items that did not change
l.renderedItems = make([]renderedItem, 0)
currentHeight := 0
currentIndex := 0
for i, item := range l.items {
currentIndex = i
if currentHeight > l.listHeight() {
break
}
rendered := l.renderItem(item)
l.renderedItems = append(l.renderedItems, rendered)
currentHeight += rendered.height + l.gap
}
// initial render
l.renderView()
if currentIndex == len(l.items)-1 {
l.isReady = true
return nil
}
// render the rest
return func() tea.Msg {
for i := currentIndex; i < len(l.items); i++ {
rendered := l.renderItem(l.items[i])
l.renderedItems = append(l.renderedItems, rendered)
}
l.renderView()
l.isReady = true
return nil
}
}
func (l *list) renderBackward() tea.Cmd {
// TODO: figure out a way to preserve items that did not change
l.renderedItems = make([]renderedItem, 0)
currentHeight := 0
currentIndex := 0
for i := len(l.items) - 1; i >= 0; i-- {
currentIndex = i
if currentHeight > l.listHeight() {
break
}
rendered := l.renderItem(l.items[i])
l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
currentHeight += rendered.height + l.gap
}
// initial render
l.renderView()
if currentIndex == len(l.items)-1 {
l.isReady = true
return nil
}
return func() tea.Msg {
for i := currentIndex; i >= 0; i-- {
rendered := l.renderItem(l.items[i])
l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
}
l.renderView()
l.isReady = true
return nil
}
}
func (l *list) selectFirstItem() tea.Cmd {
var cmd tea.Cmd
inx := l.firstSelectableItemAfter(-1)
if inx != NotFound {
l.selectedItem = l.items[inx].ID()
if focusable, ok := l.items[inx].(layout.Focusable); ok {
cmd = focusable.Focus()
}
}
return cmd
}
func (l *list) selectLastItem() tea.Cmd {
var cmd tea.Cmd
inx := l.firstSelectableItemBefore(len(l.items))
if inx != NotFound {
l.selectedItem = l.items[inx].ID()
if focusable, ok := l.items[inx].(layout.Focusable); ok {
cmd = focusable.Focus()
}
}
return cmd
}
func (l *list) renderItems() tea.Cmd {
if l.height <= 0 || l.width <= 0 {
return nil
}
if len(l.items) == 0 {
return nil
}
if l.selectedItem == "" {
if l.direction == Forward {
l.selectFirstItem()
} else {
l.selectLastItem()
}
}
return l.renderBackward()
}
func (l *list) listHeight() int {
// for the moment its the same
return l.height
}
func (l *list) SetItems(items []Item) tea.Cmd {
l.items = items
var cmds []tea.Cmd
for _, item := range l.items {
cmds = append(cmds, item.Init())
// Set height to 0 to let the item calculate its own height
cmds = append(cmds, item.SetSize(l.width, 0))
}
cmds = append(cmds, l.renderItems())
return tea.Batch(cmds...)
}
// GetSize implements List.
func (l *list) GetSize() (int, int) {
return l.width, l.height
}
// SetSize implements List.
func (l *list) SetSize(width int, height int) tea.Cmd {
l.width = width
l.height = height
var cmds []tea.Cmd
for _, item := range l.items {
cmds = append(cmds, item.SetSize(width, height))
}
cmds = append(cmds, l.renderItems())
return tea.Batch(cmds...)
}
// Blur implements List.
func (l *list) Blur() tea.Cmd {
var cmd tea.Cmd
l.focused = false
for i, item := range l.items {
if item.ID() != l.selectedItem {
continue
}
if focusable, ok := item.(layout.Focusable); ok {
cmd = focusable.Blur()
}
l.renderedItems[i] = l.renderItem(item)
}
l.renderView()
return cmd
}
// Focus implements List.
func (l *list) Focus() tea.Cmd {
var cmd tea.Cmd
l.focused = true
for i, item := range l.items {
if item.ID() != l.selectedItem {
continue
}
if focusable, ok := item.(layout.Focusable); ok {
cmd = focusable.Focus()
}
l.renderedItems[i] = l.renderItem(item)
}
l.renderView()
return cmd
}
// IsFocused implements List.
func (l *list) IsFocused() bool {
return l.focused
}

View File

@@ -0,0 +1,462 @@
package list
import (
"fmt"
"testing"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/exp/golden"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestBackwardList(t *testing.T) {
t.Run("within height", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward), WithGap(1)).(*list)
l.SetSize(10, 20)
items := []Item{}
for i := range 5 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
// should select the last item
assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should not change selected item", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 5 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
l.SetSize(10, 20)
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
// should select the last item
assert.Equal(t, l.selectedItem, items[2].ID())
})
t.Run("more than height", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward))
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("more than height multi line", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward))
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move up", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.MoveUp(1)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move at max to the top", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.MoveUp(100)
assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should do nothing with wrong move number", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.MoveUp(-10)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move to the top", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.GoToTop()
assert.Equal(t, l.direction, Forward)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should select the item above", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
selectedInx := len(l.items) - 2
currentItem := items[len(l.items)-1]
nextItem := items[selectedInx]
assert.False(t, nextItem.(SimpleItem).IsFocused())
assert.True(t, currentItem.(SimpleItem).IsFocused())
cmd = l.SelectItemAbove()
if cmd != nil {
cmd()
}
assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
assert.True(t, l.items[selectedInx].(SimpleItem).IsFocused())
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
for range 5 {
cmd = l.SelectItemAbove()
if cmd != nil {
cmd()
}
}
golden.RequireEqual(t, []byte(l.View()))
})
}
func TestForwardList(t *testing.T) {
t.Run("within height", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward), WithGap(1)).(*list)
l.SetSize(10, 20)
items := []Item{}
for i := range 5 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
// should select the last item
assert.Equal(t, l.selectedItem, items[0].ID())
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should not change selected item", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 5 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
l.SetSize(10, 20)
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
// should select the last item
assert.Equal(t, l.selectedItem, items[2].ID())
})
t.Run("more than height", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward))
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("more than height multi line", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward))
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move down", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.MoveDown(1)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move at max to the top", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.MoveDown(100)
assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should do nothing with wrong move number", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.MoveDown(-10)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move to the bottom", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
l.GoToBottom()
assert.Equal(t, l.direction, Backward)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should select the item below", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Forward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
selectedInx := 1
currentItem := items[0]
nextItem := items[selectedInx]
assert.False(t, nextItem.(SimpleItem).IsFocused())
assert.True(t, currentItem.(SimpleItem).IsFocused())
cmd = l.SelectItemBelow()
if cmd != nil {
cmd()
}
assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
assert.True(t, l.items[selectedInx].(SimpleItem).IsFocused())
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
t.Parallel()
l := New(WithDirection(Backward)).(*list)
l.SetSize(10, 5)
items := []Item{}
for i := range 10 {
item := NewSimpleItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
cmd := l.SetItems(items)
if cmd != nil {
cmd()
}
for range 5 {
cmd = l.SelectItemBelow()
if cmd != nil {
cmd()
}
}
golden.RequireEqual(t, []byte(l.View()))
})
}
type SimpleItem interface {
Item
layout.Focusable
}
type simpleItem struct {
width int
content string
id string
focused bool
}
func NewSimpleItem(content string) SimpleItem {
return &simpleItem{
width: 0,
content: content,
focused: false,
id: uuid.NewString(),
}
}
func (s *simpleItem) ID() string {
return s.id
}
func (s *simpleItem) Init() tea.Cmd {
return nil
}
func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, nil
}
func (s *simpleItem) View() string {
if s.focused {
return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
}
return lipgloss.NewStyle().Width(s.width).Render(s.content)
}
func (l *simpleItem) GetSize() (int, int) {
return l.width, 0
}
// SetSize implements Item.
func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
s.width = width
return nil
}
// Blur implements SimpleItem.
func (s *simpleItem) Blur() tea.Cmd {
s.focused = false
return nil
}
// Focus implements SimpleItem.
func (s *simpleItem) Focus() tea.Cmd {
s.focused = true
return nil
}
// IsFocused implements SimpleItem.
func (s *simpleItem) IsFocused() bool {
return s.focused
}

View File

@@ -0,0 +1,5 @@
Item 5
Item 6
Item 7
Item 8
│Item 9

View File

@@ -0,0 +1,5 @@
Line2
Item 8
Line2
│Item 9
│Line2

View File

@@ -0,0 +1,5 @@
Item 5
Item 6
Item 7
Item 8
│Item 9

View File

@@ -0,0 +1,5 @@
Item 0
Item 1
Item 2
Item 3
Item 4

View File

@@ -0,0 +1,5 @@
│Item 4
Item 5
Item 6
Item 7
Item 8

View File

@@ -0,0 +1,5 @@
│Item 0
Item 1
Item 2
Item 3
Item 4

View File

@@ -0,0 +1,5 @@
Item 4
Item 5
Item 6
Item 7
Item 8

View File

@@ -0,0 +1,5 @@
Item 5
Item 6
Item 7
│Item 8
Item 9

View File

@@ -0,0 +1,11 @@
Item 0
Item 0
Item 1
Item 2
Item 3
│Item 4

View File

@@ -0,0 +1,5 @@
│Item 0
Item 1
Item 2
Item 3
Item 4

View File

@@ -0,0 +1,5 @@
│Item 0
│Line2
Item 1
Line2
Item 2

View File

@@ -0,0 +1,5 @@
│Item 0
Item 1
Item 2
Item 3
Item 4

View File

@@ -0,0 +1,5 @@
Item 5
Item 6
Item 7
Item 8
Item 9

View File

@@ -0,0 +1,5 @@
Item 1
Item 2
Item 3
Item 4
Item 5

View File

@@ -0,0 +1,5 @@
Item 5
Item 6
Item 7
Item 8
│Item 9

View File

@@ -0,0 +1,5 @@
Item 5
Item 6
Item 7
Item 8
│Item 9

View File

@@ -0,0 +1,5 @@
Item 0
│Item 1
Item 2
Item 3
Item 4

View File

@@ -0,0 +1,11 @@
│Item 0
│Item 0
Item 1
Item 2
Item 3
Item 4